Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,34 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [10.8.0] — 2026-02-11 — PRESENTER: Output Contracts

Extracts CLI rendering into `bin/presenters/`, adds NDJSON output and color control. Net reduction of ~460 LOC in `bin/warp-graph.js`.

### Added

- **`--ndjson` flag**: Compact single-line JSON output with sorted keys for piping and scripting. Full payload structure preserved, `_`-prefixed internal keys stripped. Mutually exclusive with `--json` and `--view`.
- **`NO_COLOR` / `FORCE_COLOR` / `CI` support**: Plain-text output automatically strips ANSI escape codes when `NO_COLOR` is set, `FORCE_COLOR=0`, stdout is not a TTY, or `CI` is set. `FORCE_COLOR` (non-zero) forces color on.
- **`bin/presenters/json.js`**: `stableStringify()` (pretty-printed sorted JSON), `compactStringify()` (single-line sorted JSON), `sanitizePayload()` (strips `_`-prefixed keys).
- **`bin/presenters/text.js`**: All 9 plain-text renderers extracted from `warp-graph.js` — `renderInfo`, `renderQuery`, `renderPath`, `renderCheck`, `renderHistory`, `renderError`, `renderMaterialize`, `renderInstallHooks`, `renderSeek`.
- **`bin/presenters/index.js`**: Unified `present()` dispatcher replacing the 112-line `emit()` function. Handles format dispatch (text/json/ndjson), view mode routing (ASCII/SVG/HTML), and color control.
- **51 new unit tests** across `test/unit/presenters/` (json, text, present).
- **6 BATS integration tests** in `test/bats/cli-ndjson.bats` for NDJSON output and mutual-exclusion enforcement.

### Fixed

- **`--json` output sanitized**: Internal `_renderedSvg` and `_renderedAscii` keys are now stripped from JSON output. Previously these rendering artifacts leaked into `--json` payloads.
- **`package.json` files array**: Added `bin/presenters` so npm-published tarball includes the presenter modules (would have caused `MODULE_NOT_FOUND` at runtime).
- **`--view query` null guard**: `_renderedAscii` now uses `?? ''` fallback to prevent `"undefined"` in output when pre-rendered ASCII is missing.
- **`CliOptions` typedef**: Added missing `ndjson` property to JSDoc typedef.

### Changed

- **`bin/warp-graph.js`**: Reduced from 2893 to ~2430 LOC. Removed `stableStringify`, 9 `renderXxx` functions, `emit()`, `writeHtmlExport()`, ANSI constants. Replaced with 3-line `present()` call.
- **`renderSeek`**: Decomposed into `renderSeekSimple()`, `renderSeekList()`, `renderSeekState()`, and `renderSeekWithDiff()` to stay within ESLint complexity limits.
- **`renderCheck`**: Decomposed into `appendCheckpointAndWriters()` and `appendCoverageAndExtras()` helpers.
- **M2.T2.PRESENTER** marked `DONE` in `ROADMAP.md`.

## [10.7.0] — 2026-02-11 — MEM-ADAPTER: In-Memory Persistence

Adds `InMemoryGraphAdapter`, a zero-I/O implementation of `GraphPersistencePort` for fast tests.
Expand Down
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,7 @@ All 12 milestones (77 tasks, ~255 human hours, ~13,100 LOC) have been implemente

### M2.T2.PRESENTER — Output Contracts (A-Tier)

- **Status:** `OPEN`
- **Status:** `DONE`

**User Story:** As a contributor, I need command logic separated from rendering for stable machine outputs.

Expand Down
208 changes: 208 additions & 0 deletions bin/presenters/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
/**
* Unified output dispatcher for CLI commands.
*
* Replaces the 112-line emit() function in warp-graph.js with clean
* format dispatch: text, json, ndjson — plus view mode handling.
*/

import fs from 'node:fs';
import process from 'node:process';

import { stripAnsi } from '../../src/visualization/utils/ansi.js';
import { renderInfoView } from '../../src/visualization/renderers/ascii/info.js';
import { renderCheckView } from '../../src/visualization/renderers/ascii/check.js';
import { renderHistoryView } from '../../src/visualization/renderers/ascii/history.js';
import { renderPathView } from '../../src/visualization/renderers/ascii/path.js';
import { renderMaterializeView } from '../../src/visualization/renderers/ascii/materialize.js';
import { renderSeekView } from '../../src/visualization/renderers/ascii/seek.js';

import { stableStringify, compactStringify, sanitizePayload } from './json.js';
import {
renderInfo,
renderQuery,
renderPath,
renderCheck,
renderHistory,
renderError,
renderMaterialize,
renderInstallHooks,
renderSeek,
} from './text.js';

// ── Color control ────────────────────────────────────────────────────────────

/**
* Determines whether ANSI color codes should be stripped from output.
*
* Precedence: FORCE_COLOR=0 (strip) > FORCE_COLOR!='' (keep) > NO_COLOR > !isTTY > CI.
* @returns {boolean}
*/
export function shouldStripColor() {
if (process.env.FORCE_COLOR === '0') {
return true;
}
if (process.env.FORCE_COLOR !== undefined && process.env.FORCE_COLOR !== '') {
return false;
}
if (process.env.NO_COLOR !== undefined) {
return true;
}
if (!process.stdout.isTTY) {
return true;
}
if (process.env.CI !== undefined) {
return true;
}
return false;
}

// ── Text renderer map ────────────────────────────────────────────────────────

/** @type {Map<string, function(*): string>} */
const TEXT_RENDERERS = new Map(/** @type {[string, function(*): string][]} */ ([
['info', renderInfo],
['query', renderQuery],
['path', renderPath],
['check', renderCheck],
['history', renderHistory],
['materialize', renderMaterialize],
['seek', renderSeek],
['install-hooks', renderInstallHooks],
]));

