diff --git a/docs/for-me-personal/DEBUG.md b/docs/for-me-personal/DEBUG.md index 63174dc..2c23594 100644 --- a/docs/for-me-personal/DEBUG.md +++ b/docs/for-me-personal/DEBUG.md @@ -606,7 +606,7 @@ test satu pertanyaan. --- -## 12. `package-lock.json` Mencemari Statistik Snapshot +## 12. Jawaban AI Menampilkan Markdown Mentah di Terminal **Tanggal:** 2026-06-12 @@ -614,38 +614,37 @@ test satu pertanyaan. ### Gejala -Tarball DevMap dipasang pada project Express sementara dengan source sekitar -dua baris, tetapi `devmap analyze` melaporkan 875 baris. +Jawaban `devmap ask` menampilkan marker seperti `**bold**`, backtick, dan table +pipe secara literal. Tabel lebar terpotong oleh terminal dan sulit dipindai. ### Akar Masalah -Scanner hanya mengabaikan extension `.lock` dan nama yang berakhir dengan -`-lock.yaml`. File npm `package-lock.json` dan `npm-shrinkwrap.json` tidak cocok -dengan aturan tersebut sehingga ikut masuk `fileIndex`, fingerprint, dan -statistik line. +Konten AI langsung dikirim ke `output.codeBlock()`. Helper tersebut cocok untuk +source preview, tetapi tidak memahami struktur Markdown yang dihasilkan model. ### Solusi -- Tambahkan allowlist nama lockfile package manager yang harus diabaikan: - `package-lock.json`, `npm-shrinkwrap.json`, `pnpm-lock.yaml`, `yarn.lock`, - `bun.lock`, dan `bun.lockb`. -- Pertahankan package manager detection melalui pemeriksaan file langsung pada - project root. -- Tambahkan automated regression test untuk seluruh lockfile yang didukung. +- Tambahkan pure utility `renderTerminalMarkdown()`. +- Render heading, prose, list, fenced code, dan inline formatting. +- Ubah Markdown table menjadi record vertikal. +- Bungkus text berdasarkan lebar terminal. +- Gunakan renderer hanya untuk jawaban AI `ask` dan interpretation `analyze`. +- Pertahankan `codeBlock()` untuk static source context. ### Verifikasi -- Automated analyzer test lulus untuk enam nama lockfile. -- Tarball dibangun ulang dan dipasang pada project sementara. -- Snapshot tidak memiliki `package-lock.json` di `fileIndex`. -- Project tetap terdeteksi memakai package manager npm. -- Statistik turun menjadi 3 file dan 9 baris untuk fixture E2E. +- Unit test mencakup heading, inline marker, list, table, wrapping, dan code + fence. +- Integration test memastikan output `ask` dan cached `analyze` tidak + menampilkan marker Markdown mentah. +- Preview manual dengan contoh database menghasilkan blok `users` dan `rooms` + yang terbaca tanpa table pipe. ### Pelajaran -Deteksi package manager membutuhkan keberadaan lockfile, tetapi static scanner -tidak perlu membaca kontennya. Metadata project dan source analysis harus -memiliki aturan akses yang berbeda. +AI response dan source preview adalah dua jenis output berbeda. AI response +memerlukan semantic rendering, sedangkan source code harus dipertahankan +literal. --- diff --git a/docs/for-me-personal/PROGRESS.md b/docs/for-me-personal/PROGRESS.md index 8a32281..990b74e 100644 --- a/docs/for-me-personal/PROGRESS.md +++ b/docs/for-me-personal/PROGRESS.md @@ -17,25 +17,22 @@ Terakhir diperbarui: 2026-06-12 - Automated test mencakup warning terminal, snapshot persistence, framework fallback, dan keberhasilan pembuatan snapshot. -### Tarball End-to-End - -- Tarball `devmap-0.1.0.tgz` berhasil dibuat dan hanya berisi `dist/`, - `package.json`, `README.md`, dan `LICENSE`. -- Tarball berhasil dipasang pada project Express sementara dengan npm. -- `devmap analyze`, `devmap ask`, dan `devmap doctor` berhasil dijalankan dari - package terpasang. -- `npm exec --package -- devmap --version` dan `--help` berhasil tanpa - global install. -- `devmap init` tanpa API key gagal secara aman dengan exit code `1`, pesan - actionable, dan tidak membuat config parsial. -- Verifikasi AI live belum dijalankan karena sesi testing tidak memiliki - `GROQ_API_KEY`. -- E2E menemukan `package-lock.json` ikut dipindai sebagai source. Filter scanner - sekarang mengabaikan lockfile npm, pnpm, Yarn, dan Bun. -- Snapshot E2E setelah perbaikan mendeteksi Express, npm, route `/health`, dan - tidak memasukkan `package-lock.json`. -- Kesiapan launch checklist PRD saat ini diperkirakan 17 dari 24 item, sekitar - 71%. Core implementation diperkirakan sekitar 85%. +### Terminal Markdown Rendering + +- Jawaban AI dari `devmap ask` dan architecture interpretation dari + `devmap analyze` sekarang dirender sebagai output terminal yang terstruktur. +- Heading memakai accent aqua dan separator. +- Marker Markdown inline seperti bold, italic, strikethrough, link, dan + backtick tidak lagi tampil mentah. +- Ordered dan unordered list mempertahankan indentasi yang mudah dipindai. +- Markdown table diubah menjadi record vertikal agar tetap terbaca pada + terminal sempit. +- Prose dibungkus berdasarkan `process.stdout.columns` dengan minimum width + yang aman. +- Fenced code tetap ditampilkan sebagai source block dan static context tidak + diproses sebagai Markdown. +- Automated test mencakup heading, inline formatting, list, table, wrapping, + fenced code, serta integrasi `ask` dan cached `analyze`. ## Update 2026-06-11 @@ -117,7 +114,7 @@ Terakhir diperbarui: 2026-06-12 - Availability selected model ikut diperiksa. - Snapshot dibedakan menjadi valid, missing, corrupt, dan unsupported schema. - API key dan raw stack trace tidak pernah ditampilkan. -- Automated test saat ini berjumlah 36 dan seluruhnya lulus. +- Automated test saat ini berjumlah 38 dan seluruhnya lulus. ### Distribusi npm diff --git a/docs/for-me-personal/TEST.md b/docs/for-me-personal/TEST.md index 54a0cac..a450502 100644 --- a/docs/for-me-personal/TEST.md +++ b/docs/for-me-personal/TEST.md @@ -102,8 +102,8 @@ Saat ini test mencakup: Hasil minimum yang diharapkan: ```text -tests 36 -pass 36 +tests 38 +pass 38 fail 0 ``` @@ -144,11 +144,42 @@ npx -p node@20 node packages\cli\node_modules\tsx\dist\cli.mjs packages\cli\test Hasil minimum yang diharapkan untuk keduanya: ```text -tests 36 -pass 36 +tests 38 +pass 38 fail 0 ``` +## Testing Terminal Markdown Renderer + +Jalankan unit dan integration test terkait: + +```powershell +pnpm --filter devmap exec tsx --test test/markdown-terminal.test.ts test/ask-command.test.ts test/analyze-ai.test.ts +``` + +Pastikan: + +- heading tidak menampilkan marker `##`; +- bold, italic, dan inline code tidak menampilkan marker Markdown mentah; +- ordered dan unordered list tetap memiliki indentasi; +- table diubah menjadi blok vertikal tanpa karakter pipe; +- prose dibungkus sesuai lebar terminal; +- fenced code tetap terbaca sebagai code block; +- jawaban `ask` dan architecture `analyze` memakai renderer; +- static source context tetap ditampilkan literal. + +Manual test dengan AI live: + +```powershell +devmap ask "Jelaskan struktur database dalam tabel dan alur utama aplikasi" +``` + +Hasil yang diharapkan: + +- tidak ada `**bold**`, backtick, atau table pipe mentah; +- tabel tetap terbaca pada terminal VS Code yang sempit; +- output tidak melewati lebar terminal secara berlebihan. + ## Testing AI Client Tanpa API Call Automated test AI memakai fake `fetch` dan mock `AiClient`, sehingga tidak diff --git a/packages/cli/src/commands/analyze.ts b/packages/cli/src/commands/analyze.ts index 415e279..ff3d267 100644 --- a/packages/cli/src/commands/analyze.ts +++ b/packages/cli/src/commands/analyze.ts @@ -112,7 +112,7 @@ async function printOrGenerateInterpretation( ): Promise { if (snapshot.ai && !options.fresh) { output.section("Architecture"); - output.codeBlock(snapshot.ai.architecture); + output.markdown(snapshot.ai.architecture); output.note(formatAiMetadata(snapshot.ai.model, snapshot.ai.usage, true)); return; } @@ -154,7 +154,7 @@ async function printOrGenerateInterpretation( await saveSnapshot(projectRoot, updatedSnapshot); output.section("Architecture"); - output.codeBlock(interpretation.content); + output.markdown(interpretation.content); output.note(formatAiMetadata( interpretation.model, interpretation.usage, diff --git a/packages/cli/src/commands/ask.ts b/packages/cli/src/commands/ask.ts index 256c8af..24d0082 100644 --- a/packages/cli/src/commands/ask.ts +++ b/packages/cli/src/commands/ask.ts @@ -87,7 +87,7 @@ export async function askCommand( }); output.section("Answer"); - output.codeBlock(answer.content); + output.markdown(answer.content); output.note(formatUsage(answer.model, answer.usage)); } catch (error) { if (!(error instanceof DevmapError)) { diff --git a/packages/cli/src/utils/markdownTerminal.ts b/packages/cli/src/utils/markdownTerminal.ts new file mode 100644 index 0000000..2b29917 --- /dev/null +++ b/packages/cli/src/utils/markdownTerminal.ts @@ -0,0 +1,210 @@ +export type MarkdownRenderOptions = { + width?: number; + colors?: boolean; +}; + +const ANSI = { + aqua: "\x1b[38;2;46;230;214m", + bold: "\x1b[1m", + gray: "\x1b[90m", + reset: "\x1b[0m" +}; + +export function renderTerminalMarkdown( + markdown: string, + options: MarkdownRenderOptions = {} +): string { + const width = Math.max(32, options.width ?? 80); + const colors = options.colors ?? true; + const lines = markdown.replace(/\r\n?/g, "\n").split("\n"); + const rendered: string[] = []; + let index = 0; + let inCodeBlock = false; + + while (index < lines.length) { + const line = lines[index]; + + if (/^\s*```/.test(line)) { + inCodeBlock = !inCodeBlock; + index += 1; + continue; + } + + if (inCodeBlock) { + rendered.push(style(` ${line}`, "gray", colors)); + index += 1; + continue; + } + + if (isTableStart(lines, index)) { + const tableEnd = findTableEnd(lines, index); + rendered.push(...renderTable(lines.slice(index, tableEnd), width, colors)); + index = tableEnd; + continue; + } + + const heading = line.match(/^\s{0,3}#{1,6}\s+(.+?)\s*#*\s*$/); + if (heading) { + const title = cleanInline(heading[1]); + rendered.push(style(title, "heading", colors)); + rendered.push(style("-".repeat(Math.min(width, Math.max(12, title.length))), "gray", colors)); + index += 1; + continue; + } + + const listItem = line.match(/^(\s*)([-+*]|\d+\.)\s+(.+)$/); + if (listItem) { + const marker = /^\d/.test(listItem[2]) ? listItem[2] : "-"; + const indent = listItem[1].length + (marker === "-" ? 0 : 0); + rendered.push(...wrapWithPrefix( + cleanInline(listItem[3]), + `${" ".repeat(indent)}${marker} `, + width + )); + index += 1; + continue; + } + + if (line.trim() === "") { + if (rendered.at(-1) !== "") { + rendered.push(""); + } + index += 1; + continue; + } + + rendered.push(...wrapText(cleanInline(line.trim()), width)); + index += 1; + } + + return trimBlankLines(rendered).join("\n"); +} + +function isTableStart(lines: string[], index: number): boolean { + return ( + index + 1 < lines.length + && lines[index].includes("|") + && /^\s*\|?\s*:?-{3,}/.test(lines[index + 1]) + ); +} + +function findTableEnd(lines: string[], start: number): number { + let index = start + 2; + while (index < lines.length && lines[index].includes("|") && lines[index].trim() !== "") { + index += 1; + } + return index; +} + +function renderTable( + tableLines: string[], + width: number, + colors: boolean +): string[] { + const headers = parseTableRow(tableLines[0]).map(cleanInline); + const rows = tableLines.slice(2).map((line) => parseTableRow(line).map(cleanInline)); + const labelWidth = Math.min( + 18, + Math.max(8, ...headers.slice(1).map((header) => header.length)) + ); + const rendered: string[] = []; + + for (const row of rows) { + const title = row[0] || "Item"; + rendered.push(style(title, "heading", colors)); + + for (let index = 1; index < headers.length; index += 1) { + const label = headers[index] || `Column ${index + 1}`; + const value = row[index] || "-"; + rendered.push(...wrapWithPrefix(value, ` ${label.padEnd(labelWidth)} `, width)); + } + + rendered.push(""); + } + + return trimBlankLines(rendered); +} + +function parseTableRow(line: string): string[] { + return line + .trim() + .replace(/^\|/, "") + .replace(/\|$/, "") + .split("|") + .map((cell) => cell.trim()); +} + +function cleanInline(value: string): string { + return value + .replace(/!\[([^\]]*)]\([^)]*\)/g, "$1") + .replace(/\[([^\]]+)]\(([^)]+)\)/g, "$1 ($2)") + .replace(/(\*\*|__)(.*?)\1/g, "$2") + .replace(/\*([^*]+)\*/g, "$1") + .replace(/~~(.*?)~~/g, "$1") + .replace(/`([^`]+)`/g, "$1") + .replace(/\\([\\`*_[\]{}()#+\-.!|>])/g, "$1"); +} + +function wrapText(value: string, width: number): string[] { + return wrapWithPrefix(value, "", width); +} + +function wrapWithPrefix(value: string, prefix: string, width: number): string[] { + const continuation = " ".repeat(prefix.length); + const available = Math.max(12, width - prefix.length); + const words = value.split(/\s+/).filter(Boolean); + const lines: string[] = []; + let current = ""; + + for (const word of words) { + if (!current) { + current = word; + continue; + } + + if (`${current} ${word}`.length <= available) { + current = `${current} ${word}`; + continue; + } + + lines.push(`${lines.length === 0 ? prefix : continuation}${current}`); + current = word; + } + + if (current || lines.length === 0) { + lines.push(`${lines.length === 0 ? prefix : continuation}${current}`); + } + + return lines; +} + +function style( + value: string, + tone: "heading" | "gray", + colors: boolean +): string { + if (!colors) { + return value; + } + + if (tone === "heading") { + return `${ANSI.bold}${ANSI.aqua}${value}${ANSI.reset}`; + } + + return `${ANSI.gray}${value}${ANSI.reset}`; +} + +function trimBlankLines(lines: string[]): string[] { + let start = 0; + let end = lines.length; + + while (start < end && lines[start] === "") { + start += 1; + } + + while (end > start && lines[end - 1] === "") { + end -= 1; + } + + return lines.slice(start, end); +} diff --git a/packages/cli/src/utils/output.ts b/packages/cli/src/utils/output.ts index 2d43d35..eb02c3a 100644 --- a/packages/cli/src/utils/output.ts +++ b/packages/cli/src/utils/output.ts @@ -1,3 +1,5 @@ +import { renderTerminalMarkdown } from "./markdownTerminal.js"; + export const theme = { aqua: "\x1b[38;2;46;230;214m", gray: "\x1b[90m", @@ -50,5 +52,12 @@ export const output = { codeBlock(content: string): void { console.log(color(content, "gray")); + }, + + markdown(content: string): void { + console.log(renderTerminalMarkdown(content, { + width: process.stdout.columns ?? 80, + colors: true + })); } }; diff --git a/packages/cli/test/analyze-ai.test.ts b/packages/cli/test/analyze-ai.test.ts index b2aacba..96a8844 100644 --- a/packages/cli/test/analyze-ai.test.ts +++ b/packages/cli/test/analyze-ai.test.ts @@ -18,7 +18,7 @@ test("analyze stores and reuses AI architecture interpretation", async () => { async complete(request): Promise { requests.push(request); return { - content: "This project exposes a small TypeScript entry point.", + content: "## Overview\n\nThis project exposes a **TypeScript** entry point.", model: request.model, usage: { promptTokens: 80, @@ -62,7 +62,7 @@ test("analyze stores and reuses AI architecture interpretation", async () => { if (saved.status === "valid") { assert.equal( saved.snapshot.ai?.architecture, - "This project exposes a small TypeScript entry point." + "## Overview\n\nThis project exposes a **TypeScript** entry point." ); assert.equal(saved.snapshot.ai?.usage?.totalTokens, 95); } @@ -83,6 +83,8 @@ test("analyze stores and reuses AI architecture interpretation", async () => { assert.equal(requests.length, 1); assert.match(secondLogs, /Reused existing snapshot/); assert.match(secondLogs, /Cached: yes/); + assert.match(stripAnsi(secondLogs), /Overview\n-+/); + assert.doesNotMatch(stripAnsi(secondLogs), /\*\*/); } finally { await rm(projectRoot, { recursive: true, force: true }); } diff --git a/packages/cli/test/ask-command.test.ts b/packages/cli/test/ask-command.test.ts index 991aa20..d3de73f 100644 --- a/packages/cli/test/ask-command.test.ts +++ b/packages/cli/test/ask-command.test.ts @@ -21,7 +21,7 @@ test("ask command uses configured AI client and prints token usage", async () => async complete(request): Promise { requests.push(request); return { - content: "Authentication is handled in auth.ts.", + content: "## Authentication\n\nAuthentication is handled in **`auth.ts`**.", model: request.model, usage: { promptTokens: 100, @@ -50,7 +50,10 @@ test("ask command uses configured AI client and prints token usage", async () => assert.equal(requests[0]?.model, DEFAULT_AI_MODELS.ask); assert.equal(requests[0]?.fallbackModel, DEFAULT_AI_MODELS.fallback); assert.match(requests[0]?.messages[1]?.content ?? "", /auth\.ts/); - assert.match(logs, /Authentication is handled in auth\.ts/); + const plainLogs = stripAnsi(logs); + assert.match(plainLogs, /Authentication\n-+/); + assert.match(plainLogs, /Authentication is handled in auth\.ts/); + assert.doesNotMatch(plainLogs, /\*\*|`/); assert.match(logs, /Total tokens: 112/); } finally { await rm(projectRoot, { recursive: true, force: true }); @@ -109,6 +112,10 @@ async function createAskProject(): Promise { return projectRoot; } +function stripAnsi(value: string): string { + return value.replace(/\u001B\[[0-9;]*m/g, ""); +} + async function captureOutput(action: () => Promise): Promise { const logs: string[] = []; const originalLog = console.log; diff --git a/packages/cli/test/markdown-terminal.test.ts b/packages/cli/test/markdown-terminal.test.ts new file mode 100644 index 0000000..6baa8a3 --- /dev/null +++ b/packages/cli/test/markdown-terminal.test.ts @@ -0,0 +1,51 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { renderTerminalMarkdown } from "../src/utils/markdownTerminal.js"; + +test("terminal markdown renders headings, inline formatting, and lists cleanly", () => { + const rendered = renderTerminalMarkdown([ + "## Main Flow", + "", + "This project uses **NextAuth** from `lib/auth.ts` with `users_email_key`.", + "", + "1. **Login**", + " - Validate the session token.", + " - Redirect unauthenticated users." + ].join("\n"), { width: 60, colors: false }); + + assert.match(rendered, /Main Flow\n-+/); + assert.match(rendered, /users_email_key/); + assert.match(rendered, /1\. Login/); + assert.match(rendered, / - Validate the session token\./); + assert.doesNotMatch(rendered, /\*\*|`/); +}); + +test("terminal markdown converts tables into readable vertical records", () => { + const rendered = renderTerminalMarkdown([ + "| Table | Main columns | Relation |", + "| --- | --- | --- |", + "| users | id, name, email | Primary key id |", + "| rooms | id, userId | userId -> users.id |" + ].join("\n"), { width: 50, colors: false }); + + assert.match(rendered, /users/); + assert.match(rendered, /Main columns\s+id, name, email/); + assert.match(rendered, /Relation\s+Primary key id/); + assert.match(rendered, /rooms/); + assert.doesNotMatch(rendered, /\|/); +}); + +test("terminal markdown wraps prose and preserves fenced code", () => { + const rendered = renderTerminalMarkdown([ + "A deliberately long explanation that must wrap before it reaches the edge of a narrow terminal.", + "", + "```ts", + "const project = createProjectMap(root);", + "```" + ].join("\n"), { width: 36, colors: false }); + + const proseLines = rendered.split("\n").filter((line) => !line.startsWith(" ")); + assert.ok(proseLines.every((line) => line.length <= 36)); + assert.match(rendered, / const project = createProjectMap\(root\);/); + assert.doesNotMatch(rendered, /```/); +});