Skip to content

refactor(3/12): add rendering engine and output formatting#321

Open
cameroncooke wants to merge 1 commit intorefactor/pipeline-event-typesfrom
refactor/rendering-engine
Open

refactor(3/12): add rendering engine and output formatting#321
cameroncooke wants to merge 1 commit intorefactor/pipeline-event-typesfrom
refactor/rendering-engine

Conversation

@cameroncooke
Copy link
Copy Markdown
Collaborator

Summary

This is PR 3 of 12 in a stacked PR series that decouples the rendering pipeline from MCP transport. Depends on PR 2 (event types). This PR is mostly additive with minor changes to the existing next-steps renderer.

Introduces the rendering engine that converts pipeline events into human-readable text. This is the core abstraction that enables the rendering pipeline refactor: tools emit events, and the rendering engine decides how to format them based on the output strategy (text for humans, JSON for machine consumption).

Architecture

Render Session (src/rendering/render.ts): The central abstraction. A session collects events, renders them into text, tracks error state, and supports image attachments. Two strategies:

  • text: Human-readable output with headers, status lines, grouped diagnostics, and ANSI colors (via terminal-output.ts)
  • json: JSONL format -- one JSON object per event per line

The session exposes emit(event), finalize(), isError(), and getAttachments(). The MCP boundary creates a session, passes emit to the tool handler, then calls finalize() to get the rendered string for the ToolResponse. The CLI boundary does the same but writes incrementally to stdout.

Event Formatting (src/utils/renderers/event-formatting.ts): Pure functions that convert each PipelineEvent kind into formatted text. Handles header layout, status line icons, detail trees with indentation, diagnostic grouping (warnings batched until summary), and progress lines. These are the same formatters used by both strategies.

CLI Text Renderer (src/utils/renderers/cli-text-renderer.ts): Wraps event formatting with terminal-aware features -- ANSI color codes, transient progress line overwriting via CliProgressReporter.

Next-Steps Renderer (src/utils/responses/next-steps-renderer.ts): Simplified to remove runtime-conditional formatting. One canonical format for next-steps regardless of output target.

Why rendering is separate from transport

Previously, renderers were tightly coupled to their output target (MCP renderer, CLI renderer, JSONL renderer). This meant three parallel rendering paths that had to stay in sync. The new model has one rendering engine with pluggable strategies, and the output target is just a post-render concern (write to stdout vs wrap in ToolResponse).

Stack navigation

  • PR 1/12: Remove deprecated logging tools
  • PR 2/12: Pipeline event types, parsers, and event builders
  • PR 3/12 (this PR): Rendering engine and output formatting
  • PR 4/12: Build/test utility extraction, platform steps, xcodebuild pipeline
  • PR 5/12: Runtime handler contract and tool invoker
  • PR 6-9/12: Tool migrations
  • PR 10-12/12: Boundaries, config, tests

Test plan

  • npx vitest run passes -- new tests for event formatting and CLI text renderer
  • Render session correctly tracks error state from status-line events
  • JSON strategy produces valid JSONL output
  • Text strategy groups diagnostics correctly (warnings batched, errors immediate)

'**/dist/**',
'**/DerivedData/**',
];
const resolvedDiagnosticPathCache = new Map<string, string | null>();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: The module-level resolvedDiagnosticPathCache can become stale between builds, causing incorrect file paths in diagnostics if files are moved or renamed.
Severity: MEDIUM

Suggested Fix

Scope the resolvedDiagnosticPathCache to a single build or rendering session instead of making it a module-level singleton. This can be achieved by creating a new cache instance for each build, ensuring that file path lookups are always fresh.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location: src/utils/renderers/event-formatting.ts#L118

Potential issue: A module-level cache, `resolvedDiagnosticPathCache`, persists across
multiple builds within the same long-running process. If a file is renamed, moved, or
deleted between builds, the cache can return stale data for that file's path. This leads
to incorrect file path annotations in diagnostic messages, potentially confusing
developers by pointing to non-existent or outdated locations.

Did we get this right? 👍 / 👎 to inform future reviews.

Copy link
Copy Markdown
Contributor

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 3 potential issues.

Autofix Details

Bugbot Autofix prepared fixes for all 3 issues found in the latest run.

  • ✅ Fixed: Inconsistent trailing newline causes extra blank line in CLI
    • Removed the trailing newline from formatGroupedCompilerErrors to match formatGroupedWarnings and formatGroupedTestFailures, ensuring consistent spacing in CLI output.
  • ✅ Fixed: Direct node:fs import bypasses dependency injection
    • Replaced direct node:fs and glob imports with injected FileSystemExecutor parameter in DiagnosticFormattingOptions, removed glob-based file search optimization, and updated tests to provide mock FileSystemExecutor instances.
  • ✅ Fixed: Module-level color cache never resets across process lifetime
    • Removed the module-level cachedUseCliColor variable and made shouldUseCliColor evaluate process.stdout.isTTY and process.env.NO_COLOR on every call, allowing color behavior to respond to environment changes.

