From 3c743b7d0e14c056bf2ed39a3db052acd546e782 Mon Sep 17 00:00:00 2001 From: kaznak Date: Fri, 26 Jun 2026 13:47:50 +0900 Subject: [PATCH] CLI: surface result.debug via --json field or stderr log The Defuddle library populates `result.debug = { contentSelector, removals: DebugRemoval[] }` when `debug: true`, but the CLI never exposed it: `result.debug` was omitted from the `--json` payload's field list, and nothing was written to stderr either, so `--debug` was effectively a no-op for CLI users. This change routes the debug payload through the CLI: - With `--json --debug`: the JSON object gains a `debug` field alongside the existing fields. The shape matches `DebugInfo` from `src/types.ts` so machine consumers can parse it directly. - With `--debug` and no `--json`: `parseSource` returns a `debugLog` string formatted one removal per line (step, reason, selector, text preview). The action wrapper writes it to stderr so it does not interleave with the content body on stdout. - Without `--debug`: no behavioral change. The JSON output omits `debug` and `debugLog` is undefined. Three tests cover the three modes. --- src/cli.ts | 44 ++++++++++++++++++++++++++++++++++++++++++-- tests/cli.test.ts | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 2 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index a40c7dd08..eafd2042d 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -8,6 +8,7 @@ import { parseLinkedomHTML } from './utils/linkedom-compat'; import { countWords } from './utils'; import { buildFrontmatter } from './frontmatter'; import { getInitialUA, fetchPage, extractRawMarkdown, cleanMarkdownContent, BOT_UA } from './fetch'; +import type { DebugInfo, DebugRemoval } from './types'; export interface ParseOptions { output?: string; @@ -23,6 +24,8 @@ export interface ParseOptions { interface ParseResult { output: string; + /** Human-readable debug log, present only when --debug and not --json. */ + debugLog?: string; } // ANSI color helpers (avoids chalk dependency which is ESM-only) @@ -145,12 +148,43 @@ export async function parseSource(source: string | undefined, options: ParseOpti wordCount: result.wordCount, ...(result.contentMarkdown ? { contentMarkdown: result.contentMarkdown } : {}), ...(result.variables ? { variables: result.variables } : {}), + ...(result.debug ? { debug: result.debug } : {}), }, null, 2); } else { output = options.frontmatter ? buildFrontmatter(result, url) + result.content : result.content; } - return { output }; + // Surface debug info when --debug was set without --json. The content + // goes to stdout (unchanged); the debug log goes to stderr so it does + // not corrupt the primary output stream. We format it here so callers + // (tests, action wrapper) can route it wherever they want. + const debugLog = options.debug && !options.json && result.debug + ? formatDebugLog(result.debug) + : undefined; + + return { output, debugLog }; +} + +function formatDebugLog(debug: DebugInfo): string { + const lines: string[] = []; + lines.push(`# defuddle --debug`); + lines.push(`contentSelector: ${debug.contentSelector || '(none)'}`); + lines.push(`removals: ${debug.removals.length}`); + for (const r of debug.removals) { + lines.push(formatRemoval(r)); + } + return lines.join('\n') + '\n'; +} + +function formatRemoval(r: DebugRemoval): string { + const parts: string[] = []; + parts.push(`[${r.step}]`); + if (r.reason) parts.push(r.reason); + if (r.selector) parts.push(`(${r.selector})`); + // `text` is a short preview from the source; keep it on one line. + const preview = r.text.replace(/\s+/g, ' ').trim(); + parts.push(`— ${preview}`); + return parts.join(' '); } export function createProgram(): Command { @@ -176,7 +210,7 @@ export function createProgram(): Command { .option('-u, --user-agent ', 'Custom User-Agent header for HTTP requests (helps with 403/FORBIDDEN responses)') .action(async (source: string | undefined, options: ParseOptions) => { try { - const { output } = await parseSource(source, options); + const { output, debugLog } = await parseSource(source, options); // Handle output if (options.output) { @@ -186,6 +220,12 @@ export function createProgram(): Command { } else { console.log(output); } + + // In --debug mode without --json, surface the removal log on + // stderr so it does not interleave with the primary output. + if (debugLog) { + process.stderr.write(debugLog); + } } catch (error) { console.error(ansi.red('Error:'), error instanceof Error ? error.message : 'Unknown error occurred'); process.exit(1); diff --git a/tests/cli.test.ts b/tests/cli.test.ts index 4522602c7..705a14757 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -105,4 +105,38 @@ describe('CLI parseSource', () => { // commander camelCases --user-agent → options.userAgent, which parseSource reads. expect(option?.attributeName()).toBe('userAgent'); }); + + test('--debug --json embeds the debug payload alongside other fields', async () => { + const result = await parseSource('-', { json: true, debug: true }, createMockStdin(fixtureHtml)); + const parsed = JSON.parse(result.output); + + // Existing fields stay; new `debug` field is now present. + expect(parsed.title).toBeDefined(); + expect(parsed.debug).toBeDefined(); + expect(typeof parsed.debug.contentSelector).toBe('string'); + expect(Array.isArray(parsed.debug.removals)).toBe(true); + // debugLog is reserved for the non-JSON path; --json should not emit it. + expect(result.debugLog).toBeUndefined(); + }); + + test('--debug without --json returns a human-readable debugLog for stderr', async () => { + const result = await parseSource('-', { debug: true }, createMockStdin(fixtureHtml)); + + expect(typeof result.debugLog).toBe('string'); + expect(result.debugLog).toContain('# defuddle --debug'); + expect(result.debugLog).toContain('contentSelector:'); + expect(result.debugLog).toContain('removals:'); + // Primary output remains the content body (stdout-safe — the debug + // log goes via the returned `debugLog` to be written to stderr). + expect(result.output).not.toContain('# defuddle --debug'); + }); + + test('omits debug payload entirely when --debug is not set', async () => { + const jsonResult = await parseSource('-', { json: true }, createMockStdin(fixtureHtml)); + const parsed = JSON.parse(jsonResult.output); + expect(parsed.debug).toBeUndefined(); + + const plain = await parseSource('-', {}, createMockStdin(fixtureHtml)); + expect(plain.debugLog).toBeUndefined(); + }); });