Created
February 2, 2026 23:44
-
-
Save lukeramsden/db8e76637b7f6e188e00c45b113e97d4 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env node | |
| /* eslint-disable no-console -- this script intentionally prints CLI output */ | |
| /** | |
| * nx-filtered: Runs nx commands and only outputs tasks with failures or warnings. | |
| * | |
| * Usage: nx-filtered [nx arguments...] | |
| * Example: nx-filtered run-many -t build,lint,typecheck-all | |
| * | |
| * This script runs nx commands, captures all output, and only displays | |
| * output from tasks that have errors or warnings. Successful tasks with | |
| * clean output are hidden to reduce noise. | |
| */ | |
| import { spawn } from 'child_process'; | |
| import { clearLine, createInterface, cursorTo } from 'readline'; | |
| interface TaskOutput { | |
| taskId: string; | |
| lines: string[]; | |
| seenLines: Set<string>; | |
| hasError: boolean; | |
| hasWarning: boolean; | |
| isCacheHit: boolean; | |
| } | |
| const ERROR_PATTERNS = [ | |
| /\berror\b/i, | |
| /\bERR\b/, | |
| /\bfailed\b/i, | |
| /\bexception\b/i, | |
| /✖/, | |
| /❌/, | |
| /\berror\[/i, | |
| /TS\d{4,5}:/, | |
| /ESLint:/, | |
| ]; | |
| const WARNING_PATTERNS = [ | |
| /\bwarning\b/i, | |
| /\bWARN\b/, | |
| /⚠/, | |
| /\bdeprecated\b/i, | |
| ]; | |
| const IGNORE_LINE_PATTERNS = [ | |
| /^\s*$/, | |
| /^\s*Licensed to/, | |
| /^\s*>\s*$/, | |
| ]; | |
| const STATUS_LINE_PATTERNS = [ | |
| /^\s*NX\s+/, | |
| /^>\s*nx run\s+/, | |
| ]; | |
| function hasError(line: string): boolean { | |
| // Don't count lines that are just showing what command ran | |
| if (line.startsWith('>')) return false; | |
| return ERROR_PATTERNS.some((pattern) => pattern.test(line)); | |
| } | |
| function hasWarning(line: string): boolean { | |
| if (line.startsWith('>')) return false; | |
| return WARNING_PATTERNS.some((pattern) => pattern.test(line)); | |
| } | |
| function shouldIgnoreLine(line: string): boolean { | |
| return IGNORE_LINE_PATTERNS.some((pattern) => pattern.test(line)); | |
| } | |
| function isStatusLine(line: string): boolean { | |
| return STATUS_LINE_PATTERNS.some((pattern) => pattern.test(line)); | |
| } | |
| function isCacheHit(lines: string[]): boolean { | |
| return lines.some( | |
| (line) => | |
| /\[(?:local|remote) cache\]/i.test(line) || | |
| /Nx read the output from the cache/i.test(line) | |
| ); | |
| } | |
| async function main(): Promise<void> { | |
| const args = process.argv.slice(2); | |
| if (args.length === 0) { | |
| console.error('Usage: nx-filtered [nx arguments...]'); | |
| console.error('Example: nx-filtered run-many -t build,lint,typecheck-all'); | |
| process.exit(1); | |
| } | |
| // Use npx to ensure nx is found in node_modules/.bin | |
| // Disable colors for clean parsing | |
| const child = spawn('npx', ['nx', ...args], { | |
| shell: true, | |
| stdio: ['inherit', 'pipe', 'pipe'], | |
| env: { ...process.env, FORCE_COLOR: '0', NO_COLOR: '1' }, | |
| }); | |
| const tasks = new Map<string, TaskOutput>(); | |
| let currentTaskId: string | null = null; | |
| const globalLines: string[] = []; | |
| const failedTaskIds: string[] = []; | |
| let inFailedTasksSummary = false; | |
| let tasksStarted = 0; | |
| const showProgress = process.stderr.isTTY; | |
| const renderProgress = (): void => { | |
| if (!showProgress) return; | |
| cursorTo(process.stderr, 0); | |
| clearLine(process.stderr, 0); | |
| process.stderr.write(`Started ${String(tasksStarted)} task(s)...`); | |
| }; | |
| const clearProgress = (): void => { | |
| if (!showProgress) return; | |
| cursorTo(process.stderr, 0); | |
| clearLine(process.stderr, 0); | |
| }; | |
| const processLine = (line: string, isStderr = false): void => { | |
| // Check for task start: "> nx run project:target" | |
| const taskStartMatch = line.match(/^>\s*nx run\s+([^\s]+)/); | |
| if (taskStartMatch?.[1]) { | |
| const taskId = taskStartMatch[1]; | |
| currentTaskId = taskId; | |
| inFailedTasksSummary = false; | |
| if (!tasks.has(taskId)) { | |
| tasksStarted += 1; | |
| tasks.set(taskId, { | |
| taskId: taskId, | |
| lines: [], | |
| seenLines: new Set(), | |
| hasError: false, | |
| hasWarning: false, | |
| isCacheHit: false, | |
| }); | |
| renderProgress(); | |
| } | |
| // Add the task start line | |
| const task = tasks.get(taskId); | |
| if (task) { | |
| task.lines.push(line); | |
| } | |
| return; | |
| } | |
| // Check for NX status lines (reset current task context) | |
| const nxStatusMatch = line.match( | |
| /^\s*NX\s+(Successfully ran|Running target|Ran target)/ | |
| ); | |
| if (nxStatusMatch) { | |
| currentTaskId = null; | |
| inFailedTasksSummary = false; | |
| globalLines.push(line); | |
| return; | |
| } | |
| // Check for failed tasks list | |
| if (line.trim() === 'Failed tasks:') { | |
| inFailedTasksSummary = true; | |
| currentTaskId = null; | |
| globalLines.push(line); | |
| return; | |
| } | |
| if (inFailedTasksSummary) { | |
| // Check for task in failed list: " - project:target" | |
| const failedMatch = line.match(/^\s*-\s+([^\s]+:[^\s]+)/); | |
| if (failedMatch?.[1]) { | |
| failedTaskIds.push(failedMatch[1]); | |
| globalLines.push(line); | |
| return; | |
| } | |
| // Keep blank lines in the summary section, but stop parsing on any other content | |
| if (!line.trim()) { | |
| globalLines.push(line); | |
| return; | |
| } | |
| inFailedTasksSummary = false; | |
| } | |
| // Add line to current task or global (avoid duplicates) | |
| if (currentTaskId) { | |
| const task = tasks.get(currentTaskId); | |
| if (task) { | |
| // Skip duplicate lines (nx sometimes outputs the same content twice) | |
| if (!task.seenLines.has(line)) { | |
| task.seenLines.add(line); | |
| task.lines.push(line); | |
| } | |
| if (hasError(line)) { | |
| task.hasError = true; | |
| } | |
| if (hasWarning(line)) { | |
| task.hasWarning = true; | |
| } | |
| if (isStderr && line.trim()) { | |
| // Non-empty stderr usually indicates problems | |
| task.hasError = true; | |
| } | |
| } | |
| } else if (!shouldIgnoreLine(line) && !isStatusLine(line)) { | |
| globalLines.push(line); | |
| } | |
| }; | |
| const stdoutRL = createInterface({ input: child.stdout }); | |
| stdoutRL.on('line', (line) => { | |
| processLine(line, false); | |
| }); | |
| const stderrRL = createInterface({ input: child.stderr }); | |
| stderrRL.on('line', (line) => { | |
| processLine(line, true); | |
| }); | |
| return new Promise((resolve) => { | |
| child.on('close', (code) => { | |
| clearProgress(); | |
| const overallSuccess = code === 0; | |
| // Update cache hit status | |
| for (const task of tasks.values()) { | |
| task.isCacheHit = isCacheHit(task.lines); | |
| } | |
| // Mark tasks as failed based on exit code and failed tasks list | |
| for (const taskId of failedTaskIds) { | |
| const task = tasks.get(taskId); | |
| if (task) { | |
| task.hasError = true; | |
| } | |
| } | |
| // Get tasks to show (errors or warnings) | |
| const tasksToShow = Array.from(tasks.values()).filter( | |
| (task) => task.hasError || task.hasWarning | |
| ); | |
| // Output section for tasks with issues | |
| if (tasksToShow.length > 0) { | |
| console.log(''); | |
| console.log( | |
| '────────────────────────────────────────────────────────────────────────' | |
| ); | |
| console.log( | |
| overallSuccess | |
| ? '⚠ Tasks with warnings:' | |
| : '❌ Tasks with errors or warnings:' | |
| ); | |
| console.log( | |
| '────────────────────────────────────────────────────────────────────────' | |
| ); | |
| for (const task of tasksToShow) { | |
| const icon = task.hasError ? '❌' : '⚠'; | |
| console.log(''); | |
| console.log(`${icon} ${task.taskId}`); | |
| console.log('─'.repeat(60)); | |
| // Show meaningful lines (skip empty and command echo lines) | |
| const meaningfulLines = task.lines.filter((line) => { | |
| if (shouldIgnoreLine(line)) return false; | |
| // Keep task start line for context | |
| if (line.match(/^>\s*nx run\s+/)) return true; | |
| // Skip pure command echo lines (just "> command") | |
| if ( | |
| line.startsWith('>') && | |
| !hasError(line) && | |
| !hasWarning(line) | |
| ) { | |
| return false; | |
| } | |
| return true; | |
| }); | |
| for (const line of meaningfulLines) { | |
| console.log(line); | |
| } | |
| } | |
| console.log(''); | |
| console.log( | |
| '────────────────────────────────────────────────────────────────────────' | |
| ); | |
| } | |
| // Summary | |
| const totalTasks = tasks.size; | |
| const errorTasks = Array.from(tasks.values()).filter( | |
| (t) => t.hasError | |
| ).length; | |
| const warningTasks = Array.from(tasks.values()).filter( | |
| (t) => t.hasWarning && !t.hasError | |
| ).length; | |
| const cacheHits = Array.from(tasks.values()).filter( | |
| (t) => t.isCacheHit | |
| ).length; | |
| console.log(''); | |
| if (overallSuccess) { | |
| if (totalTasks > 0) { | |
| console.log(`✓ ${String(totalTasks)} tasks completed (${String(cacheHits)} from cache)`); | |
| } | |
| if (warningTasks > 0) { | |
| console.log(` ${String(warningTasks)} task(s) with warnings`); | |
| } | |
| } else { | |
| console.log(`✗ ${String(errorTasks)} of ${String(totalTasks)} tasks failed`); | |
| if (warningTasks > 0) { | |
| console.log(` ${String(warningTasks)} additional task(s) with warnings`); | |
| } | |
| // List failed tasks | |
| if (errorTasks > 0) { | |
| console.log(''); | |
| console.log('Failed tasks:'); | |
| for (const task of tasks.values()) { | |
| if (task.hasError) { | |
| console.log(` - ${task.taskId}`); | |
| } | |
| } | |
| } | |
| } | |
| // Set exit code and let process exit naturally after stdout flushes | |
| process.exitCode = code ?? 1; | |
| resolve(); | |
| }); | |
| }); | |
| } | |
| main().catch((err: unknown) => { | |
| console.error('nx-filtered error:', err); | |
| process.exitCode = 1; | |
| }); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment