diff --git a/PRD.md b/PRD.md index c2263b2..e9317a2 100644 --- a/PRD.md +++ b/PRD.md @@ -618,7 +618,7 @@ DevMap automatically detects the language used by the user. - `devmap ask` - `devmap explain` *(future)* -- `devmap onboard` *(future)* +- `devmap onboarding` - `devmap docs` *(future)* ### CLI Metadata @@ -1072,7 +1072,7 @@ Future CI should test: | Command | Phase | Priority | |---|---|---| -| `devmap onboard` | Phase 3 | High | +| `devmap onboarding` | MVP 0.1.0 candidate | High | | `devmap docs` | Phase 3 | Medium | | `devmap flow` | Phase 4 | Medium | | `devmap trace` | Phase 4 | Medium | @@ -1083,7 +1083,7 @@ Future CI should test: --- -### `devmap onboard` *(Phase 3 — High Priority)* +### `devmap onboarding` *(MVP 0.1.0 Candidate)* Purpose: developer productivity accelerator. @@ -1120,7 +1120,8 @@ docs/ ``` `devmap docs` generates documentation artifacts. -`devmap onboard` generates a learning/productivity guide. +`devmap onboarding` generates a learning/productivity guide from the current +snapshot. `devmap onboard` remains a shorthand alias. These are related but not the same. diff --git a/README.md b/README.md index 23e5354..3587c47 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,7 @@ snapshot.json | `DEVMAP.md` | DevMap instructions | | `AGENTS.md` | AI agent entry point | | `.devmap/snapshot.json` | Core project context | +| `ONBOARDING.md` | Optional onboarding guide | The snapshot is the primary output of DevMap. @@ -136,6 +137,11 @@ devmap analyze # Verify your setup devmap doctor +# Generate a reading guide from the snapshot +devmap onboarding +devmap onboarding --write +devmap onboarding --write --language id + # Ask questions about your codebase devmap ask "explain the main architecture" devmap ask "where is the auth logic?" @@ -143,6 +149,7 @@ devmap ask "what external services does this use?" # Machine-readable output for AI agents and scripts devmap ask "where is the auth logic?" --json +devmap onboarding --json ``` --- @@ -295,19 +302,19 @@ Node.js 18+ * [x] `devmap init` * [x] `devmap analyze` * [x] `devmap ask` +* [x] `devmap onboarding` * [x] `devmap doctor` ### Next -* [ ] `devmap onboard` * [ ] `devmap features` +* [ ] `devmap flow` * [ ] OpenAI provider * [ ] Gemini provider ### Later * [ ] `devmap explain` -* [ ] `devmap flow` * [ ] `devmap docs` * [ ] Local AI mode * [ ] VS Code Extension diff --git a/docs/commands.md b/docs/commands.md index d36000d..27af6b3 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -6,15 +6,17 @@ ## Overview -DevMap MVP provides four core project commands and one configuration command: +DevMap MVP provides five core project commands and one configuration command: * `devmap init` -* `devmap analyze` -* `devmap ask` +* `devmap analyze` +* `devmap ask` +* `devmap onboarding` * `devmap doctor` * `devmap config model` -No additional product commands should be added until the MVP is shipped. +Additional product commands should wait until the MVP is shipped unless the PRD +explicitly promotes them into the `0.1.0` scope. Future commands are documented in: @@ -395,9 +397,9 @@ Running quick analysis first... Then continue answering the question. -### Stale Snapshot Behavior - -If project files changed after last analyze: +### Stale Snapshot Behavior + +If project files changed after last analyze: ```txt Project changed since last analyze. @@ -407,12 +409,78 @@ Use existing snapshot or re-analyze first? [1] Use existing snapshot [2] Re-analyze now ``` - ---- - + +--- + +## `devmap onboarding` + +Generate a project onboarding guide from the current snapshot. + +Alias: `devmap onboard` + +### Purpose + +`devmap onboarding` turns `.devmap/snapshot.json` into a practical reading +guide for humans and AI agents. It should help answer: + +> Where should I start reading this project? + +### Usage + +```bash +devmap onboarding +devmap onboarding --write +devmap onboarding --write --language id +devmap onboarding --json +``` + +### Responsibilities + +* Read `.devmap/snapshot.json` +* Use `project`, `onboarding.recommendedPath`, `features`, `flows`, + `criticalFiles`, and `changeImpact` +* Include a concise project narrative from snapshot facts, with a trimmed + architecture note when useful +* Surface entry points, external services, and critical files before the + reading path +* Print a readable terminal guide by default +* Show a follow-up hint explaining that `--write` creates `ONBOARDING.md` +* Write `ONBOARDING.md` when `--write` is passed +* Ask for Indonesian or English when writing from an interactive terminal and + no language is provided +* Use `--language en` or `--language id` to skip the prompt +* Emit one structured JSON document when `--json` is passed +* Warn when the snapshot is stale + +### Output Sections + +1. What This Project Does +2. Mental Model +3. Main Concepts +4. Important Areas to Understand +5. Key Flows +6. Where to Start + +### Rules + +* Do not invent files that are not present in the snapshot +* Prefer snapshot-derived paths over generic advice +* Avoid placeholder wording such as `not inferred yet`; omit unavailable fields +* Explain what each important file is responsible for and why it should be read +* Avoid raw metadata dumps such as scores, import counts, and exported symbol + lists in human onboarding output +* Keep the guide useful without requiring an AI call +* Treat `devmap flow` and full docs generation as future commands +* Include snapshot freshness and agent navigation policy in JSON output +* Keep `--json` non-interactive; never prompt in machine-readable mode +* Default generated onboarding language is English; use `--language id` for + Bahasa Indonesia + +--- + ## `devmap doctor` - -Run diagnostics for DevMap setup. + +Run diagnostics for DevMap setup. ### Purpose @@ -542,6 +610,7 @@ devmap init --json devmap analyze --json devmap analyze --deep --json devmap ask "where is authentication handled?" --json +devmap onboarding --json devmap doctor --json devmap config model auto --json ``` @@ -559,8 +628,9 @@ Contract: DevMap JSON document `analyze --json` returns the project snapshot. `ask --json` returns the answer, -selected files, model, and token usage. `doctor --json` returns diagnostics and -issues as structured fields. +selected files, model, and token usage. `onboarding --json` returns guide +metadata and Markdown. `doctor --json` returns diagnostics and issues as +structured fields. --- @@ -575,8 +645,7 @@ They are not part of the current MVP command scope. | `devmap features` | Detect implemented project features | | `devmap explain` | Explain folders, modules, and architecture | | `devmap flow` | Explain system flows as narrative steps | -| `devmap docs` | Generate project documentation | -| `devmap onboard` | Generate onboarding guide | +| `devmap docs` | Generate project documentation | | `devmap deadcode` | Detect unused files, exports, and functions | | `devmap report` | Generate project health report | | `devmap watch` | Auto-update snapshot on file changes | diff --git a/docs/design.md b/docs/design.md index 335f1a9..d7425bf 100644 --- a/docs/design.md +++ b/docs/design.md @@ -172,9 +172,10 @@ Start with: Popular commands: - devmap analyze scan current project - devmap ask "..." ask your codebase -``` + devmap analyze scan current project + devmap ask "..." ask your codebase + devmap onboarding generate reading guide +``` --- diff --git a/docs/development-testing.md b/docs/development-testing.md index 21f6d08..6e378b2 100644 --- a/docs/development-testing.md +++ b/docs/development-testing.md @@ -7,6 +7,7 @@ Packaged-command verification should include machine-readable output: ```bash devmap analyze --json devmap ask "where is the main entry point?" --json +devmap onboarding --json devmap doctor --json ``` @@ -29,6 +30,8 @@ With a live Groq key, run: devmap analyze --fresh devmap ask "explain the main architecture" devmap ask "explain the main architecture" --json +devmap onboarding +devmap onboarding --write ``` Human output should appear progressively without raw Markdown markers. JSON diff --git a/docs/for-me-personal/PROGRESS.md b/docs/for-me-personal/PROGRESS.md index fd3c222..6b94ffd 100644 --- a/docs/for-me-personal/PROGRESS.md +++ b/docs/for-me-personal/PROGRESS.md @@ -1,6 +1,27 @@ # Progress DevMap -Terakhir diperbarui: 2026-06-18 +Terakhir diperbarui: 2026-06-19 + +## Update 2026-06-19 + +### Onboarding Command + +- `devmap onboarding` ditambahkan sebagai kandidat MVP 0.1.0, dengan alias + `devmap onboard`. +- Command membaca `.devmap/snapshot.json` dan menghasilkan guide berbasis + snapshot tanpa membutuhkan AI call. +- Output human berisi Project Overview, Recommended Reading Path, Feature Map, + Important Flows, Change Impact Notes, dan Agent Workflow. +- `devmap onboarding --write` menulis `ONBOARDING.md`. +- `devmap onboarding --json` menghasilkan satu dokumen JSON untuk agent, + editor, atau script. +- README, PRD, command docs, roadmap, design docs, dan CLI README diperbarui + supaya onboarding tidak lagi tercatat sebagai future-only command. +- Renderer onboarding direfaktor menjadi guide pemahaman untuk developer dan + AI agent: pembuka menjelaskan tujuan project, mental model, konsep utama, + area penting untuk dibaca, flow penting, dan rekomendasi mulai membaca. +- Default bahasa onboarding tetap English, sementara `--language id` dan prompt + interaktif `--write` tetap dapat menghasilkan Bahasa Indonesia. ## Update 2026-06-18 diff --git a/docs/for-me-personal/TEST.md b/docs/for-me-personal/TEST.md index 1dfb3df..1f2ba26 100644 --- a/docs/for-me-personal/TEST.md +++ b/docs/for-me-personal/TEST.md @@ -15,6 +15,51 @@ 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 Command + +Focused automated test: + +```powershell +pnpm --filter devmap exec tsx --test test/onboarding-command.test.ts test/json-output.test.ts +``` + +Manual source-mode check dari root DevMap: + +```powershell +$root = (Get-Location).Path +pnpm dev:cli analyze "$root" +pnpm dev:cli onboarding "$root" +pnpm dev:cli onboarding "$root" --json +pnpm dev:cli onboarding "$root" --write +pnpm dev:cli onboarding "$root" --write --language id +``` + +Catatan: `pnpm dev:cli` memakai `pnpm --filter devmap`, sehingga command +source-mode berjalan dari `packages/cli`. Untuk mengetes root workspace DevMap, +selalu kirim path target eksplisit seperti contoh di atas. + +Expected result: + +- `devmap onboarding` membaca `.devmap/snapshot.json` yang sudah ada. +- Jika snapshot belum ada atau stale, jalankan `pnpm dev:cli analyze` dulu. +- Jika snapshot stale, human output memberi warning dan JSON berisi + `snapshot.stale: true`. +- JSON output menyertakan `agentInstructions` agar agent mengikuti policy + snapshot-first. +- Human output berfokus sebagai guide pemahaman, bukan file index: What This + Project Does, Mental Model, Main Concepts, Important Areas to Understand, Key + Flows, dan Where to Start. +- Setiap file penting dalam reading area menyertakan `Purpose` dan + `Why read this`, bukan score/import count/export list mentah. +- Entry point kosong di feature/flow tidak boleh ditampilkan sebagai + `not inferred yet`; field tersebut cukup dihilangkan. +- `--json` menghasilkan satu dokumen JSON tanpa ANSI atau dekorasi terminal. +- `--write` membuat atau memperbarui `ONBOARDING.md` di root project target. +- Di terminal interaktif, `--write` menanyakan bahasa onboarding jika + `--language` belum diberikan. Default bahasa tetap English. +- `--language en` dan `--language id` melewati prompt, cocok untuk automation + dan agent. + ## Context Builder Ranking Jalankan focused test ranking dan evaluation: diff --git a/docs/roadmap.md b/docs/roadmap.md index 1056f63..adbbec3 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -41,8 +41,9 @@ adding AI on top. If the foundation is wrong, AI output will be wrong too. - Stale snapshot detection + user prompt - All error scenarios handled (no raw stack traces) -**Deliverable:** `devmap analyze` with AI interpretation. -`devmap ask` with context-aware answers. +**Deliverable:** `devmap analyze` with AI interpretation, +`devmap ask` with context-aware answers, and `devmap onboarding` for a +snapshot-based reading guide when the output is stable enough for `0.1.0`. --- @@ -50,8 +51,9 @@ adding AI on top. If the foundation is wrong, AI output will be wrong too. **Goal:** DevMap generates useful project documentation automatically. **Tasks:** -- `devmap docs` — generate structured markdown docs folder -- `devmap onboard` — generate onboarding guide with reading order +- `devmap docs` — generate structured markdown docs folder +- Expand `devmap onboarding` beyond the MVP guide when richer snapshot fields + are available **Deliverable:** ``` @@ -118,6 +120,6 @@ Not planned. Not scheduled. Revisit when Phase 5 ships. | 1.0.0 | 2 | Stable `devmap analyze` + `devmap ask` release | | 1.1.0 | 2 | Performance improvements, cache optimization | | 1.2.0 | 2 | Express support solidified | -| 2.0.0 | 3 | `devmap docs` + `devmap onboard` | +| 2.0.0 | 3 | `devmap docs` + expanded onboarding | | 3.0.0 | 4 | `devmap deadcode` + `devmap flow` + `devmap report` | | 4.0.0 | 5 | OpenAI + Gemini support | diff --git a/packages/cli/README.md b/packages/cli/README.md index bf52419..33baeea 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -35,6 +35,7 @@ Run these commands from the root of the project you want to understand: devmap init devmap analyze devmap ask "How does authentication work?" +devmap onboarding devmap doctor ``` @@ -74,6 +75,8 @@ devmap analyze devmap analyze --deep devmap analyze --fresh devmap ask "Where is payment logic handled?" +devmap onboarding --write +devmap onboarding --write --language id devmap doctor devmap config model auto ``` @@ -104,6 +107,7 @@ Use `--json` for scripts, editors, CI, or AI agents: ```bash devmap analyze --json devmap ask "Where is authentication handled?" --json +devmap onboarding --json devmap doctor --json ``` diff --git a/packages/cli/src/ai/snapshotEnrichment.ts b/packages/cli/src/ai/snapshotEnrichment.ts index 7172e94..2a3e9bd 100644 --- a/packages/cli/src/ai/snapshotEnrichment.ts +++ b/packages/cli/src/ai/snapshotEnrichment.ts @@ -71,7 +71,12 @@ function selectEligibleFiles(snapshot: ProjectMap): FilePurposeInput[] { .filter(([path, file]) => file.scope !== "test" && file.scope !== "docs" - && (criticalPaths.has(path) || file.importance >= 20) + && ( + criticalPaths.has(path) + || file.importance >= 20 + || file.featureRefs.length > 0 + || isSemanticEnrichmentCandidate(path, file) + ) ) .map(([path, file]) => ({ path, @@ -91,7 +96,9 @@ function buildFilePurposeMessages(files: FilePurposeInput[]): AiMessage[] { "You summarize codebase files for a compact DevMap snapshot.", "Return a JSON array only.", "Each item must have path, purpose, and searchTerms.", - "purpose must be one sentence maximum and describe what the file does.", + "purpose must be one sentence maximum.", + "Use this purpose shape when possible: '[what this file exports] used by [what consumes it] to [accomplish what]'.", + "Prefer specific semantics such as auth provider config, middleware guard, session provider, route handler, or UI consumer.", "searchTerms must be max 8 concrete retrieval terms.", "Avoid vague terms: data, logic, handler, service, feature, app, page.", "Do not invent files, frameworks, or behavior not supported by the input." @@ -104,6 +111,15 @@ function buildFilePurposeMessages(files: FilePurposeInput[]): AiMessage[] { ]; } +function isSemanticEnrichmentCandidate(path: string, file: ProjectMap["fileIndex"][string]): boolean { + const normalizedPath = path.toLowerCase(); + const text = `${normalizedPath} ${file.exportedSymbols.join(" ")} ${file.imports.join(" ")}`.toLowerCase(); + + return /(^|\/)(src\/)?(auth|proxy|middleware)\.[cm]?[jt]sx?$/.test(normalizedPath) + || /providers?\.[cm]?[jt]sx?$/.test(normalizedPath) && text.includes("next-auth") + || /(app-shell|layout)\.[cm]?[jt]sx?$/.test(normalizedPath) && /\b(signout|usesession|sessionprovider)\b/.test(text); +} + function buildFeatureTermsMessages(snapshot: ProjectMap): AiMessage[] { const features = snapshot.features.map((feature) => ({ name: feature.name, diff --git a/packages/cli/src/analyzers/featureDetector.ts b/packages/cli/src/analyzers/featureDetector.ts index 3b6ee79..7bd8a30 100644 --- a/packages/cli/src/analyzers/featureDetector.ts +++ b/packages/cli/src/analyzers/featureDetector.ts @@ -15,6 +15,8 @@ export type FeatureInfo = { evidence: string[]; }; +export type AuthSemanticRole = "auth-config" | "guard" | "provider" | "consumer"; + const FEATURE_SIGNALS: Array<{ name: string; terms: string[]; @@ -68,7 +70,8 @@ export function detectFeatures( ])); } - return features.sort((left, right) => left.name.localeCompare(right.name)); + return enrichAuthenticationFeature(features, scopedFiles) + .sort((left, right) => left.name.localeCompare(right.name)); } function createFeatureInfo( @@ -100,6 +103,142 @@ function matchesSignal(file: ScannedFile, terms: string[]): boolean { ); } +function enrichAuthenticationFeature(features: FeatureInfo[], files: ScannedFile[]): FeatureInfo[] { + const authFiles = collectAuthenticationFeatureFiles(files); + if (authFiles.length === 0) { + return features; + } + + const existingAuth = features.find((feature) => feature.name === "Authentication"); + if (existingAuth) { + return features.map((feature) => + feature.name === "Authentication" + ? { + ...feature, + files: orderAuthenticationFiles([...new Set([...feature.files, ...authFiles])]), + evidence: orderAuthenticationFiles([...new Set([...feature.evidence, ...authFiles])]), + confidence: "high" + } + : feature + ); + } + + return [ + ...features, + createFeatureInfo("Authentication", authFiles, [ + "auth", + "authentication", + "login", + "session", + "jwt", + "next-auth" + ]) + ]; +} + +function collectAuthenticationFeatureFiles(files: ScannedFile[]): string[] { + return orderAuthenticationFiles(files + .filter((file) => isArchitectureSource(file.path)) + .filter((file) => !isAnalyzerImplementationFile(file.path)) + .filter((file) => { + const imports = readImportSpecifiers(file.content); + const symbols = readSemanticSymbols(file.content); + return detectAuthenticationSemanticRole(file.path, symbols, imports, file.content) !== null; + }) + .map((file) => file.path)); +} + +function isAnalyzerImplementationFile(path: string): boolean { + return /(^|\/)(analyzers?|detectors?)\//i.test(path); +} + +export function detectAuthenticationSemanticRole( + path: string, + symbols: string[], + imports: string[], + content = "" +): AuthSemanticRole | null { + const normalizedPath = path.toLowerCase(); + const text = `${normalizedPath} ${symbols.join(" ")} ${imports.join(" ")} ${content}`.toLowerCase(); + + if (/(^|\/)src\/auth\.[cm]?[jt]sx?$/.test(normalizedPath) + || /(^|\/)auth\.[cm]?[jt]sx?$/.test(normalizedPath) + || (hasSymbol(symbols, "auth") && hasSymbol(symbols, "handlers")) + || /\b(nextauth|getserversession|getsession|credentials)\b/.test(text) + ) { + return "auth-config"; + } + + if (/(^|\/)(src\/)?(proxy|middleware)\.[cm]?[jt]sx?$/.test(normalizedPath) + || /\b(auth|session|token|jwt|redirect|unauthorized|authenticated)\b/.test(text) + && /\b(middleware|guard|proxy)\b/.test(text) + ) { + return "guard"; + } + + if (/providers?\.[cm]?[jt]sx?$/.test(normalizedPath) + && (imports.some((specifier) => specifier.includes("next-auth")) + || /\b(sessionprovider|usesession)\b/.test(text)) + ) { + return "provider"; + } + + if (/(app-shell|layout)\.[cm]?[jt]sx?$/.test(normalizedPath) + && /\b(signout|handlesignout|usesession|sessionprovider)\b/.test(text) + ) { + return "consumer"; + } + + if (/\b(auth|nextauth|getserversession|getsession|signin|signout|usesession|sessionprovider|handlelogin|handleregister|handlesignout)\b/.test(text)) { + return "consumer"; + } + + return null; +} + +export function orderAuthenticationFiles(files: string[]): string[] { + return [...new Set(files)].sort((left, right) => + authenticationFilePriority(left) - authenticationFilePriority(right) + || left.localeCompare(right) + ); +} + +export function authenticationFilePriority(path: string): number { + const normalized = path.toLowerCase(); + if (/(^|\/)(src\/)?(proxy|middleware)\.[cm]?[jt]sx?$/.test(normalized)) return 10; + if (/(^|\/)(src\/)?auth\.[cm]?[jt]sx?$/.test(normalized)) return 20; + if (/\/api\/.*register|register.*\/route\.[cm]?[jt]s$/.test(normalized)) return 30; + if (/login.*(page|form)\.[cm]?[jt]sx?$/.test(normalized)) return 40; + if (/register.*(page|form)\.[cm]?[jt]sx?$/.test(normalized)) return 45; + if (/providers?\.[cm]?[jt]sx?$/.test(normalized)) return 50; + if (/(dashboard|app-shell|layout)\.[cm]?[jt]sx?$/.test(normalized)) return 60; + return 80; +} + +function readSemanticSymbols(content: string): string[] { + const symbols = new Set(); + const patterns = [ + /export\s+(?:async\s+)?function\s+([A-Za-z_$][A-Za-z0-9_$]*)/g, + /export\s+const\s+([A-Za-z_$][A-Za-z0-9_$]*)/g, + /export\s+class\s+([A-Za-z_$][A-Za-z0-9_$]*)/g, + /(?:function|const)\s+([A-Za-z_$][A-Za-z0-9_$]*(?:Auth|Session|Login|Register|SignOut|Provider)[A-Za-z0-9_$]*)/g + ]; + + for (const pattern of patterns) { + let match = pattern.exec(content); + while (match) { + symbols.add(match[1]); + match = pattern.exec(content); + } + } + + return [...symbols]; +} + +function hasSymbol(symbols: string[], name: string): boolean { + return symbols.some((symbol) => symbol.toLowerCase() === name); +} + function readImportSpecifiers(content: string): string[] { const imports: string[] = []; const pattern = /(?:import\s+(?:[^'"]+\s+from\s+)?|require\()\s*['"]([^'"]+)['"]/g; diff --git a/packages/cli/src/analyzers/projectMap.ts b/packages/cli/src/analyzers/projectMap.ts index 1623c5a..f5e43d4 100644 --- a/packages/cli/src/analyzers/projectMap.ts +++ b/packages/cli/src/analyzers/projectMap.ts @@ -2,7 +2,13 @@ import { hashContent } from "../cache/fileHash.js"; import { detectDatabase, type DatabaseInfo } from "./databaseDetector.js"; import { buildDependencyGraph, countReferences } from "./dependencyGraph.js"; import { detectEntryPoints } from "./entryPoints.js"; -import { detectFeatures, type FeatureInfo } from "./featureDetector.js"; +import { + authenticationFilePriority, + detectAuthenticationSemanticRole, + detectFeatures, + orderAuthenticationFiles, + type FeatureInfo +} from "./featureDetector.js"; import type { ScannedFile } from "./fileScanner.js"; import { scanFiles } from "./fileScanner.js"; import { detectFramework, type Framework } from "./frameworkDetector.js"; @@ -218,6 +224,12 @@ function rankCriticalFiles( reasons.push("core project concern"); } + const semanticBonus = calculateCriticalSemanticBonus(file); + if (semanticBonus > 0) { + score += semanticBonus; + reasons.push("semantic feature anchor"); + } + if (/(^|\/)(page|layout|route|server|app|main|index)\.[cm]?[jt]sx?$/.test(file.path)) { score += 2; reasons.push("framework convention"); @@ -272,7 +284,10 @@ function createFileIndexEntry( references[file.path] ?? 0, entryPoints.includes(file.path), criticalFile?.score ?? 0, - featureRefs.length + featureRefs, + scope, + exportedSymbols, + topFunctions ); const searchTerms = buildFileSearchTerms(file.path, scope, exportedSymbols, topFunctions, featureRefs); const purpose = inferFilePurpose(file.path, scope, exportedSymbols, topFunctions, featureRefs); @@ -323,16 +338,59 @@ function calculateImportance( referencedBy: number, isEntryPoint: boolean, criticalScore: number, - featureCount: number + featureRefs: string[], + scope: FileScope, + exportedSymbols: string[], + topFunctions: FileIndexEntry["topFunctions"] ): number { - let importance = referencedBy * 10 + criticalScore * 5 + featureCount * 8; + let importance = referencedBy * 10 + criticalScore * 5 + featureRefs.length * 8; if (isEntryPoint) importance += 20; if (/(^|\/)(index|main|app|server|layout|page|route)\./.test(path)) importance += 5; + if (scope !== "test" && scope !== "docs") { + importance += calculateSemanticImportanceBonus(path, exportedSymbols, topFunctions, featureRefs); + } return Math.min(100, importance); } +function calculateSemanticImportanceBonus( + path: string, + exportedSymbols: string[], + topFunctions: FileIndexEntry["topFunctions"], + featureRefs: string[] +): number { + const role = detectAuthenticationSemanticRole( + path, + [...exportedSymbols, ...topFunctions.map((item) => item.name)], + [] + ); + if (role === "auth-config") return 70; + if (role === "guard") return 60; + if (role === "provider") return 45; + if (role === "consumer") return 35; + if (isFeatureConfigFile(path, featureRefs)) return 30; + return featureRefs.length > 0 ? 20 : 0; +} + +function calculateCriticalSemanticBonus(file: ScannedFile): number { + const exportedSymbols = findExportedSymbols(file.content); + const topFunctions = findTopFunctions(file.content); + const imports = readImportSpecifiers(file.content); + const role = detectAuthenticationSemanticRole( + file.path, + [...exportedSymbols, ...topFunctions.map((item) => item.name)], + imports, + file.content + ); + + if (role === "auth-config") return 50; + if (role === "guard") return 40; + if (role === "provider") return 35; + if (role === "consumer") return 25; + return 0; +} + function buildFileSearchTerms( path: string, scope: FileScope, @@ -466,6 +524,24 @@ function getLineNumber(content: string, index: number): number { return content.slice(0, index).split(/\r?\n/).length; } +function isFeatureConfigFile(path: string, featureRefs: string[]): boolean { + return featureRefs.length > 0 + && /(^|\/)src\/(lib|utils)\/(config|constants)\.[cm]?[jt]sx?$/.test(path.toLowerCase()); +} + +function readImportSpecifiers(content: string): string[] { + const imports: string[] = []; + const pattern = /(?:import\s+(?:[^'"]+\s+from\s+)?|export\s+[^'"]+\s+from\s+|require\()\s*['"]([^'"]+)['"]/g; + let match = pattern.exec(content); + + while (match) { + imports.push(match[1].toLowerCase()); + match = pattern.exec(content); + } + + return imports; +} + function attachFeatureEntryPoints( features: FeatureInfo[], routes: RouteInfo[], @@ -480,11 +556,15 @@ function attachFeatureEntryPoints( ...feature.files.filter((file) => entryPoints.includes(file)), ...relatedRouteEntries ])].sort(); - const entryPoint = chooseFeatureEntryPoint(feature.files, relatedEntries, routes, graph); - const businessFlow = buildFeatureBusinessFlow(feature.name, entryPoint, graph); + const orderedFiles = feature.name === "Authentication" + ? orderAuthenticationFiles(feature.files) + : feature.files; + const entryPoint = chooseFeatureEntryPoint(feature.name, orderedFiles, relatedEntries, routes, graph); + const businessFlow = buildFeatureBusinessFlow(feature.name, entryPoint, graph, orderedFiles); return { ...feature, + files: orderedFiles, ...(entryPoint ? { entryPoint } : {}), entryPoints: relatedEntries, businessFlow, @@ -496,11 +576,24 @@ function attachFeatureEntryPoints( } function chooseFeatureEntryPoint( + featureName: string, files: string[], relatedEntries: string[], routes: RouteInfo[], graph: Record ): string | undefined { + if (featureName === "Authentication") { + const apiEntry = relatedEntries.find((file) => /(^|\/)api\//.test(file)); + if (apiEntry) { + return apiEntry; + } + + const authConfig = files.find((file) => authenticationFilePriority(file) === 20); + if (authConfig) { + return authConfig; + } + } + if (relatedEntries.length > 0) { return relatedEntries[0]; } @@ -520,8 +613,21 @@ function chooseFeatureEntryPoint( function buildFeatureBusinessFlow( featureName: string, entryPoint: string | undefined, - graph: Record + graph: Record, + featureFiles: string[] ): string[] { + if (featureName === "Authentication" && featureFiles.length > 0) { + const orderedFiles = entryPoint + ? [entryPoint, ...featureFiles.filter((file) => file !== entryPoint)] + : featureFiles; + + return orderedFiles.slice(0, 8).map((file) => + file === entryPoint && /(^|\/)api\//.test(file) + ? `Start at ${file}.` + : describeAuthenticationFlowStep(file, graph[file] ?? []) + ); + } + if (!entryPoint) { return [`Identify files related to ${featureName}.`]; } @@ -533,10 +639,40 @@ function buildFeatureBusinessFlow( steps.push(`Follow dependency ${file}.`); } - steps.push(`Review related files for ${featureName}.`); return steps; } +function describeAuthenticationFlowStep(file: string, dependencies: string[]): string { + const normalized = file.toLowerCase(); + const dependencyText = dependencies.length > 0 + ? ` and connects to ${dependencies.slice(0, 2).join(", ")}` + : ""; + + if (/(^|\/)(src\/)?(proxy|middleware)\.[cm]?[jt]sx?$/.test(normalized)) { + return `Guard requests in ${file} by checking authentication state before protected routes${dependencyText}.`; + } + if (/(^|\/)(src\/)?auth\.[cm]?[jt]sx?$/.test(normalized)) { + return `Configure authentication in ${file}, including providers, session/JWT callbacks, and shared auth helpers${dependencyText}.`; + } + if (/register.*\/route\.[cm]?[jt]s$|\/api\/.*register/.test(normalized)) { + return `Handle registration in ${file}, validating new users before creating credentials${dependencyText}.`; + } + if (/login.*(page|form)\.[cm]?[jt]sx?$/.test(normalized)) { + return `Render login UI in ${file} and submit credentials to the auth provider${dependencyText}.`; + } + if (/register.*(page|form)\.[cm]?[jt]sx?$/.test(normalized)) { + return `Render registration UI in ${file} and collect account creation details${dependencyText}.`; + } + if (/providers?\.[cm]?[jt]sx?$/.test(normalized)) { + return `Expose session context in ${file} so client components can read authentication state${dependencyText}.`; + } + if (/(app-shell|layout)\.[cm]?[jt]sx?$/.test(normalized)) { + return `Consume session state in ${file} for authenticated layouts, user navigation, or sign-out behavior${dependencyText}.`; + } + + return `Review authentication-related behavior in ${file}${dependencyText}.`; +} + function generateMinimalFlows( features: FeatureInfo[], fileIndex: Record, diff --git a/packages/cli/src/commands/onboarding.ts b/packages/cli/src/commands/onboarding.ts new file mode 100644 index 0000000..1a9ef28 --- /dev/null +++ b/packages/cli/src/commands/onboarding.ts @@ -0,0 +1,1118 @@ +import { writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import type { ProjectMap } from "../analyzers/projectMap.js"; +import { isSnapshotStale, readSnapshotOrThrow } from "../cache/snapshot.js"; +import { output, withJsonOutput } from "../utils/output.js"; +import { createPrompt, type Prompt } from "../utils/prompt.js"; + +export type OnboardingLanguage = "en" | "id"; + +export type OnboardingOptions = { + json?: boolean; + language?: string; + projectRoot?: string; + prompt?: Prompt; + target?: string; + write?: boolean; +}; + +export type OnboardingGuide = { + status: "ok"; + language: OnboardingLanguage; + project: ProjectMap["project"]; + overview: string | null; + snapshot: { + generatedAt: string; + stale: boolean; + }; + agentInstructions: ProjectMap["agentInstructions"]; + entryPoints: string[]; + criticalFiles: ProjectMap["criticalFiles"]; + externalServices: string[]; + recommendedPath: string[]; + features: Array<{ + name: string; + entryPoint: string | null; + businessFlow: string[]; + }>; + flows: Array<{ + name: string; + type: ProjectMap["flows"][number]["type"]; + entryPoint: string | null; + steps: string[]; + }>; + changeImpact: ProjectMap["changeImpact"]; + markdown: string; + writtenPath: string | null; +}; + +export async function onboardingCommand(options: OnboardingOptions = {}): Promise { + if (options.json) { + await withJsonOutput(async () => { + output.json(await runOnboarding(options)); + }); + return; + } + + const guide = await runOnboarding(options); + output.section("DevMap Onboarding"); + if (guide.snapshot.stale) { + output.warning("Snapshot is stale: this guide may use outdated project structure."); + output.note("Run devmap analyze --fresh, then repeat devmap onboarding."); + } + output.markdown(guide.markdown); + + if (guide.writtenPath) { + output.success(`Wrote ${guide.writtenPath}`); + } else { + output.note("To write this guide to ONBOARDING.md, run devmap onboarding --write."); + } +} + +async function runOnboarding(options: OnboardingOptions): Promise { + const projectRoot = resolve(options.projectRoot ?? options.target ?? "."); + const snapshot = await readSnapshotOrThrow(projectRoot); + const stale = await isSnapshotStale(projectRoot, snapshot); + const language = await resolveOnboardingLanguage(options); + const markdown = buildOnboardingMarkdown(snapshot, { stale, language }); + const writtenPath = options.write ? "ONBOARDING.md" : null; + + if (writtenPath) { + await writeFile(join(projectRoot, writtenPath), `${markdown}\n`, "utf8"); + } + + return { + status: "ok", + language, + project: snapshot.project, + overview: snapshot.ai?.architecture ?? null, + snapshot: { + generatedAt: snapshot.generatedAt, + stale + }, + agentInstructions: snapshot.agentInstructions, + entryPoints: snapshot.entryPoints, + criticalFiles: snapshot.criticalFiles, + externalServices: snapshot.externalServices, + recommendedPath: snapshot.onboarding.recommendedPath, + features: snapshot.features.map((feature) => ({ + name: feature.name, + entryPoint: feature.entryPoint ?? null, + businessFlow: feature.businessFlow + })), + flows: snapshot.flows.map((flow) => ({ + name: flow.name, + type: flow.type, + entryPoint: flow.entryPoint ?? null, + steps: flow.steps.map((step) => step.file ?? step.label) + })), + changeImpact: snapshot.changeImpact, + markdown, + writtenPath + }; +} + +export function buildOnboardingMarkdown( + snapshot: ProjectMap, + options: { language?: OnboardingLanguage; stale?: boolean } = {} +): string { + const language = options.language ?? "en"; + const labels = getLabels(language); + const guide = getGuideLabels(language); + const sections = [ + "# Onboarding Project", + "", + ...(options.stale ? [ + guide.staleNote, + "" + ] : []), + `## ${guide.whatProjectDoes}`, + "", + ...renderProjectIntroduction(snapshot, language), + "", + "## Mental Model", + "", + ...renderMentalModel(snapshot, language), + "", + `## ${guide.mainConcepts}`, + "", + ...renderMainConcepts(snapshot, language), + "", + `## ${guide.importantAreas}`, + "", + ...renderReadingAreas(snapshot, language), + "", + ...renderImportantFlows(snapshot, language), + "", + `## ${guide.whereToStart}`, + "", + ...renderWhereToStart(snapshot, language), + "", + labels.generatedBy + ]; + + return sections.join("\n").replace(/\n{3,}/g, "\n\n"); +} + +async function resolveOnboardingLanguage(options: OnboardingOptions): Promise { + const explicitLanguage = normalizeOnboardingLanguage(options.language); + if (explicitLanguage) { + return explicitLanguage; + } + + if (!options.write || options.json || (!options.prompt && !process.stdin.isTTY)) { + return "en"; + } + + const prompt = options.prompt ?? createPrompt(); + + try { + const answer = await prompt.ask( + "Onboarding language? [en/id] (default: en): " + ); + return normalizeOnboardingLanguage(answer) ?? "en"; + } finally { + prompt.close(); + } +} + +function normalizeOnboardingLanguage(value: string | undefined): OnboardingLanguage | null { + const normalized = value?.trim().toLowerCase(); + if (!normalized) { + return null; + } + + if (["id", "indo", "indonesia", "bahasa indonesia"].includes(normalized)) { + return "id"; + } + if (["en", "eng", "english", "inggris"].includes(normalized)) { + return "en"; + } + + return null; +} + +type ReadingPriority = 1 | 2 | 3 | 4; + +type ReadingItem = { + path: string; + priority: ReadingPriority; + purpose: string; + why: string; +}; + +function renderProjectIntroduction(snapshot: ProjectMap, language: OnboardingLanguage): string[] { + const name = snapshot.project.name || (language === "id" ? "Project ini" : "This project"); + const framework = snapshot.project.framework !== "unknown" + ? language === "id" ? `berbasis ${snapshot.project.framework}` : `built with ${snapshot.project.framework}` + : null; + const projectLanguage = snapshot.project.language !== "unknown" + ? language === "id" ? `menggunakan ${snapshot.project.language}` : snapshot.project.language + : null; + const features = snapshot.features.map((feature) => feature.name); + const services = snapshot.externalServices; + const entryPoint = snapshot.entryPoints[0]; + const lines = language === "id" + ? [ + `${name} adalah project ${[projectLanguage, framework].filter(Boolean).join(" ") || "software"} yang dipetakan dari snapshot DevMap.`, + features.length > 0 + ? `Area utamanya terlihat dari fitur terdeteksi seperti ${formatInlineList(features, "id")}.` + : "Snapshot belum mendeteksi fitur domain yang kuat, jadi guide ini fokus pada entry point dan file penting yang tersedia.", + entryPoint + ? `Untuk memahami cara project berjalan, mulai dari entry point ${entryPoint}, lalu ikuti file yang terhubung dengannya.` + : null, + services.length > 0 + ? `Project ini juga terhubung ke external service seperti ${formatInlineList(services, "id")}, jadi bagian integrasi perlu dibaca dengan hati-hati.` + : null + ].filter((line): line is string => Boolean(line)) + : [ + `${name} is a ${formatEnglishProjectDescriptor(projectLanguage, framework)} project mapped from the DevMap snapshot.`, + features.length > 0 + ? `Its main areas include detected features such as ${formatInlineList(features, "en")}.` + : "The snapshot does not show strong domain features yet, so this guide focuses on entry points and important files.", + entryPoint + ? `To understand how the project runs, start from ${entryPoint}, then follow the files connected to it.` + : null, + services.length > 0 + ? `The project also integrates with external services such as ${formatInlineList(services, "en")}, so integration files deserve extra care.` + : null + ].filter((line): line is string => Boolean(line)); + + return lines.slice(0, 4); +} + +function formatEnglishProjectDescriptor(language: string | null, framework: string | null): string { + if (language && framework) { + return `${language} ${framework}`; + } + return language ?? framework ?? "software"; +} + +function renderMentalModel(snapshot: ProjectMap, language: OnboardingLanguage): string[] { + const hasCli = hasScope(snapshot, "cli") || hasPathSegment(snapshot, "commands"); + const hasRoutes = snapshot.routes.length > 0 || snapshot.apiRoutes.length > 0; + const hasDatabase = Boolean(snapshot.database); + const hasSnapshotEngine = hasPathSegment(snapshot, "snapshot") || hasPathSegment(snapshot, "projectMap"); + + if (hasCli || hasSnapshotEngine) { + return language === "id" ? [ + "User menjalankan CLI command.", + "Command membaca konfigurasi dan menentukan proses yang dibutuhkan.", + "Project files discan dan dianalisis menjadi Project Map.", + "Hasil analisis disimpan sebagai Snapshot.", + "Command lain memakai Snapshot untuk menjawab, membuat guide, atau memberi output." + ] : [ + "User runs a CLI command.", + "The command reads configuration and decides which process is needed.", + "Project files are scanned and analyzed into a Project Map.", + "The analysis result is saved as a Snapshot.", + "Other commands reuse the Snapshot to answer, guide, or render output." + ]; + } + + if (hasRoutes) { + const steps = (language === "id" ? [ + "User membuka route atau mengirim request.", + snapshot.routes.length > 0 ? "Route UI merender halaman dan menghubungkan komponen terkait." : null, + snapshot.apiRoutes.length > 0 ? "API route menjalankan business logic di sisi server." : null, + hasDatabase ? "Data layer membaca atau menulis data yang dibutuhkan." : null, + "Response dikembalikan ke user atau client." + ] : [ + "User opens a route or sends a request.", + snapshot.routes.length > 0 ? "UI routes render pages and connect related components." : null, + snapshot.apiRoutes.length > 0 ? "API routes run server-side business logic." : null, + hasDatabase ? "The data layer reads or writes the required data." : null, + "A response is returned to the user or client." + ]).filter((line): line is string => Boolean(line)); + return steps.slice(0, 10); + } + + if (snapshot.entryPoints.length > 0) { + return language === "id" ? [ + "Runtime masuk melalui entry point project.", + "Entry point memanggil module utama yang terhubung lewat import.", + "File penting dan feature-related files menjelaskan responsibility utama.", + "Output akhir mengikuti framework atau runtime yang dipakai project." + ] : [ + "Runtime starts from the project entry point.", + "The entry point calls main modules connected through imports.", + "Important files and feature-related files explain the main responsibilities.", + "The final output follows the framework or runtime used by the project." + ]; + } + + return language === "id" ? [ + "Snapshot belum punya flow runtime yang kuat.", + "Mulai dari file penting dan dependency yang terdeteksi sebelum membuka area lain." + ] : [ + "The snapshot does not expose a strong runtime flow yet.", + "Start from important files and detected dependencies before opening other areas." + ]; +} + +function renderMainConcepts(snapshot: ProjectMap, language: OnboardingLanguage): string[] { + const concepts: Array<{ name: string; description: string; reason: string }> = []; + const addConcept = (name: string, description: string, reason: string) => { + if (!concepts.some((concept) => concept.name === name)) { + concepts.push({ name, description, reason }); + } + }; + + for (const feature of snapshot.features.slice(0, 4)) { + addConcept( + feature.name, + describeFeatureConcept(feature, language), + feature.entryPoint + ? language === "id" + ? `Penting karena punya entry point ${feature.entryPoint}.` + : `It matters because it has entry point ${feature.entryPoint}.` + : language === "id" + ? "Penting karena beberapa file snapshot mengarah ke area ini." + : "It matters because multiple snapshot files point to this area." + ); + } + + if (snapshot.entryPoints.length > 0) { + addConcept( + "Entry Points", + language === "id" + ? "File tempat runtime, command, atau request mulai masuk ke project." + : "Files where runtime, commands, or requests enter the project.", + language === "id" + ? "Ini membantu agent membuka file pertama yang benar sebelum membaca detail lain." + : "This helps an agent open the right first file before reading details." + ); + } + + if (snapshot.routes.length > 0 || snapshot.apiRoutes.length > 0) { + addConcept( + "Routes", + language === "id" + ? "Mapping halaman atau API yang menjadi permukaan utama project." + : "Page or API mappings that form the main project surface.", + language === "id" + ? "Routes menunjukkan bagaimana user atau client berinteraksi dengan sistem." + : "Routes show how users or clients interact with the system." + ); + } + + if (snapshot.database) { + addConcept( + "Database Layer", + language === "id" + ? `Bagian project yang berhubungan dengan ${snapshot.database.provider}.` + : `The project area connected to ${snapshot.database.provider}.`, + language === "id" + ? "Penting untuk memahami persistence, schema, dan risiko perubahan data." + : "This matters for understanding persistence, schema, and data-change risk." + ); + } + + if (snapshot.externalServices.length > 0) { + addConcept( + "External Services", + language === "id" + ? `Integrasi ke service seperti ${formatInlineList(snapshot.externalServices, "id")}.` + : `Integrations with services such as ${formatInlineList(snapshot.externalServices, "en")}.`, + language === "id" + ? "Area ini biasanya berkaitan dengan credential, network call, dan failure handling." + : "This area usually involves credentials, network calls, and failure handling." + ); + } + + if (hasPathSegment(snapshot, "snapshot")) { + addConcept( + "Snapshot", + language === "id" + ? "Representasi hasil analisis project yang digunakan ulang oleh command lain." + : "A reusable representation of project analysis used by other commands.", + language === "id" + ? "Mengurangi kebutuhan agent membaca repository dari nol setiap kali bekerja." + : "It reduces the need for agents to reread the repository from scratch." + ); + } + + if (hasPathSegment(snapshot, "contextBuilder")) { + addConcept( + "Context Retrieval", + language === "id" + ? "Proses memilih file paling relevan sebelum AI menjawab pertanyaan." + : "The process of selecting the most relevant files before AI answers.", + language === "id" + ? "Ini menjaga jawaban tetap fokus dan menghindari eksplorasi repository yang terlalu luas." + : "It keeps answers focused and avoids broad repository exploration." + ); + } + + if (concepts.length === 0) { + return [language === "id" + ? "Belum ada konsep utama yang cukup kuat dari snapshot saat ini." + : "No strong main concepts were detected from the current snapshot."]; + } + + return concepts.slice(0, 8).flatMap((concept) => [ + `### ${concept.name}`, + "", + concept.description, + "", + concept.reason, + "" + ]); +} + +function renderReadingAreas(snapshot: ProjectMap, language: OnboardingLanguage): string[] { + const groups = groupReadingItems(snapshot, language); + const labels: Record = { + 1: "Priority 1 - Core architecture", + 2: "Priority 2 - Core execution flow", + 3: "Priority 3 - Supporting infrastructure", + 4: "Priority 4 - Utilities and helpers" + }; + const lines: string[] = []; + + for (const priority of [1, 2, 3, 4] as ReadingPriority[]) { + const items = groups[priority]; + if (items.length === 0) { + continue; + } + + lines.push(`### ${labels[priority]}`, ""); + for (const item of items.slice(0, 5)) { + lines.push( + `- ${item.path}`, + ` Purpose: ${item.purpose}`, + ` Why read this: ${item.why}`, + "" + ); + } + } + + return lines.length > 0 ? lines : [language === "id" + ? "Belum ada reading area yang cukup kuat dari snapshot." + : "No strong reading areas were detected from the snapshot."]; +} + +function groupReadingItems(snapshot: ProjectMap, language: OnboardingLanguage): Record { + const items = new Map(); + const add = (path: string | undefined, priority: ReadingPriority) => { + if (!path || !hasFile(snapshot, path)) { + return; + } + + const existing = items.get(path); + const item: ReadingItem = { + path, + priority: existing ? Math.min(existing.priority, priority) as ReadingPriority : priority, + purpose: describeFilePurpose(path, snapshot, language), + why: describeFileImportance(path, snapshot, language) + }; + items.set(path, item); + }; + + for (const path of snapshot.entryPoints) add(path, 1); + for (const path of snapshot.onboarding.recommendedPath.slice(0, 4)) add(path, 1); + for (const file of snapshot.criticalFiles.slice(0, 6)) add(file.path, 1); + + for (const feature of snapshot.features) { + add(feature.entryPoint, 2); + for (const path of feature.files.slice(0, 4)) add(path, 2); + } + + for (const flow of snapshot.flows.slice(0, 3)) { + add(flow.entryPoint, 2); + for (const step of flow.steps.slice(0, 4)) add(step.file, 2); + } + + for (const [path, entry] of Object.entries(snapshot.fileIndex)) { + if (["api", "service", "database", "config"].includes(entry.scope) || entry.featureRefs.length > 0) { + add(path, 3); + } + } + + for (const path of snapshot.onboarding.recommendedPath) add(path, 4); + + const grouped: Record = { 1: [], 2: [], 3: [], 4: [] }; + for (const item of items.values()) { + grouped[item.priority].push(item); + } + + for (const priority of [1, 2, 3, 4] as ReadingPriority[]) { + grouped[priority].sort((left, right) => + fileSortScore(right.path, snapshot) - fileSortScore(left.path, snapshot) + || left.path.localeCompare(right.path) + ); + } + + return grouped; +} + +function renderImportantFlows(snapshot: ProjectMap, language: OnboardingLanguage): string[] { + const flows = snapshot.flows.slice(0, 3); + if (flows.length === 0) { + return []; + } + + return [ + `## ${language === "id" ? "Flow Penting" : "Key Flows"}`, + "", + ...flows.flatMap((flow) => [ + `### ${flow.name}`, + "", + ...flow.steps.slice(0, 8).map((step, index) => + `${index + 1}. ${describeFlowStep(step.file ?? step.label, step.purpose, language)}` + ), + "" + ]) + ]; +} + +function renderWhereToStart(snapshot: ProjectMap, language: OnboardingLanguage): string[] { + const groups = groupReadingItems(snapshot, language); + const firstFiles = [...groups[1], ...groups[2]].slice(0, 3).map((item) => item.path); + const lines = language === "id" ? [ + "Jika baru pertama kali masuk project ini:", + "", + "1. Baca `DEVMAP.md` untuk memahami cara memakai Snapshot dan aturan agent.", + "2. Pahami Mental Model di atas sebelum membuka source file.", + firstFiles.length > 0 + ? `3. Buka ${formatInlineList(firstFiles, "id")} sebagai file awal.` + : "3. Mulai dari entry point atau critical file yang tersedia di snapshot.", + "4. Ikuti Flow Penting atau feature entry point yang paling dekat dengan task.", + "5. Baru buka file tambahan jika Snapshot belum cukup menjawab pertanyaan." + ] : [ + "If this is your first time in the project:", + "", + "1. Read `DEVMAP.md` to understand how to use the Snapshot and agent rules.", + "2. Understand the Mental Model above before opening source files.", + firstFiles.length > 0 + ? `3. Open ${formatInlineList(firstFiles, "en")} as the first source files.` + : "3. Start from the entry point or critical files available in the snapshot.", + "4. Follow the Key Flows or the feature entry point closest to your task.", + "5. Only open extra files when the Snapshot is not enough." + ]; + + return lines; +} + +function describeFilePurpose(path: string, snapshot: ProjectMap, language: OnboardingLanguage): string { + const entry = snapshot.fileIndex[path]; + const featureRefs = entry?.featureRefs ?? []; + const critical = snapshot.criticalFiles.find((file) => file.path === path); + + if (entry?.purpose && !isLowValuePurpose(entry.purpose)) { + return entry.purpose; + } + + if (snapshot.entryPoints.includes(path)) { + return language === "id" + ? "Menjadi titik awal runtime atau command utama project." + : "Acts as a runtime or command entry point for the project."; + } + + if (featureRefs.length > 0) { + return language === "id" + ? `Mendukung area fitur ${formatInlineList(featureRefs, "id")}.` + : `Supports the ${formatInlineList(featureRefs, "en")} feature area.`; + } + + if (critical) { + return language === "id" + ? "Menghubungkan beberapa bagian penting dalam project." + : "Connects important parts of the project."; + } + + if (entry?.scope && entry.scope !== "unknown") { + return language === "id" + ? `Menangani responsibility ${entry.scope} dalam struktur project.` + : `Handles the ${entry.scope} responsibility in the project structure.`; + } + + return language === "id" + ? "Membantu melengkapi konteks project berdasarkan snapshot." + : "Helps complete project context from the snapshot."; +} + +function describeFileImportance(path: string, snapshot: ProjectMap, language: OnboardingLanguage): string { + const entry = snapshot.fileIndex[path]; + const impact = snapshot.changeImpact[path]; + const critical = snapshot.criticalFiles.find((file) => file.path === path); + + if (snapshot.entryPoints.includes(path)) { + return language === "id" + ? "File ini menjelaskan bagaimana eksekusi project dimulai." + : "This file explains how project execution starts."; + } + + if (entry?.featureRefs.length) { + return language === "id" + ? `File ini memberi konteks langsung untuk ${formatInlineList(entry.featureRefs, "id")}.` + : `This file gives direct context for ${formatInlineList(entry.featureRefs, "en")}.`; + } + + if (impact?.impacts.length) { + return language === "id" + ? `Perubahan di sini dapat memengaruhi ${formatInlineList(impact.impacts, "id")}.` + : `Changes here can affect ${formatInlineList(impact.impacts, "en")}.`; + } + + if (impact?.dependents.length) { + return language === "id" + ? "Banyak bagian project bergantung pada file ini." + : "Several project areas depend on this file."; + } + + if (critical) { + return language === "id" + ? "Snapshot menandai file ini sebagai critical karena perannya dalam struktur project." + : "The snapshot marks this file as critical because of its structural role."; + } + + return language === "id" + ? "File ini membantu agent memahami konteks sebelum membuka detail yang lebih kecil." + : "This file helps an agent understand context before opening smaller details."; +} + +function describeFlowStep(label: string, purpose: string | undefined, language: OnboardingLanguage): string { + if (purpose && !isLowValuePurpose(purpose)) { + return purpose; + } + + if (/\.[cm]?[jt]sx?$|\.json$|\.md$/.test(label)) { + return language === "id" + ? `Masuk ke ${label} untuk memahami bagian flow ini.` + : `Open ${label} to understand this part of the flow.`; + } + + return label; +} + +function describeFeatureConcept(feature: ProjectMap["features"][number], language: OnboardingLanguage): string { + if (feature.purpose && !isLowValuePurpose(feature.purpose)) { + return feature.purpose; + } + + if (feature.entryPoint) { + return language === "id" + ? `Area ${feature.name} dimulai dari ${feature.entryPoint} dan terhubung ke file pendukung yang terdeteksi di snapshot.` + : `${feature.name} starts from ${feature.entryPoint} and connects to supporting files detected in the snapshot.`; + } + + if (feature.files.length > 0) { + return language === "id" + ? `Area ${feature.name} terlihat dari beberapa file terkait di snapshot.` + : `${feature.name} appears across several related files in the snapshot.`; + } + + return language === "id" + ? `Area ${feature.name} terdeteksi sebagai konsep penting dalam project.` + : `${feature.name} is detected as an important project concept.`; +} + +function isLowValuePurpose(purpose: string): boolean { + return /\b(exposes|contains project code|identifies .* capability)\b/i.test(purpose); +} + +function hasScope(snapshot: ProjectMap, scope: string): boolean { + return Object.values(snapshot.fileIndex).some((entry) => entry.scope === scope); +} + +function hasPathSegment(snapshot: ProjectMap, segment: string): boolean { + const normalizedSegment = segment.toLowerCase(); + return Object.keys(snapshot.fileIndex).some((path) => + path.toLowerCase().includes(normalizedSegment) + ); +} + +function hasFile(snapshot: ProjectMap, path: string): boolean { + return Boolean(snapshot.fileIndex[path]) + || snapshot.entryPoints.includes(path) + || snapshot.criticalFiles.some((file) => file.path === path) + || snapshot.onboarding.recommendedPath.includes(path); +} + +function fileSortScore(path: string, snapshot: ProjectMap): number { + const entry = snapshot.fileIndex[path]; + const critical = snapshot.criticalFiles.find((file) => file.path === path); + return (entry?.importance ?? 0) + + (critical?.score ?? 0) + + (snapshot.entryPoints.includes(path) ? 100 : 0) + + ((entry?.featureRefs.length ?? 0) * 20); +} + +function renderAgentWorkflow(snapshot: ProjectMap, labels: OnboardingLabels): string[] { + const instructions = snapshot.agentInstructions; + return [ + `- ${labels.navigationPolicy}: ${instructions.navigationPolicy}`, + `- ${labels.defaultMode}: ${instructions.defaultMode}`, + `- ${labels.maxInitialFiles}: ${instructions.maxInitialFiles}`, + `- ${labels.missingSnapshotAction}: ${instructions.missingSnapshotAction}`, + `- ${labels.staleSnapshotAction}: ${instructions.staleSnapshotAction}`, + `- ${labels.fallbackRule}: ${instructions.fallbackRule}`, + "", + labels.recommendedSequence, + ...labels.agentSteps.map((step, index) => `${index + 1}. ${step}`) + ]; +} + +function renderLearningPath(snapshot: ProjectMap, labels: OnboardingLabels): string[] { + const path = snapshot.onboarding.recommendedPath.slice(0, 8); + if (path.length === 0) { + return [labels.noLearningPath]; + } + + return path.flatMap((file, index) => [ + `### ${index + 1}. ${file}`, + "", + `- ${labels.learnWhy}: ${describeLearningPurpose(file, snapshot, labels)}`, + `- ${labels.focusOn}: ${describeLearningFocus(file, snapshot, labels)}`, + `- ${labels.nextStep}: ${describeLearningNextStep(file, path[index + 1], labels)}`, + "" + ]); +} + +function describeLearningPurpose( + file: string, + snapshot: ProjectMap, + labels: OnboardingLabels +): string { + const entry = snapshot.fileIndex[file]; + const critical = snapshot.criticalFiles.find((item) => item.path === file); + + if (file.toLowerCase().includes("readme")) { + return labels.learnReadme; + } + if (file.toLowerCase().includes("agents")) { + return labels.learnAgents; + } + if (file.endsWith("package.json")) { + return labels.learnPackageJson; + } + if (snapshot.entryPoints.includes(file)) { + return labels.learnEntryPoint; + } + if (entry?.purpose) { + return entry.purpose; + } + if (critical) { + return `${labels.learnCriticalPrefix} ${critical.reasons.join(", ")}.`; + } + + return labels.learnGeneric; +} + +function describeLearningFocus( + file: string, + snapshot: ProjectMap, + labels: OnboardingLabels +): string { + const entry = snapshot.fileIndex[file]; + const exports = entry?.exportedSymbols.slice(0, 4) ?? []; + const functions = entry?.topFunctions.slice(0, 4).map((item) => item.name) ?? []; + const featureRefs = entry?.featureRefs.slice(0, 3) ?? []; + const focusItems = [ + exports.length > 0 ? `${labels.exports}: ${exports.join(", ")}` : null, + functions.length > 0 ? `${labels.functions}: ${functions.join(", ")}` : null, + featureRefs.length > 0 ? `${labels.relatedFeatures}: ${featureRefs.join(", ")}` : null, + entry?.scope ? `${labels.scope}: ${entry.scope}` : null + ].filter(Boolean); + + return focusItems.length > 0 ? focusItems.join("; ") : labels.focusGeneric; +} + +function describeLearningNextStep( + file: string, + nextFile: string | undefined, + labels: OnboardingLabels +): string { + if (!nextFile) { + return labels.nextStepFinal; + } + + return labels.nextStepTemplate + .replace("{current}", file) + .replace("{next}", nextFile); +} + +function renderFeatureMap(snapshot: ProjectMap, labels: OnboardingLabels): string[] { + if (snapshot.features.length === 0) { + return [labels.noFeatures]; + } + + return snapshot.features.flatMap((feature) => [ + `### ${feature.name}`, + "", + `- ${labels.purpose}: ${feature.purpose}`, + ...renderOptionalPath(labels.entryPoint, feature.entryPoint), + `- ${labels.confidence}: ${feature.confidence}`, + "", + ...renderBusinessFlow(feature.businessFlow, labels), + "" + ]); +} + +function renderBusinessFlow(steps: string[], labels: OnboardingLabels): string[] { + if (steps.length === 0) { + return [`- ${labels.businessFlow}: ${labels.notAvailable}`]; + } + + return [ + `- ${labels.businessFlow}:`, + ...steps.map((step, index) => ` ${index + 1}. ${step}`) + ]; +} + +function renderFlows(snapshot: ProjectMap, labels: OnboardingLabels): string[] { + const flows = snapshot.flows.slice(0, 6); + if (flows.length === 0) { + return [labels.noFlows]; + } + + return flows.flatMap((flow) => [ + `### ${flow.name}`, + "", + `- ${labels.type}: ${flow.type}`, + ...renderOptionalPath(labels.entryPoint, flow.entryPoint), + "", + ...flow.steps.map((step, index) => + `${index + 1}. ${step.file ?? step.label}${step.purpose ? ` - ${step.purpose}` : ""}` + ), + "" + ]); +} + +function renderProjectNarrative(snapshot: ProjectMap, language: OnboardingLanguage): string[] { + const featureNames = snapshot.features.map((feature) => feature.name); + const services = snapshot.externalServices; + const entryPoints = snapshot.entryPoints; + const summary = language === "id" + ? [ + `${snapshot.project.name} adalah project ${snapshot.project.language}`, + `yang memakai ${snapshot.project.packageManager}`, + snapshot.project.framework !== "unknown" ? `dengan ${snapshot.project.framework}` : null, + entryPoints.length > 0 ? `dan mulai dari ${entryPoints[0]}` : null, + featureNames.length > 0 ? `dengan area fitur terdeteksi seperti ${formatInlineList(featureNames, "id")}` : null, + services.length > 0 ? `serta external service seperti ${formatInlineList(services, "id")}` : null + ].filter(Boolean).join(" ") + : [ + `${snapshot.project.name} is a ${snapshot.project.language} project`, + `using ${snapshot.project.packageManager}`, + snapshot.project.framework !== "unknown" ? `with ${snapshot.project.framework}` : null, + entryPoints.length > 0 ? `starting from ${entryPoints[0]}` : null, + featureNames.length > 0 ? `with detected feature areas such as ${formatInlineList(featureNames, "en")}` : null, + services.length > 0 ? `and external services such as ${formatInlineList(services, "en")}` : null + ].filter(Boolean).join(" "); + + const lines = [`${summary}.`]; + const architectureExcerpt = extractArchitectureExcerpt(snapshot.ai?.architecture); + if (architectureExcerpt) { + lines.push("", language === "id" + ? `Catatan arsitektur: ${architectureExcerpt}` + : `Architecture note: ${architectureExcerpt}`); + } + + return lines; +} + +function renderExternalServices(snapshot: ProjectMap, labels: OnboardingLabels): string[] { + if (snapshot.externalServices.length === 0) { + return [labels.noExternalServices]; + } + + return snapshot.externalServices.map((service) => `- ${service}`); +} + +function renderCriticalFiles(snapshot: ProjectMap, labels: OnboardingLabels): string[] { + const files = snapshot.criticalFiles.slice(0, 10); + if (files.length === 0) { + return [labels.noCriticalFiles]; + } + + return files.map((file, index) => + `${index + 1}. ${file.path} - ${labels.score} ${file.score}; ${file.reasons.join(", ")}` + ); +} + +function renderOptionalPath(label: string, value: string | undefined): string[] { + return value ? [`- ${label}: ${value}`] : []; +} + +function extractArchitectureExcerpt(architecture: string | undefined): string | null { + if (!architecture) { + return null; + } + + const text = architecture + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => + line + && !line.startsWith("#") + && !line.startsWith("|") + && !line.startsWith("---") + && !/^[-*]\s/.test(line) + ) + .join(" ") + .replace(/[`*_]/g, "") + .replace(/\s+/g, " ") + .trim(); + + if (text.length < 80) { + return null; + } + + return text.length > 500 ? `${text.slice(0, 497).trim()}...` : text; +} + +function formatInlineList(values: string[], language: OnboardingLanguage): string { + const uniqueValues = [...new Set(values)].slice(0, 4); + if (uniqueValues.length <= 1) { + return uniqueValues[0] ?? "none"; + } + + const conjunction = language === "id" ? "dan" : "and"; + return `${uniqueValues.slice(0, -1).join(", ")} ${conjunction} ${uniqueValues.at(-1)}`; +} + +function renderChangeImpact(snapshot: ProjectMap, labels: OnboardingLabels): string[] { + const entries = Object.entries(snapshot.changeImpact) + .filter(([, impact]) => impact.impacts.length > 0) + .slice(0, 8); + + if (entries.length === 0) { + return [labels.noChangeImpact]; + } + + return entries.map(([file, impact]) => + `- ${file}: impacts ${impact.impacts.join(", ")}` + ); +} + +function renderList(values: string[], emptyMessage = "No recommended path detected yet."): string[] { + if (values.length === 0) { + return [emptyMessage]; + } + + return values.map((value, index) => `${index + 1}. ${value}`); +} + +type OnboardingLabels = ReturnType; + +function getGuideLabels(language: OnboardingLanguage) { + if (language === "id") { + return { + staleNote: "> Snapshot ini stale. Jalankan `devmap analyze --fresh` sebelum memakai guide ini untuk keputusan penting.", + whatProjectDoes: "Apa yang Dilakukan Project Ini", + mainConcepts: "Konsep Utama", + importantAreas: "Area Penting untuk Dipahami", + whereToStart: "Mulai dari Mana" + }; + } + + return { + staleNote: "> This snapshot is stale. Run `devmap analyze --fresh` before using this guide for important decisions.", + whatProjectDoes: "What This Project Does", + mainConcepts: "Main Concepts", + importantAreas: "Important Areas to Understand", + whereToStart: "Where to Start" + }; +} + +function getLabels(language: OnboardingLanguage) { + if (language === "id") { + return { + title: "Onboarding Project", + projectOverview: "Gambaran Project", + name: "Nama", + framework: "Framework", + language: "Bahasa", + packageManager: "Package manager", + filesIndexed: "File terindeks", + snapshotGenerated: "Snapshot dibuat", + snapshotStatus: "Status snapshot", + staleSnapshot: "stale - jalankan devmap analyze --fresh", + freshSnapshot: "fresh", + entryPoints: "Entry Points", + externalServices: "External Services", + criticalFiles: "Critical Files", + recommendedReadingPath: "Urutan Baca yang Disarankan", + learningPath: "Jalur Belajar Step-by-Step", + featureMap: "Peta Fitur", + importantFlows: "Flow Penting", + changeImpactNotes: "Catatan Dampak Perubahan", + agentWorkflow: "Workflow Agent", + noRecommendedPath: "Belum ada urutan baca yang terdeteksi.", + noLearningPath: "Belum ada jalur belajar yang bisa dibuat dari snapshot.", + noExternalServices: "Belum ada external service yang terdeteksi.", + noCriticalFiles: "Belum ada critical file yang terdeteksi.", + noFeatures: "Belum ada fitur yang terdeteksi.", + noFlows: "Belum ada flow yang terdeteksi.", + noChangeImpact: "Belum ada metadata dampak perubahan.", + purpose: "Tujuan", + entryPoint: "Entry point", + confidence: "Confidence", + businessFlow: "Business flow", + type: "Tipe", + score: "score", + notAvailable: "belum tersedia", + learnWhy: "Kenapa dipelajari", + focusOn: "Fokus saat membaca", + nextStep: "Lanjut ke", + exports: "exports", + functions: "fungsi", + relatedFeatures: "fitur terkait", + scope: "scope", + learnReadme: "Mulai dari README untuk memahami tujuan project, cara instalasi, dan perintah utama sebelum masuk ke source code.", + learnAgents: "Baca AGENTS.md untuk memahami aturan kerja AI agent, workflow kontribusi, dan kebiasaan repository ini.", + learnPackageJson: "Pelajari package.json untuk melihat package manager, script, dependency, entry CLI, dan metadata release.", + learnEntryPoint: "Ini adalah entry point runtime; baca untuk memahami command yang tersedia dan alur eksekusi awal.", + learnCriticalPrefix: "File ini penting karena", + learnGeneric: "File ini masuk recommended path dari snapshot dan membantu membangun konteks project.", + focusGeneric: "Perhatikan responsibility file, import, export, dan hubungannya dengan file setelahnya.", + nextStepTemplate: "Setelah {current}, lanjut baca {next} untuk memperluas konteks.", + nextStepFinal: "Setelah tahap ini, lanjut eksplor feature map atau flow sesuai task yang sedang dikerjakan.", + navigationPolicy: "Navigation policy", + defaultMode: "Default mode", + maxInitialFiles: "Maksimal file awal", + missingSnapshotAction: "Aksi jika snapshot hilang", + staleSnapshotAction: "Aksi jika snapshot stale", + fallbackRule: "Fallback rule", + recommendedSequence: "Urutan yang disarankan:", + agentSteps: [ + "Baca `DEVMAP.md` terlebih dahulu.", + "Baca `.devmap/snapshot.json` sebelum eksplorasi repo secara luas.", + "Mulai dari urutan baca yang disarankan dan feature entry point.", + "Buka source file sesedikit mungkin sesuai kebutuhan task.", + "Refresh snapshot sebelum mengandalkan output onboarding yang stale." + ], + generatedBy: "Dibuat oleh DevMap dari `.devmap/snapshot.json`." + }; + } + + return { + title: "Project Onboarding", + projectOverview: "Project Overview", + name: "Name", + framework: "Framework", + language: "Language", + packageManager: "Package manager", + filesIndexed: "Files indexed", + snapshotGenerated: "Snapshot generated", + snapshotStatus: "Snapshot status", + staleSnapshot: "stale - run devmap analyze --fresh", + freshSnapshot: "fresh", + entryPoints: "Entry Points", + externalServices: "External Services", + criticalFiles: "Critical Files", + recommendedReadingPath: "Recommended Reading Path", + learningPath: "Step-by-Step Learning Path", + featureMap: "Feature Map", + importantFlows: "Important Flows", + changeImpactNotes: "Change Impact Notes", + agentWorkflow: "Agent Workflow", + noRecommendedPath: "No recommended path detected yet.", + noLearningPath: "No learning path can be built from the snapshot yet.", + noExternalServices: "No external services detected yet.", + noCriticalFiles: "No critical files detected yet.", + noFeatures: "No features detected yet.", + noFlows: "No flows detected yet.", + noChangeImpact: "No change impact metadata detected yet.", + purpose: "Purpose", + entryPoint: "Entry point", + confidence: "Confidence", + businessFlow: "Business flow", + type: "Type", + score: "score", + notAvailable: "not available yet", + learnWhy: "Why learn this", + focusOn: "What to focus on", + nextStep: "Next step", + exports: "exports", + functions: "functions", + relatedFeatures: "related features", + scope: "scope", + learnReadme: "Start with the README to understand the project purpose, installation path, and main commands before reading source code.", + learnAgents: "Read AGENTS.md to understand AI-agent rules, contribution workflow, and repository-specific working habits.", + learnPackageJson: "Study package.json to understand the package manager, scripts, dependencies, CLI entry, and release metadata.", + learnEntryPoint: "This is a runtime entry point; read it to understand available commands and the initial execution flow.", + learnCriticalPrefix: "This file is important because", + learnGeneric: "This file is part of the snapshot-recommended path and helps build project context.", + focusGeneric: "Pay attention to file responsibility, imports, exports, and how it connects to the next file.", + nextStepTemplate: "After {current}, read {next} to expand the context.", + nextStepFinal: "After this step, continue through the feature map or flow that matches your task.", + navigationPolicy: "Navigation policy", + defaultMode: "Default mode", + maxInitialFiles: "Max initial files", + missingSnapshotAction: "Missing snapshot action", + staleSnapshotAction: "Stale snapshot action", + fallbackRule: "Fallback rule", + recommendedSequence: "Recommended sequence:", + agentSteps: [ + "Read `DEVMAP.md` first.", + "Read `.devmap/snapshot.json` before broad repository exploration.", + "Start with the recommended reading path and feature entry points.", + "Inspect only the smallest source-file set needed for the task.", + "Refresh the snapshot before relying on stale onboarding output." + ], + generatedBy: "Generated by DevMap from `.devmap/snapshot.json`." + }; +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 33f6bf1..8f6222a 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -5,6 +5,7 @@ import { askCommand } from "./commands/ask.js"; import { configModelCommand } from "./commands/config.js"; import { doctorCommand } from "./commands/doctor.js"; import { initCommand } from "./commands/init.js"; +import { onboardingCommand } from "./commands/onboarding.js"; import { printHelp } from "./utils/help.js"; import { printWelcome } from "./utils/welcome.js"; import { runSafely } from "./utils/errors.js"; @@ -39,6 +40,21 @@ program .option("--json", "output machine-readable JSON") .action((question, options) => askCommand(question, { json: options.json })); +program + .command("onboarding") + .alias("onboard") + .description("Generate a project onboarding guide from the DevMap snapshot") + .argument("[target]", "folder with a DevMap snapshot", ".") + .option("--write", "write ONBOARDING.md") + .option("--language ", "language for generated onboarding markdown (en or id)") + .option("--json", "output machine-readable JSON") + .action((target, options) => onboardingCommand({ + target, + language: options.language, + write: options.write, + json: options.json + })); + const configCommand = program .command("config") .description("Update DevMap configuration"); diff --git a/packages/cli/src/utils/help.ts b/packages/cli/src/utils/help.ts index 1e2030d..27e2006 100644 --- a/packages/cli/src/utils/help.ts +++ b/packages/cli/src/utils/help.ts @@ -4,6 +4,7 @@ const commands = [ ["init", "Initialize DevMap configuration"], ["analyze", "Analyze project structure"], ["ask ", "Ask about your codebase"], + ["onboarding", "Generate project onboarding guide"], ["config model", "Set model override or automatic routing"], ["doctor", "Diagnose DevMap setup"] ] as const; diff --git a/packages/cli/src/utils/welcome.ts b/packages/cli/src/utils/welcome.ts index b9a63aa..8242f9c 100644 --- a/packages/cli/src/utils/welcome.ts +++ b/packages/cli/src/utils/welcome.ts @@ -33,7 +33,7 @@ export function printWelcome(projectRoot: string): void { printCommand("devmap explain", "explain architecture"); printCommand('devmap ask "..."', "ask your codebase"); printCommand("devmap docs", "generate documentation"); - printCommand("devmap onboard", "generate onboarding guide"); + printCommand("devmap onboarding", "generate onboarding guide"); console.log(""); } diff --git a/packages/cli/test/json-output.test.ts b/packages/cli/test/json-output.test.ts index ba8bbed..333c292 100644 --- a/packages/cli/test/json-output.test.ts +++ b/packages/cli/test/json-output.test.ts @@ -11,6 +11,7 @@ import { askCommand } from "../src/commands/ask.js"; import { configModelCommand } from "../src/commands/config.js"; import { doctorCommand } from "../src/commands/doctor.js"; import { initCommand } from "../src/commands/init.js"; +import { onboardingCommand } from "../src/commands/onboarding.js"; test("analyze --json emits one parseable snapshot document", async () => { const projectRoot = await createProject("json-analyze"); @@ -124,6 +125,34 @@ test("doctor and config JSON outputs contain no formatting noise", async () => { } }); +test("onboarding --json emits guide metadata and markdown", async () => { + const projectRoot = await createProject("json-onboarding"); + await saveSnapshot(projectRoot, await createProjectMap(projectRoot)); + + try { + const output = await captureStdout(() => onboardingCommand({ + json: true, + projectRoot + })); + const payload = parseSingleJson(output); + + assert.equal(payload.status, "ok"); + assert.equal(payload.language, "en"); + assert.equal(payload.project.name, "json-onboarding"); + assert.equal(payload.overview, null); + assert.equal(payload.snapshot.stale, false); + assert.equal(payload.agentInstructions.navigationPolicy, "snapshot-first"); + assert.ok(Array.isArray(payload.entryPoints)); + assert.ok(Array.isArray(payload.criticalFiles)); + assert.ok(Array.isArray(payload.externalServices)); + assert.ok(Array.isArray(payload.recommendedPath)); + assert.match(payload.markdown, /# Onboarding Project/); + assert.match(payload.markdown, /## What This Project Does/); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } +}); + test("init --json is non-interactive and returns setup metadata", async () => { const projectRoot = await createProject("json-init"); diff --git a/packages/cli/test/onboarding-command.test.ts b/packages/cli/test/onboarding-command.test.ts new file mode 100644 index 0000000..c09a9ab --- /dev/null +++ b/packages/cli/test/onboarding-command.test.ts @@ -0,0 +1,135 @@ +import assert from "node:assert/strict"; +import { access, mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import { createProjectMap } from "../src/analyzers/projectMap.js"; +import { saveSnapshot } from "../src/cache/snapshot.js"; +import { onboardingCommand } from "../src/commands/onboarding.js"; +import type { Prompt } from "../src/utils/prompt.js"; + +const testDirectory = dirname(fileURLToPath(import.meta.url)); +const nextFixture = join(testDirectory, "fixtures", "nextjs-project"); + +test("onboarding command renders a snapshot-based guide", async () => { + const projectRoot = await createOnboardingProject(); + + try { + const logs = await captureOutput(() => onboardingCommand({ projectRoot })); + const plainLogs = stripAnsi(logs); + + assert.match(plainLogs, /DevMap Onboarding/); + assert.match(plainLogs, /What This Project Does/); + assert.match(plainLogs, /Snapshot is stale/); + assert.match(plainLogs, /This snapshot is stale/); + assert.match(plainLogs, /Mental Model/); + assert.match(plainLogs, /Main Concepts/); + assert.match(plainLogs, /Important Areas to Understand/); + assert.match(plainLogs, /Priority 1 - Core architecture/); + assert.match(plainLogs, /Purpose:/); + assert.match(plainLogs, /Why read this:/); + assert.match(plainLogs, /Key Flows/); + assert.match(plainLogs, /Where to Start/); + assert.match(plainLogs, /app\/page\.tsx/); + assert.match(plainLogs, /Authentication/); + assert.match(plainLogs, /Request \/api\/session/); + assert.match(plainLogs, /devmap onboarding --write/); + assert.doesNotMatch(plainLogs, /not inferred yet/); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } +}); + +test("onboarding command writes ONBOARDING.md when requested", async () => { + const projectRoot = await createOnboardingProject(); + + try { + const logs = await captureOutput(() => onboardingCommand({ projectRoot, write: true })); + const outputPath = join(projectRoot, "ONBOARDING.md"); + await access(outputPath); + const content = await readFile(outputPath, "utf8"); + + assert.match(stripAnsi(logs), /Wrote ONBOARDING\.md/); + assert.match(content, /^# Onboarding Project/m); + assert.match(content, /## What This Project Does/); + assert.match(content, /## Mental Model/); + assert.match(content, /## Important Areas to Understand/); + assert.match(content, /app\/page\.tsx/); + assert.match(content, /## Where to Start/); + assert.doesNotMatch(content, /score \d+/); + assert.doesNotMatch(content, /exports:/); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } +}); + +test("onboarding write can generate Indonesian markdown after language prompt", async () => { + const projectRoot = await createOnboardingProject(); + const prompt = createFakePrompt(["id"]); + + try { + await captureOutput(() => onboardingCommand({ projectRoot, write: true, prompt })); + const content = await readFile(join(projectRoot, "ONBOARDING.md"), "utf8"); + + assert.equal(prompt.closed, true); + assert.match(prompt.questions.join("\n"), /Onboarding language/); + assert.match(content, /^# Onboarding Project/m); + assert.match(content, /## Apa yang Dilakukan Project Ini/); + assert.match(content, /## Konsep Utama/); + assert.match(content, /## Area Penting untuk Dipahami/); + assert.match(content, /Why read this/); + assert.match(content, /Dibuat oleh DevMap/); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } +}); + +async function createOnboardingProject(): Promise { + const projectRoot = await mkdtemp(join(tmpdir(), "devmap-onboarding-test-")); + const snapshot = await createProjectMap(nextFixture); + await saveSnapshot(projectRoot, { + ...snapshot, + projectRoot, + project: { + ...snapshot.project, + root: projectRoot + } + }); + return projectRoot; +} + +async function captureOutput(action: () => Promise): Promise { + const logs: string[] = []; + const originalLog = console.log; + const originalError = console.error; + + console.log = (...values: unknown[]) => logs.push(values.join(" ")); + console.error = (...values: unknown[]) => logs.push(values.join(" ")); + + try { + await action(); + return logs.join("\n"); + } finally { + console.log = originalLog; + console.error = originalError; + } +} + +function stripAnsi(value: string): string { + return value.replace(/\x1b\[[0-9;]*m/g, ""); +} + +function createFakePrompt(answers: string[]): Prompt & { closed: boolean; questions: string[] } { + return { + closed: false, + questions: [], + async ask(question: string): Promise { + this.questions.push(question); + return answers.shift() ?? ""; + }, + close(): void { + this.closed = true; + } + }; +}