/** @type {Map<string, function(*): string>} */
const VIEW_RENDERERS = new Map(/** @type {[string, function(*): string][]} */ ([
['info', renderInfoView],
['check', renderCheckView],
['history', renderHistoryView],
['path', renderPathView],
['materialize', renderMaterializeView],
['seek', renderSeekView],
]));

// ── HTML export ──────────────────────────────────────────────────────────────

/**
* Wraps SVG content in a minimal HTML document and writes it to disk.
* @param {string} filePath
* @param {string} svgContent
*/
function writeHtmlExport(filePath, svgContent) {
const html = `<!DOCTYPE html>\n<html><head><meta charset="utf-8"><title>git-warp</title></head><body>\n${svgContent}\n</body></html>`;
fs.writeFileSync(filePath, html);
}

// ── SVG / HTML file export ───────────────────────────────────────────────────

/**
* Handles svg:PATH and html:PATH view modes for commands that carry _renderedSvg.
* @param {*} payload
* @param {string} view
* @returns {boolean} true if handled
*/
function handleFileExport(payload, view) {
if (typeof view === 'string' && view.startsWith('svg:')) {
const svgPath = view.slice(4);
if (!payload._renderedSvg) {
process.stderr.write('No graph data — skipping SVG export.\n');
} else {
fs.writeFileSync(svgPath, payload._renderedSvg);
process.stderr.write(`SVG written to ${svgPath}\n`);
}
return true;
}
if (typeof view === 'string' && view.startsWith('html:')) {
const htmlPath = view.slice(5);
if (!payload._renderedSvg) {
process.stderr.write('No graph data — skipping HTML export.\n');
} else {
writeHtmlExport(htmlPath, payload._renderedSvg);
process.stderr.write(`HTML written to ${htmlPath}\n`);
}
return true;
}
return false;
}

// ── Output helpers ───────────────────────────────────────────────────────────

/**
* Writes text to stdout, optionally stripping ANSI codes.
* @param {string} text
* @param {boolean} strip
*/
function writeText(text, strip) {
process.stdout.write(strip ? stripAnsi(text) : text);
}

// ── Main dispatcher ──────────────────────────────────────────────────────────

/**
* Writes a command result to stdout/stderr in the requested format.
*
* @param {*} payload - Command result payload
* @param {{format: string, command: string, view: string|null|boolean}} options
*/
export function present(payload, { format, command, view }) {
// Error payloads always go to stderr as plain text
if (payload?.error) {
process.stderr.write(renderError(payload));
return;
}

// JSON: sanitize + pretty-print
if (format === 'json') {
process.stdout.write(`${stableStringify(sanitizePayload(payload))}\n`);
return;
}

// NDJSON: sanitize + compact single line
if (format === 'ndjson') {
process.stdout.write(`${compactStringify(sanitizePayload(payload))}\n`);
return;
}

// Text with view mode
if (view) {
presentView(payload, command, view);
return;
}

// Plain text
const renderer = TEXT_RENDERERS.get(command);
if (renderer) {
writeText(renderer(payload), shouldStripColor());
} else {
// Fallback for unknown commands
process.stdout.write(`${stableStringify(sanitizePayload(payload))}\n`);
}
}

/**
* Handles --view output dispatch (ASCII view, SVG file, HTML file).
* @param {*} payload
* @param {string} command
* @param {string|boolean} view
*/
function presentView(payload, command, view) {
const strip = shouldStripColor();

// File exports: svg:PATH, html:PATH
if (handleFileExport(payload, /** @type {string} */ (view))) {
return;
}

// query is special: uses pre-rendered _renderedAscii
if (command === 'query') {
writeText(`${payload._renderedAscii ?? ''}\n`, strip);
return;
}

// Dispatch to view renderer
const viewRenderer = VIEW_RENDERERS.get(command);
if (viewRenderer) {
writeText(viewRenderer(payload), strip);
} else {
writeText(`${stableStringify(sanitizePayload(payload))}\n`, strip);
}
}
66 changes: 66 additions & 0 deletions bin/presenters/json.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/**
* JSON / NDJSON serialization utilities for CLI output.
*
* - stableStringify: pretty-printed, sorted-key JSON (--json)
* - compactStringify: single-line, sorted-key JSON (--ndjson)
* - sanitizePayload: strips internal _-prefixed keys before serialization
*/

/**
* Recursively sorts object keys for deterministic JSON output.
* @param {*} input
* @returns {*}
*/
function normalize(input) {
if (Array.isArray(input)) {
return input.map(normalize);
}
if (input && typeof input === 'object') {
/** @type {Record<string, *>} */
const sorted = {};
for (const key of Object.keys(input).sort()) {
sorted[key] = normalize(input[key]);
}
return sorted;
}
return input;
}

/**
* Pretty-printed JSON with sorted keys (2-space indent).
* @param {*} value
* @returns {string}
*/
export function stableStringify(value) {
return JSON.stringify(normalize(value), null, 2);
}

/**
* Single-line JSON with sorted keys (no indent).
* @param {*} value
* @returns {string}
*/
export function compactStringify(value) {
return JSON.stringify(normalize(value));
}

/**
* Shallow-clones a payload, removing all top-level underscore-prefixed keys.
* These are internal rendering artifacts (e.g. _renderedSvg, _renderedAscii)
* that should not leak into JSON/NDJSON output.
* @param {*} payload
* @returns {*}
*/
export function sanitizePayload(payload) {
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
return payload;
}
/** @type {Record<string, *>} */
const clean = {};
for (const key of Object.keys(payload)) {
if (!key.startsWith('_')) {
clean[key] = payload[key];
}
}
return clean;
}
Loading
Loading