From bbb1fa921293424e0e0e6ffca52054c35599d6ef Mon Sep 17 00:00:00 2001 From: Mufacoderz Date: Fri, 19 Jun 2026 09:44:10 +0800 Subject: [PATCH 1/9] feat: add onboarding command Co-authored-by: devmap-agent <238585242+devmap-agent@users.noreply.github.com> --- PRD.md | 9 +- README.md | 10 +- docs/commands.md | 83 ++++++-- docs/design.md | 7 +- docs/development-testing.md | 3 + docs/for-me-personal/PROGRESS.md | 18 +- docs/for-me-personal/TEST.md | 31 +++ docs/roadmap.md | 12 +- packages/cli/README.md | 3 + packages/cli/src/commands/onboarding.ts | 191 +++++++++++++++++++ packages/cli/src/index.ts | 14 ++ packages/cli/src/utils/help.ts | 1 + packages/cli/src/utils/welcome.ts | 2 +- packages/cli/test/json-output.test.ts | 21 ++ packages/cli/test/onboarding-command.test.ts | 87 +++++++++ 15 files changed, 460 insertions(+), 32 deletions(-) create mode 100644 packages/cli/src/commands/onboarding.ts create mode 100644 packages/cli/test/onboarding-command.test.ts 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..ec4b955 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,10 @@ devmap analyze # Verify your setup devmap doctor +# Generate a reading guide from the snapshot +devmap onboarding +devmap onboarding --write + # Ask questions about your codebase devmap ask "explain the main architecture" devmap ask "where is the auth logic?" @@ -143,6 +148,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 +301,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..81f708d 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,60 @@ 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 --json +``` + +### Responsibilities + +* Read `.devmap/snapshot.json` +* Use `project`, `onboarding.recommendedPath`, `features`, `flows`, + `criticalFiles`, and `changeImpact` +* Print a readable terminal guide by default +* Write `ONBOARDING.md` when `--write` is passed +* Emit one structured JSON document when `--json` is passed + +### Output Sections + +1. Project Overview +2. Recommended Reading Path +3. Feature Map +4. Important Flows +5. Change Impact Notes +6. Agent Workflow + +### Rules + +* Do not invent files that are not present in the snapshot +* Prefer snapshot-derived paths over generic advice +* Keep the guide useful without requiring an AI call +* Treat `devmap flow` and full docs generation as future commands + +--- + ## `devmap doctor` - -Run diagnostics for DevMap setup. + +Run diagnostics for DevMap setup. ### Purpose @@ -542,6 +592,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 +610,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 +627,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..7ca15fd 100644 --- a/docs/for-me-personal/PROGRESS.md +++ b/docs/for-me-personal/PROGRESS.md @@ -1,6 +1,22 @@ # 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. ## Update 2026-06-18 diff --git a/docs/for-me-personal/TEST.md b/docs/for-me-personal/TEST.md index 1dfb3df..1b015f5 100644 --- a/docs/for-me-personal/TEST.md +++ b/docs/for-me-personal/TEST.md @@ -15,6 +15,37 @@ 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 +``` + +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. +- Human output menampilkan reading path, feature map, flows, change impact, dan + workflow agent tanpa menyebut file yang tidak ada di snapshot. +- `--json` menghasilkan satu dokumen JSON tanpa ANSI atau dekorasi terminal. +- `--write` membuat atau memperbarui `ONBOARDING.md` di root project target. + ## 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..3e9cabd 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,7 @@ devmap analyze devmap analyze --deep devmap analyze --fresh devmap ask "Where is payment logic handled?" +devmap onboarding --write devmap doctor devmap config model auto ``` @@ -104,6 +106,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/commands/onboarding.ts b/packages/cli/src/commands/onboarding.ts new file mode 100644 index 0000000..f369de0 --- /dev/null +++ b/packages/cli/src/commands/onboarding.ts @@ -0,0 +1,191 @@ +import { writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import type { ProjectMap } from "../analyzers/projectMap.js"; +import { readSnapshotOrThrow } from "../cache/snapshot.js"; +import { output, withJsonOutput } from "../utils/output.js"; + +export type OnboardingOptions = { + json?: boolean; + projectRoot?: string; + target?: string; + write?: boolean; +}; + +export type OnboardingGuide = { + status: "ok"; + project: ProjectMap["project"]; + 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"); + output.markdown(guide.markdown); + + if (guide.writtenPath) { + output.success(`Wrote ${guide.writtenPath}`); + } +} + +async function runOnboarding(options: OnboardingOptions): Promise { + const projectRoot = resolve(options.projectRoot ?? options.target ?? "."); + const snapshot = await readSnapshotOrThrow(projectRoot); + const markdown = buildOnboardingMarkdown(snapshot); + const writtenPath = options.write ? "ONBOARDING.md" : null; + + if (writtenPath) { + await writeFile(join(projectRoot, writtenPath), `${markdown}\n`, "utf8"); + } + + return { + status: "ok", + project: snapshot.project, + 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): string { + const sections = [ + "# Project Onboarding", + "", + "## Project Overview", + "", + `- Name: ${snapshot.project.name}`, + `- Framework: ${snapshot.project.framework}`, + `- Language: ${snapshot.project.language}`, + `- Package manager: ${snapshot.project.packageManager}`, + `- Files indexed: ${snapshot.stats.relevantFiles}`, + "", + "## Recommended Reading Path", + "", + ...renderList(snapshot.onboarding.recommendedPath), + "", + "## Feature Map", + "", + ...renderFeatureMap(snapshot), + "", + "## Important Flows", + "", + ...renderFlows(snapshot), + "", + "## Change Impact Notes", + "", + ...renderChangeImpact(snapshot), + "", + "## Agent Workflow", + "", + "1. Read `DEVMAP.md` first.", + "2. Read `.devmap/snapshot.json` before broad repository exploration.", + "3. Start with the recommended reading path and feature entry points.", + "4. Inspect only the smallest source-file set needed for the task.", + "5. Run `devmap analyze --fresh` if the snapshot may be stale.", + "", + "Generated by DevMap from `.devmap/snapshot.json`." + ]; + + return sections.join("\n").replace(/\n{3,}/g, "\n\n"); +} + +function renderFeatureMap(snapshot: ProjectMap): string[] { + if (snapshot.features.length === 0) { + return ["No features detected yet."]; + } + + return snapshot.features.flatMap((feature) => [ + `### ${feature.name}`, + "", + `- Purpose: ${feature.purpose}`, + `- Entry point: ${feature.entryPoint ?? "not inferred yet"}`, + `- Confidence: ${feature.confidence}`, + "", + ...renderBusinessFlow(feature.businessFlow), + "" + ]); +} + +function renderBusinessFlow(steps: string[]): string[] { + if (steps.length === 0) { + return ["- Business flow: not inferred yet"]; + } + + return [ + "- Business flow:", + ...steps.map((step, index) => ` ${index + 1}. ${step}`) + ]; +} + +function renderFlows(snapshot: ProjectMap): string[] { + const flows = snapshot.flows.slice(0, 6); + if (flows.length === 0) { + return ["No flows detected yet."]; + } + + return flows.flatMap((flow) => [ + `### ${flow.name}`, + "", + `- Type: ${flow.type}`, + `- Entry point: ${flow.entryPoint ?? "not inferred yet"}`, + "", + ...flow.steps.map((step, index) => + `${index + 1}. ${step.file ?? step.label}${step.purpose ? ` - ${step.purpose}` : ""}` + ), + "" + ]); +} + +function renderChangeImpact(snapshot: ProjectMap): string[] { + const entries = Object.entries(snapshot.changeImpact) + .filter(([, impact]) => impact.impacts.length > 0) + .slice(0, 8); + + if (entries.length === 0) { + return ["No change impact metadata detected yet."]; + } + + return entries.map(([file, impact]) => + `- ${file}: impacts ${impact.impacts.join(", ")}` + ); +} + +function renderList(values: string[]): string[] { + if (values.length === 0) { + return ["No recommended path detected yet."]; + } + + return values.map((value, index) => `${index + 1}. ${value}`); +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 33f6bf1..3ac8c28 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,19 @@ 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("--json", "output machine-readable JSON") + .action((target, options) => onboardingCommand({ + target, + 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..0fba1ca 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,26 @@ 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.project.name, "json-onboarding"); + assert.ok(Array.isArray(payload.recommendedPath)); + assert.match(payload.markdown, /# Project Onboarding/); + } 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..1a9f048 --- /dev/null +++ b/packages/cli/test/onboarding-command.test.ts @@ -0,0 +1,87 @@ +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"; + +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, /Project Overview/); + assert.match(plainLogs, /Recommended Reading Path/); + assert.match(plainLogs, /Feature Map/); + assert.match(plainLogs, /Important Flows/); + assert.match(plainLogs, /Agent Workflow/); + assert.match(plainLogs, /app\/page\.tsx/); + assert.match(plainLogs, /Authentication/); + assert.match(plainLogs, /Request \/api\/session/); + } 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, /^# Project Onboarding/m); + assert.match(content, /## Recommended Reading Path/); + assert.match(content, /app\/page\.tsx/); + assert.match(content, /## Agent Workflow/); + } 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, ""); +} From 77aee94e909d2cb9e6046d5eef496573f9d06c9a Mon Sep 17 00:00:00 2001 From: Mufacoderz Date: Fri, 19 Jun 2026 09:49:44 +0800 Subject: [PATCH 2/9] feat: add onboarding snapshot freshness Co-authored-by: devmap-agent <238585242+devmap-agent@users.noreply.github.com> --- docs/commands.md | 2 + docs/for-me-personal/TEST.md | 4 ++ packages/cli/src/commands/onboarding.ts | 51 +++++++++++++++++--- packages/cli/test/json-output.test.ts | 2 + packages/cli/test/onboarding-command.test.ts | 3 ++ 5 files changed, 54 insertions(+), 8 deletions(-) diff --git a/docs/commands.md b/docs/commands.md index 81f708d..cb5d623 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -441,6 +441,7 @@ devmap onboarding --json * Print a readable terminal guide by default * Write `ONBOARDING.md` when `--write` is passed * Emit one structured JSON document when `--json` is passed +* Warn when the snapshot is stale ### Output Sections @@ -457,6 +458,7 @@ devmap onboarding --json * Prefer snapshot-derived paths over generic advice * 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 --- diff --git a/docs/for-me-personal/TEST.md b/docs/for-me-personal/TEST.md index 1b015f5..410a26e 100644 --- a/docs/for-me-personal/TEST.md +++ b/docs/for-me-personal/TEST.md @@ -41,6 +41,10 @@ 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 menampilkan reading path, feature map, flows, change impact, dan workflow agent tanpa menyebut file yang tidak ada di snapshot. - `--json` menghasilkan satu dokumen JSON tanpa ANSI atau dekorasi terminal. diff --git a/packages/cli/src/commands/onboarding.ts b/packages/cli/src/commands/onboarding.ts index f369de0..27baaaa 100644 --- a/packages/cli/src/commands/onboarding.ts +++ b/packages/cli/src/commands/onboarding.ts @@ -1,7 +1,7 @@ import { writeFile } from "node:fs/promises"; import { join, resolve } from "node:path"; import type { ProjectMap } from "../analyzers/projectMap.js"; -import { readSnapshotOrThrow } from "../cache/snapshot.js"; +import { isSnapshotStale, readSnapshotOrThrow } from "../cache/snapshot.js"; import { output, withJsonOutput } from "../utils/output.js"; export type OnboardingOptions = { @@ -14,6 +14,11 @@ export type OnboardingOptions = { export type OnboardingGuide = { status: "ok"; project: ProjectMap["project"]; + snapshot: { + generatedAt: string; + stale: boolean; + }; + agentInstructions: ProjectMap["agentInstructions"]; recommendedPath: string[]; features: Array<{ name: string; @@ -41,6 +46,10 @@ export async function onboardingCommand(options: OnboardingOptions = {}): Promis 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) { @@ -51,7 +60,8 @@ export async function onboardingCommand(options: OnboardingOptions = {}): Promis async function runOnboarding(options: OnboardingOptions): Promise { const projectRoot = resolve(options.projectRoot ?? options.target ?? "."); const snapshot = await readSnapshotOrThrow(projectRoot); - const markdown = buildOnboardingMarkdown(snapshot); + const stale = await isSnapshotStale(projectRoot, snapshot); + const markdown = buildOnboardingMarkdown(snapshot, { stale }); const writtenPath = options.write ? "ONBOARDING.md" : null; if (writtenPath) { @@ -61,6 +71,11 @@ async function runOnboarding(options: OnboardingOptions): Promise ({ name: feature.name, @@ -79,7 +94,10 @@ async function runOnboarding(options: OnboardingOptions): Promise { assert.equal(payload.status, "ok"); assert.equal(payload.project.name, "json-onboarding"); + assert.equal(payload.snapshot.stale, false); + assert.equal(payload.agentInstructions.navigationPolicy, "snapshot-first"); assert.ok(Array.isArray(payload.recommendedPath)); assert.match(payload.markdown, /# Project Onboarding/); } finally { diff --git a/packages/cli/test/onboarding-command.test.ts b/packages/cli/test/onboarding-command.test.ts index 1a9f048..f543dd4 100644 --- a/packages/cli/test/onboarding-command.test.ts +++ b/packages/cli/test/onboarding-command.test.ts @@ -20,10 +20,13 @@ test("onboarding command renders a snapshot-based guide", async () => { assert.match(plainLogs, /DevMap Onboarding/); assert.match(plainLogs, /Project Overview/); + assert.match(plainLogs, /Snapshot is stale/); + assert.match(plainLogs, /Snapshot status: stale - run devmap analyze --fresh/); assert.match(plainLogs, /Recommended Reading Path/); assert.match(plainLogs, /Feature Map/); assert.match(plainLogs, /Important Flows/); assert.match(plainLogs, /Agent Workflow/); + assert.match(plainLogs, /Navigation policy: snapshot-first/); assert.match(plainLogs, /app\/page\.tsx/); assert.match(plainLogs, /Authentication/); assert.match(plainLogs, /Request \/api\/session/); From 0a6c06c85605206d827c1c0b4014fa19252d5661 Mon Sep 17 00:00:00 2001 From: Mufacoderz Date: Fri, 19 Jun 2026 10:00:01 +0800 Subject: [PATCH 3/9] feat: strengthen onboarding guide content Co-authored-by: devmap-agent <238585242+devmap-agent@users.noreply.github.com> --- docs/commands.md | 18 +++- docs/for-me-personal/TEST.md | 7 +- packages/cli/src/commands/onboarding.ts | 107 ++++++++++++++++++- packages/cli/test/json-output.test.ts | 4 + packages/cli/test/onboarding-command.test.ts | 4 + 5 files changed, 131 insertions(+), 9 deletions(-) diff --git a/docs/commands.md b/docs/commands.md index cb5d623..2d8ffae 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -438,6 +438,10 @@ devmap onboarding --json * 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 * Write `ONBOARDING.md` when `--write` is passed * Emit one structured JSON document when `--json` is passed @@ -446,16 +450,20 @@ devmap onboarding --json ### Output Sections 1. Project Overview -2. Recommended Reading Path -3. Feature Map -4. Important Flows -5. Change Impact Notes -6. Agent Workflow +2. Entry Points +3. External Services +4. Critical Files +5. Recommended Reading Path +6. Feature Map +7. Important Flows +8. Change Impact Notes +9. Agent Workflow ### 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 * 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 diff --git a/docs/for-me-personal/TEST.md b/docs/for-me-personal/TEST.md index 410a26e..6ab7402 100644 --- a/docs/for-me-personal/TEST.md +++ b/docs/for-me-personal/TEST.md @@ -45,8 +45,11 @@ Expected result: `snapshot.stale: true`. - JSON output menyertakan `agentInstructions` agar agent mengikuti policy snapshot-first. -- Human output menampilkan reading path, feature map, flows, change impact, dan - workflow agent tanpa menyebut file yang tidak ada di snapshot. +- Human output menampilkan architecture narrative jika ada, entry points, + external services, critical files, reading path, feature map, flows, change + impact, dan workflow agent tanpa menyebut file yang tidak ada di snapshot. +- 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. diff --git a/packages/cli/src/commands/onboarding.ts b/packages/cli/src/commands/onboarding.ts index 27baaaa..c0e8e24 100644 --- a/packages/cli/src/commands/onboarding.ts +++ b/packages/cli/src/commands/onboarding.ts @@ -14,11 +14,15 @@ export type OnboardingOptions = { export type OnboardingGuide = { status: "ok"; 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; @@ -71,11 +75,15 @@ async function runOnboarding(options: OnboardingOptions): Promise ({ name: feature.name, @@ -111,6 +119,20 @@ export function buildOnboardingMarkdown( `- Snapshot generated: ${snapshot.generatedAt}`, `- Snapshot status: ${options.stale ? "stale - run devmap analyze --fresh" : "fresh"}`, "", + ...renderProjectNarrative(snapshot), + "", + "## Entry Points", + "", + ...renderList(snapshot.entryPoints), + "", + "## External Services", + "", + ...renderExternalServices(snapshot), + "", + "## Critical Files", + "", + ...renderCriticalFiles(snapshot), + "", "## Recommended Reading Path", "", ...renderList(snapshot.onboarding.recommendedPath), @@ -165,7 +187,7 @@ function renderFeatureMap(snapshot: ProjectMap): string[] { `### ${feature.name}`, "", `- Purpose: ${feature.purpose}`, - `- Entry point: ${feature.entryPoint ?? "not inferred yet"}`, + ...renderOptionalPath("Entry point", feature.entryPoint), `- Confidence: ${feature.confidence}`, "", ...renderBusinessFlow(feature.businessFlow), @@ -194,7 +216,7 @@ function renderFlows(snapshot: ProjectMap): string[] { `### ${flow.name}`, "", `- Type: ${flow.type}`, - `- Entry point: ${flow.entryPoint ?? "not inferred yet"}`, + ...renderOptionalPath("Entry point", flow.entryPoint), "", ...flow.steps.map((step, index) => `${index + 1}. ${step.file ?? step.label}${step.purpose ? ` - ${step.purpose}` : ""}` @@ -203,6 +225,87 @@ function renderFlows(snapshot: ProjectMap): string[] { ]); } +function renderProjectNarrative(snapshot: ProjectMap): string[] { + const featureNames = snapshot.features.map((feature) => feature.name); + const services = snapshot.externalServices; + const entryPoints = snapshot.entryPoints; + const summary = [ + `${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)}` : null, + services.length > 0 ? `and external services such as ${formatInlineList(services)}` : null + ].filter(Boolean).join(" "); + + const lines = [`${summary}.`]; + const architectureExcerpt = extractArchitectureExcerpt(snapshot.ai?.architecture); + if (architectureExcerpt) { + lines.push("", `Architecture note: ${architectureExcerpt}`); + } + + return lines; +} + +function renderExternalServices(snapshot: ProjectMap): string[] { + if (snapshot.externalServices.length === 0) { + return ["No external services detected yet."]; + } + + return snapshot.externalServices.map((service) => `- ${service}`); +} + +function renderCriticalFiles(snapshot: ProjectMap): string[] { + const files = snapshot.criticalFiles.slice(0, 10); + if (files.length === 0) { + return ["No critical files detected yet."]; + } + + return files.map((file, index) => + `${index + 1}. ${file.path} - 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[]): string { + const uniqueValues = [...new Set(values)].slice(0, 4); + if (uniqueValues.length <= 1) { + return uniqueValues[0] ?? "none"; + } + + return `${uniqueValues.slice(0, -1).join(", ")} and ${uniqueValues.at(-1)}`; +} + function renderChangeImpact(snapshot: ProjectMap): string[] { const entries = Object.entries(snapshot.changeImpact) .filter(([, impact]) => impact.impacts.length > 0) diff --git a/packages/cli/test/json-output.test.ts b/packages/cli/test/json-output.test.ts index d83a04d..2594ffb 100644 --- a/packages/cli/test/json-output.test.ts +++ b/packages/cli/test/json-output.test.ts @@ -138,8 +138,12 @@ test("onboarding --json emits guide metadata and markdown", async () => { assert.equal(payload.status, "ok"); 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, /# Project Onboarding/); } finally { diff --git a/packages/cli/test/onboarding-command.test.ts b/packages/cli/test/onboarding-command.test.ts index f543dd4..ecab1e1 100644 --- a/packages/cli/test/onboarding-command.test.ts +++ b/packages/cli/test/onboarding-command.test.ts @@ -22,6 +22,9 @@ test("onboarding command renders a snapshot-based guide", async () => { assert.match(plainLogs, /Project Overview/); assert.match(plainLogs, /Snapshot is stale/); assert.match(plainLogs, /Snapshot status: stale - run devmap analyze --fresh/); + assert.match(plainLogs, /Entry Points/); + assert.match(plainLogs, /External Services/); + assert.match(plainLogs, /Critical Files/); assert.match(plainLogs, /Recommended Reading Path/); assert.match(plainLogs, /Feature Map/); assert.match(plainLogs, /Important Flows/); @@ -30,6 +33,7 @@ test("onboarding command renders a snapshot-based guide", async () => { assert.match(plainLogs, /app\/page\.tsx/); assert.match(plainLogs, /Authentication/); assert.match(plainLogs, /Request \/api\/session/); + assert.doesNotMatch(plainLogs, /not inferred yet/); } finally { await rm(projectRoot, { recursive: true, force: true }); } From c1fda489b29b8cea0028528a7878e4580cd4ea01 Mon Sep 17 00:00:00 2001 From: Mufacoderz Date: Fri, 19 Jun 2026 10:06:01 +0800 Subject: [PATCH 4/9] feat: add onboarding language selection Co-authored-by: devmap-agent <238585242+devmap-agent@users.noreply.github.com> --- README.md | 1 + docs/commands.md | 5 + docs/for-me-personal/TEST.md | 5 + packages/cli/README.md | 1 + packages/cli/src/commands/onboarding.ts | 311 ++++++++++++++----- packages/cli/src/index.ts | 2 + packages/cli/test/json-output.test.ts | 1 + packages/cli/test/onboarding-command.test.ts | 34 ++ 8 files changed, 285 insertions(+), 75 deletions(-) diff --git a/README.md b/README.md index ec4b955..3587c47 100644 --- a/README.md +++ b/README.md @@ -140,6 +140,7 @@ 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" diff --git a/docs/commands.md b/docs/commands.md index 2d8ffae..3f1846a 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -430,6 +430,7 @@ guide for humans and AI agents. It should help answer: ```bash devmap onboarding devmap onboarding --write +devmap onboarding --write --language id devmap onboarding --json ``` @@ -444,6 +445,9 @@ devmap onboarding --json reading path * Print a readable terminal guide by default * 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 @@ -467,6 +471,7 @@ devmap onboarding --json * 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 --- diff --git a/docs/for-me-personal/TEST.md b/docs/for-me-personal/TEST.md index 6ab7402..5f9d811 100644 --- a/docs/for-me-personal/TEST.md +++ b/docs/for-me-personal/TEST.md @@ -31,6 +31,7 @@ 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 @@ -52,6 +53,10 @@ Expected result: `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. +- `--language en` dan `--language id` melewati prompt, cocok untuk automation + dan agent. ## Context Builder Ranking diff --git a/packages/cli/README.md b/packages/cli/README.md index 3e9cabd..33baeea 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -76,6 +76,7 @@ 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 ``` diff --git a/packages/cli/src/commands/onboarding.ts b/packages/cli/src/commands/onboarding.ts index c0e8e24..cd4bf5f 100644 --- a/packages/cli/src/commands/onboarding.ts +++ b/packages/cli/src/commands/onboarding.ts @@ -3,16 +3,22 @@ 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: { @@ -65,7 +71,8 @@ async function runOnboarding(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; +} + +function renderAgentWorkflow(snapshot: ProjectMap, labels: OnboardingLabels): string[] { const instructions = snapshot.agentInstructions; return [ - `- Navigation policy: ${instructions.navigationPolicy}`, - `- Default mode: ${instructions.defaultMode}`, - `- Max initial files: ${instructions.maxInitialFiles}`, - `- Missing snapshot action: ${instructions.missingSnapshotAction}`, - `- Stale snapshot action: ${instructions.staleSnapshotAction}`, - `- Fallback rule: ${instructions.fallbackRule}`, + `- ${labels.navigationPolicy}: ${instructions.navigationPolicy}`, + `- ${labels.defaultMode}: ${instructions.defaultMode}`, + `- ${labels.maxInitialFiles}: ${instructions.maxInitialFiles}`, + `- ${labels.missingSnapshotAction}: ${instructions.missingSnapshotAction}`, + `- ${labels.staleSnapshotAction}: ${instructions.staleSnapshotAction}`, + `- ${labels.fallbackRule}: ${instructions.fallbackRule}`, "", - "Recommended sequence:", - "1. Read `DEVMAP.md` first.", - "2. Read `.devmap/snapshot.json` before broad repository exploration.", - "3. Start with the recommended reading path and feature entry points.", - "4. Inspect only the smallest source-file set needed for the task.", - "5. Refresh the snapshot before relying on stale onboarding output." + labels.recommendedSequence, + ...labels.agentSteps.map((step, index) => `${index + 1}. ${step}`) ]; } -function renderFeatureMap(snapshot: ProjectMap): string[] { +function renderFeatureMap(snapshot: ProjectMap, labels: OnboardingLabels): string[] { if (snapshot.features.length === 0) { - return ["No features detected yet."]; + return [labels.noFeatures]; } return snapshot.features.flatMap((feature) => [ `### ${feature.name}`, "", - `- Purpose: ${feature.purpose}`, - ...renderOptionalPath("Entry point", feature.entryPoint), - `- Confidence: ${feature.confidence}`, + `- ${labels.purpose}: ${feature.purpose}`, + ...renderOptionalPath(labels.entryPoint, feature.entryPoint), + `- ${labels.confidence}: ${feature.confidence}`, "", - ...renderBusinessFlow(feature.businessFlow), + ...renderBusinessFlow(feature.businessFlow, labels), "" ]); } -function renderBusinessFlow(steps: string[]): string[] { +function renderBusinessFlow(steps: string[], labels: OnboardingLabels): string[] { if (steps.length === 0) { - return ["- Business flow: not inferred yet"]; + return [`- ${labels.businessFlow}: ${labels.notAvailable}`]; } return [ - "- Business flow:", + `- ${labels.businessFlow}:`, ...steps.map((step, index) => ` ${index + 1}. ${step}`) ]; } -function renderFlows(snapshot: ProjectMap): string[] { +function renderFlows(snapshot: ProjectMap, labels: OnboardingLabels): string[] { const flows = snapshot.flows.slice(0, 6); if (flows.length === 0) { - return ["No flows detected yet."]; + return [labels.noFlows]; } return flows.flatMap((flow) => [ `### ${flow.name}`, "", - `- Type: ${flow.type}`, - ...renderOptionalPath("Entry point", flow.entryPoint), + `- ${labels.type}: ${flow.type}`, + ...renderOptionalPath(labels.entryPoint, flow.entryPoint), "", ...flow.steps.map((step, index) => `${index + 1}. ${step.file ?? step.label}${step.purpose ? ` - ${step.purpose}` : ""}` @@ -225,44 +268,55 @@ function renderFlows(snapshot: ProjectMap): string[] { ]); } -function renderProjectNarrative(snapshot: ProjectMap): string[] { +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 = [ - `${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)}` : null, - services.length > 0 ? `and external services such as ${formatInlineList(services)}` : null - ].filter(Boolean).join(" "); + 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("", `Architecture note: ${architectureExcerpt}`); + lines.push("", language === "id" + ? `Catatan arsitektur: ${architectureExcerpt}` + : `Architecture note: ${architectureExcerpt}`); } return lines; } -function renderExternalServices(snapshot: ProjectMap): string[] { +function renderExternalServices(snapshot: ProjectMap, labels: OnboardingLabels): string[] { if (snapshot.externalServices.length === 0) { - return ["No external services detected yet."]; + return [labels.noExternalServices]; } return snapshot.externalServices.map((service) => `- ${service}`); } -function renderCriticalFiles(snapshot: ProjectMap): string[] { +function renderCriticalFiles(snapshot: ProjectMap, labels: OnboardingLabels): string[] { const files = snapshot.criticalFiles.slice(0, 10); if (files.length === 0) { - return ["No critical files detected yet."]; + return [labels.noCriticalFiles]; } return files.map((file, index) => - `${index + 1}. ${file.path} - score ${file.score}; ${file.reasons.join(", ")}` + `${index + 1}. ${file.path} - ${labels.score} ${file.score}; ${file.reasons.join(", ")}` ); } @@ -297,22 +351,23 @@ function extractArchitectureExcerpt(architecture: string | undefined): string | return text.length > 500 ? `${text.slice(0, 497).trim()}...` : text; } -function formatInlineList(values: string[]): string { +function formatInlineList(values: string[], language: OnboardingLanguage): string { const uniqueValues = [...new Set(values)].slice(0, 4); if (uniqueValues.length <= 1) { return uniqueValues[0] ?? "none"; } - return `${uniqueValues.slice(0, -1).join(", ")} and ${uniqueValues.at(-1)}`; + const conjunction = language === "id" ? "dan" : "and"; + return `${uniqueValues.slice(0, -1).join(", ")} ${conjunction} ${uniqueValues.at(-1)}`; } -function renderChangeImpact(snapshot: ProjectMap): string[] { +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 ["No change impact metadata detected yet."]; + return [labels.noChangeImpact]; } return entries.map(([file, impact]) => @@ -320,10 +375,116 @@ function renderChangeImpact(snapshot: ProjectMap): string[] { ); } -function renderList(values: string[]): string[] { +function renderList(values: string[], emptyMessage = "No recommended path detected yet."): string[] { if (values.length === 0) { - return ["No recommended path detected yet."]; + return [emptyMessage]; } return values.map((value, index) => `${index + 1}. ${value}`); } + +type OnboardingLabels = ReturnType; + +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", + featureMap: "Peta Fitur", + importantFlows: "Flow Penting", + changeImpactNotes: "Catatan Dampak Perubahan", + agentWorkflow: "Workflow Agent", + noRecommendedPath: "Belum ada urutan baca yang terdeteksi.", + 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", + 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", + featureMap: "Feature Map", + importantFlows: "Important Flows", + changeImpactNotes: "Change Impact Notes", + agentWorkflow: "Agent Workflow", + noRecommendedPath: "No recommended path detected 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", + 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 3ac8c28..8f6222a 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -46,9 +46,11 @@ program .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 })); diff --git a/packages/cli/test/json-output.test.ts b/packages/cli/test/json-output.test.ts index 2594ffb..c889da4 100644 --- a/packages/cli/test/json-output.test.ts +++ b/packages/cli/test/json-output.test.ts @@ -137,6 +137,7 @@ test("onboarding --json emits guide metadata and markdown", async () => { 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); diff --git a/packages/cli/test/onboarding-command.test.ts b/packages/cli/test/onboarding-command.test.ts index ecab1e1..3817db9 100644 --- a/packages/cli/test/onboarding-command.test.ts +++ b/packages/cli/test/onboarding-command.test.ts @@ -7,6 +7,7 @@ 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"); @@ -58,6 +59,25 @@ test("onboarding command writes ONBOARDING.md when requested", async () => { } }); +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, /## Gambaran Project/); + assert.match(content, /## Urutan Baca yang Disarankan/); + 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); @@ -92,3 +112,17 @@ async function captureOutput(action: () => Promise): Promise { 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; + } + }; +} From a34a3e9b1a83b0e4234e85c64fcb808a09febc7c Mon Sep 17 00:00:00 2001 From: Mufacoderz Date: Fri, 19 Jun 2026 10:08:23 +0800 Subject: [PATCH 5/9] feat: hint onboarding markdown export Co-authored-by: devmap-agent <238585242+devmap-agent@users.noreply.github.com> --- docs/commands.md | 1 + packages/cli/src/commands/onboarding.ts | 2 ++ packages/cli/test/onboarding-command.test.ts | 1 + 3 files changed, 4 insertions(+) diff --git a/docs/commands.md b/docs/commands.md index 3f1846a..53ffd2a 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -444,6 +444,7 @@ devmap onboarding --json * 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 diff --git a/packages/cli/src/commands/onboarding.ts b/packages/cli/src/commands/onboarding.ts index cd4bf5f..38a0c52 100644 --- a/packages/cli/src/commands/onboarding.ts +++ b/packages/cli/src/commands/onboarding.ts @@ -64,6 +64,8 @@ export async function onboardingCommand(options: OnboardingOptions = {}): Promis if (guide.writtenPath) { output.success(`Wrote ${guide.writtenPath}`); + } else { + output.note("To write this guide to ONBOARDING.md, run devmap onboarding --write."); } } diff --git a/packages/cli/test/onboarding-command.test.ts b/packages/cli/test/onboarding-command.test.ts index 3817db9..1226484 100644 --- a/packages/cli/test/onboarding-command.test.ts +++ b/packages/cli/test/onboarding-command.test.ts @@ -34,6 +34,7 @@ test("onboarding command renders a snapshot-based guide", async () => { 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 }); From c9ce1877380d638d9184f5fdaa29365a81fba76a Mon Sep 17 00:00:00 2001 From: Mufacoderz Date: Fri, 19 Jun 2026 10:16:28 +0800 Subject: [PATCH 6/9] feat: add onboarding learning path Co-authored-by: devmap-agent <238585242+devmap-agent@users.noreply.github.com> --- docs/commands.md | 11 +- docs/for-me-personal/TEST.md | 2 + packages/cli/src/commands/onboarding.ts | 135 +++++++++++++++++-- packages/cli/test/onboarding-command.test.ts | 6 + 4 files changed, 142 insertions(+), 12 deletions(-) diff --git a/docs/commands.md b/docs/commands.md index 53ffd2a..af0da47 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -459,16 +459,19 @@ devmap onboarding --json 3. External Services 4. Critical Files 5. Recommended Reading Path -6. Feature Map -7. Important Flows -8. Change Impact Notes -9. Agent Workflow +6. Step-by-Step Learning Path +7. Feature Map +8. Important Flows +9. Change Impact Notes +10. Agent Workflow ### 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 why each recommended learning step matters, what to focus on, and + which file to read next * 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 diff --git a/docs/for-me-personal/TEST.md b/docs/for-me-personal/TEST.md index 5f9d811..605c827 100644 --- a/docs/for-me-personal/TEST.md +++ b/docs/for-me-personal/TEST.md @@ -49,6 +49,8 @@ Expected result: - Human output menampilkan architecture narrative jika ada, entry points, external services, critical files, reading path, feature map, flows, change impact, dan workflow agent tanpa menyebut file yang tidak ada di snapshot. +- Human output menyertakan jalur belajar step-by-step berisi alasan membaca + file, fokus saat membaca, dan langkah berikutnya. - 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. diff --git a/packages/cli/src/commands/onboarding.ts b/packages/cli/src/commands/onboarding.ts index 38a0c52..8d3755a 100644 --- a/packages/cli/src/commands/onboarding.ts +++ b/packages/cli/src/commands/onboarding.ts @@ -148,6 +148,10 @@ export function buildOnboardingMarkdown( "", ...renderList(snapshot.onboarding.recommendedPath, labels.noRecommendedPath), "", + `## ${labels.learningPath}`, + "", + ...renderLearningPath(snapshot, labels), + "", `## ${labels.featureMap}`, "", ...renderFeatureMap(snapshot, labels), @@ -223,6 +227,85 @@ function renderAgentWorkflow(snapshot: ProjectMap, labels: OnboardingLabels): st ]; } +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]; @@ -405,11 +488,13 @@ function getLabels(language: OnboardingLanguage) { 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.", @@ -422,6 +507,22 @@ function getLabels(language: OnboardingLanguage) { 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", @@ -454,14 +555,16 @@ function getLabels(language: OnboardingLanguage) { freshSnapshot: "fresh", entryPoints: "Entry Points", externalServices: "External Services", - criticalFiles: "Critical Files", - recommendedReadingPath: "Recommended Reading Path", - featureMap: "Feature Map", + 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.", - noExternalServices: "No external services detected yet.", + 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.", @@ -471,9 +574,25 @@ function getLabels(language: OnboardingLanguage) { confidence: "Confidence", businessFlow: "Business flow", type: "Type", - score: "score", - notAvailable: "not available yet", - navigationPolicy: "Navigation policy", + 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", diff --git a/packages/cli/test/onboarding-command.test.ts b/packages/cli/test/onboarding-command.test.ts index 1226484..09d897d 100644 --- a/packages/cli/test/onboarding-command.test.ts +++ b/packages/cli/test/onboarding-command.test.ts @@ -27,6 +27,9 @@ test("onboarding command renders a snapshot-based guide", async () => { assert.match(plainLogs, /External Services/); assert.match(plainLogs, /Critical Files/); assert.match(plainLogs, /Recommended Reading Path/); + assert.match(plainLogs, /Step-by-Step Learning Path/); + assert.match(plainLogs, /Why learn this/); + assert.match(plainLogs, /What to focus on/); assert.match(plainLogs, /Feature Map/); assert.match(plainLogs, /Important Flows/); assert.match(plainLogs, /Agent Workflow/); @@ -53,6 +56,7 @@ test("onboarding command writes ONBOARDING.md when requested", async () => { assert.match(stripAnsi(logs), /Wrote ONBOARDING\.md/); assert.match(content, /^# Project Onboarding/m); assert.match(content, /## Recommended Reading Path/); + assert.match(content, /## Step-by-Step Learning Path/); assert.match(content, /app\/page\.tsx/); assert.match(content, /## Agent Workflow/); } finally { @@ -73,6 +77,8 @@ test("onboarding write can generate Indonesian markdown after language prompt", assert.match(content, /^# Onboarding Project/m); assert.match(content, /## Gambaran Project/); assert.match(content, /## Urutan Baca yang Disarankan/); + assert.match(content, /## Jalur Belajar Step-by-Step/); + assert.match(content, /Kenapa dipelajari/); assert.match(content, /Dibuat oleh DevMap/); } finally { await rm(projectRoot, { recursive: true, force: true }); From bf997eedbf4124620ad0a8586f49f1f793774292 Mon Sep 17 00:00:00 2001 From: Mufacoderz Date: Fri, 19 Jun 2026 15:11:39 +0800 Subject: [PATCH 7/9] fix: improve snapshot auth attribution Refs #36 Co-authored-by: devmap-agent <238585242+devmap-agent@users.noreply.github.com> --- packages/cli/src/ai/snapshotEnrichment.ts | 20 +- packages/cli/src/analyzers/projectMap.ts | 254 +++++++++++++++++++++- 2 files changed, 264 insertions(+), 10 deletions(-) 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/projectMap.ts b/packages/cli/src/analyzers/projectMap.ts index 1623c5a..cfa1231 100644 --- a/packages/cli/src/analyzers/projectMap.ts +++ b/packages/cli/src/analyzers/projectMap.ts @@ -122,7 +122,7 @@ export async function createProjectMap(projectRoot: string): Promise const routes = detectRoutes(files, framework); const database = detectDatabase(files); const features = attachFeatureEntryPoints( - detectFeatures(files, routes, database), + enrichFeatureFiles(detectFeatures(files, routes, database), files), routes, entryPoints, graph @@ -218,6 +218,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 +278,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 +332,50 @@ 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 = detectAuthSemanticRole(path, exportedSymbols, topFunctions, []); + 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 = detectAuthSemanticRole(file.path, exportedSymbols, topFunctions, imports); + + 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 +509,141 @@ function getLineNumber(content: string, index: number): number { return content.slice(0, index).split(/\r?\n/).length; } +type AuthSemanticRole = "auth-config" | "guard" | "provider" | "consumer"; + +function enrichFeatureFiles(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, + { + name: "Authentication", + purpose: "Identifies authentication capability in the project.", + files: orderAuthenticationFiles(authFiles), + businessFlow: [], + entryPoints: [], + searchTerms: ["auth", "authentication", "login", "session", "jwt", "next-auth"], + confidence: "high", + evidence: orderAuthenticationFiles(authFiles) + } + ]; +} + +function collectAuthenticationFeatureFiles(files: ScannedFile[]): string[] { + return orderAuthenticationFiles(files + .filter((file) => isArchitectureSource(file.path) && !isTestFile(file.path)) + .filter((file) => { + const exportedSymbols = findExportedSymbols(file.content); + const topFunctions = findTopFunctions(file.content); + const imports = readImportSpecifiers(file.content); + return detectAuthSemanticRole(file.path, exportedSymbols, topFunctions, imports) !== null; + }) + .map((file) => file.path)); +} + +function detectAuthSemanticRole( + path: string, + exportedSymbols: string[], + topFunctions: FileIndexEntry["topFunctions"], + imports: string[] +): AuthSemanticRole | null { + const normalizedPath = path.toLowerCase(); + const symbols = [...exportedSymbols, ...topFunctions.map((item) => item.name)]; + const text = `${normalizedPath} ${symbols.join(" ")} ${imports.join(" ")}`.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; +} + +function orderAuthenticationFiles(files: string[]): string[] { + return [...new Set(files)].sort((left, right) => + authenticationFilePriority(left) - authenticationFilePriority(right) + || left.localeCompare(right) + ); +} + +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 hasSymbol(symbols: string[], name: string): boolean { + return symbols.some((symbol) => symbol.toLowerCase() === name); +} + +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 +658,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 +678,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 +715,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 +741,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, From 894117bae8e98cc242e7717ce13f075791bd5390 Mon Sep 17 00:00:00 2001 From: Mufacoderz Date: Fri, 19 Jun 2026 15:24:54 +0800 Subject: [PATCH 8/9] fix: move auth feature attribution into detector Refs #36 Co-authored-by: devmap-agent <238585242+devmap-agent@users.noreply.github.com> --- packages/cli/src/analyzers/featureDetector.ts | 141 +++++++++++++++++- packages/cli/src/analyzers/projectMap.ts | 140 +++-------------- 2 files changed, 159 insertions(+), 122 deletions(-) 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 cfa1231..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"; @@ -122,7 +128,7 @@ export async function createProjectMap(projectRoot: string): Promise const routes = detectRoutes(files, framework); const database = detectDatabase(files); const features = attachFeatureEntryPoints( - enrichFeatureFiles(detectFeatures(files, routes, database), files), + detectFeatures(files, routes, database), routes, entryPoints, graph @@ -354,7 +360,11 @@ function calculateSemanticImportanceBonus( topFunctions: FileIndexEntry["topFunctions"], featureRefs: string[] ): number { - const role = detectAuthSemanticRole(path, exportedSymbols, topFunctions, []); + 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; @@ -367,7 +377,12 @@ function calculateCriticalSemanticBonus(file: ScannedFile): number { const exportedSymbols = findExportedSymbols(file.content); const topFunctions = findTopFunctions(file.content); const imports = readImportSpecifiers(file.content); - const role = detectAuthSemanticRole(file.path, exportedSymbols, topFunctions, imports); + 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; @@ -509,123 +524,6 @@ function getLineNumber(content: string, index: number): number { return content.slice(0, index).split(/\r?\n/).length; } -type AuthSemanticRole = "auth-config" | "guard" | "provider" | "consumer"; - -function enrichFeatureFiles(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, - { - name: "Authentication", - purpose: "Identifies authentication capability in the project.", - files: orderAuthenticationFiles(authFiles), - businessFlow: [], - entryPoints: [], - searchTerms: ["auth", "authentication", "login", "session", "jwt", "next-auth"], - confidence: "high", - evidence: orderAuthenticationFiles(authFiles) - } - ]; -} - -function collectAuthenticationFeatureFiles(files: ScannedFile[]): string[] { - return orderAuthenticationFiles(files - .filter((file) => isArchitectureSource(file.path) && !isTestFile(file.path)) - .filter((file) => { - const exportedSymbols = findExportedSymbols(file.content); - const topFunctions = findTopFunctions(file.content); - const imports = readImportSpecifiers(file.content); - return detectAuthSemanticRole(file.path, exportedSymbols, topFunctions, imports) !== null; - }) - .map((file) => file.path)); -} - -function detectAuthSemanticRole( - path: string, - exportedSymbols: string[], - topFunctions: FileIndexEntry["topFunctions"], - imports: string[] -): AuthSemanticRole | null { - const normalizedPath = path.toLowerCase(); - const symbols = [...exportedSymbols, ...topFunctions.map((item) => item.name)]; - const text = `${normalizedPath} ${symbols.join(" ")} ${imports.join(" ")}`.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; -} - -function orderAuthenticationFiles(files: string[]): string[] { - return [...new Set(files)].sort((left, right) => - authenticationFilePriority(left) - authenticationFilePriority(right) - || left.localeCompare(right) - ); -} - -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 hasSymbol(symbols: string[], name: string): boolean { - return symbols.some((symbol) => symbol.toLowerCase() === name); -} - function isFeatureConfigFile(path: string, featureRefs: string[]): boolean { return featureRefs.length > 0 && /(^|\/)src\/(lib|utils)\/(config|constants)\.[cm]?[jt]sx?$/.test(path.toLowerCase()); From ad38cc732fd757009cfaed42cf8e6a75e4d66c03 Mon Sep 17 00:00:00 2001 From: Mufacoderz Date: Fri, 19 Jun 2026 18:51:12 +0800 Subject: [PATCH 9/9] feat: improve onboarding guide generation Refs #36 Co-authored-by: devmap-agent <238585242+devmap-agent@users.noreply.github.com> --- docs/commands.md | 23 +- docs/for-me-personal/PROGRESS.md | 5 + docs/for-me-personal/TEST.md | 12 +- packages/cli/src/commands/onboarding.ts | 585 +++++++++++++++++-- packages/cli/test/json-output.test.ts | 3 +- packages/cli/test/onboarding-command.test.ts | 42 +- 6 files changed, 591 insertions(+), 79 deletions(-) diff --git a/docs/commands.md b/docs/commands.md index af0da47..27af6b3 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -454,28 +454,27 @@ devmap onboarding --json ### Output Sections -1. Project Overview -2. Entry Points -3. External Services -4. Critical Files -5. Recommended Reading Path -6. Step-by-Step Learning Path -7. Feature Map -8. Important Flows -9. Change Impact Notes -10. Agent Workflow +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 why each recommended learning step matters, what to focus on, and - which file to read next +* 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 --- diff --git a/docs/for-me-personal/PROGRESS.md b/docs/for-me-personal/PROGRESS.md index 7ca15fd..6b94ffd 100644 --- a/docs/for-me-personal/PROGRESS.md +++ b/docs/for-me-personal/PROGRESS.md @@ -17,6 +17,11 @@ Terakhir diperbarui: 2026-06-19 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 605c827..1f2ba26 100644 --- a/docs/for-me-personal/TEST.md +++ b/docs/for-me-personal/TEST.md @@ -46,17 +46,17 @@ Expected result: `snapshot.stale: true`. - JSON output menyertakan `agentInstructions` agar agent mengikuti policy snapshot-first. -- Human output menampilkan architecture narrative jika ada, entry points, - external services, critical files, reading path, feature map, flows, change - impact, dan workflow agent tanpa menyebut file yang tidak ada di snapshot. -- Human output menyertakan jalur belajar step-by-step berisi alasan membaca - file, fokus saat membaca, dan langkah berikutnya. +- 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. + `--language` belum diberikan. Default bahasa tetap English. - `--language en` dan `--language id` melewati prompt, cocok untuk automation dan agent. diff --git a/packages/cli/src/commands/onboarding.ts b/packages/cli/src/commands/onboarding.ts index 8d3755a..1a9ef28 100644 --- a/packages/cli/src/commands/onboarding.ts +++ b/packages/cli/src/commands/onboarding.ts @@ -116,57 +116,37 @@ export function buildOnboardingMarkdown( snapshot: ProjectMap, options: { language?: OnboardingLanguage; stale?: boolean } = {} ): string { - const labels = getLabels(options.language ?? "en"); + const language = options.language ?? "en"; + const labels = getLabels(language); + const guide = getGuideLabels(language); const sections = [ - `# ${labels.title}`, + "# Onboarding Project", "", - `## ${labels.projectOverview}`, + ...(options.stale ? [ + guide.staleNote, + "" + ] : []), + `## ${guide.whatProjectDoes}`, "", - `- ${labels.name}: ${snapshot.project.name}`, - `- ${labels.framework}: ${snapshot.project.framework}`, - `- ${labels.language}: ${snapshot.project.language}`, - `- ${labels.packageManager}: ${snapshot.project.packageManager}`, - `- ${labels.filesIndexed}: ${snapshot.stats.relevantFiles}`, - `- ${labels.snapshotGenerated}: ${snapshot.generatedAt}`, - `- ${labels.snapshotStatus}: ${options.stale ? labels.staleSnapshot : labels.freshSnapshot}`, + ...renderProjectIntroduction(snapshot, language), "", - ...renderProjectNarrative(snapshot, options.language ?? "en"), + "## Mental Model", "", - `## ${labels.entryPoints}`, + ...renderMentalModel(snapshot, language), "", - ...renderList(snapshot.entryPoints), + `## ${guide.mainConcepts}`, "", - `## ${labels.externalServices}`, + ...renderMainConcepts(snapshot, language), "", - ...renderExternalServices(snapshot, labels), + `## ${guide.importantAreas}`, "", - `## ${labels.criticalFiles}`, + ...renderReadingAreas(snapshot, language), "", - ...renderCriticalFiles(snapshot, labels), + ...renderImportantFlows(snapshot, language), "", - `## ${labels.recommendedReadingPath}`, + `## ${guide.whereToStart}`, "", - ...renderList(snapshot.onboarding.recommendedPath, labels.noRecommendedPath), - "", - `## ${labels.learningPath}`, - "", - ...renderLearningPath(snapshot, labels), - "", - `## ${labels.featureMap}`, - "", - ...renderFeatureMap(snapshot, labels), - "", - `## ${labels.importantFlows}`, - "", - ...renderFlows(snapshot, labels), - "", - `## ${labels.changeImpactNotes}`, - "", - ...renderChangeImpact(snapshot, labels), - "", - `## ${labels.agentWorkflow}`, - "", - ...renderAgentWorkflow(snapshot, labels), + ...renderWhereToStart(snapshot, language), "", labels.generatedBy ]; @@ -212,6 +192,513 @@ function normalizeOnboardingLanguage(value: string | undefined): OnboardingLangu 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 [ @@ -470,6 +957,26 @@ function renderList(values: string[], emptyMessage = "No recommended path detect 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 { diff --git a/packages/cli/test/json-output.test.ts b/packages/cli/test/json-output.test.ts index c889da4..333c292 100644 --- a/packages/cli/test/json-output.test.ts +++ b/packages/cli/test/json-output.test.ts @@ -146,7 +146,8 @@ test("onboarding --json emits guide metadata and markdown", async () => { assert.ok(Array.isArray(payload.criticalFiles)); assert.ok(Array.isArray(payload.externalServices)); assert.ok(Array.isArray(payload.recommendedPath)); - assert.match(payload.markdown, /# Project Onboarding/); + assert.match(payload.markdown, /# Onboarding Project/); + assert.match(payload.markdown, /## What This Project Does/); } finally { await rm(projectRoot, { recursive: true, force: true }); } diff --git a/packages/cli/test/onboarding-command.test.ts b/packages/cli/test/onboarding-command.test.ts index 09d897d..c09a9ab 100644 --- a/packages/cli/test/onboarding-command.test.ts +++ b/packages/cli/test/onboarding-command.test.ts @@ -20,20 +20,17 @@ test("onboarding command renders a snapshot-based guide", async () => { const plainLogs = stripAnsi(logs); assert.match(plainLogs, /DevMap Onboarding/); - assert.match(plainLogs, /Project Overview/); + assert.match(plainLogs, /What This Project Does/); assert.match(plainLogs, /Snapshot is stale/); - assert.match(plainLogs, /Snapshot status: stale - run devmap analyze --fresh/); - assert.match(plainLogs, /Entry Points/); - assert.match(plainLogs, /External Services/); - assert.match(plainLogs, /Critical Files/); - assert.match(plainLogs, /Recommended Reading Path/); - assert.match(plainLogs, /Step-by-Step Learning Path/); - assert.match(plainLogs, /Why learn this/); - assert.match(plainLogs, /What to focus on/); - assert.match(plainLogs, /Feature Map/); - assert.match(plainLogs, /Important Flows/); - assert.match(plainLogs, /Agent Workflow/); - assert.match(plainLogs, /Navigation policy: snapshot-first/); + 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/); @@ -54,11 +51,14 @@ test("onboarding command writes ONBOARDING.md when requested", async () => { const content = await readFile(outputPath, "utf8"); assert.match(stripAnsi(logs), /Wrote ONBOARDING\.md/); - assert.match(content, /^# Project Onboarding/m); - assert.match(content, /## Recommended Reading Path/); - assert.match(content, /## Step-by-Step Learning Path/); + 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, /## Agent Workflow/); + assert.match(content, /## Where to Start/); + assert.doesNotMatch(content, /score \d+/); + assert.doesNotMatch(content, /exports:/); } finally { await rm(projectRoot, { recursive: true, force: true }); } @@ -75,10 +75,10 @@ test("onboarding write can generate Indonesian markdown after language prompt", assert.equal(prompt.closed, true); assert.match(prompt.questions.join("\n"), /Onboarding language/); assert.match(content, /^# Onboarding Project/m); - assert.match(content, /## Gambaran Project/); - assert.match(content, /## Urutan Baca yang Disarankan/); - assert.match(content, /## Jalur Belajar Step-by-Step/); - assert.match(content, /Kenapa dipelajari/); + 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 });