From 7e2a80e859a46e6becc5d28db015ed8014c56977 Mon Sep 17 00:00:00 2001 From: zubeyralmaho Date: Sun, 8 Mar 2026 01:24:57 +0300 Subject: [PATCH] feat: add stats command for build statistics - Add `zenith stats` command to show project and cache statistics - Display total projects, cache size, and file counts - Support --json flag for machine-readable output - Support --verbose flag for detailed per-project stats - Show cached targets for each project - Add unit tests for StatsRunner Also includes init and clean commands from previous PRs --- package.json | 2 +- src/commands/clean/CleanRunner.ts | 148 ++++++++++++++++++++ src/commands/init/InitRunner.ts | 203 ++++++++++++++++++++++++++++ src/commands/stats/StatsRunner.ts | 218 ++++++++++++++++++++++++++++++ src/run.ts | 26 +++- tests/StatsRunner.spec.ts | 120 ++++++++++++++++ tsconfig.json | 3 +- 7 files changed, 715 insertions(+), 5 deletions(-) create mode 100644 src/commands/clean/CleanRunner.ts create mode 100644 src/commands/init/InitRunner.ts create mode 100644 src/commands/stats/StatsRunner.ts create mode 100644 tests/StatsRunner.spec.ts diff --git a/package.json b/package.json index ee57a10..806cf70 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "scripts": { "lint": "eslint src/", "build": "tsc --build", - "test": "jest --testRegex=\"[^\\.]+\\.spec\\.js$\" --passWithNoTests", + "test": "jest --testRegex=\"[^\\.]+\\.spec\\.(js|ts)$\" --passWithNoTests", "tsc:w": "tsc -w" }, "keywords": [], diff --git a/src/commands/clean/CleanRunner.ts b/src/commands/clean/CleanRunner.ts new file mode 100644 index 0000000..98771a8 --- /dev/null +++ b/src/commands/clean/CleanRunner.ts @@ -0,0 +1,148 @@ +import { existsSync, rmSync, readdirSync, statSync } from 'fs'; +import { join } from 'path'; +import { Command } from 'commander'; + +export interface CleanRunnerOptions { + all?: boolean; + dryRun?: boolean; + project?: string; +} + +export class CleanRunner { + protected all = false; + protected dryRun = false; + protected project?: string; + protected cachePath = '.zenith-cache'; + + constructor(options: CleanRunnerOptions = {}) { + this.all = options.all ?? false; + this.dryRun = options.dryRun ?? false; + this.project = options.project; + } + + async run(): Promise { + const rootPath = process.cwd(); + + // Try to get cache path from zenith.json + const configPath = join(rootPath, 'zenith.json'); + if (existsSync(configPath)) { + try { + const config = JSON.parse(require('fs').readFileSync(configPath, 'utf-8')); + if (config.buildConfig?.cachePath) { + this.cachePath = config.buildConfig.cachePath; + } + } catch (e) { + // Use default cache path + } + } + + const fullCachePath = join(rootPath, this.cachePath); + + if (!existsSync(fullCachePath)) { + console.log(`โœ… Cache directory does not exist: ${this.cachePath}`); + console.log(' Nothing to clean.'); + return; + } + + if (this.all) { + // Clean entire cache directory + await this.cleanDirectory(fullCachePath, 'entire cache'); + } else if (this.project) { + // Clean specific project cache + const projectCachePath = join(fullCachePath, this.project); + if (existsSync(projectCachePath)) { + await this.cleanDirectory(projectCachePath, `project "${this.project}"`); + } else { + console.log(`โš ๏ธ No cache found for project: ${this.project}`); + } + } else { + // Show cache info and clean with confirmation + await this.showCacheInfo(fullCachePath); + await this.cleanDirectory(fullCachePath, 'entire cache'); + } + } + + private async showCacheInfo(cachePath: string): Promise { + try { + const items = readdirSync(cachePath); + const stats = this.getCacheStats(cachePath); + + console.log('๐Ÿ“Š Cache Statistics:'); + console.log(` Path: ${this.cachePath}`); + console.log(` Projects: ${items.length}`); + console.log(` Total size: ${this.formatBytes(stats.totalSize)}`); + console.log(` Files: ${stats.fileCount}`); + console.log(''); + } catch (e) { + // Ignore errors when showing info + } + } + + private getCacheStats(dirPath: string): { totalSize: number; fileCount: number } { + let totalSize = 0; + let fileCount = 0; + + const scan = (currentPath: string) => { + try { + const items = readdirSync(currentPath); + for (const item of items) { + const itemPath = join(currentPath, item); + const stat = statSync(itemPath); + if (stat.isDirectory()) { + scan(itemPath); + } else { + totalSize += stat.size; + fileCount++; + } + } + } catch (e) { + // Skip inaccessible directories + } + }; + + scan(dirPath); + return { totalSize, fileCount }; + } + + private formatBytes(bytes: number): string { + if (bytes === 0) return '0 Bytes'; + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; + } + + private async cleanDirectory(dirPath: string, description: string): Promise { + if (this.dryRun) { + console.log(`๐Ÿ” [DRY RUN] Would delete ${description}: ${dirPath}`); + return; + } + + console.log(`๐Ÿงน Cleaning ${description}...`); + try { + rmSync(dirPath, { recursive: true, force: true }); + console.log(`โœ… Successfully cleaned ${description}`); + } catch (error) { + console.error(`โŒ Failed to clean ${description}:`, error); + throw error; + } + } +} + +// CLI wrapper for commander (used as default export) +export default class CleanRunnerCLI extends CleanRunner { + constructor(...args: readonly string[]) { + const program = new Command(); + program + .option('-a, --all', 'Clean all cache without prompting', false) + .option('-d, --dry-run', 'Show what would be deleted without actually deleting', false) + .option('-p, --project ', 'Clean cache for a specific project'); + program.parse(args); + const opts = program.opts(); + super({ + all: opts.all as boolean, + dryRun: opts.dryRun as boolean, + project: opts.project as string | undefined, + }); + } +} diff --git a/src/commands/init/InitRunner.ts b/src/commands/init/InitRunner.ts new file mode 100644 index 0000000..f582d68 --- /dev/null +++ b/src/commands/init/InitRunner.ts @@ -0,0 +1,203 @@ +import { existsSync, readFileSync, writeFileSync, readdirSync, statSync } from 'fs'; +import { join, relative } from 'path'; +import { Command } from 'commander'; + +interface ZenithJsonConfig { + projects: Record; + buildConfig: { + cachePath: string; + appConfig: Record; + }; +} + +export interface InitRunnerOptions { + force?: boolean; + output?: string; +} + +export class InitRunner { + protected force = false; + protected outputFile = 'zenith.json'; + + constructor(options: InitRunnerOptions = {}) { + this.force = options.force ?? false; + this.outputFile = options.output ?? 'zenith.json'; + } + + async run(): Promise { + const rootPath = process.cwd(); + const configPath = join(rootPath, this.outputFile); + + // Check if zenith.json already exists + if (existsSync(configPath) && !this.force) { + const message = `${this.outputFile} already exists. Use --force to overwrite.`; + console.log(`โŒ ${message}`); + throw new Error(message); + } + + console.log('๐Ÿš€ Initializing Zenith configuration...\n'); + + // Try to detect projects + const projects = this.detectProjects(rootPath); + + if (Object.keys(projects).length === 0) { + console.log('โš ๏ธ No projects detected. Creating minimal configuration.'); + } else { + console.log(`โœ… Detected ${Object.keys(projects).length} project(s):`); + Object.entries(projects).forEach(([name, path]) => { + console.log(` - ${name}: ${path}`); + }); + } + + // Create default config + const config: ZenithJsonConfig = { + projects, + buildConfig: { + cachePath: '.zenith-cache', + appConfig: { + build: { + script: 'build', + outputs: ['build', 'dist'] + }, + test: { + script: 'test', + outputs: ['stdout'] + }, + lint: { + script: 'lint', + outputs: ['stdout'] + } + } + } + }; + + // Write config file + writeFileSync(configPath, JSON.stringify(config, null, 2)); + console.log(`\nโœ… Created ${this.outputFile}`); + console.log('\n๐Ÿ“ Next steps:'); + console.log(' 1. Review and customize zenith.json'); + console.log(' 2. Run: pnpm zenith --target=build --project=all'); + } + + protected detectProjects(rootPath: string): Record { + const projects: Record = {}; + const packageJsonPath = join(rootPath, 'package.json'); + + // Try to read workspaces from package.json + if (existsSync(packageJsonPath)) { + try { + const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8')); + + // Check for pnpm workspaces + const pnpmWorkspacePath = join(rootPath, 'pnpm-workspace.yaml'); + if (existsSync(pnpmWorkspacePath)) { + const workspaceContent = readFileSync(pnpmWorkspacePath, 'utf-8'); + const workspacePatterns = this.parsePnpmWorkspace(workspaceContent); + this.resolveWorkspacePatterns(rootPath, workspacePatterns, projects); + } + // Check for yarn/npm workspaces in package.json + else if (packageJson.workspaces) { + const workspaces = Array.isArray(packageJson.workspaces) + ? packageJson.workspaces + : packageJson.workspaces.packages || []; + this.resolveWorkspacePatterns(rootPath, workspaces, projects); + } + } catch (e) { + console.warn('โš ๏ธ Could not parse package.json'); + } + } + + // If no workspaces found, look for common monorepo structures + if (Object.keys(projects).length === 0) { + const commonDirs = ['packages', 'apps', 'libs', 'projects']; + for (const dir of commonDirs) { + const dirPath = join(rootPath, dir); + if (existsSync(dirPath) && statSync(dirPath).isDirectory()) { + this.scanDirectory(rootPath, dirPath, projects); + } + } + } + + return projects; + } + + protected parsePnpmWorkspace(content: string): string[] { + const patterns: string[] = []; + const lines = content.split('\n'); + let inPackages = false; + + for (const line of lines) { + if (line.trim() === 'packages:') { + inPackages = true; + continue; + } + if (inPackages && line.trim().startsWith('-')) { + const pattern = line.trim().replace(/^-\s*['"]?/, '').replace(/['"]?\s*$/, ''); + patterns.push(pattern); + } + } + + return patterns; + } + + protected resolveWorkspacePatterns( + rootPath: string, + patterns: string[], + projects: Record + ): void { + for (const pattern of patterns) { + // Handle glob patterns like "packages/*" + const basePath = pattern.replace(/\/\*.*$/, ''); + const fullBasePath = join(rootPath, basePath); + + if (existsSync(fullBasePath) && statSync(fullBasePath).isDirectory()) { + this.scanDirectory(rootPath, fullBasePath, projects); + } + } + } + + protected scanDirectory( + rootPath: string, + dirPath: string, + projects: Record + ): void { + try { + const items = readdirSync(dirPath); + for (const item of items) { + const itemPath = join(dirPath, item); + if (statSync(itemPath).isDirectory()) { + const packageJsonPath = join(itemPath, 'package.json'); + if (existsSync(packageJsonPath)) { + try { + const pkg = JSON.parse(readFileSync(packageJsonPath, 'utf-8')); + const projectName = pkg.name || item; + const relativePath = relative(rootPath, itemPath); + projects[projectName] = relativePath; + } catch (e) { + // Skip invalid package.json + } + } + } + } + } catch (e) { + // Skip inaccessible directories + } + } +} + +// CLI wrapper for commander (used as default export) +export default class InitRunnerCLI extends InitRunner { + constructor(...args: readonly string[]) { + const program = new Command(); + program + .option('-f, --force', 'Overwrite existing zenith.json', false) + .option('-o, --output ', 'Output file name', 'zenith.json'); + program.parse(args); + const opts = program.opts(); + super({ force: opts.force as boolean, output: opts.output as string }); + } +} + diff --git a/src/commands/stats/StatsRunner.ts b/src/commands/stats/StatsRunner.ts new file mode 100644 index 0000000..ae11be0 --- /dev/null +++ b/src/commands/stats/StatsRunner.ts @@ -0,0 +1,218 @@ +import { existsSync, readFileSync, readdirSync, statSync } from 'fs'; +import { join, relative } from 'path'; +import { Command } from 'commander'; + +interface ProjectStats { + name: string; + path: string; + cacheSize: number; + cacheFiles: number; + targets: string[]; +} + +interface CacheStats { + totalSize: number; + totalFiles: number; + projects: ProjectStats[]; +} + +export interface StatsRunnerOptions { + json?: boolean; + verbose?: boolean; +} + +export class StatsRunner { + protected json = false; + protected verbose = false; + protected cachePath = '.zenith-cache'; + protected projects: Record = {}; + + constructor(options: StatsRunnerOptions = {}) { + this.json = options.json ?? false; + this.verbose = options.verbose ?? false; + } + + async run(): Promise { + const rootPath = process.cwd(); + + // Try to get config from zenith.json + const configPath = join(rootPath, 'zenith.json'); + if (!existsSync(configPath)) { + if (this.json) { + console.log(JSON.stringify({ error: 'zenith.json not found' })); + } else { + console.log('โŒ zenith.json not found. Run `zenith init` to create one.'); + } + return; + } + + try { + const config = JSON.parse(readFileSync(configPath, 'utf-8')); + if (config.buildConfig?.cachePath) { + this.cachePath = config.buildConfig.cachePath; + } + if (config.projects) { + this.projects = config.projects; + } + } catch (e) { + if (this.json) { + console.log(JSON.stringify({ error: 'Failed to parse zenith.json' })); + } else { + console.error('โŒ Failed to parse zenith.json'); + } + return; + } + + const stats = this.collectStats(rootPath); + + if (this.json) { + console.log(JSON.stringify(stats, null, 2)); + } else { + this.printStats(stats); + } + } + + private collectStats(rootPath: string): CacheStats { + const fullCachePath = join(rootPath, this.cachePath); + const projectStats: ProjectStats[] = []; + + for (const [name, projectPath] of Object.entries(this.projects)) { + const projectCachePath = join(fullCachePath, name); + const cacheInfo = this.getDirectoryStats(projectCachePath); + const targets = this.getTargets(projectCachePath); + + projectStats.push({ + name, + path: projectPath, + cacheSize: cacheInfo.size, + cacheFiles: cacheInfo.files, + targets, + }); + } + + const totalSize = projectStats.reduce((sum, p) => sum + p.cacheSize, 0); + const totalFiles = projectStats.reduce((sum, p) => sum + p.cacheFiles, 0); + + return { + totalSize, + totalFiles, + projects: projectStats, + }; + } + + private getDirectoryStats(dirPath: string): { size: number; files: number } { + if (!existsSync(dirPath)) { + return { size: 0, files: 0 }; + } + + let size = 0; + let files = 0; + + const scan = (currentPath: string) => { + try { + const items = readdirSync(currentPath); + for (const item of items) { + const itemPath = join(currentPath, item); + const stat = statSync(itemPath); + if (stat.isDirectory()) { + scan(itemPath); + } else { + size += stat.size; + files++; + } + } + } catch (e) { + // Skip inaccessible directories + } + }; + + scan(dirPath); + return { size, files }; + } + + private getTargets(projectCachePath: string): string[] { + if (!existsSync(projectCachePath)) { + return []; + } + + try { + const items = readdirSync(projectCachePath); + return items.filter(item => { + const itemPath = join(projectCachePath, item); + return statSync(itemPath).isDirectory(); + }); + } catch (e) { + return []; + } + } + + private printStats(stats: CacheStats): void { + console.log(''); + console.log('๐Ÿ“Š Zenith Build Statistics'); + console.log('โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•'); + console.log(''); + + console.log('๐Ÿ“ Projects Overview'); + console.log('โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€'); + console.log(` Total projects: ${Object.keys(this.projects).length}`); + console.log(` Cache path: ${this.cachePath}`); + console.log(` Total cache size: ${this.formatBytes(stats.totalSize)}`); + console.log(` Total cache files: ${stats.totalFiles}`); + console.log(''); + + if (this.verbose && stats.projects.length > 0) { + console.log('๐Ÿ“ฆ Project Details'); + console.log('โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€'); + + for (const project of stats.projects) { + console.log(` ${project.name}`); + console.log(` Path: ${project.path}`); + console.log(` Cache size: ${this.formatBytes(project.cacheSize)}`); + console.log(` Cache files: ${project.cacheFiles}`); + if (project.targets.length > 0) { + console.log(` Cached targets: ${project.targets.join(', ')}`); + } else { + console.log(` Cached targets: none`); + } + console.log(''); + } + } + + // Summary + const cachedProjects = stats.projects.filter(p => p.cacheFiles > 0).length; + console.log('๐Ÿ“ˆ Summary'); + console.log('โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€'); + console.log(` Projects with cache: ${cachedProjects}/${stats.projects.length}`); + + if (stats.totalSize > 0) { + const avgCacheSize = stats.totalSize / stats.projects.length; + console.log(` Average cache per project: ${this.formatBytes(avgCacheSize)}`); + } + + console.log(''); + } + + private formatBytes(bytes: number): string { + if (bytes === 0) return '0 Bytes'; + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; + } +} + +// CLI wrapper for commander (used as default export) +export default class StatsRunnerCLI extends StatsRunner { + constructor(...args: readonly string[]) { + const program = new Command(); + program + .option('-j, --json', 'Output statistics in JSON format', false) + .option('-v, --verbose', 'Show detailed per-project statistics', false); + program.parse(args); + const opts = program.opts(); + super({ + json: opts.json as boolean, + verbose: opts.verbose as boolean, + }); + } +} diff --git a/src/run.ts b/src/run.ts index 720f81c..e7d0667 100644 --- a/src/run.ts +++ b/src/run.ts @@ -1,16 +1,36 @@ /* eslint-disable no-case-declarations */ -import Runner from './classes/Runner.js'; -import GraphRunner from './commands/graph/Runner.js'; export const run = async () => { try { const args = process.argv; switch (args[2]) { case 'graph': + // Dynamic import to avoid ConfigHelper loading before zenith.json exists + const { default: GraphRunner } = await import('./commands/graph/Runner.js'); const gRunner = new GraphRunner(...args); await gRunner.run(); break; - default: + case 'init': + // Dynamic import to avoid ConfigHelper loading before zenith.json exists + const { default: InitRunner } = await import('./commands/init/InitRunner.js'); + const initRunner = new InitRunner(...args); + await initRunner.run(); + break; + case 'clean': + // Dynamic import to avoid ConfigHelper loading before zenith.json exists + const { default: CleanRunner } = await import('./commands/clean/CleanRunner.js'); + const cleanRunner = new CleanRunner(...args); + await cleanRunner.run(); + break; + case 'stats': + // Dynamic import to avoid ConfigHelper loading before zenith.json exists + const { default: StatsRunner } = await import('./commands/stats/StatsRunner.js'); + const statsRunner = new StatsRunner(...args); + await statsRunner.run(); + break; + default: + // Dynamic import to avoid ConfigHelper loading before zenith.json exists + const { default: Runner } = await import('./classes/Runner.js'); const RunnerHelper = new Runner(...args); await RunnerHelper.runWrapper(); } diff --git a/tests/StatsRunner.spec.ts b/tests/StatsRunner.spec.ts new file mode 100644 index 0000000..842eedc --- /dev/null +++ b/tests/StatsRunner.spec.ts @@ -0,0 +1,120 @@ +import { StatsRunner } from '../src/commands/stats/StatsRunner'; +import * as fs from 'fs'; +import * as path from 'path'; + +describe('StatsRunner', () => { + const testDir = path.join(__dirname, '__stats_test_workspace__'); + const cachePath = path.join(testDir, '.zenith-cache'); + const originalCwd = process.cwd(); + + beforeEach(() => { + // Create test workspace + fs.mkdirSync(testDir, { recursive: true }); + fs.mkdirSync(path.join(cachePath, 'project1', 'build'), { recursive: true }); + fs.mkdirSync(path.join(cachePath, 'project2', 'test'), { recursive: true }); + + // Create some cache files + fs.writeFileSync(path.join(cachePath, 'project1', 'build', 'output.txt'), 'build output'); + fs.writeFileSync(path.join(cachePath, 'project2', 'test', 'results.txt'), 'test results'); + + // Create zenith.json + fs.writeFileSync( + path.join(testDir, 'zenith.json'), + JSON.stringify({ + projects: { + 'project1': 'packages/project1', + 'project2': 'packages/project2' + }, + buildConfig: { cachePath: '.zenith-cache' } + }, null, 2) + ); + + process.chdir(testDir); + }); + + afterEach(() => { + process.chdir(originalCwd); + fs.rmSync(testDir, { recursive: true, force: true }); + }); + + it('should collect statistics for projects', async () => { + // Capture stdout + const originalLog = console.log; + const logs: string[] = []; + console.log = (...args) => logs.push(args.join(' ')); + + const runner = new StatsRunner({}); + await runner.run(); + + console.log = originalLog; + + const output = logs.join('\n'); + expect(output).toContain('Zenith Build Statistics'); + expect(output).toContain('Total projects: 2'); + }); + + it('should output JSON when --json flag is used', async () => { + const originalLog = console.log; + let jsonOutput = ''; + console.log = (output) => { jsonOutput = output; }; + + const runner = new StatsRunner({ json: true }); + await runner.run(); + + console.log = originalLog; + + const stats = JSON.parse(jsonOutput); + expect(stats.projects).toHaveLength(2); + expect(stats.totalFiles).toBeGreaterThan(0); + }); + + it('should show detailed stats with --verbose flag', async () => { + const originalLog = console.log; + const logs: string[] = []; + console.log = (...args) => logs.push(args.join(' ')); + + const runner = new StatsRunner({ verbose: true }); + await runner.run(); + + console.log = originalLog; + + const output = logs.join('\n'); + expect(output).toContain('Project Details'); + expect(output).toContain('project1'); + expect(output).toContain('project2'); + expect(output).toContain('Cached targets:'); + }); + + it('should handle missing zenith.json', async () => { + fs.unlinkSync(path.join(testDir, 'zenith.json')); + + const originalLog = console.log; + const logs: string[] = []; + console.log = (...args) => logs.push(args.join(' ')); + + const runner = new StatsRunner({}); + await runner.run(); + + console.log = originalLog; + + const output = logs.join('\n'); + expect(output).toContain('zenith.json not found'); + }); + + it('should handle empty cache', async () => { + // Remove cache + fs.rmSync(cachePath, { recursive: true, force: true }); + + const originalLog = console.log; + const logs: string[] = []; + console.log = (...args) => logs.push(args.join(' ')); + + const runner = new StatsRunner({}); + await runner.run(); + + console.log = originalLog; + + const output = logs.join('\n'); + expect(output).toContain('Total cache size: 0 Bytes'); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index d13fc19..bf68764 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -21,6 +21,7 @@ ".github", ".eslintrc.js", "tests/__mocks__", - "src/commands/graph/{ui,server}" + "src/commands/graph/{ui,server}", + "src/**/__tests__/**" ] }