diff --git a/CHANGELOG.md b/CHANGELOG.md index 76d1b3c..0c47067 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,22 @@ All notable changes to DevMap are documented in this file. - Public benchmark results - Feedback-driven fixes from the `0.2.0` beta +## [0.4.1] - 2026-10-03 + +### Added + +- Analyzer pipeline loading spinners in `createProjectMap`: scanning, + analyzing, dependency mapping, entity/feature extraction, and optional + AI domain inference, with TTY animation, non-TTY fallback, and silent + JSON output + +### Changed + +- Welcome banner trimmed to the big wordmark plus separator; removed the + `[ DEVMAP CLI ]` label and capabilities line +- `devmap analyze` drops the duplicate scanning line now covered by the + pipeline spinner + ## [0.4.0] - 2026-09-13 ### Added diff --git a/docs/for-me-personal/DEBUG.md b/docs/for-me-personal/DEBUG.md index f16094d..87f2202 100644 --- a/docs/for-me-personal/DEBUG.md +++ b/docs/for-me-personal/DEBUG.md @@ -1551,5 +1551,48 @@ pm view @flaid/devmap version -> .3.0. assert bentuk/polanya saja. - Urutan gate penting: jalankan full test suite SETELAH bump versi sebelum menagih tag, karena bump bisa merusak asumsi test. -- Tag rilis yang belum ter-publish boleh digeser; setelah publish, tag +- Tag rilis yang belum ter-publish boleh digeser; setelah publish, tag bersifat permanen. + +## 29. Test Onboarding Flaky di PowerShell karena stdin Masih TTY + +**Tanggal:** 2026-10-03 + +### Gejala + +`pnpm test:cli` lokal Windows: 272 pass / 1 fail. +`onboarding command renders a snapshot-based guide` fail — output markdown +berbahasa Indonesia (`Tentang project ini`) padahal assertion menunggu +`/What this is/`. Durasi test 321 detik (menggantung). Di sandbox Linux dan +CI, 273 pass semua. + +### Akar Masalah + +`resolveOnboardingLanguage()` (`packages/cli/src/commands/onboarding.ts`) +kembali `en` hanya bila `!prompt && !process.stdin.isTTY`; selebihnya membuat +prompt asli dan menunggu input. Di bawah pnpm di PowerShell, stdout sudah +di-pipe (spinner jatuh ke fallback `console.log`) tetapi stdin tetap TTY, +jadi test masuk jalur prompt interaktif dan menggantung. Dua test pertama +tidak mengoper `language` maupun prompt palsu, sehingga hasilnya tergantung +lingkungan — lolos di CI/sandbox secara kebetulan, bukan by design. + +### Solusi + +Buat kedua test hermetic dengan bahasa eksplisit: +`onboardingCommand({ projectRoot, language: "en" })`. Source tidak diubah — +prompt interaktif tanpa `--write` adalah intent dari commit `244373b`. +Test ketiga (bahasa `id` via prompt palsu) sudah hermetic dan tidak diubah. + +### Verifikasi + +- Targeted: `tsx --test test/onboarding-command.test.ts` → 3 pass / 0 fail + dalam ~2 detik (sebelumnya 1 fail / 321 detik). +- Full: `pnpm test:cli` → 273 pass / 0 fail + `tsc --noEmit` bersih. + +### Pelajaran + +- Test yang memanggil kode ber-cabang TTY harus menginjeksi input + (prompt palsu / flag eksplisit), jangan mengandalkan `stdin.isTTY` + lingkungan runner. +- Durasi test yang melonjak (detik → menit) adalah sinyal test menunggu + input interaktif, bukan test yang lambat. diff --git a/docs/for-me-personal/PROGRESS.md b/docs/for-me-personal/PROGRESS.md index ec1e30b..48be6e3 100644 --- a/docs/for-me-personal/PROGRESS.md +++ b/docs/for-me-personal/PROGRESS.md @@ -1,6 +1,28 @@ # Progress DevMap -Terakhir diperbarui: 2026-09-04 +Terakhir diperbarui: 2026-10-03 + +## Update 2026-10-03 + +### Analyzer Spinners + Welcome Trim — branch `ui-improve` (rilis 0.4.1) + +1. `output.spinner()` baru di `packages/cli/src/utils/output.ts`: animasi + TTY, fallback satu-baris untuk non-TTY, silent untuk `--json`. +2. `createProjectMap` membungkus 5 tahap (scan, analyze, dependency map, + entity/feature extraction, AI domain inference) dengan spinner; + `analyze.ts` menghapus baris `Scanning` duplikat. +3. Banner welcome dipangkas ke wordmark besar + separator (hapus label + `[ DEVMAP CLI ]` dan capabilities); `welcome.test.ts` disesuaikan. +4. `CHANGELOG.md`: entry `[0.4.1] - 2026-10-03` (Added/Changed). +5. Fix flake `onboarding-command.test.ts`: dua test pertama oper + `language: "en"` eksplisit agar tidak tergantung `stdin.isTTY` + (detail di `DEBUG.md` #29). + +**Verifikasi**: `pnpm test:cli` → 273 pass / 0 fail + type-check bersih. +Rencana rilis: PR `ui-improve` → `main`, merge, lalu `npm version patch` +di `main` + `push --follow-tags` (publish via OIDC). + +--- ## Update 2026-09-04 diff --git a/docs/for-me-personal/TEST.md b/docs/for-me-personal/TEST.md index 7de8f1c..9a72cb8 100644 --- a/docs/for-me-personal/TEST.md +++ b/docs/for-me-personal/TEST.md @@ -15,6 +15,20 @@ Ada beberapa versi DevMap yang dapat diuji: | npm link | CLI global sementara | Menguji command `devmap` dari folder mana pun | | CI/runtime | OS dan versi Node berbeda | Verifikasi lintas platform sebelum release | +## Onboarding TTY flake (2026-10-03) + +Dua test onboarding (`renders a snapshot-based guide`, +`writes ONBOARDING.md`) sekarang mengoper `language: "en"` eksplisit agar +tidak membuka prompt bahasa saat `stdin.isTTY` true (PowerShell lokal — +menggantung ~5 menit lalu fail). Targeted run: + +```powershell +pnpm --filter @flaid/devmap exec tsx --test test/onboarding-command.test.ts +``` + +Expected: `3 pass / 0 fail` dalam hitungan detik. Kalau ada test baru yang +memanggil `onboardingCommand`, selalu oper `language` atau prompt palsu. + ## Custom Provider & Registry (2026-08-26) Focused automated tests: diff --git a/packages/cli/src/analyzers/pipeline/projectMap.ts b/packages/cli/src/analyzers/pipeline/projectMap.ts index 4e7b686..48943ed 100644 --- a/packages/cli/src/analyzers/pipeline/projectMap.ts +++ b/packages/cli/src/analyzers/pipeline/projectMap.ts @@ -1,3 +1,4 @@ +import { output, type Spinner } from "../../utils/output.js"; import { hashContent } from "../../cache/fileHash.js"; import { REASON_TAGS } from "./reasonTags.js"; import { analyzeFiles } from "./analyzerRegistry.js"; @@ -168,120 +169,142 @@ export async function createProjectMap( */ callAI?: (prompt: string) => Promise ): Promise { - const files = await scanFiles(projectRoot); - const analyses = await analyzeFiles(files); - const aliasMappings = await loadAliasMappings(projectRoot); - const { graph, diagnostics: graphDiagnostics } = buildDependencyGraph(files, analyses, aliasMappings); - const references = countReferences(graph); - const detectedFramework = detectFramework(files); - const frameworks = detectFrameworks(files); - const project = detectProjectMetadata( - projectRoot, - detectedFramework, - files, - frameworks - ); - const framework = project.framework; - const entryPoints = detectEntryPoints(graph); - const routes = detectRoutes(files, project.frameworks, graph); - const database = detectDatabase(files); - - // Step 1: Extract entities dari schema (Prisma dll) atau route fallback - const entityGraph = extractEntities(files, routes); - - // Step 2: Detect capabilities dari route patterns + HTTP methods - const capabilities = detectCapabilities(routes, entityGraph); - - // Step 3: Detect features — consume entityGraph + capabilities - const featureResult = detectFeatures(files, analyses, routes, database, entityGraph, capabilities, graph); - const features = attachFeatureEntryPoints( - featureResult.features, - routes, - entryPoints, - graph, - analyses - ); - // Step 4: AI domain inference (optional — hanya jalan kalau callAI disediakan) - // Kirim structured metadata ke AI, dapat domain summary + domain-specific features. - // Kalau gagal atau callAI tidak ada, static features tetap lengkap. - let domain: DomainInferenceResult | undefined; - if (callAI) { - const inferenceInput = buildDomainInferenceInput( - entityGraph, - capabilities, - features, - framework, - routes.length + let activeSpinner: Spinner | undefined; + + try { + activeSpinner = output.spinner(`Scanning ${projectRoot}`); + const files = await scanFiles(projectRoot); + activeSpinner.succeed(`Scanned ${files.length} files`); + + activeSpinner = output.spinner("Analyzing files"); + const analyses = await analyzeFiles(files); + activeSpinner.succeed(`Analyzed ${files.length} files`); + + activeSpinner = output.spinner("Mapping dependencies"); + const aliasMappings = await loadAliasMappings(projectRoot); + const { graph, diagnostics: graphDiagnostics } = buildDependencyGraph(files, analyses, aliasMappings); + const references = countReferences(graph); + const detectedFramework = detectFramework(files); + const frameworks = detectFrameworks(files); + const project = detectProjectMetadata( + projectRoot, + detectedFramework, + files, + frameworks ); - const result = await inferDomain(inferenceInput, callAI, projectRoot); - if (result) { - domain = result; - // Merge domain-specific features ke features list. - // Pakai similarity engine — bukan name equality — sehingga - // "Customizable Plans" tidak duplicate "Plan Management" yang sudah ada. - // Canonical name (first-seen) dipertahankan oleh mergeDomainFeatures. - const domainFeatures = domainFeaturesToFeatureInfo(result.domainFeatures); - mergeDomainFeatures(features, domainFeatures); - features.sort((a, b) => a.name.localeCompare(b.name)); + const framework = project.framework; + const entryPoints = detectEntryPoints(graph); + const routes = detectRoutes(files, project.frameworks, graph); + const database = detectDatabase(files); + activeSpinner.succeed("Dependencies mapped"); + + activeSpinner = output.spinner("Extracting entities & features"); + // Step 1: Extract entities dari schema (Prisma dll) atau route fallback + const entityGraph = extractEntities(files, routes); + + // Step 2: Detect capabilities dari route patterns + HTTP methods + const capabilities = detectCapabilities(routes, entityGraph); + + // Step 3: Detect features — consume entityGraph + capabilities + const featureResult = detectFeatures(files, analyses, routes, database, entityGraph, capabilities, graph); + const features = attachFeatureEntryPoints( + featureResult.features, + routes, + entryPoints, + graph, + analyses + ); + activeSpinner.succeed(`Detected ${features.length} features`); + + // Step 4: AI domain inference (optional — hanya jalan kalau callAI disediakan) + // Kirim structured metadata ke AI, dapat domain summary + domain-specific features. + // Kalau gagal atau callAI tidak ada, static features tetap lengkap. + let domain: DomainInferenceResult | undefined; + if (callAI) { + activeSpinner = output.spinner("Inferring domain with AI"); + const inferenceInput = buildDomainInferenceInput( + entityGraph, + capabilities, + features, + framework, + routes.length + ); + const result = await inferDomain(inferenceInput, callAI, projectRoot); + if (result) { + domain = result; + // Merge domain-specific features ke features list. + // Pakai similarity engine — bukan name equality — sehingga + // "Customizable Plans" tidak duplicate "Plan Management" yang sudah ada. + // Canonical name (first-seen) dipertahankan oleh mergeDomainFeatures. + const domainFeatures = domainFeaturesToFeatureInfo(result.domainFeatures); + mergeDomainFeatures(features, domainFeatures); + features.sort((a, b) => a.name.localeCompare(b.name)); + } + activeSpinner.succeed(domain ? `Domain inferred: ${domain.domain}` : "Domain inference skipped"); } - } - const criticalFiles = rankCriticalFiles(files, analyses, references, entryPoints); - const fileIndex = Object.fromEntries(files.map((file) => [ - file.path, - createFileIndexEntry( - file, - analyses[file.path], - graph[file.path] ?? [], - references, + activeSpinner = output.spinner("Building project index"); + const criticalFiles = rankCriticalFiles(files, analyses, references, entryPoints); + const fileIndex = Object.fromEntries(files.map((file) => [ + file.path, + createFileIndexEntry( + file, + analyses[file.path], + graph[file.path] ?? [], + references, + entryPoints, + criticalFiles, + features + ) + ])); + + const flows = generateMinimalFlows(features, fileIndex, routes, graph); + activeSpinner.succeed("Project index built"); + + return { + version: SNAPSHOT_SCHEMA_VERSION, + generatedAt: new Date().toISOString(), + agentInstructions: createAgentInstructions(), + fingerprint: createProjectFingerprint(files), + projectRoot, + framework, + project, + stats: { + // A pre-filter filesystem count is not collected in schema v1. + totalFiles: files.length, + relevantFiles: files.length, + totalLines: files.reduce((sum, file) => sum + file.lines, 0) + }, entryPoints, criticalFiles, - features - ) - ])); - - const flows = generateMinimalFlows(features, fileIndex, routes, graph); - - return { - version: SNAPSHOT_SCHEMA_VERSION, - generatedAt: new Date().toISOString(), - agentInstructions: createAgentInstructions(), - fingerprint: createProjectFingerprint(files), - projectRoot, - framework, - project, - stats: { - // A pre-filter filesystem count is not collected in schema v1. - totalFiles: files.length, - relevantFiles: files.length, - totalLines: files.reduce((sum, file) => sum + file.lines, 0) - }, - entryPoints, - criticalFiles, - routes, - apiRoutes: routes.filter((route) => route.kind === "api"), - externalServices: detectExternalServices(files), - ...(database ? { database } : {}), - features, - ...(entityGraph.source !== "empty" ? { entityGraph } : {}), - ...(capabilities.length > 0 ? { capabilities } : {}), - ...(domain ? { domain } : {}), - flows, - onboarding: { - recommendedPath: buildOnboardingPath(files, entryPoints, criticalFiles, fileIndex) - }, - changeImpact: buildChangeImpact(fileIndex, features, flows, graph), - warnings: detectAnalysisWarnings(files, entryPoints, criticalFiles, features), - ...(graphDiagnostics.unresolvedAliases.length > 0 || graphDiagnostics.parserFallbacks.length > 0 - ? { diagnostics: graphDiagnostics } - : {}), - ...(featureResult.mergeDecisions.length > 0 || featureResult.rejectedCandidateIds.length > 0 - ? { featureDiagnostics: { mergeDecisions: featureResult.mergeDecisions, rejectedCandidateIds: featureResult.rejectedCandidateIds } } - : {}), - dependencies: readPackageDependencies(files), - fileGraph: graph, - fileIndex - }; + routes, + apiRoutes: routes.filter((route) => route.kind === "api"), + externalServices: detectExternalServices(files), + ...(database ? { database } : {}), + features, + ...(entityGraph.source !== "empty" ? { entityGraph } : {}), + ...(capabilities.length > 0 ? { capabilities } : {}), + ...(domain ? { domain } : {}), + flows, + onboarding: { + recommendedPath: buildOnboardingPath(files, entryPoints, criticalFiles, fileIndex) + }, + changeImpact: buildChangeImpact(fileIndex, features, flows, graph), + warnings: detectAnalysisWarnings(files, entryPoints, criticalFiles, features), + ...(graphDiagnostics.unresolvedAliases.length > 0 || graphDiagnostics.parserFallbacks.length > 0 + ? { diagnostics: graphDiagnostics } + : {}), + ...(featureResult.mergeDecisions.length > 0 || featureResult.rejectedCandidateIds.length > 0 + ? { featureDiagnostics: { mergeDecisions: featureResult.mergeDecisions, rejectedCandidateIds: featureResult.rejectedCandidateIds } } + : {}), + dependencies: readPackageDependencies(files), + fileGraph: graph, + fileIndex + }; + } catch (error) { + activeSpinner?.fail(); + throw error; + } } function createAgentInstructions(): ProjectMap["agentInstructions"] { diff --git a/packages/cli/src/commands/analyze.ts b/packages/cli/src/commands/analyze.ts index a8e69a2..55c9cb9 100644 --- a/packages/cli/src/commands/analyze.ts +++ b/packages/cli/src/commands/analyze.ts @@ -48,7 +48,6 @@ async function runAnalyze( const projectRoot = resolve(target); output.section("DevMap Analyze"); - output.step(`Scanning ${projectRoot}`); // Build callAI wrapper — dipakai oleh domain inference (Step 5) di createProjectMap. // Dibuat di sini biar analyze command yang kontrol config + client lifecycle, diff --git a/packages/cli/src/utils/output.ts b/packages/cli/src/utils/output.ts index 00c31ff..d4862d9 100644 --- a/packages/cli/src/utils/output.ts +++ b/packages/cli/src/utils/output.ts @@ -26,6 +26,60 @@ function color(value: string | number, tone: keyof typeof theme): string { return `${theme[tone]}${value}${theme.reset}`; } +export type Spinner = { + succeed(message?: string): void; + fail(message?: string): void; + stop(): void; +}; + +const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; +const SPINNER_INTERVAL_MS = 80; + +function createSpinner(label: string): Spinner { + if (process.stdout.isTTY !== true) { + // No live terminal to animate against (piped output, CI, etc.) — announce once + // and report completion as a second line instead of framing in place. + console.log(`${color("◆", "aqua")} ${label}`); + return { + succeed(message?: string): void { + console.log(`${color("◆", "green")} ${message ?? label}`); + }, + fail(message?: string): void { + console.log(`${color("◆", "red")} ${message ?? label}`); + }, + stop(): void {} + }; + } + + let frame = 0; + const render = (): void => { + const glyph = SPINNER_FRAMES[frame % SPINNER_FRAMES.length]; + process.stdout.write(`\r\x1b[K${color(glyph, "aqua")} ${label}`); + frame += 1; + }; + render(); + const timer = setInterval(render, SPINNER_INTERVAL_MS); + timer.unref(); // never let a stray spinner keep the process alive + + const finish = (symbol: string, tone: "green" | "red", message?: string): void => { + clearInterval(timer); + process.stdout.write(`\r\x1b[K${color(symbol, tone)} ${message ?? label}\n`); + }; + + return { + succeed(message?: string): void { + finish("◆", "green", message); + }, + fail(message?: string): void { + finish("◆", "red", message); + }, + stop(): void { + clearInterval(timer); + process.stdout.write("\r\x1b[K"); + } + }; +} + export type MarkdownStream = { write(chunk: string): void; end(): void; @@ -43,6 +97,13 @@ export const output = { console.log(`${color("◆", "aqua")} ${message}`); }, + spinner(label: string): Spinner { + if (isJsonOutput()) { + return { succeed(): void {}, fail(): void {}, stop(): void {} }; + } + return createSpinner(label); + }, + success(message: string): void { if (isJsonOutput()) return; console.log(`${color("◆", "green")} ${message}`); diff --git a/packages/cli/src/utils/welcome.ts b/packages/cli/src/utils/welcome.ts index a26d2e1..63a9643 100644 --- a/packages/cli/src/utils/welcome.ts +++ b/packages/cli/src/utils/welcome.ts @@ -14,8 +14,6 @@ const WIDE_WORDMARK = [ const WIDE_PANEL_WIDTH = 76; const COMPACT_PANEL_WIDTH = 48; const WIDE_TERMINAL_MINIMUM = 72; -const PRODUCT_LABEL = "DEVMAP CLI"; -const PRODUCT_CAPABILITIES = "CODEBASE MAP / STATIC ANALYSIS / AI CONTEXT"; type Step = { cmd: string; desc: string }; @@ -67,34 +65,18 @@ export function renderWelcomeBrandPanel(terminalWidth: number): string { Math.min(terminalWidth, isWide ? WIDE_PANEL_WIDTH : COMPACT_PANEL_WIDTH) ); const wordmark = isWide ? WIDE_WORDMARK : ["DEVMAP"]; - const label = centerLine(`[ ${PRODUCT_LABEL} ]`, contentWidth); const wordmarkRows = centerBlock(wordmark, contentWidth); - const capabilities = centerLine( - isWide ? PRODUCT_CAPABILITIES : "CODEBASE INTELLIGENCE", - contentWidth - ); const separator = "━".repeat(Math.min(contentWidth, 64)); return [ - `${theme.gray}${label}${theme.reset}`, - "", ...wordmarkRows.map((line) => `${theme.aqua}${line}${theme.reset}`), "", - `${theme.gray}${capabilities}${theme.reset}`, `${theme.aqua}${separator}${theme.reset}` ].join("\n"); } -function centerLine(line: string, width: number): string { - const visibleLine = line.slice(0, width); - const leftPadding = Math.floor((width - visibleLine.length) / 2); - return `${" ".repeat(leftPadding)}${visibleLine}`; -} - function centerBlock(lines: string[], width: number): string[] { const blockWidth = Math.min(width, Math.max(...lines.map((line) => line.length))); const leftPadding = Math.floor((width - blockWidth) / 2); return lines.map((line) => `${" ".repeat(leftPadding)}${line.slice(0, width)}`); } - - diff --git a/packages/cli/test/onboarding-command.test.ts b/packages/cli/test/onboarding-command.test.ts index 4e55e71..0f2b177 100644 --- a/packages/cli/test/onboarding-command.test.ts +++ b/packages/cli/test/onboarding-command.test.ts @@ -16,7 +16,7 @@ test("onboarding command renders a snapshot-based guide", async () => { const projectRoot = await createOnboardingProject(); try { - const logs = await captureOutput(() => onboardingCommand({ projectRoot })); + const logs = await captureOutput(() => onboardingCommand({ projectRoot, language: "en" })); const plainLogs = stripAnsi(logs); assert.match(plainLogs, /DevMap Onboarding/); @@ -41,7 +41,7 @@ test("onboarding command writes ONBOARDING.md when requested", async () => { const projectRoot = await createOnboardingProject(); try { - const logs = await captureOutput(() => onboardingCommand({ projectRoot, write: true })); + const logs = await captureOutput(() => onboardingCommand({ projectRoot, write: true, language: "en" })); const outputPath = join(projectRoot, "ONBOARDING.md"); await access(outputPath); const content = await readFile(outputPath, "utf8"); diff --git a/packages/cli/test/welcome.test.ts b/packages/cli/test/welcome.test.ts index 462eae4..192f0b2 100644 --- a/packages/cli/test/welcome.test.ts +++ b/packages/cli/test/welcome.test.ts @@ -2,26 +2,26 @@ import assert from "node:assert/strict"; import test from "node:test"; import { renderWelcomeBrandPanel, printStatusLine, printNextSteps } from "../src/utils/welcome.js"; -test("welcome brand panel renders an outlined block wordmark and tool identity", () => { +test("welcome brand panel renders only the big outlined block wordmark", () => { const panel = stripAnsi(renderWelcomeBrandPanel(100)); const lines = panel.split("\n"); - assert.match(panel, /\[ DEVMAP CLI \]/); assert.match(panel, /██████╗ ███████╗/); assert.match(panel, /██╔══██╗██╔════╝/); - assert.match(panel, /CODEBASE MAP \/ STATIC ANALYSIS \/ AI CONTEXT/); + assert.doesNotMatch(panel, /DEVMAP CLI/); + assert.doesNotMatch(panel, /STATIC ANALYSIS/); assert.ok(lines.every((line) => line.length <= 76)); assert.match(lines.at(-1) ?? "", /^━+$/); - assert.equal(lines[2]?.indexOf("█"), lines[3]?.indexOf("█")); + assert.equal(lines[0]?.indexOf("█"), lines[1]?.indexOf("█")); }); -test("welcome brand panel uses a compact tool identity on narrow terminals", () => { +test("welcome brand panel uses a compact plain-text wordmark on narrow terminals", () => { const panel = stripAnsi(renderWelcomeBrandPanel(48)); const lines = panel.split("\n"); - assert.match(panel, /\[ DEVMAP CLI \]/); assert.match(panel, /DEVMAP/); - assert.match(panel, /CODEBASE INTELLIGENCE/); + assert.doesNotMatch(panel, /DEVMAP CLI/); + assert.doesNotMatch(panel, /CODEBASE INTELLIGENCE/); assert.ok(lines.every((line) => line.length <= 48)); assert.doesNotMatch(panel, /██████╗/); assert.match(lines.at(-1) ?? "", /^━+$/);