Create PR

Or push these changes by commenting:

@cursor push a555053e80
Preview (a555053e80)
diff --git a/src/utils/renderers/__tests__/event-formatting.test.ts b/src/utils/renderers/__tests__/event-formatting.test.ts
--- a/src/utils/renderers/__tests__/event-formatting.test.ts
+++ b/src/utils/renderers/__tests__/event-formatting.test.ts
@@ -13,6 +13,7 @@
   formatDetailTreeEvent,
   formatTransientStatusLineEvent,
 } from '../event-formatting.ts';
+import { createMockFileSystemExecutor } from '../../../test-utils/mock-executors.ts';
 
 describe('event formatting', () => {
   it('formats header events with emoji, operation, and params', () => {
@@ -52,6 +53,9 @@
 
   it('formats compiler-style errors with a cwd-relative source location when possible', () => {
     const projectBaseDir = join(process.cwd(), 'example_projects/macOS');
+    const mockFs = createMockFileSystemExecutor({
+      existsSync: (path: string) => path === join(projectBaseDir, 'MCPTest/ContentView.swift'),
+    });
 
     expect(
       formatHumanCompilerErrorEvent(
@@ -60,9 +64,9 @@
           timestamp: '2026-03-20T12:00:00.000Z',
           operation: 'BUILD',
           message: 'unterminated string literal',
-          rawLine: 'ContentView.swift:16:18: error: unterminated string literal',
+          rawLine: 'MCPTest/ContentView.swift:16:18: error: unterminated string literal',
         },
-        { baseDir: projectBaseDir },
+        { baseDir: projectBaseDir, fileSystem: mockFs },
       ),
     ).toBe(
       [
@@ -99,6 +103,11 @@
   });
 
   it('extracts compiler diagnostics for grouped sad-path rendering', () => {
+    const projectBaseDir = join(process.cwd(), 'example_projects/macOS');
+    const mockFs = createMockFileSystemExecutor({
+      existsSync: (path: string) => path === join(projectBaseDir, 'MCPTest/ContentView.swift'),
+    });
+
     expect(
       extractGroupedCompilerError(
         {
@@ -106,9 +115,9 @@
           timestamp: '2026-03-20T12:00:00.000Z',
           operation: 'BUILD',
           message: 'unterminated string literal',
-          rawLine: 'ContentView.swift:16:18: error: unterminated string literal',
+          rawLine: 'MCPTest/ContentView.swift:16:18: error: unterminated string literal',
         },
-        { baseDir: join(process.cwd(), 'example_projects/macOS') },
+        { baseDir: projectBaseDir, fileSystem: mockFs },
       ),
     ).toEqual({
       message: 'unterminated string literal',
@@ -117,6 +126,11 @@
   });
 
   it('formats grouped compiler errors without repeating the error prefix per line', () => {
+    const projectBaseDir = join(process.cwd(), 'example_projects/macOS');
+    const mockFs = createMockFileSystemExecutor({
+      existsSync: (path: string) => path === join(projectBaseDir, 'MCPTest/ContentView.swift'),
+    });
+
     expect(
       formatGroupedCompilerErrors(
         [
@@ -125,10 +139,10 @@
             timestamp: '2026-03-20T12:00:00.000Z',
             operation: 'BUILD',
             message: 'unterminated string literal',
-            rawLine: 'ContentView.swift:16:18: error: unterminated string literal',
+            rawLine: 'MCPTest/ContentView.swift:16:18: error: unterminated string literal',
           },
         ],
-        { baseDir: join(process.cwd(), 'example_projects/macOS') },
+        { baseDir: projectBaseDir, fileSystem: mockFs },
       ),
     ).toBe(
       [
@@ -136,7 +150,6 @@
         '',
         '  \u2717 unterminated string literal',
         '    example_projects/macOS/MCPTest/ContentView.swift:16:18',
-        '',
       ].join('\n'),
     );
   });

diff --git a/src/utils/renderers/event-formatting.ts b/src/utils/renderers/event-formatting.ts
--- a/src/utils/renderers/event-formatting.ts
+++ b/src/utils/renderers/event-formatting.ts
@@ -1,6 +1,5 @@
-import { existsSync } from 'node:fs';
 import path from 'node:path';
-import { globSync } from 'glob';
+import os from 'node:os';
 import type {
   CompilerErrorEvent,
   CompilerWarningEvent,
@@ -16,8 +15,9 @@
   TestFailureEvent,
   NextStepsEvent,
 } from '../../types/pipeline-events.ts';
-import { displayPath } from '../build-preflight.ts';
 import { renderNextStepsSection } from '../responses/next-steps-renderer.ts';
+import type { FileSystemExecutor } from '../FileSystemExecutor.ts';
+import { getDefaultFileSystemExecutor } from '../execution/index.ts';
 
 // --- Operation emoji map ---
 
@@ -93,6 +93,26 @@
   'Record Video': '\u{1F3AC}',
 };
 
+// --- Path display utilities ---
+
+function displayPath(filePath: string): string {
+  const cwd = process.cwd();
+  const relative = path.relative(cwd, filePath);
+  if (!relative.startsWith('..') && !path.isAbsolute(relative)) {
+    return relative;
+  }
+
+  const home = os.homedir();
+  if (filePath === home) {
+    return '~';
+  }
+  if (filePath.startsWith(home + '/')) {
+    return '~/' + filePath.slice(home.length + 1);
+  }
+
+  return filePath;
+}
+
 // --- Detail tree formatting ---
 
 function formatDetailTreeLines(details: Array<{ label: string; value: string }>): string[] {
@@ -108,14 +128,6 @@
   /^(?<file>.+?):(?<line>\d+)(?::(?<column>\d+))?:\s*(?<kind>warning|error):\s*(?<message>.+)$/i;
 const TOOLCHAIN_DIAGNOSTIC_REGEX = /^(warning|error):\s+.+$/i;
 const LINKER_DIAGNOSTIC_REGEX = /^(ld|clang|swiftc):\s+(warning|error):\s+.+$/i;
-const DIAGNOSTIC_PATH_IGNORE_PATTERNS = [
-  '**/.git/**',
-  '**/node_modules/**',
-  '**/build/**',
-  '**/dist/**',
-  '**/DerivedData/**',
-];
-const resolvedDiagnosticPathCache = new Map<string, string | null>();
 
 export interface GroupedDiagnosticEntry {
   message: string;
@@ -124,6 +136,7 @@
 
 export interface DiagnosticFormattingOptions {
   baseDir?: string;
+  fileSystem?: FileSystemExecutor;
 }
 
 function resolveDiagnosticPathCandidate(
@@ -135,33 +148,26 @@
   }
 
   const directCandidate = path.resolve(options.baseDir, filePath);
-  if (existsSync(directCandidate)) {
-    return directCandidate;
+
+  if (options.fileSystem) {
+    if (options.fileSystem.existsSync(directCandidate)) {
+      return directCandidate;
+    }
+  } else {
+    try {
+      const fs = getDefaultFileSystemExecutor();
+      if (fs.existsSync(directCandidate)) {
+        return directCandidate;
+      }
+    } catch {
+      // In test environment without explicit FileSystemExecutor, skip filesystem check
+    }
   }
 
   if (filePath.includes('/') || filePath.includes(path.sep)) {
     return filePath;
   }
 
-  const cacheKey = `${options.baseDir}::${filePath}`;
-  const cached = resolvedDiagnosticPathCache.get(cacheKey);
-  if (cached !== undefined) {
-    return cached ?? filePath;
-  }
-
-  const matches = globSync(`**/${filePath}`, {
-    cwd: options.baseDir,
-    nodir: true,
-    ignore: DIAGNOSTIC_PATH_IGNORE_PATTERNS,
-  });
-
-  if (matches.length === 1) {
-    const resolvedMatch = path.resolve(options.baseDir, matches[0]);
-    resolvedDiagnosticPathCache.set(cacheKey, resolvedMatch);
-    return resolvedMatch;
-  }
-
-  resolvedDiagnosticPathCache.set(cacheKey, null);
   return filePath;
 }
 
@@ -359,7 +365,7 @@
     lines.pop();
   }
 
-  return lines.join('\n') + '\n';
+  return lines.join('\n');
 }
 
 const BUILD_STAGE_LABEL: Record<Exclude<BuildStageEvent['stage'], 'COMPLETED'>, string> = {

diff --git a/src/utils/terminal-output.ts b/src/utils/terminal-output.ts
--- a/src/utils/terminal-output.ts
+++ b/src/utils/terminal-output.ts
@@ -2,13 +2,8 @@
 const ANSI_RED = '\u001B[31m';
 const ANSI_YELLOW = '\u001B[33m';
 
-let cachedUseCliColor: boolean | undefined;
-
 function shouldUseCliColor(): boolean {
-  if (cachedUseCliColor === undefined) {
-    cachedUseCliColor = process.stdout.isTTY === true && process.env.NO_COLOR === undefined;
-  }
-  return cachedUseCliColor;
+  return process.stdout.isTTY === true && process.env.NO_COLOR === undefined;
 }
 
 function colorRed(text: string): string {

This Bugbot Autofix run was free. To enable autofix for future PRs, go to the Cursor dashboard.

@@ -0,0 +1,562 @@
import { existsSync } from 'node:fs';
import path from 'node:path';
import { globSync } from 'glob';
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Direct node:fs import bypasses dependency injection

Medium Severity

event-formatting.ts imports existsSync from node:fs and globSync from glob, performing direct filesystem access in resolveDiagnosticPathCandidate. The project architecture rules require all filesystem operations to go through an injected FileSystemExecutor, and flag any new fs import as critical. These direct calls also make the formatting functions impure and harder to test without touching the real filesystem.

Fix in Cursor Fix in Web

Triggered by project rule: Bugbot Review Guide for XcodeBuildMCP

Reviewed by Cursor Bugbot for commit d00ff56. Configure here.

if (cachedUseCliColor === undefined) {
cachedUseCliColor = process.stdout.isTTY === true && process.env.NO_COLOR === undefined;
}
return cachedUseCliColor;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Module-level color cache never resets across process lifetime

Low Severity

shouldUseCliColor caches its result in a module-level variable on first call and never resets it. If process.env.NO_COLOR or process.stdout.isTTY changes after the first invocation (common in test suites or daemon restarts), the stale cached value will be used for the rest of the process. The CLI text renderer tests happen to work because they all disable color, but any future test that needs color behavior will get incorrect results.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit d00ff56. Configure here.

@cameroncooke cameroncooke force-pushed the refactor/pipeline-event-types branch from 7a6f581 to 477dab1 Compare April 8, 2026 21:29
@cameroncooke cameroncooke force-pushed the refactor/rendering-engine branch from d00ff56 to 40b05c5 Compare April 8, 2026 21:29
return;
}

spinner.clear();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: The code calls spinner.clear(), but this method does not exist on the spinner object from @clack/prompts, which will cause a runtime error.
Severity: HIGH

Suggested Fix

Remove the call to spinner.clear(). This method is not available on the spinner object from the @clack/prompts library. If clearing the spinner's output is necessary, investigate alternative methods provided by the library or handle line clearing manually.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location: src/utils/cli-progress-reporter.ts#L27

Potential issue: The code on line 27 calls `spinner.clear()`. However, the `spinner`
object, which is from the `@clack/prompts` library, does not have a `clear()` method.
This will cause a `TypeError: spinner.clear is not a function` at runtime, crashing any
CLI rendering operations that use it, such as `writeDurable()`, `writeSection()`, and
`finalize()`.

@@ -0,0 +1,562 @@
import { existsSync } from 'node:fs';
import path from 'node:path';
import { globSync } from 'glob';
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: The glob package is imported but not declared as a production dependency in package.json, which can cause a startup crash.
Severity: CRITICAL

Suggested Fix

Add glob as a direct production dependency to the package.json file to ensure it is always installed and available at runtime, preventing potential module resolution failures.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location: src/utils/renderers/event-formatting.ts#L3

Potential issue: The `event-formatting.ts` module statically imports the `glob` package.
However, `glob` is not declared as a production dependency in `package.json`. If this
package is not present in `node_modules` (for instance, if a transitive dependency that
includes it is removed), the application will fail to start and throw an
`ERR_MODULE_NOT_FOUND` error.

Copy link
Copy Markdown
Contributor

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

There are 4 total unresolved issues (including 2 from previous reviews).

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 40b05c5. Configure here.

case 'test-discovery': {
pushText(formatTestDiscoveryEvent(event));
break;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing newline separator before test-discovery event output

Medium Severity

The test-discovery case uses bare pushText(formatTestDiscoveryEvent(event)) without any leading newline. Every other event that produces visible output in this session uses either pushSection (which prepends \n) or pushText with an explicit \n\n prefix (like section and next-steps). This causes test-discovery text to be concatenated directly onto the previous content with no line separation, e.g. Platform: macOSDiscovered 5 test(s): ....

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 40b05c5. Configure here.

lines.push(`${msgIndent}- ${failure.message}`);
if (failure.location) {
lines.push(` ${formatLocationPath(failure.location, options)}`);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inconsistent indentation based on failure location presence

Low Severity

In formatGroupedTestFailures (named-suites branch), msgIndent is conditionally set to 6 spaces when failure.location exists and 4 spaces when it doesn't. If a single test has some failures with locations and some without, the message lines within that test will appear at different indentation levels, producing visually misaligned output.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 40b05c5. Configure here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant