diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d93bc9..690ea55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/ROADMAP.md b/ROADMAP.md index 5e5b75e..d0d30cc 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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. diff --git a/bin/presenters/index.js b/bin/presenters/index.js new file mode 100644 index 0000000..b09ec3d --- /dev/null +++ b/bin/presenters/index.js @@ -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} */ +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} */ +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 = `\ngit-warp\n${svgContent}\n`; + 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); + } +} diff --git a/bin/presenters/json.js b/bin/presenters/json.js new file mode 100644 index 0000000..b885791 --- /dev/null +++ b/bin/presenters/json.js @@ -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} */ + 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} */ + const clean = {}; + for (const key of Object.keys(payload)) { + if (!key.startsWith('_')) { + clean[key] = payload[key]; + } + } + return clean; +} diff --git a/bin/presenters/text.js b/bin/presenters/text.js new file mode 100644 index 0000000..5f5097b --- /dev/null +++ b/bin/presenters/text.js @@ -0,0 +1,407 @@ +/** + * Plain-text renderers for CLI output. + * + * Each function accepts a command payload and returns a formatted string + * (with trailing newline) suitable for process.stdout.write(). + */ + +import { formatStructuralDiff } from '../../src/visualization/renderers/ascii/seek.js'; + +// ── ANSI helpers ───────────────────────────────────────────────────────────── + +const ANSI_GREEN = '\x1b[32m'; +const ANSI_YELLOW = '\x1b[33m'; +const ANSI_RED = '\x1b[31m'; +const ANSI_DIM = '\x1b[2m'; +const ANSI_RESET = '\x1b[0m'; + +/** @param {string} state */ +function colorCachedState(state) { + if (state === 'fresh') { + return `${ANSI_GREEN}${state}${ANSI_RESET}`; + } + if (state === 'stale') { + return `${ANSI_YELLOW}${state}${ANSI_RESET}`; + } + return `${ANSI_RED}${ANSI_DIM}${state}${ANSI_RESET}`; +} + +/** @param {*} hook */ +function formatHookStatusLine(hook) { + if (!hook.installed && hook.foreign) { + return "Hook: foreign hook present — run 'git warp install-hooks'"; + } + if (!hook.installed) { + return "Hook: not installed — run 'git warp install-hooks'"; + } + if (hook.current) { + return `Hook: installed (v${hook.version}) — up to date`; + } + return `Hook: installed (v${hook.version}) — upgrade available, run 'git warp install-hooks'`; +} + +// ── Simple renderers ───────────────────────────────────────────────────────── + +/** @param {*} payload */ +export function renderInfo(payload) { + const lines = [`Repo: ${payload.repo}`]; + lines.push(`Graphs: ${payload.graphs.length}`); + for (const graph of payload.graphs) { + const writers = graph.writers ? ` writers=${graph.writers.count}` : ''; + lines.push(`- ${graph.name}${writers}`); + if (graph.checkpoint?.sha) { + lines.push(` checkpoint: ${graph.checkpoint.sha}`); + } + if (graph.coverage?.sha) { + lines.push(` coverage: ${graph.coverage.sha}`); + } + if (graph.cursor?.active) { + lines.push(` cursor: tick ${graph.cursor.tick} (${graph.cursor.mode})`); + } + } + return `${lines.join('\n')}\n`; +} + +/** @param {*} payload */ +export function renderQuery(payload) { + const lines = [ + `Graph: ${payload.graph}`, + `State: ${payload.stateHash}`, + `Nodes: ${payload.nodes.length}`, + ]; + + for (const node of payload.nodes) { + const id = node.id ?? '(unknown)'; + lines.push(`- ${id}`); + if (node.props && Object.keys(node.props).length > 0) { + lines.push(` props: ${JSON.stringify(node.props)}`); + } + } + + return `${lines.join('\n')}\n`; +} + +/** @param {*} payload */ +export function renderPath(payload) { + const lines = [ + `Graph: ${payload.graph}`, + `From: ${payload.from}`, + `To: ${payload.to}`, + `Found: ${payload.found ? 'yes' : 'no'}`, + `Length: ${payload.length}`, + ]; + + if (payload.path && payload.path.length > 0) { + lines.push(`Path: ${payload.path.join(' -> ')}`); + } + + return `${lines.join('\n')}\n`; +} + +/** + * Appends checkpoint and writer lines to check output. + * @param {string[]} lines + * @param {*} payload + */ +function appendCheckpointAndWriters(lines, payload) { + if (payload.checkpoint?.sha) { + lines.push(`Checkpoint: ${payload.checkpoint.sha}`); + if (payload.checkpoint.ageSeconds !== null) { + lines.push(`Checkpoint Age: ${payload.checkpoint.ageSeconds}s`); + } + } else { + lines.push('Checkpoint: none'); + } + + if (!payload.status) { + lines.push(`Writers: ${payload.writers.count}`); + } + for (const head of payload.writers.heads) { + lines.push(`- ${head.writerId}: ${head.sha}`); + } +} + +/** + * Appends coverage, gc, and hook lines to check output. + * @param {string[]} lines + * @param {*} payload + */ +function appendCoverageAndExtras(lines, payload) { + if (payload.coverage?.sha) { + lines.push(`Coverage: ${payload.coverage.sha}`); + lines.push(`Coverage Missing: ${payload.coverage.missingWriters.length}`); + } else { + lines.push('Coverage: none'); + } + + if (payload.gc) { + lines.push(`Tombstones: ${payload.gc.totalTombstones}`); + if (!payload.status) { + lines.push(`Tombstone Ratio: ${payload.gc.tombstoneRatio}`); + } + } + + if (payload.hook) { + lines.push(formatHookStatusLine(payload.hook)); + } +} + +/** @param {*} payload */ +export function renderCheck(payload) { + const lines = [ + `Graph: ${payload.graph}`, + `Health: ${payload.health.status}`, + ]; + + if (payload.status) { + lines.push(`Cached State: ${colorCachedState(payload.status.cachedState)}`); + lines.push(`Patches Since Checkpoint: ${payload.status.patchesSinceCheckpoint}`); + lines.push(`Tombstone Ratio: ${payload.status.tombstoneRatio.toFixed(2)}`); + lines.push(`Writers: ${payload.status.writers}`); + } + + appendCheckpointAndWriters(lines, payload); + appendCoverageAndExtras(lines, payload); + return `${lines.join('\n')}\n`; +} + +/** @param {*} payload */ +export function renderHistory(payload) { + const lines = [ + `Graph: ${payload.graph}`, + `Writer: ${payload.writer}`, + `Entries: ${payload.entries.length}`, + ]; + + if (payload.nodeFilter) { + lines.push(`Node Filter: ${payload.nodeFilter}`); + } + + for (const entry of payload.entries) { + lines.push(`- ${entry.sha} (lamport: ${entry.lamport}, ops: ${entry.opCount})`); + } + + return `${lines.join('\n')}\n`; +} + +/** @param {*} payload */ +export function renderError(payload) { + return `Error: ${payload.error.message}\n`; +} + +/** @param {*} payload */ +export function renderMaterialize(payload) { + if (payload.graphs.length === 0) { + return 'No graphs found in repo.\n'; + } + + const lines = []; + for (const entry of payload.graphs) { + if (entry.error) { + lines.push(`${entry.graph}: error — ${entry.error}`); + } else { + lines.push(`${entry.graph}: ${entry.nodes} nodes, ${entry.edges} edges, checkpoint ${entry.checkpoint}`); + } + } + return `${lines.join('\n')}\n`; +} + +/** @param {*} payload */ +export function renderInstallHooks(payload) { + if (payload.action === 'up-to-date') { + return `Hook: already up to date (v${payload.version}) at ${payload.hookPath}\n`; + } + if (payload.action === 'skipped') { + return 'Hook: installation skipped\n'; + } + const lines = [`Hook: ${payload.action} (v${payload.version})`, `Path: ${payload.hookPath}`]; + if (payload.backupPath) { + lines.push(`Backup: ${payload.backupPath}`); + } + return `${lines.join('\n')}\n`; +} + +// ── Seek helpers (extracted for ESLint 50-line limit) ──────────────────────── + +/** + * Formats a numeric delta as " (+N)" or " (-N)", or empty string for zero/non-finite. + * @param {*} n + * @returns {string} + */ +function formatDelta(n) { // TODO(ts-cleanup): type CLI payload + if (typeof n !== 'number' || !Number.isFinite(n) || n === 0) { + return ''; + } + const sign = n > 0 ? '+' : ''; + return ` (${sign}${n})`; +} + +/** + * Formats an operation summary object as a compact plain-text string. + * @param {*} summary + * @returns {string} + */ +function formatOpSummaryPlain(summary) { // TODO(ts-cleanup): type CLI payload + const order = [ + ['NodeAdd', '+', 'node'], + ['EdgeAdd', '+', 'edge'], + ['PropSet', '~', 'prop'], + ['NodeTombstone', '-', 'node'], + ['EdgeTombstone', '-', 'edge'], + ['BlobValue', '+', 'blob'], + ]; + + const parts = []; + for (const [opType, symbol, label] of order) { + const n = summary?.[opType]; + if (typeof n === 'number' && Number.isFinite(n) && n > 0) { + parts.push(`${symbol}${n}${label}`); + } + } + return parts.length > 0 ? parts.join(' ') : '(empty)'; +} + +/** + * Appends a per-writer tick receipt summary below a base line. + * @param {string} baseLine + * @param {*} payload + * @returns {string} + */ +function appendReceiptSummary(baseLine, payload) { + const tickReceipt = payload?.tickReceipt; + if (!tickReceipt || typeof tickReceipt !== 'object') { + return `${baseLine}\n`; + } + + const entries = Object.entries(tickReceipt) + .filter(([writerId, entry]) => writerId && entry && typeof entry === 'object') + .sort(([a], [b]) => a.localeCompare(b)); + + if (entries.length === 0) { + return `${baseLine}\n`; + } + + const maxWriterLen = Math.max(5, ...entries.map(([writerId]) => writerId.length)); + const receiptLines = [` Tick ${payload.tick}:`]; + for (const [writerId, entry] of entries) { + const sha = typeof entry.sha === 'string' ? entry.sha.slice(0, 7) : ''; + const opSummary = entry.opSummary && typeof entry.opSummary === 'object' ? entry.opSummary : entry; + receiptLines.push(` ${writerId.padEnd(maxWriterLen)} ${sha.padEnd(7)} ${formatOpSummaryPlain(opSummary)}`); + } + + return `${baseLine}\n${receiptLines.join('\n')}\n`; +} + +/** + * Builds human-readable state count strings from a seek payload. + * @param {*} payload + * @returns {{nodesStr: string, edgesStr: string, patchesStr: string}} + */ +function buildStateStrings(payload) { + const nodeLabel = payload.nodes === 1 ? 'node' : 'nodes'; + const edgeLabel = payload.edges === 1 ? 'edge' : 'edges'; + const patchLabel = payload.patchCount === 1 ? 'patch' : 'patches'; + return { + nodesStr: `${payload.nodes} ${nodeLabel}${formatDelta(payload.diff?.nodes)}`, + edgesStr: `${payload.edges} ${edgeLabel}${formatDelta(payload.diff?.edges)}`, + patchesStr: `${payload.patchCount} ${patchLabel}`, + }; +} + +/** + * Renders the "tick" / "latest" / "load" seek action with receipt + structural diff. + * @param {*} payload + * @param {string} headerLine + * @returns {string} + */ +function renderSeekWithDiff(payload, headerLine) { + const base = appendReceiptSummary(headerLine, payload); + return base + formatStructuralDiff(payload); +} + +// ── Seek simple-action renderers ───────────────────────────────────────────── + +/** + * Renders seek actions that don't involve state counts: clear-cache, list, drop, save. + * @param {*} payload + * @returns {string|null} Rendered string, or null if action is not simple + */ +function renderSeekSimple(payload) { + if (payload.action === 'clear-cache') { + return `${payload.message}\n`; + } + if (payload.action === 'drop') { + return `Dropped cursor "${payload.name}" (was at tick ${payload.tick}).\n`; + } + if (payload.action === 'save') { + return `Saved cursor "${payload.name}" at tick ${payload.tick}.\n`; + } + if (payload.action === 'list') { + return renderSeekList(payload); + } + return null; +} + +/** + * Renders the cursor list action. + * @param {*} payload + * @returns {string} + */ +function renderSeekList(payload) { + if (payload.cursors.length === 0) { + return 'No saved cursors.\n'; + } + const lines = []; + for (const c of payload.cursors) { + const active = c.tick === payload.activeTick ? ' (active)' : ''; + lines.push(` ${c.name}: tick ${c.tick}${active}`); + } + return `${lines.join('\n')}\n`; +} + +// ── Seek state-action renderer ─────────────────────────────────────────────── + +/** + * Renders seek actions that show state: latest, load, tick, status. + * @param {*} payload + * @returns {string} + */ +function renderSeekState(payload) { + if (payload.action === 'latest') { + const { nodesStr, edgesStr } = buildStateStrings(payload); + return renderSeekWithDiff( + payload, + `${payload.graph}: returned to present (tick ${payload.maxTick}, ${nodesStr}, ${edgesStr})`, + ); + } + if (payload.action === 'load') { + const { nodesStr, edgesStr } = buildStateStrings(payload); + return renderSeekWithDiff( + payload, + `${payload.graph}: loaded cursor "${payload.name}" at tick ${payload.tick} of ${payload.maxTick} (${nodesStr}, ${edgesStr})`, + ); + } + if (payload.action === 'tick') { + const { nodesStr, edgesStr, patchesStr } = buildStateStrings(payload); + return renderSeekWithDiff( + payload, + `${payload.graph}: tick ${payload.tick} of ${payload.maxTick} (${nodesStr}, ${edgesStr}, ${patchesStr})`, + ); + } + // status (structuralDiff is never populated here; no formatStructuralDiff call) + if (payload.cursor && payload.cursor.active) { + const { nodesStr, edgesStr, patchesStr } = buildStateStrings(payload); + return appendReceiptSummary( + `${payload.graph}: tick ${payload.tick} of ${payload.maxTick} (${nodesStr}, ${edgesStr}, ${patchesStr})`, + payload, + ); + } + return `${payload.graph}: no cursor active, ${payload.ticks.length} ticks available\n`; +} + +// ── Seek main renderer ────────────────────────────────────────────────────── + +/** @param {*} payload */ +export function renderSeek(payload) { + return renderSeekSimple(payload) ?? renderSeekState(payload); +} diff --git a/bin/warp-graph.js b/bin/warp-graph.js index 90c50f1..c05b6e3 100755 --- a/bin/warp-graph.js +++ b/bin/warp-graph.js @@ -25,17 +25,15 @@ import { } from '../src/domain/utils/RefLayout.js'; import CasSeekCacheAdapter from '../src/infrastructure/adapters/CasSeekCacheAdapter.js'; import { HookInstaller, classifyExistingHook } from '../src/domain/services/HookInstaller.js'; -import { renderInfoView } from '../src/visualization/renderers/ascii/info.js'; -import { renderCheckView } from '../src/visualization/renderers/ascii/check.js'; -import { renderHistoryView, summarizeOps } 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 { summarizeOps } from '../src/visualization/renderers/ascii/history.js'; import { parseCursorBlob } from '../src/domain/utils/parseCursorBlob.js'; import { diffStates } from '../src/domain/services/StateDiff.js'; -import { renderSeekView, formatStructuralDiff } from '../src/visualization/renderers/ascii/seek.js'; import { renderGraphView } from '../src/visualization/renderers/ascii/graph.js'; import { renderSvg } from '../src/visualization/renderers/svg/index.js'; import { layoutGraph, queryResultToGraphData, pathResultToGraphData } from '../src/visualization/layouts/index.js'; +import { present } from './presenters/index.js'; +import { stableStringify, compactStringify } from './presenters/json.js'; +import { renderError } from './presenters/text.js'; /** * @typedef {Object} Persistence @@ -93,6 +91,7 @@ import { layoutGraph, queryResultToGraphData, pathResultToGraphData } from '../s * @typedef {Object} CliOptions * @property {string} repo * @property {boolean} json + * @property {boolean} ndjson * @property {string|null} view * @property {string|null} graph * @property {string} writer @@ -142,7 +141,8 @@ Commands: Options: --repo Path to git repo (default: cwd) - --json Emit JSON output + --json Emit JSON output (pretty-printed, sorted keys) + --ndjson Emit compact single-line JSON (for piping/scripting) --view [mode] Visual output (ascii, browser, svg:FILE, html:FILE) --graph Graph name (required if repo has multiple graphs) --writer Writer id (default: cli) @@ -208,27 +208,6 @@ function notFoundError(message) { return new CliError(message, { code: 'E_NOT_FOUND', exitCode: EXIT_CODES.NOT_FOUND }); } -/** @param {*} value */ -function stableStringify(value) { - /** @param {*} input @returns {*} */ - const normalize = (input) => { - if (Array.isArray(input)) { - return input.map(normalize); - } - if (input && typeof input === 'object') { - /** @type {Record} */ - const sorted = {}; - for (const key of Object.keys(input).sort()) { - sorted[key] = normalize(input[key]); - } - return sorted; - } - return input; - }; - - return JSON.stringify(normalize(value), null, 2); -} - /** @param {string[]} argv */ function parseArgs(argv) { const options = createDefaultOptions(); @@ -256,6 +235,7 @@ function createDefaultOptions() { return { repo: process.cwd(), json: false, + ndjson: false, view: null, graph: null, writer: 'cli', @@ -284,6 +264,11 @@ function consumeBaseArg({ argv, index, options, optionDefs, positionals }) { return { consumed: 0 }; } + if (arg === '--ndjson') { + options.ndjson = true; + return { consumed: 0 }; + } + if (arg === '--view') { // Valid view modes: ascii, browser, svg:FILE, html:FILE // Don't consume known commands as modes @@ -793,299 +778,6 @@ function patchTouchesNode(patch, nodeId) { return false; } -/** @param {*} payload */ -function renderInfo(payload) { - const lines = [`Repo: ${payload.repo}`]; - lines.push(`Graphs: ${payload.graphs.length}`); - for (const graph of payload.graphs) { - const writers = graph.writers ? ` writers=${graph.writers.count}` : ''; - lines.push(`- ${graph.name}${writers}`); - if (graph.checkpoint?.sha) { - lines.push(` checkpoint: ${graph.checkpoint.sha}`); - } - if (graph.coverage?.sha) { - lines.push(` coverage: ${graph.coverage.sha}`); - } - if (graph.cursor?.active) { - lines.push(` cursor: tick ${graph.cursor.tick} (${graph.cursor.mode})`); - } - } - return `${lines.join('\n')}\n`; -} - -/** @param {*} payload */ -function renderQuery(payload) { - const lines = [ - `Graph: ${payload.graph}`, - `State: ${payload.stateHash}`, - `Nodes: ${payload.nodes.length}`, - ]; - - for (const node of payload.nodes) { - const id = node.id ?? '(unknown)'; - lines.push(`- ${id}`); - if (node.props && Object.keys(node.props).length > 0) { - lines.push(` props: ${JSON.stringify(node.props)}`); - } - } - - return `${lines.join('\n')}\n`; -} - -/** @param {*} payload */ -function renderPath(payload) { - const lines = [ - `Graph: ${payload.graph}`, - `From: ${payload.from}`, - `To: ${payload.to}`, - `Found: ${payload.found ? 'yes' : 'no'}`, - `Length: ${payload.length}`, - ]; - - if (payload.path && payload.path.length > 0) { - lines.push(`Path: ${payload.path.join(' -> ')}`); - } - - return `${lines.join('\n')}\n`; -} - -const ANSI_GREEN = '\x1b[32m'; -const ANSI_YELLOW = '\x1b[33m'; -const ANSI_RED = '\x1b[31m'; -const ANSI_DIM = '\x1b[2m'; -const ANSI_RESET = '\x1b[0m'; - -/** @param {string} state */ -function colorCachedState(state) { - if (state === 'fresh') { - return `${ANSI_GREEN}${state}${ANSI_RESET}`; - } - if (state === 'stale') { - return `${ANSI_YELLOW}${state}${ANSI_RESET}`; - } - return `${ANSI_RED}${ANSI_DIM}${state}${ANSI_RESET}`; -} - -/** @param {*} payload */ -function renderCheck(payload) { - const lines = [ - `Graph: ${payload.graph}`, - `Health: ${payload.health.status}`, - ]; - - if (payload.status) { - lines.push(`Cached State: ${colorCachedState(payload.status.cachedState)}`); - lines.push(`Patches Since Checkpoint: ${payload.status.patchesSinceCheckpoint}`); - lines.push(`Tombstone Ratio: ${payload.status.tombstoneRatio.toFixed(2)}`); - lines.push(`Writers: ${payload.status.writers}`); - } - - if (payload.checkpoint?.sha) { - lines.push(`Checkpoint: ${payload.checkpoint.sha}`); - if (payload.checkpoint.ageSeconds !== null) { - lines.push(`Checkpoint Age: ${payload.checkpoint.ageSeconds}s`); - } - } else { - lines.push('Checkpoint: none'); - } - - if (!payload.status) { - lines.push(`Writers: ${payload.writers.count}`); - } - for (const head of payload.writers.heads) { - lines.push(`- ${head.writerId}: ${head.sha}`); - } - - if (payload.coverage?.sha) { - lines.push(`Coverage: ${payload.coverage.sha}`); - lines.push(`Coverage Missing: ${payload.coverage.missingWriters.length}`); - } else { - lines.push('Coverage: none'); - } - - if (payload.gc) { - lines.push(`Tombstones: ${payload.gc.totalTombstones}`); - if (!payload.status) { - lines.push(`Tombstone Ratio: ${payload.gc.tombstoneRatio}`); - } - } - - if (payload.hook) { - lines.push(formatHookStatusLine(payload.hook)); - } - - return `${lines.join('\n')}\n`; -} - -/** @param {*} hook */ -function formatHookStatusLine(hook) { - if (!hook.installed && hook.foreign) { - return "Hook: foreign hook present — run 'git warp install-hooks'"; - } - if (!hook.installed) { - return "Hook: not installed — run 'git warp install-hooks'"; - } - if (hook.current) { - return `Hook: installed (v${hook.version}) — up to date`; - } - return `Hook: installed (v${hook.version}) — upgrade available, run 'git warp install-hooks'`; -} - -/** @param {*} payload */ -function renderHistory(payload) { - const lines = [ - `Graph: ${payload.graph}`, - `Writer: ${payload.writer}`, - `Entries: ${payload.entries.length}`, - ]; - - if (payload.nodeFilter) { - lines.push(`Node Filter: ${payload.nodeFilter}`); - } - - for (const entry of payload.entries) { - lines.push(`- ${entry.sha} (lamport: ${entry.lamport}, ops: ${entry.opCount})`); - } - - return `${lines.join('\n')}\n`; -} - -/** @param {*} payload */ -function renderError(payload) { - return `Error: ${payload.error.message}\n`; -} - -/** - * Wraps SVG content in a minimal HTML document and writes it to disk. - * @param {string} filePath - Destination file path - * @param {string} svgContent - SVG markup to embed - */ -function writeHtmlExport(filePath, svgContent) { - const html = `\ngit-warp\n${svgContent}\n`; - fs.writeFileSync(filePath, html); -} - -/** - * Writes a command result to stdout/stderr in the appropriate format. - * Dispatches to JSON, SVG file, HTML file, ASCII view, or plain text - * based on the combination of flags. - * @param {*} payload - Command result payload - * @param {{json: boolean, command: string, view: string|null}} options - */ -function emit(payload, { json, command, view }) { - if (json) { - process.stdout.write(`${stableStringify(payload)}\n`); - return; - } - - if (command === 'info') { - if (view) { - process.stdout.write(renderInfoView(payload)); - } else { - process.stdout.write(renderInfo(payload)); - } - return; - } - - if (command === 'query') { - if (view && 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`); - } - } else if (view && 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`); - } - } else if (view) { - process.stdout.write(`${payload._renderedAscii}\n`); - } else { - process.stdout.write(renderQuery(payload)); - } - return; - } - - if (command === 'path') { - if (view && typeof view === 'string' && view.startsWith('svg:')) { - const svgPath = view.slice(4); - if (!payload._renderedSvg) { - process.stderr.write('No path found — skipping SVG export.\n'); - } else { - fs.writeFileSync(svgPath, payload._renderedSvg); - process.stderr.write(`SVG written to ${svgPath}\n`); - } - } else if (view && typeof view === 'string' && view.startsWith('html:')) { - const htmlPath = view.slice(5); - if (!payload._renderedSvg) { - process.stderr.write('No path found — skipping HTML export.\n'); - } else { - writeHtmlExport(htmlPath, payload._renderedSvg); - process.stderr.write(`HTML written to ${htmlPath}\n`); - } - } else if (view) { - process.stdout.write(renderPathView(payload)); - } else { - process.stdout.write(renderPath(payload)); - } - return; - } - - if (command === 'check') { - if (view) { - process.stdout.write(renderCheckView(payload)); - } else { - process.stdout.write(renderCheck(payload)); - } - return; - } - - if (command === 'history') { - if (view) { - process.stdout.write(renderHistoryView(payload)); - } else { - process.stdout.write(renderHistory(payload)); - } - return; - } - - if (command === 'materialize') { - if (view) { - process.stdout.write(renderMaterializeView(payload)); - } else { - process.stdout.write(renderMaterialize(payload)); - } - return; - } - - if (command === 'seek') { - if (view) { - process.stdout.write(renderSeekView(payload)); - } else { - process.stdout.write(renderSeek(payload)); - } - return; - } - - if (command === 'install-hooks') { - process.stdout.write(renderInstallHooks(payload)); - return; - } - - if (payload?.error) { - process.stderr.write(renderError(payload)); - return; - } - - process.stdout.write(`${stableStringify(payload)}\n`); -} - /** * Handles the `info` command: summarizes graphs in the repository. * @param {{options: CliOptions}} params @@ -1589,38 +1281,6 @@ async function handleMaterialize({ options }) { }; } -/** @param {*} payload */ -function renderMaterialize(payload) { - if (payload.graphs.length === 0) { - return 'No graphs found in repo.\n'; - } - - const lines = []; - for (const entry of payload.graphs) { - if (entry.error) { - lines.push(`${entry.graph}: error — ${entry.error}`); - } else { - lines.push(`${entry.graph}: ${entry.nodes} nodes, ${entry.edges} edges, checkpoint ${entry.checkpoint}`); - } - } - return `${lines.join('\n')}\n`; -} - -/** @param {*} payload */ -function renderInstallHooks(payload) { - if (payload.action === 'up-to-date') { - return `Hook: already up to date (v${payload.version}) at ${payload.hookPath}\n`; - } - if (payload.action === 'skipped') { - return 'Hook: installation skipped\n'; - } - const lines = [`Hook: ${payload.action} (v${payload.version})`, `Path: ${payload.hookPath}`]; - if (payload.backupPath) { - lines.push(`Backup: ${payload.backupPath}`); - } - return `${lines.join('\n')}\n`; -} - function createHookInstaller() { const __filename = new URL(import.meta.url).pathname; const __dirname = path.dirname(__filename); @@ -2601,138 +2261,6 @@ function applyDiffLimit(diff, diffBaseline, baselineTick, diffLimit) { return { structuralDiff: capped, diffBaseline, baselineTick, truncated: true, totalChanges, shownChanges }; } -/** - * Renders a seek command payload as a human-readable string for terminal output. - * - * Handles all seek actions: list, drop, save, latest, load, tick, and status. - * - * @param {*} payload - Seek result payload from handleSeek - * @returns {string} Formatted output string (includes trailing newline) - */ -function renderSeek(payload) { - const formatDelta = (/** @type {*} */ n) => { // TODO(ts-cleanup): type CLI payload - if (typeof n !== 'number' || !Number.isFinite(n) || n === 0) { - return ''; - } - const sign = n > 0 ? '+' : ''; - return ` (${sign}${n})`; - }; - - const formatOpSummaryPlain = (/** @type {*} */ summary) => { // TODO(ts-cleanup): type CLI payload - const order = [ - ['NodeAdd', '+', 'node'], - ['EdgeAdd', '+', 'edge'], - ['PropSet', '~', 'prop'], - ['NodeTombstone', '-', 'node'], - ['EdgeTombstone', '-', 'edge'], - ['BlobValue', '+', 'blob'], - ]; - - const parts = []; - for (const [opType, symbol, label] of order) { - const n = summary?.[opType]; - if (typeof n === 'number' && Number.isFinite(n) && n > 0) { - parts.push(`${symbol}${n}${label}`); - } - } - return parts.length > 0 ? parts.join(' ') : '(empty)'; - }; - - const appendReceiptSummary = (/** @type {string} */ baseLine) => { - const tickReceipt = payload?.tickReceipt; - if (!tickReceipt || typeof tickReceipt !== 'object') { - return `${baseLine}\n`; - } - - const entries = Object.entries(tickReceipt) - .filter(([writerId, entry]) => writerId && entry && typeof entry === 'object') - .sort(([a], [b]) => a.localeCompare(b)); - - if (entries.length === 0) { - return `${baseLine}\n`; - } - - const maxWriterLen = Math.max(5, ...entries.map(([writerId]) => writerId.length)); - const receiptLines = [` Tick ${payload.tick}:`]; - for (const [writerId, entry] of entries) { - const sha = typeof entry.sha === 'string' ? entry.sha.slice(0, 7) : ''; - const opSummary = entry.opSummary && typeof entry.opSummary === 'object' ? entry.opSummary : entry; - receiptLines.push(` ${writerId.padEnd(maxWriterLen)} ${sha.padEnd(7)} ${formatOpSummaryPlain(opSummary)}`); - } - - return `${baseLine}\n${receiptLines.join('\n')}\n`; - }; - - const buildStateStrings = () => { - const nodeLabel = payload.nodes === 1 ? 'node' : 'nodes'; - const edgeLabel = payload.edges === 1 ? 'edge' : 'edges'; - const patchLabel = payload.patchCount === 1 ? 'patch' : 'patches'; - return { - nodesStr: `${payload.nodes} ${nodeLabel}${formatDelta(payload.diff?.nodes)}`, - edgesStr: `${payload.edges} ${edgeLabel}${formatDelta(payload.diff?.edges)}`, - patchesStr: `${payload.patchCount} ${patchLabel}`, - }; - }; - - if (payload.action === 'clear-cache') { - return `${payload.message}\n`; - } - - if (payload.action === 'list') { - if (payload.cursors.length === 0) { - return 'No saved cursors.\n'; - } - const lines = []; - for (const c of payload.cursors) { - const active = c.tick === payload.activeTick ? ' (active)' : ''; - lines.push(` ${c.name}: tick ${c.tick}${active}`); - } - return `${lines.join('\n')}\n`; - } - - if (payload.action === 'drop') { - return `Dropped cursor "${payload.name}" (was at tick ${payload.tick}).\n`; - } - - if (payload.action === 'save') { - return `Saved cursor "${payload.name}" at tick ${payload.tick}.\n`; - } - - if (payload.action === 'latest') { - const { nodesStr, edgesStr } = buildStateStrings(); - const base = appendReceiptSummary( - `${payload.graph}: returned to present (tick ${payload.maxTick}, ${nodesStr}, ${edgesStr})`, - ); - return base + formatStructuralDiff(payload); - } - - if (payload.action === 'load') { - const { nodesStr, edgesStr } = buildStateStrings(); - const base = appendReceiptSummary( - `${payload.graph}: loaded cursor "${payload.name}" at tick ${payload.tick} of ${payload.maxTick} (${nodesStr}, ${edgesStr})`, - ); - return base + formatStructuralDiff(payload); - } - - if (payload.action === 'tick') { - const { nodesStr, edgesStr, patchesStr } = buildStateStrings(); - const base = appendReceiptSummary( - `${payload.graph}: tick ${payload.tick} of ${payload.maxTick} (${nodesStr}, ${edgesStr}, ${patchesStr})`, - ); - return base + formatStructuralDiff(payload); - } - - // status (structuralDiff is never populated here; no formatStructuralDiff call) - if (payload.cursor && payload.cursor.active) { - const { nodesStr, edgesStr, patchesStr } = buildStateStrings(); - return appendReceiptSummary( - `${payload.graph}: tick ${payload.tick} of ${payload.maxTick} (${nodesStr}, ${edgesStr}, ${patchesStr})`, - ); - } - - return `${payload.graph}: no cursor active, ${payload.ticks.length} ticks available\n`; -} - /** * Reads the active cursor and sets `_seekCeiling` on the graph instance * so that subsequent materialize calls respect the time-travel boundary. @@ -2837,6 +2365,12 @@ async function main() { if (options.json && options.view) { throw usageError('--json and --view are mutually exclusive'); } + if (options.ndjson && options.view) { + throw usageError('--ndjson and --view are mutually exclusive'); + } + if (options.json && options.ndjson) { + throw usageError('--json and --ndjson are mutually exclusive'); + } const command = positionals[0]; if (!command) { @@ -2867,7 +2401,8 @@ async function main() { : { payload: result, exitCode: EXIT_CODES.OK }; if (normalized.payload !== undefined) { - emit(normalized.payload, { json: options.json, command, view: options.view }); + const format = options.ndjson ? 'ndjson' : options.json ? 'json' : 'text'; + present(normalized.payload, { format, command, view: options.view }); } // Use process.exit() to avoid waiting for fire-and-forget I/O (e.g. seek cache writes). process.exit(normalized.exitCode ?? EXIT_CODES.OK); @@ -2884,8 +2419,9 @@ main().catch((error) => { payload.error.cause = error.cause instanceof Error ? error.cause.message : error.cause; } - if (process.argv.includes('--json')) { - process.stdout.write(`${stableStringify(payload)}\n`); + if (process.argv.includes('--json') || process.argv.includes('--ndjson')) { + const stringify = process.argv.includes('--ndjson') ? compactStringify : stableStringify; + process.stdout.write(`${stringify(payload)}\n`); } else { process.stderr.write(renderError(payload)); } diff --git a/docs/GUIDE.md b/docs/GUIDE.md index 837fcef..d6190e4 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -897,7 +897,7 @@ git warp seek --latest # Return to present git warp install-hooks # Install post-merge hook ``` -All commands accept `--repo `, `--graph `, `--json`. +All commands accept `--repo `, `--graph `, `--json`, `--ndjson`. Visual ASCII output is available with `--view`: @@ -907,7 +907,18 @@ git warp --view check # Health status visualization git warp --view seek # Seek dashboard with timeline ``` -`--view` is mutually exclusive with `--json`. +#### Output formats + +| Flag | Description | +|------|-------------| +| *(none)* | Human-readable plain text (default) | +| `--json` | Pretty-printed JSON with sorted keys (2-space indent) | +| `--ndjson` | Compact single-line JSON (for piping/scripting) | +| `--view` | ASCII visualization | + +`--json`, `--ndjson`, and `--view` are mutually exclusive. + +Plain-text output respects `NO_COLOR`, `FORCE_COLOR`, and `CI` environment variables. When stdout is not a TTY (e.g., piped), ANSI color codes are automatically stripped. ### Time Travel (`seek`) @@ -1035,7 +1046,7 @@ The `--view` flag enables visual ASCII dashboards for supported commands. Add `- **Notes:** - `--view` must appear before the subcommand (e.g., `git warp --view info`, not `git warp info --view`) -- `--view` and `--json` are mutually exclusive +- `--view`, `--json`, and `--ndjson` are mutually exclusive - All visualizations are color-coded and terminal-width aware --- diff --git a/jsr.json b/jsr.json index cfa93d7..b54773f 100644 --- a/jsr.json +++ b/jsr.json @@ -1,6 +1,6 @@ { "name": "@git-stunts/git-warp", - "version": "10.7.0", + "version": "10.8.0", "exports": { ".": "./index.js", "./node": "./src/domain/entities/GraphNode.js", diff --git a/package.json b/package.json index 43af3f2..4b67010 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@git-stunts/git-warp", - "version": "10.7.0", + "version": "10.8.0", "description": "Deterministic WARP graph over Git: graph-native storage, traversal, and tooling.", "type": "module", "license": "Apache-2.0", @@ -42,6 +42,7 @@ }, "files": [ "bin/warp-graph.js", + "bin/presenters", "bin/git-warp", "src", "index.js", diff --git a/test/bats/cli-ndjson.bats b/test/bats/cli-ndjson.bats new file mode 100644 index 0000000..06bc1dd --- /dev/null +++ b/test/bats/cli-ndjson.bats @@ -0,0 +1,68 @@ +#!/usr/bin/env bats + +load helpers/setup.bash + +setup() { + setup_test_repo + seed_graph "seed-graph.js" +} + +teardown() { + teardown_test_repo +} + +@test "--ndjson query produces single valid JSON line" { + run git warp --repo "${TEST_REPO}" --graph demo --ndjson query --match "*" + assert_success + # Must be exactly one line + local line_count + line_count=$(echo "$output" | wc -l | tr -d ' ') + [ "$line_count" -eq 1 ] + # Must parse as JSON + echo "$output" | node -e "JSON.parse(require('fs').readFileSync(0,'utf8'))" +} + +@test "--ndjson materialize produces single valid JSON line" { + run git warp --repo "${TEST_REPO}" --ndjson materialize + assert_success + local line_count + line_count=$(echo "$output" | wc -l | tr -d ' ') + [ "$line_count" -eq 1 ] + echo "$output" | node -e "JSON.parse(require('fs').readFileSync(0,'utf8'))" +} + +@test "--ndjson output has no _-prefixed keys" { + run git warp --repo "${TEST_REPO}" --graph demo --ndjson query --match "*" + assert_success + # Check no underscore-prefixed keys in output + echo "$output" | node -e " + const obj = JSON.parse(require('fs').readFileSync(0,'utf8')); + const bad = Object.keys(obj).filter(k => k.startsWith('_')); + if (bad.length) { console.error('Found _-prefixed keys:', bad); process.exit(1); } + " +} + +@test "--ndjson + --json is rejected" { + run git warp --repo "${TEST_REPO}" --ndjson --json info + assert_failure + echo "$output" | grep -q "mutually exclusive" +} + +@test "--ndjson + --view is rejected" { + run git warp --repo "${TEST_REPO}" --graph demo --ndjson --view query --match "*" + assert_failure + echo "$output" | grep -q "mutually exclusive" +} + +@test "error with --ndjson produces single-line JSON" { + run git warp --repo /nonexistent/path --ndjson info + assert_failure + # stdout should be single-line JSON with error key + local line_count + line_count=$(echo "$output" | wc -l | tr -d ' ') + [ "$line_count" -eq 1 ] + echo "$output" | node -e " + const obj = JSON.parse(require('fs').readFileSync(0,'utf8')); + if (!obj.error) { console.error('Missing error key'); process.exit(1); } + " +} diff --git a/test/unit/presenters/json.test.js b/test/unit/presenters/json.test.js new file mode 100644 index 0000000..b0a1298 --- /dev/null +++ b/test/unit/presenters/json.test.js @@ -0,0 +1,90 @@ +import { describe, it, expect } from 'vitest'; +import { stableStringify, compactStringify, sanitizePayload } from '../../../bin/presenters/json.js'; + +describe('stableStringify', () => { + it('sorts top-level keys', () => { + const result = stableStringify({ z: 1, a: 2 }); + expect(result).toBe('{\n "a": 2,\n "z": 1\n}'); + }); + + it('sorts nested object keys', () => { + const result = stableStringify({ b: { z: 1, a: 2 }, a: 3 }); + const parsed = JSON.parse(result); + expect(Object.keys(parsed)).toEqual(['a', 'b']); + expect(Object.keys(parsed.b)).toEqual(['a', 'z']); + }); + + it('preserves array order', () => { + const result = stableStringify({ arr: [3, 1, 2] }); + expect(JSON.parse(result).arr).toEqual([3, 1, 2]); + }); + + it('uses 2-space indent', () => { + const result = stableStringify({ a: 1 }); + expect(result).toContain(' "a"'); + }); + + it('handles null', () => { + expect(stableStringify(null)).toBe('null'); + }); + + it('handles primitives', () => { + expect(stableStringify(42)).toBe('42'); + expect(stableStringify('hello')).toBe('"hello"'); + expect(stableStringify(true)).toBe('true'); + }); + + it('normalizes nested arrays of objects', () => { + const result = stableStringify([{ b: 1, a: 2 }]); + const parsed = JSON.parse(result); + expect(Object.keys(parsed[0])).toEqual(['a', 'b']); + }); +}); + +describe('compactStringify', () => { + it('produces single-line output', () => { + const result = compactStringify({ a: 1, b: { c: 2 } }); + expect(result).not.toContain('\n'); + }); + + it('sorts keys', () => { + const result = compactStringify({ z: 1, a: 2 }); + expect(result).toBe('{"a":2,"z":1}'); + }); + + it('sorts nested keys', () => { + const result = compactStringify({ b: { z: 1, a: 2 } }); + expect(result).toBe('{"b":{"a":2,"z":1}}'); + }); +}); + +describe('sanitizePayload', () => { + it('strips _-prefixed keys', () => { + const result = sanitizePayload({ graph: 'test', _renderedSvg: '', _renderedAscii: 'ascii' }); + expect(result).toEqual({ graph: 'test' }); + }); + + it('preserves all public keys', () => { + const input = { graph: 'g', nodes: 3, structuralDiff: {} }; + expect(sanitizePayload(input)).toEqual(input); + }); + + it('returns null/undefined/primitives unchanged', () => { + expect(sanitizePayload(null)).toBe(null); + expect(sanitizePayload(undefined)).toBe(undefined); + expect(sanitizePayload(42)).toBe(42); + expect(sanitizePayload('hello')).toBe('hello'); + }); + + it('returns arrays unchanged', () => { + const arr = [1, 2, 3]; + expect(sanitizePayload(arr)).toBe(arr); + }); + + it('shallow clones (does not mutate original)', () => { + const original = { a: 1, _private: 2 }; + const result = sanitizePayload(original); + expect(original._private).toBe(2); + expect(result).toEqual({ a: 1 }); + }); +}); diff --git a/test/unit/presenters/present.test.js b/test/unit/presenters/present.test.js new file mode 100644 index 0000000..e7d0d29 --- /dev/null +++ b/test/unit/presenters/present.test.js @@ -0,0 +1,151 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { present, shouldStripColor } from '../../../bin/presenters/index.js'; + +describe('present', () => { + /** @type {string[]} */ + let stdoutChunks; + /** @type {string[]} */ + let stderrChunks; + /** @type {typeof process.stdout.write} */ + let originalWrite; + /** @type {typeof process.stderr.write} */ + let originalErrWrite; + + beforeEach(() => { + stdoutChunks = []; + stderrChunks = []; + originalWrite = process.stdout.write; + originalErrWrite = process.stderr.write; + process.stdout.write = /** @type {*} */ ((/** @type {string} */ chunk) => { stdoutChunks.push(chunk); return true; }); + process.stderr.write = /** @type {*} */ ((/** @type {string} */ chunk) => { stderrChunks.push(chunk); return true; }); + }); + + afterEach(() => { + process.stdout.write = originalWrite; + process.stderr.write = originalErrWrite; + }); + + it('outputs JSON with sorted keys and 2-space indent', () => { + present({ z: 1, a: 2 }, { format: 'json', command: 'info', view: null }); + const output = stdoutChunks.join(''); + expect(output).toContain('"a": 2'); + expect(output).toContain('"z": 1'); + expect(output.endsWith('\n')).toBe(true); + }); + + it('outputs NDJSON as single compact line', () => { + present({ z: 1, a: 2 }, { format: 'ndjson', command: 'info', view: null }); + const output = stdoutChunks.join(''); + expect(output).toBe('{"a":2,"z":1}\n'); + }); + + it('strips _-prefixed keys from JSON output', () => { + present({ graph: 'g', _renderedSvg: '' }, { format: 'json', command: 'query', view: null }); + const parsed = JSON.parse(stdoutChunks.join('')); + expect(parsed).not.toHaveProperty('_renderedSvg'); + expect(parsed).toHaveProperty('graph', 'g'); + }); + + it('strips _-prefixed keys from NDJSON output', () => { + present({ graph: 'g', _renderedAscii: 'art' }, { format: 'ndjson', command: 'query', view: null }); + const parsed = JSON.parse(stdoutChunks.join('')); + expect(parsed).not.toHaveProperty('_renderedAscii'); + }); + + it('renders plain text for info command', () => { + const payload = { repo: '/repo', graphs: [{ name: 'g' }] }; + present(payload, { format: 'text', command: 'info', view: null }); + const output = stdoutChunks.join(''); + expect(output).toContain('Repo: /repo'); + }); + + it('renders error payloads to stderr', () => { + present({ error: { message: 'boom' } }, { format: 'text', command: 'info', view: null }); + expect(stdoutChunks).toHaveLength(0); + expect(stderrChunks.join('')).toContain('Error: boom'); + }); + + it('renders error payloads to stderr even in json mode', () => { + present({ error: { message: 'fail' } }, { format: 'json', command: 'info', view: null }); + expect(stdoutChunks).toHaveLength(0); + expect(stderrChunks.join('')).toContain('Error: fail'); + }); + + it('view query with missing _renderedAscii does not crash', () => { + present({ graph: 'g', nodes: [] }, { format: 'text', command: 'query', view: 'ascii' }); + const output = stdoutChunks.join(''); + expect(output).not.toContain('undefined'); + }); + + it('falls back to JSON for unknown text commands', () => { + present({ custom: 'data' }, { format: 'text', command: 'unknown-cmd', view: null }); + const output = stdoutChunks.join(''); + expect(JSON.parse(output)).toEqual({ custom: 'data' }); + }); +}); + +describe('shouldStripColor', () => { + /** @type {Record} */ + const envBackup = {}; + + beforeEach(() => { + for (const key of ['FORCE_COLOR', 'NO_COLOR', 'CI']) { + envBackup[key] = process.env[key]; + delete process.env[key]; + } + }); + + afterEach(() => { + for (const key of ['FORCE_COLOR', 'NO_COLOR', 'CI']) { + if (envBackup[key] === undefined) { + delete process.env[key]; + } else { + process.env[key] = envBackup[key]; + } + } + }); + + it('strips when FORCE_COLOR=0', () => { + process.env.FORCE_COLOR = '0'; + expect(shouldStripColor()).toBe(true); + }); + + it('keeps color when FORCE_COLOR=1', () => { + process.env.FORCE_COLOR = '1'; + expect(shouldStripColor()).toBe(false); + }); + + it('FORCE_COLOR overrides NO_COLOR', () => { + process.env.FORCE_COLOR = '1'; + process.env.NO_COLOR = ''; + expect(shouldStripColor()).toBe(false); + }); + + it('strips when NO_COLOR is set', () => { + process.env.NO_COLOR = ''; + expect(shouldStripColor()).toBe(true); + }); + + it('strips when CI is set (with TTY)', () => { + Object.defineProperty(process.stdout, 'isTTY', { value: true, configurable: true }); + process.env.CI = 'true'; + expect(shouldStripColor()).toBe(true); + Object.defineProperty(process.stdout, 'isTTY', { value: undefined, configurable: true }); + }); + + it('strips when stdout is not a TTY', () => { + Object.defineProperty(process.stdout, 'isTTY', { value: undefined, configurable: true }); + expect(shouldStripColor()).toBe(true); + }); +}); + +describe('package.json files array', () => { + it('includes bin/presenters so npm publish works', () => { + const __dirname = dirname(fileURLToPath(import.meta.url)); + const pkg = JSON.parse(readFileSync(resolve(__dirname, '../../../package.json'), 'utf8')); + expect(pkg.files).toContain('bin/presenters'); + }); +}); diff --git a/test/unit/presenters/text.test.js b/test/unit/presenters/text.test.js new file mode 100644 index 0000000..c742911 --- /dev/null +++ b/test/unit/presenters/text.test.js @@ -0,0 +1,268 @@ +import { describe, it, expect } from 'vitest'; +import { stripAnsi } from '../../../src/visualization/utils/ansi.js'; +import { + renderInfo, + renderQuery, + renderPath, + renderCheck, + renderHistory, + renderError, + renderMaterialize, + renderInstallHooks, + renderSeek, +} from '../../../bin/presenters/text.js'; + +describe('renderInfo', () => { + it('renders repo and graphs', () => { + const payload = { + repo: '/tmp/test', + graphs: [ + { name: 'default', writers: { count: 2 }, checkpoint: { sha: 'abc123' }, coverage: null, cursor: null }, + ], + }; + const out = stripAnsi(renderInfo(payload)); + expect(out).toContain('Repo: /tmp/test'); + expect(out).toContain('Graphs: 1'); + expect(out).toContain('- default writers=2'); + expect(out).toContain('checkpoint: abc123'); + }); + + it('renders cursor info when active', () => { + const payload = { + repo: '/tmp/test', + graphs: [ + { name: 'g', cursor: { active: true, tick: 5, mode: 'tick' } }, + ], + }; + const out = renderInfo(payload); + expect(out).toContain('cursor: tick 5 (tick)'); + }); +}); + +describe('renderQuery', () => { + it('renders graph, state, and nodes', () => { + const payload = { + graph: 'default', + stateHash: 'abc', + nodes: [ + { id: 'user:alice', props: { name: 'Alice' } }, + { id: 'user:bob', props: {} }, + ], + }; + const out = renderQuery(payload); + expect(out).toContain('Graph: default'); + expect(out).toContain('Nodes: 2'); + expect(out).toContain('- user:alice'); + expect(out).toContain('props: {"name":"Alice"}'); + expect(out).not.toContain('props: {}'); + }); +}); + +describe('renderPath', () => { + it('renders path details', () => { + const payload = { graph: 'g', from: 'a', to: 'b', found: true, length: 2, path: ['a', 'x', 'b'] }; + const out = renderPath(payload); + expect(out).toContain('Found: yes'); + expect(out).toContain('Path: a -> x -> b'); + }); + + it('omits path when not found', () => { + const payload = { graph: 'g', from: 'a', to: 'b', found: false, length: 0, path: [] }; + const out = renderPath(payload); + expect(out).toContain('Found: no'); + expect(out).not.toContain('Path:'); + }); +}); + +describe('renderCheck', () => { + it('renders health and writers', () => { + const payload = { + graph: 'g', + health: { status: 'ok' }, + status: null, + checkpoint: { sha: 'ckpt', ageSeconds: 120 }, + writers: { count: 1, heads: [{ writerId: 'alice', sha: 'abc' }] }, + coverage: null, + gc: null, + hook: null, + }; + const out = stripAnsi(renderCheck(payload)); + expect(out).toContain('Health: ok'); + expect(out).toContain('Checkpoint: ckpt'); + expect(out).toContain('- alice: abc'); + }); + + it('renders status block when present', () => { + const payload = { + graph: 'g', + health: { status: 'ok' }, + status: { cachedState: 'fresh', patchesSinceCheckpoint: 3, tombstoneRatio: 0.1, writers: 2 }, + checkpoint: null, + writers: { count: 2, heads: [] }, + coverage: null, + gc: null, + hook: null, + }; + const out = stripAnsi(renderCheck(payload)); + expect(out).toContain('Cached State: fresh'); + expect(out).toContain('Tombstone Ratio: 0.10'); + }); + + it('renders hook status', () => { + const payload = { + graph: 'g', + health: { status: 'ok' }, + status: null, + checkpoint: null, + writers: { count: 0, heads: [] }, + coverage: null, + gc: null, + hook: { installed: true, current: true, version: '1.0.0' }, + }; + const out = stripAnsi(renderCheck(payload)); + expect(out).toContain('Hook: installed (v1.0.0) — up to date'); + }); +}); + +describe('renderHistory', () => { + it('renders entries', () => { + const payload = { + graph: 'g', + writer: 'alice', + entries: [{ sha: 'abc123', lamport: 1, opCount: 3 }], + nodeFilter: null, + }; + const out = renderHistory(payload); + expect(out).toContain('Writer: alice'); + expect(out).toContain('Entries: 1'); + expect(out).toContain('abc123 (lamport: 1, ops: 3)'); + }); + + it('shows node filter when present', () => { + const payload = { + graph: 'g', + writer: 'alice', + entries: [], + nodeFilter: 'user:*', + }; + const out = renderHistory(payload); + expect(out).toContain('Node Filter: user:*'); + }); +}); + +describe('renderError', () => { + it('formats error message', () => { + expect(renderError({ error: { message: 'boom' } })).toBe('Error: boom\n'); + }); +}); + +describe('renderMaterialize', () => { + it('renders empty repo', () => { + expect(renderMaterialize({ graphs: [] })).toBe('No graphs found in repo.\n'); + }); + + it('renders graph entries', () => { + const payload = { + graphs: [ + { graph: 'g1', nodes: 5, edges: 3, checkpoint: 'abc' }, + { graph: 'g2', error: 'broken' }, + ], + }; + const out = renderMaterialize(payload); + expect(out).toContain('g1: 5 nodes, 3 edges, checkpoint abc'); + expect(out).toContain('g2: error — broken'); + }); +}); + +describe('renderInstallHooks', () => { + it('renders up-to-date', () => { + const out = renderInstallHooks({ action: 'up-to-date', version: '1.0', hookPath: '/hooks/post-commit' }); + expect(out).toContain('already up to date'); + expect(out).toContain('v1.0'); + }); + + it('renders skipped', () => { + expect(renderInstallHooks({ action: 'skipped' })).toContain('skipped'); + }); + + it('renders install with backup', () => { + const out = renderInstallHooks({ action: 'installed', version: '2.0', hookPath: '/hooks/post-commit', backupPath: '/hooks/post-commit.bak' }); + expect(out).toContain('installed (v2.0)'); + expect(out).toContain('Backup: /hooks/post-commit.bak'); + }); +}); + +describe('renderSeek', () => { + it('renders clear-cache', () => { + expect(renderSeek({ action: 'clear-cache', message: 'Cache cleared' })).toBe('Cache cleared\n'); + }); + + it('renders empty list', () => { + expect(renderSeek({ action: 'list', cursors: [] })).toBe('No saved cursors.\n'); + }); + + it('renders cursor list', () => { + const out = renderSeek({ + action: 'list', + activeTick: 3, + cursors: [ + { name: 'snap', tick: 3 }, + { name: 'other', tick: 5 }, + ], + }); + expect(out).toContain('snap: tick 3 (active)'); + expect(out).toContain('other: tick 5'); + expect(out).not.toContain('other: tick 5 (active)'); + }); + + it('renders drop', () => { + const out = renderSeek({ action: 'drop', name: 'snap', tick: 3 }); + expect(out).toContain('Dropped cursor "snap" (was at tick 3)'); + }); + + it('renders save', () => { + const out = renderSeek({ action: 'save', name: 'snap', tick: 3 }); + expect(out).toContain('Saved cursor "snap" at tick 3'); + }); + + it('renders tick with state counts', () => { + const out = renderSeek({ + action: 'tick', + graph: 'g', + tick: 2, + maxTick: 5, + nodes: 3, + edges: 1, + patchCount: 2, + diff: { nodes: 1, edges: 0 }, + }); + expect(out).toContain('g: tick 2 of 5'); + expect(out).toContain('3 nodes (+1)'); + expect(out).toContain('1 edge'); + expect(out).toContain('2 patches'); + }); + + it('renders latest', () => { + const out = renderSeek({ + action: 'latest', + graph: 'g', + maxTick: 5, + nodes: 10, + edges: 3, + diff: null, + }); + expect(out).toContain('returned to present'); + expect(out).toContain('tick 5'); + }); + + it('renders status with no active cursor', () => { + const out = renderSeek({ + action: 'status', + graph: 'g', + cursor: { active: false }, + ticks: [1, 2, 3], + }); + expect(out).toContain('no cursor active'); + expect(out).toContain('3 ticks available'); + }); +});