From 365ac1bb89913cee7bbd0cb4f3c25d7a1de6226f Mon Sep 17 00:00:00 2001 From: Mufacoderz Date: Sat, 20 Jun 2026 15:52:52 +0800 Subject: [PATCH] feat: improve AI fallback and agent navigation Co-authored-by: devmap-agent <238585242+devmap-agent@users.noreply.github.com> --- PRD.md | 28 ++- docs/architecture.md | 41 ++++- docs/commands.md | 19 +- docs/for-me-personal/DEBUG.md | 40 +++++ docs/for-me-personal/PROGRESS.md | 31 ++++ docs/for-me-personal/TEST.md | 24 ++- packages/cli/src/ai/groq.ts | 106 ++++++----- packages/cli/src/ai/snapshotEnrichment.ts | 13 +- packages/cli/src/ai/types.ts | 2 + packages/cli/src/analyzers/projectMetadata.ts | 128 +++++++++++--- packages/cli/src/cache/agentNavigation.ts | 90 +++++++--- packages/cli/src/cache/snapshot.ts | 13 ++ packages/cli/src/commands/analyze.ts | 16 +- packages/cli/src/commands/ask.ts | 10 +- packages/cli/test/agent-navigation.test.ts | 91 +++++++++- packages/cli/test/ai-client.test.ts | 164 ++++++++++++++++++ packages/cli/test/analyze-ai.test.ts | 9 +- packages/cli/test/analyzers.test.ts | 22 ++- packages/cli/test/ask-command.test.ts | 7 +- 19 files changed, 726 insertions(+), 128 deletions(-) diff --git a/PRD.md b/PRD.md index 335df00..1cdf77c 100644 --- a/PRD.md +++ b/PRD.md @@ -511,6 +511,19 @@ Preferred reading order: The index must remain short and must not duplicate full dependency or change impact data. +The index project header includes separate `framework`, `projectType`, and +`workspaceType` fields. Framework remains a detected framework such as Next.js +or Express; project type describes the primary shape such as `node-cli`, +`web-app`, `api-service`, or `library`; workspace type distinguishes a +monorepo from a single package. Its deterministic summary uses package +description and detected capabilities instead of file-count filler. + +`criticalFiles` is a start-here list, not an import-count leaderboard. It +prioritizes executable entry points, CLI/feature orchestrators, and files that +own detected flows before dependency popularity. Feature maps expose an +ordered `sourcePriority`, while `flow` describes system actions rather than a +second file list. + --- ### `.devmap/snapshot.json` @@ -602,13 +615,20 @@ ai/ | `ask` | `llama-3.1-8b-instant` | Fast model for focused codebase questions | | `analyze` | `openai/gpt-oss-20b` | Balanced architecture interpretation | | `analyze --deep` | `openai/gpt-oss-120b` | Heavy cross-module reasoning | -| Fallback | `openai/gpt-oss-20b` | Production fallback when a different primary model is unavailable | +| `ask` fallbacks | `qwen/qwen3.6-27b` -> `llama-3.3-70b-versatile` -> `openai/gpt-oss-20b` | Preserve responsiveness while increasing reasoning capacity only when needed | +| `analyze` fallbacks | `qwen/qwen3.6-27b` -> `llama-3.3-70b-versatile` -> `llama-3.1-8b-instant` | Keep snapshot enrichment available across model-specific limits | +| `analyze --deep` fallbacks | `llama-3.3-70b-versatile` -> `qwen/qwen3.6-27b` -> `openai/gpt-oss-20b` | Degrade heavy reasoning gradually instead of failing immediately | -If a model becomes unavailable, DevMap gracefully falls back. No raw provider errors shown to users. +DevMap retries a rate-limited model up to three times, then advances through +the command-specific chain. It also advances when a model is unavailable or +returns a transient provider error. Authentication and malformed-request +errors stop immediately. Duplicate model IDs are removed, including when a +user-configured primary model also appears in the fallback chain. No raw +provider errors are shown to users. Model availability changes over time. Before changing the default routing, -verify the current Groq production model list. Preview models must not be used -as the default for a public DevMap release. +verify the current Groq model list and lifecycle status. Preview models must +not be used as a primary default for a public DevMap release. ### User API Key Principle diff --git a/docs/architecture.md b/docs/architecture.md index 6a8745c..8c8466d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -442,6 +442,24 @@ agents to read `.devmap/index.json`, open the relevant feature map, and inspect its `sourcePriority` files before broad repository exploration. The full `.devmap/snapshot.json` is used only when the lightweight maps are insufficient. +The index separates technical framework detection from repository shape: + +```txt +framework -> nextjs | express | unknown +projectType -> node-cli | web-app | api-service | library | unknown +workspaceType -> monorepo | single-package +``` + +This avoids labeling a TypeScript CLI monorepo as a fake framework while still +giving agents an immediate mental model. Project summaries are deterministic +and combine this classification with the primary package description and +detected capabilities. + +Index `criticalFiles` are ranked for reading order: executable entry points +first, then command/flow owners, feature owners, and finally structural +importance. Import count remains a supporting signal rather than the primary +definition of where an agent should start. + The snapshot also stores a compact `agentInstructions` object for machine readers. This is intentionally small: policy fields live in JSON, while the human-readable workflow lives in `DEVMAP.md`. @@ -607,15 +625,20 @@ Commands should not call provider APIs directly. MVP default model routing: -| Command | Model | -| ---------------- | ------------------------- | -| `ask` | `llama-3.1-8b-instant` | -| `analyze` | `openai/gpt-oss-20b` | -| `analyze --deep` | `openai/gpt-oss-120b` | -| Fallback | `openai/gpt-oss-20b` | +| Command | Primary | Ordered fallbacks | +| ---------------- | -------------------------- | ----------------- | +| `ask` | `llama-3.1-8b-instant` | `qwen/qwen3.6-27b` -> `llama-3.3-70b-versatile` -> `openai/gpt-oss-20b` | +| `analyze` | `openai/gpt-oss-20b` | `qwen/qwen3.6-27b` -> `llama-3.3-70b-versatile` -> `llama-3.1-8b-instant` | +| `analyze --deep` | `openai/gpt-oss-120b` | `llama-3.3-70b-versatile` -> `qwen/qwen3.6-27b` -> `openai/gpt-oss-20b` | + +Each model receives up to three exponential-backoff retries for HTTP 429. +After those retries, or when a model is unavailable or returns HTTP 5xx, +DevMap advances to the next unique model. Credentials and invalid requests do +not trigger failover. The chain is resolved before streaming emits content, so +a fallback cannot duplicate a partially rendered answer. -If a model is unavailable, DevMap should fall back gracefully. -Only Groq production models should be used as public defaults. +Model IDs in this table were confirmed active through the Groq model-list API +on 2026-06-20. Recheck provider lifecycle status before publishing a release. Users can override automatic routing with `devmap config model `. Running `devmap config model auto` restores the defaults above. @@ -639,7 +662,7 @@ Rules: * streaming is an optional `AiClient` capability * commands fall back to regular completion for clients without streaming * the final reconstructed text is used for snapshot persistence and metadata -* rate-limit retry and model fallback happen before consuming response deltas +* rate-limit retry and ordered model fallback happen before consuming response deltas * `--json` never streams because stdout must contain one complete JSON document --- diff --git a/docs/commands.md b/docs/commands.md index 53c65f7..41617be 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -138,7 +138,8 @@ devmap analyze --deep * Apply ignore rules * Detect framework * Detect package manager -* Detect language +* Detect language +* Classify project type and workspace shape separately from framework * Detect routes * Detect API routes * Detect dependencies @@ -156,6 +157,11 @@ devmap analyze --deep * Generate architecture overview * Generate `.devmap/index.json` and `.devmap/features/*.json` for agents * Save snapshot to `.devmap/snapshot.json` + +The lightweight index gives agents a concise project summary and a +start-here-oriented `criticalFiles` list. Each feature map provides +`sourcePriority` for reading order and behavioral `flow` steps when enough +static evidence exists. ### Internal Flow @@ -576,6 +582,17 @@ devmap config model auto * `analyze` uses `openai/gpt-oss-20b` * `analyze --deep` uses `openai/gpt-oss-120b` +Automatic routing also uses ordered fallback chains: + +* `ask`: `qwen/qwen3.6-27b`, `llama-3.3-70b-versatile`, then `openai/gpt-oss-20b` +* `analyze`: `qwen/qwen3.6-27b`, `llama-3.3-70b-versatile`, then `llama-3.1-8b-instant` +* `analyze --deep`: `llama-3.3-70b-versatile`, `qwen/qwen3.6-27b`, then `openai/gpt-oss-20b` + +DevMap advances after model-unavailable and transient provider responses. For +rate limits, it first retries the current model three times with exponential +backoff. Invalid API keys stop immediately instead of wasting requests on the +rest of the chain. + The command preserves the configured provider and API key. DevMap must be initialized before changing the model. diff --git a/docs/for-me-personal/DEBUG.md b/docs/for-me-personal/DEBUG.md index a130161..274d5af 100644 --- a/docs/for-me-personal/DEBUG.md +++ b/docs/for-me-personal/DEBUG.md @@ -999,3 +999,43 @@ serta Web Landing tanpa Authentication. Kata teknis di prompt, docs, dan detector source bukan bukti capability runtime. Feature attribution harus bertumpu pada struktur kode dan ownership file. + +--- + +## 15. Single Groq Fallback Gagal Saat Model Kedua Terbatas + +**Tanggal:** 2026-06-20 + +**Status:** Selesai. + +### Gejala + +Semua command hanya memiliki satu fallback `openai/gpt-oss-20b`. Jika primary +dan fallback sama-sama unavailable atau terkena rate limit, DevMap langsung +jatuh ke static output walaupun model Groq lain masih aktif. + +### Akar Masalah + +`AiCompletionRequest` hanya membawa `fallbackModel` tunggal dan `GroqClient` +hanya mencoba fallback untuk model-unavailable. HTTP 429 yang tetap gagal +setelah tiga retry tidak dapat berpindah model. + +### Solusi + +- Tambahkan ordered `fallbackModels` sambil mempertahankan field tunggal lama. +- Gunakan chain berbeda untuk kebutuhan ringan, standard, dan deep analysis. +- Izinkan failover setelah 429 retries, model-unavailable, dan HTTP 5xx. +- Hentikan chain untuk credential dan request errors. +- Deduplikasi primary dan fallback sebelum request dikirim. + +### Verifikasi + +Endpoint model Groq akun development mengonfirmasi seluruh model pada chain +aktif pada 2026-06-20. Unit test mencakup completion, streaming, rate-limit +exhaustion, deduplication, dan credential failure tanpa mengekspos API key. + +### Pelajaran + +Fallback model harus berupa strategi berurutan per workload, bukan satu model +global. Namun error yang tidak mungkin pulih lewat pergantian model tidak boleh +memicu request tambahan. diff --git a/docs/for-me-personal/PROGRESS.md b/docs/for-me-personal/PROGRESS.md index 5916e4d..250a4f6 100644 --- a/docs/for-me-personal/PROGRESS.md +++ b/docs/for-me-personal/PROGRESS.md @@ -4,6 +4,37 @@ Terakhir diperbarui: 2026-06-20 ## Update 2026-06-20 +### Project Classification Dan Start-Here Ranking + +- Agent index sekarang memisahkan `framework`, `projectType`, dan + `workspaceType`; DevMap terdeteksi sebagai TypeScript `node-cli` monorepo + tanpa memalsukan framework baru. +- Package manifest utama dipilih berdasarkan bentuk project, sehingga summary + CLI memakai description package CLI dan bukan statistik jumlah file. +- Deteksi language memakai dominasi source agar sedikit file config JS tidak + mengubah TypeScript codebase menjadi `mixed`. +- `criticalFiles` index memprioritaskan executable entry point, CLI + orchestrator, flow owner, dan feature owner sebelum importance/import count. +- Fresh static validation menghasilkan urutan `index.ts`, `analyze.ts`, lalu + `projectMap.ts`; `groq.ts` tidak lagi mendahului analysis flow utama. +- `sourcePriority` dan behavioral flow dipertahankan; keduanya sudah tersedia + sebelum perubahan ini dan kini memiliki regression coverage bersama. + +### Ordered Groq Model Fallback + +- Mengganti single fallback dengan chain berbeda untuk `ask`, `analyze`, dan + `analyze --deep`. +- Chain memakai model Groq aktif dari Qwen, Llama Versatile, GPT-OSS, dan + Llama Instant sesuai kebutuhan command. +- HTTP 429 tetap mendapat tiga exponential-backoff retry pada model aktif, + lalu berpindah ke model berikutnya jika limit belum pulih. +- Model unavailable dan HTTP 5xx dapat berpindah model; error API key atau + request invalid berhenti langsung. +- Resolver menghapus model duplikat dan tetap mendukung field legacy + `fallbackModel` untuk kompatibilitas client. +- Model list diverifikasi melalui endpoint Groq akun development pada + 2026-06-20 tanpa mencetak atau menyimpan API key. + ### AST Analyzer Dan Agent Navigation - Menambahkan analyzer registry dengan output `FileAnalysis` yang konsisten. diff --git a/docs/for-me-personal/TEST.md b/docs/for-me-personal/TEST.md index ea379fb..f4415fe 100644 --- a/docs/for-me-personal/TEST.md +++ b/docs/for-me-personal/TEST.md @@ -52,6 +52,16 @@ Expected: `Follow dependency` atau salinan daftar feature files; - `index.json.criticalFiles` dimulai dari executable/feature entry points dan tidak mempromosikan type-only hub hanya karena import count; +- project header DevMap berisi `projectType: node-cli`, + `workspaceType: monorepo`, dan language `typescript`, sementara framework + tetap `unknown` karena CLI bukan framework; +- summary menjelaskan TypeScript monorepo, Node.js CLI, package description, + dan capabilities tanpa file-count filler; +- tiga critical file pertama untuk DevMap adalah `packages/cli/src/index.ts`, + `packages/cli/src/commands/analyze.ts`, dan + `packages/cli/src/analyzers/projectMap.ts`; +- feature map Analysis Engine memulai `sourcePriority` dari `projectMap.ts` + dan flow menjelaskan scan/analyze/build behavior tanpa `Follow dependency`; - DevMap sendiri tidak mendeteksi Authentication dari README, prompt example, onboarding text, atau landing page; - feature anchor DevMap mengarah ke `projectMap.ts`, `analyze.ts`, dan landing @@ -194,7 +204,19 @@ Expected automatic routing: - `ask`: `llama-3.1-8b-instant` - `analyze`: `openai/gpt-oss-20b` - `analyze --deep`: `openai/gpt-oss-120b` -- fallback: `openai/gpt-oss-20b` +- `ask` fallback: Qwen 3.6 27B -> Llama 70B Versatile -> GPT-OSS 20B +- `analyze` fallback: Qwen 3.6 27B -> Llama 70B Versatile -> Llama 8B Instant +- deep fallback: Llama 70B Versatile -> Qwen 3.6 27B -> GPT-OSS 20B + +Automated expectations: + +- unavailable model immediately advances to the next unique model; +- HTTP 429 performs three retries with delays `1000`, `2000`, and `4000` ms, + then advances to the next model; +- HTTP 401/403 stops without trying fallback models; +- regular completion and streaming use the same ordered chain; +- a custom configured primary remains first and duplicate fallback IDs are + removed. Manual override: diff --git a/packages/cli/src/ai/groq.ts b/packages/cli/src/ai/groq.ts index 060de90..a5f57ed 100644 --- a/packages/cli/src/ai/groq.ts +++ b/packages/cli/src/ai/groq.ts @@ -20,6 +20,24 @@ export const DEFAULT_AI_MODELS = { fallback: "openai/gpt-oss-20b" } as const; +export const DEFAULT_AI_FALLBACKS = { + ask: [ + "qwen/qwen3.6-27b", + "llama-3.3-70b-versatile", + "openai/gpt-oss-20b" + ], + analyze: [ + "qwen/qwen3.6-27b", + "llama-3.3-70b-versatile", + "llama-3.1-8b-instant" + ], + deepAnalyze: [ + "llama-3.3-70b-versatile", + "qwen/qwen3.6-27b", + "openai/gpt-oss-20b" + ] +} as const; + export type GroqClientDependencies = { fetch?: typeof fetch; sleep?: (milliseconds: number) => Promise; @@ -43,60 +61,42 @@ export class GroqClient implements AiClient { } async complete(request: AiCompletionRequest): Promise { - const primaryResult = await this.requestModel(request, request.model); + let lastError: DevmapError | undefined; - if (primaryResult.ok) { - return primaryResult.result; - } - - if ( - primaryResult.modelUnavailable - && request.fallbackModel - && request.fallbackModel !== request.model - ) { - const fallbackResult = await this.requestModel(request, request.fallbackModel); - if (fallbackResult.ok) { - return fallbackResult.result; + for (const model of resolveModelChain(request)) { + const result = await this.requestModel(request, model); + if (result.ok) { + return result.result; } - throw fallbackResult.error; + lastError = result.error; + if (!result.canFallback) { + throw result.error; + } } - throw primaryResult.error; + throw lastError ?? new DevmapError("No Groq model was configured."); } async stream( request: AiCompletionRequest, onDelta: AiDeltaHandler ): Promise { - const primaryResult = await this.requestModelStream( - request, - request.model, - onDelta - ); - - if (primaryResult.ok) { - return primaryResult.result; - } + let lastError: DevmapError | undefined; - if ( - primaryResult.modelUnavailable - && request.fallbackModel - && request.fallbackModel !== request.model - ) { - const fallbackResult = await this.requestModelStream( - request, - request.fallbackModel, - onDelta - ); - if (fallbackResult.ok) { - return fallbackResult.result; + for (const model of resolveModelChain(request)) { + const result = await this.requestModelStream(request, model, onDelta); + if (result.ok) { + return result.result; } - throw fallbackResult.error; + lastError = result.error; + if (!result.canFallback) { + throw result.error; + } } - throw primaryResult.error; + throw lastError ?? new DevmapError("No Groq model was configured."); } private async requestModel( @@ -282,13 +282,13 @@ type GroqStreamPayload = { type GroqRequestResult = | { ok: true; result: AiCompletionResult } - | { ok: false; modelUnavailable: boolean; error: DevmapError }; + | { ok: false; canFallback: boolean; error: DevmapError }; async function readFailedRequest(response: Response): Promise { const providerMessage = await readProviderError(response); return { ok: false, - modelUnavailable: isModelUnavailable(response.status, providerMessage), + canFallback: shouldTryFallback(response.status, providerMessage), error: mapGroqError(response.status, providerMessage) }; } @@ -296,7 +296,7 @@ async function readFailedRequest(response: Response): Promise function emptyResponseResult(): GroqRequestResult { return { ok: false, - modelUnavailable: false, + canFallback: false, error: new DevmapError( "Groq returned an empty response.", "Try the question again or run devmap doctor." @@ -459,14 +459,24 @@ function mapGroqError(status: number, providerMessage: string): DevmapError { ); } -function isModelUnavailable(status: number, message: string): boolean { - return ( - status === 404 +function shouldTryFallback(status: number, message: string): boolean { + return status === 429 + || status >= 500 || ( - status === 400 - && /model|decommissioned|not available|not found|permission/i.test(message) - ) - ); + status === 404 + || ( + status === 400 + && /model|decommissioned|not available|not found|permission/i.test(message) + ) + ); +} + +function resolveModelChain(request: AiCompletionRequest): string[] { + return Array.from(new Set([ + request.model, + ...(request.fallbackModels ?? []), + ...(request.fallbackModel ? [request.fallbackModel] : []) + ].filter((model) => model.trim().length > 0))); } function readRetryDelay(response: Response): number { diff --git a/packages/cli/src/ai/snapshotEnrichment.ts b/packages/cli/src/ai/snapshotEnrichment.ts index 2a3e9bd..34e816c 100644 --- a/packages/cli/src/ai/snapshotEnrichment.ts +++ b/packages/cli/src/ai/snapshotEnrichment.ts @@ -8,16 +8,19 @@ export async function enrichSnapshotWithAi( snapshot: ProjectMap, client: AiClient, model: string, - fallbackModel: string + fallbackModels: readonly string[] | string ): Promise { let enriched = snapshot; + const modelFallbacks = typeof fallbackModels === "string" + ? [fallbackModels] + : fallbackModels; for (const batch of chunk(selectEligibleFiles(enriched), FILE_BATCH_SIZE)) { const updates = await completeJsonArray( client, buildFilePurposeMessages(batch), model, - fallbackModel + modelFallbacks ); if (updates.length === 0) { @@ -32,7 +35,7 @@ export async function enrichSnapshotWithAi( client, buildFeatureTermsMessages(enriched), model, - fallbackModel + modelFallbacks ); if (featureUpdates.length > 0) { @@ -152,13 +155,13 @@ async function completeJsonArray( client: AiClient, messages: AiMessage[], model: string, - fallbackModel: string + fallbackModels: readonly string[] ): Promise { try { const response = await client.complete({ messages, model, - fallbackModel, + fallbackModels, maxCompletionTokens: 900, temperature: 0 }); diff --git a/packages/cli/src/ai/types.ts b/packages/cli/src/ai/types.ts index 74d7951..78a67af 100644 --- a/packages/cli/src/ai/types.ts +++ b/packages/cli/src/ai/types.ts @@ -6,6 +6,8 @@ export type AiMessage = { export type AiCompletionRequest = { messages: AiMessage[]; model: string; + fallbackModels?: readonly string[]; + /** @deprecated Use fallbackModels for ordered multi-model failover. */ fallbackModel?: string; maxCompletionTokens?: number; temperature?: number; diff --git a/packages/cli/src/analyzers/projectMetadata.ts b/packages/cli/src/analyzers/projectMetadata.ts index a4bfe76..43b58b9 100644 --- a/packages/cli/src/analyzers/projectMetadata.ts +++ b/packages/cli/src/analyzers/projectMetadata.ts @@ -5,6 +5,8 @@ import type { Framework } from "./frameworkDetector.js"; export type ProjectLanguage = "typescript" | "javascript" | "mixed" | "unknown"; export type PackageManager = "pnpm" | "npm" | "yarn" | "bun" | "unknown"; +export type ProjectType = "node-cli" | "web-app" | "api-service" | "library" | "unknown"; +export type WorkspaceType = "monorepo" | "single-package"; export type ProjectMetadata = { name: string; @@ -12,6 +14,9 @@ export type ProjectMetadata = { framework: Framework; language: ProjectLanguage; packageManager: PackageManager; + projectType: ProjectType; + workspaceType: WorkspaceType; + description?: string; }; export function detectProjectMetadata( @@ -19,44 +24,119 @@ export function detectProjectMetadata( framework: Framework, files: ScannedFile[] ): ProjectMetadata { + const manifests = readPackageManifests(files); + const projectType = detectProjectType(framework, manifests); + const primaryManifest = selectPrimaryManifest(manifests, projectType); + return { - name: readProjectName(files) ?? basename(projectRoot), + name: readProjectName(manifests) ?? basename(projectRoot), root: projectRoot, framework, language: detectLanguage(files), - packageManager: detectPackageManager(projectRoot) + packageManager: detectPackageManager(projectRoot), + projectType, + workspaceType: detectWorkspaceType(projectRoot, manifests), + ...(primaryManifest?.description ? { description: primaryManifest.description } : {}) }; } -function readProjectName(files: ScannedFile[]): string | null { - const packageJson = files.find((file) => file.path === "package.json"); - if (!packageJson) { - return null; - } +type PackageManifest = { + path: string; + name?: string; + description?: string; + bin?: unknown; + main?: unknown; + exports?: unknown; + workspaces?: unknown; + dependencies: Record; + devDependencies: Record; +}; - try { - const parsed = JSON.parse(packageJson.content) as { name?: unknown }; - return typeof parsed.name === "string" && parsed.name.trim() ? parsed.name : null; - } catch { - return null; - } +function readPackageManifests(files: ScannedFile[]): PackageManifest[] { + return files + .filter((file) => file.path.endsWith("package.json")) + .flatMap((file) => { + try { + const parsed = JSON.parse(file.content) as Record; + return [{ + path: file.path, + ...(typeof parsed.name === "string" ? { name: parsed.name } : {}), + ...(typeof parsed.description === "string" ? { description: parsed.description } : {}), + bin: parsed.bin, + main: parsed.main, + exports: parsed.exports, + workspaces: parsed.workspaces, + dependencies: isStringRecord(parsed.dependencies) ? parsed.dependencies : {}, + devDependencies: isStringRecord(parsed.devDependencies) ? parsed.devDependencies : {} + }]; + } catch { + return []; + } + }); } -function detectLanguage(files: ScannedFile[]): ProjectLanguage { - const hasTypeScript = files.some((file) => [".ts", ".tsx", ".mts", ".cts"].includes(file.extension)); - const hasJavaScript = files.some((file) => [".js", ".jsx", ".mjs", ".cjs"].includes(file.extension)); +function readProjectName(manifests: PackageManifest[]): string | null { + const rootManifest = manifests.find((manifest) => manifest.path === "package.json"); + return rootManifest?.name?.trim() || null; +} - if (hasTypeScript && hasJavaScript) { - return "mixed"; - } +function detectProjectType( + framework: Framework, + manifests: PackageManifest[] +): ProjectType { + if (manifests.some((manifest) => manifest.bin)) return "node-cli"; + if (framework === "nextjs" || hasDependency(manifests, "astro")) return "web-app"; + if (framework === "express") return "api-service"; + if (manifests.some((manifest) => manifest.exports || manifest.main)) return "library"; + return "unknown"; +} - if (hasTypeScript) { - return "typescript"; +function selectPrimaryManifest( + manifests: PackageManifest[], + projectType: ProjectType +): PackageManifest | undefined { + if (projectType === "node-cli") { + return manifests.find((manifest) => manifest.bin); } - if (hasJavaScript) { - return "javascript"; - } + return manifests.find((manifest) => manifest.path === "package.json") ?? manifests[0]; +} + +function detectWorkspaceType( + projectRoot: string, + manifests: PackageManifest[] +): WorkspaceType { + const rootManifest = manifests.find((manifest) => manifest.path === "package.json"); + return existsSync(join(projectRoot, "pnpm-workspace.yaml")) + || Boolean(rootManifest?.workspaces) + || manifests.length > 1 + ? "monorepo" + : "single-package"; +} + +function hasDependency(manifests: PackageManifest[], dependency: string): boolean { + return manifests.some((manifest) => + dependency in manifest.dependencies || dependency in manifest.devDependencies + ); +} + +function isStringRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function detectLanguage(files: ScannedFile[]): ProjectLanguage { + const typeScriptFiles = files.filter((file) => + [".ts", ".tsx", ".mts", ".cts"].includes(file.extension) + ).length; + const javaScriptFiles = files.filter((file) => + [".js", ".jsx", ".mjs", ".cjs"].includes(file.extension) + ).length; + + if (typeScriptFiles > 0 && javaScriptFiles === 0) return "typescript"; + if (javaScriptFiles > 0 && typeScriptFiles === 0) return "javascript"; + if (typeScriptFiles >= javaScriptFiles * 2) return "typescript"; + if (javaScriptFiles >= typeScriptFiles * 2) return "javascript"; + if (typeScriptFiles > 0 && javaScriptFiles > 0) return "mixed"; return "unknown"; } diff --git a/packages/cli/src/cache/agentNavigation.ts b/packages/cli/src/cache/agentNavigation.ts index 0aa4cd5..4c81816 100644 --- a/packages/cli/src/cache/agentNavigation.ts +++ b/packages/cli/src/cache/agentNavigation.ts @@ -45,6 +45,8 @@ export async function writeAgentNavigationFiles( framework: snapshot.project.framework, language: snapshot.project.language, packageManager: snapshot.project.packageManager, + projectType: snapshot.project.projectType, + workspaceType: snapshot.project.workspaceType, summary: createProjectSummary(snapshot) }, generatedAt: snapshot.generatedAt, @@ -117,37 +119,61 @@ function createFeatureMap(id: string, feature: FeatureInfo, snapshot: ProjectMap } function selectIndexCriticalFiles(snapshot: ProjectMap): string[] { - const selected = new Set(); + const candidates = new Set(); for (const path of snapshot.entryPoints) { - selected.add(path); + candidates.add(path); } for (const feature of snapshot.features) { if (feature.name === "Documentation") continue; - if (feature.entryPoint) selected.add(feature.entryPoint); + if (feature.entryPoint) candidates.add(feature.entryPoint); + feature.entryPoints.forEach((path) => candidates.add(path)); + feature.files.forEach((path) => candidates.add(path)); + } - const supportingFile = feature.files.find((path) => { - if (path === feature.entryPoint) return false; - const metadata = snapshot.fileIndex[path]; - return metadata - && metadata.scope !== "docs" - && metadata.scope !== "test" - && (metadata.topFunctions.length > 0 || ["api", "cli", "ui"].includes(metadata.scope)); + for (const flow of snapshot.flows) { + if (flow.entryPoint) candidates.add(flow.entryPoint); + flow.steps.forEach((step) => { + if (step.file) candidates.add(step.file); }); - if (supportingFile) selected.add(supportingFile); } for (const critical of snapshot.criticalFiles) { - const metadata = snapshot.fileIndex[critical.path]; - if (!metadata || metadata.scope === "docs" || metadata.scope === "test") continue; - if (metadata.topFunctions.length === 0 && metadata.scope !== "api" && metadata.scope !== "cli") { - continue; - } - selected.add(critical.path); + candidates.add(critical.path); } - return [...selected].slice(0, 8); + return [...candidates] + .filter((path) => { + const metadata = snapshot.fileIndex[path]; + return metadata && metadata.scope !== "docs" && metadata.scope !== "test"; + }) + .sort((left, right) => + calculateStartHereScore(right, snapshot) - calculateStartHereScore(left, snapshot) + || left.localeCompare(right) + ) + .slice(0, 8); +} + +function calculateStartHereScore(path: string, snapshot: ProjectMap): number { + const metadata = snapshot.fileIndex[path]; + const entryIndex = snapshot.entryPoints.indexOf(path); + const flowOwnership = snapshot.flows.filter((flow) => + flow.entryPoint === path || flow.steps.some((step) => step.file === path) + ).length; + const featureOwnership = snapshot.features.filter((feature) => + feature.entryPoint === path || feature.entryPoints.includes(path) + ).length; + const commandBonus = metadata?.scope === "cli" ? 500 : 0; + const commandPathBonus = /(^|\/)commands?\//.test(path) ? 300 : 0; + + return (entryIndex >= 0 ? 1_000_000 - entryIndex * 10_000 : 0) + + commandBonus + + commandPathBonus + + flowOwnership * 120 + + featureOwnership * 100 + + (metadata?.featureRefs.length ?? 0) * 40 + + (metadata?.importance ?? 0); } function selectCriticalFiles(feature: FeatureInfo, snapshot: ProjectMap): string[] { @@ -171,18 +197,36 @@ function selectCriticalFiles(feature: FeatureInfo, snapshot: ProjectMap): string } function createProjectSummary(snapshot: ProjectMap): string { - const stack = snapshot.project.framework === "unknown" - ? snapshot.project.language - : `${snapshot.project.framework} ${snapshot.project.language}`; + const language = formatLabel(snapshot.project.language); + const projectKind = describeProjectKind(snapshot); const featureNames = snapshot.features .filter((feature) => feature.confidence !== "low") .map((feature) => feature.name) .slice(0, 4); const featureText = featureNames.length > 0 - ? ` Main concerns: ${featureNames.join(", ")}.` + ? ` Main capabilities: ${featureNames.join(", ")}.` : ""; + const description = snapshot.project.description?.trim(); + const descriptionText = description + ? ` ${description.replace(/[.!?]+$/, "")}.` + : ""; + + return `${snapshot.project.name} is a ${language} ${projectKind}.${descriptionText}${featureText}`; +} + +function describeProjectKind(snapshot: ProjectMap): string { + const workspace = snapshot.project.workspaceType === "monorepo" ? "monorepo" : "project"; + if (snapshot.project.projectType === "node-cli") return `${workspace} centered on a Node.js CLI`; + if (snapshot.project.projectType === "web-app") return `${workspace} containing a web application`; + if (snapshot.project.projectType === "api-service") return `${workspace} containing an API service`; + if (snapshot.project.projectType === "library") return `${workspace} containing a reusable library`; + return workspace; +} - return `${snapshot.project.name} is a ${stack} project with ${snapshot.stats.relevantFiles} analyzed files.${featureText}`; +function formatLabel(value: string): string { + if (value === "typescript") return "TypeScript"; + if (value === "javascript") return "JavaScript"; + return value.charAt(0).toUpperCase() + value.slice(1); } function featureId(name: string): string { diff --git a/packages/cli/src/cache/snapshot.ts b/packages/cli/src/cache/snapshot.ts index 2527ebe..5b20925 100644 --- a/packages/cli/src/cache/snapshot.ts +++ b/packages/cli/src/cache/snapshot.ts @@ -140,6 +140,19 @@ function normalizeSnapshotDefaults(snapshot: Record): void { snapshot.changeImpact = {}; } + if (isRecord(snapshot.project)) { + if (typeof snapshot.project.projectType !== "string") { + snapshot.project.projectType = snapshot.project.framework === "nextjs" + ? "web-app" + : snapshot.project.framework === "express" + ? "api-service" + : "unknown"; + } + if (typeof snapshot.project.workspaceType !== "string") { + snapshot.project.workspaceType = "single-package"; + } + } + const fileIndex = snapshot.fileIndex as Record>; for (const entry of Object.values(fileIndex)) { if (typeof entry.analyzer !== "string") entry.analyzer = "heuristic"; diff --git a/packages/cli/src/commands/analyze.ts b/packages/cli/src/commands/analyze.ts index a4fbf63..d26b635 100644 --- a/packages/cli/src/commands/analyze.ts +++ b/packages/cli/src/commands/analyze.ts @@ -1,6 +1,10 @@ import { resolve } from "node:path"; import { completeWithOptionalStreaming } from "../ai/completion.js"; -import { DEFAULT_AI_MODELS, GroqClient } from "../ai/groq.js"; +import { + DEFAULT_AI_FALLBACKS, + DEFAULT_AI_MODELS, + GroqClient +} from "../ai/groq.js"; import { buildAnalyzeMessages } from "../ai/prompts.js"; import { enrichSnapshotWithAi } from "../ai/snapshotEnrichment.js"; import type { AiClient } from "../ai/types.js"; @@ -95,12 +99,15 @@ async function enrichSnapshot( ? DEFAULT_AI_MODELS.deepAnalyze : DEFAULT_AI_MODELS.analyze; const model = config.model === "auto" ? defaultModel : config.model; + const fallbackModels = options.deep + ? DEFAULT_AI_FALLBACKS.deepAnalyze + : DEFAULT_AI_FALLBACKS.analyze; const enriched = await enrichSnapshotWithAi( snapshot, client, model, - DEFAULT_AI_MODELS.fallback + fallbackModels ); if (enriched !== snapshot) { @@ -187,6 +194,9 @@ async function printOrGenerateInterpretation( ? DEFAULT_AI_MODELS.deepAnalyze : DEFAULT_AI_MODELS.analyze; const model = config.model === "auto" ? defaultModel : config.model; + const fallbackModels = options.deep + ? DEFAULT_AI_FALLBACKS.deepAnalyze + : DEFAULT_AI_FALLBACKS.analyze; output.step(`Interpreting architecture with ${model}`); @@ -194,7 +204,7 @@ async function printOrGenerateInterpretation( const execution = await completeWithOptionalStreaming(client, { messages: buildAnalyzeMessages(snapshot, options.deep), model, - fallbackModel: DEFAULT_AI_MODELS.fallback, + fallbackModels, maxCompletionTokens: options.deep ? 1800 : 1000, temperature: 0.2 }, !options.json, () => output.section("Architecture")); diff --git a/packages/cli/src/commands/ask.ts b/packages/cli/src/commands/ask.ts index dc245db..8953daf 100644 --- a/packages/cli/src/commands/ask.ts +++ b/packages/cli/src/commands/ask.ts @@ -1,6 +1,10 @@ import { buildQuestionContext } from "../ai/contextBuilder.js"; import { completeWithOptionalStreaming } from "../ai/completion.js"; -import { DEFAULT_AI_MODELS, GroqClient } from "../ai/groq.js"; +import { + DEFAULT_AI_FALLBACKS, + DEFAULT_AI_MODELS, + GroqClient +} from "../ai/groq.js"; import { buildAskMessages, buildQueryExpansionMessages } from "../ai/prompts.js"; import type { AiClient } from "../ai/types.js"; import { inspectSnapshot, isSnapshotStale } from "../cache/snapshot.js"; @@ -142,7 +146,7 @@ async function runAsk( const execution = await completeWithOptionalStreaming(client, { messages: buildAskMessages(context, snapshot.project), model, - fallbackModel: DEFAULT_AI_MODELS.fallback, + fallbackModels: DEFAULT_AI_FALLBACKS.ask, maxCompletionTokens: 1200, temperature: 0.2 }, !dependencies.json, () => output.section("Answer")); @@ -204,7 +208,7 @@ async function expandQuestionTerms( const result = await client.complete({ messages: buildQueryExpansionMessages(question), model, - fallbackModel: DEFAULT_AI_MODELS.fallback, + fallbackModels: DEFAULT_AI_FALLBACKS.ask, maxCompletionTokens: 180, temperature: 0 }); diff --git a/packages/cli/test/agent-navigation.test.ts b/packages/cli/test/agent-navigation.test.ts index 1f39807..9e7b1f1 100644 --- a/packages/cli/test/agent-navigation.test.ts +++ b/packages/cli/test/agent-navigation.test.ts @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import test from "node:test"; @@ -58,3 +58,92 @@ test("agent navigation writer creates a compact index and feature maps", async ( await rm(outputRoot, { recursive: true, force: true }); } }); + +test("agent navigation identifies a CLI monorepo and prioritizes its main flow", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "devmap-cli-navigation-")); + const outputRoot = await mkdtemp(join(tmpdir(), "devmap-cli-navigation-output-")); + + try { + await writeFixtureFile(projectRoot, "package.json", JSON.stringify({ + name: "navigator-workspace", + private: true, + description: "Maps codebases for developers and AI agents." + })); + await writeFixtureFile(projectRoot, "pnpm-workspace.yaml", "packages:\n - packages/*\n"); + await writeFixtureFile(projectRoot, "packages/cli/package.json", JSON.stringify({ + name: "navigator", + description: "CLI that scans projects and generates reusable navigation context.", + bin: { navigator: "./dist/index.js" } + })); + await writeFixtureFile( + projectRoot, + "packages/cli/src/index.ts", + 'import { analyzeCommand } from "./commands/analyze.js";\nanalyzeCommand();\n' + ); + await writeFixtureFile( + projectRoot, + "packages/cli/src/commands/analyze.ts", + 'import { createProjectMap } from "../analyzers/projectMap.js";\nexport async function analyzeCommand() { return createProjectMap(); }\n' + ); + await writeFixtureFile( + projectRoot, + "packages/cli/src/analyzers/projectMap.ts", + 'import { scanFiles } from "./fileScanner.js";\nexport async function createProjectMap() { return scanFiles(); }\n' + ); + await writeFixtureFile( + projectRoot, + "packages/cli/src/analyzers/fileScanner.ts", + "export async function scanFiles() { return []; }\n" + ); + await writeFixtureFile( + projectRoot, + "packages/cli/src/ai/groq.ts", + "export async function completeWithGroq() { return 'ok'; }\n" + ); + + const snapshot = await createProjectMap(projectRoot); + const result = await writeAgentNavigationFiles(outputRoot, snapshot); + const index = JSON.parse(await readFile(result.indexPath, "utf8")) as { + project: { + projectType: string; + workspaceType: string; + summary: string; + }; + criticalFiles: string[]; + features: Array<{ id: string; map: string }>; + }; + + assert.equal(index.project.projectType, "node-cli"); + assert.equal(index.project.workspaceType, "monorepo"); + assert.match(index.project.summary, /TypeScript monorepo centered on a Node\.js CLI/i); + assert.match(index.project.summary, /generates reusable navigation context/i); + assert.deepEqual(index.criticalFiles.slice(0, 3), [ + "packages/cli/src/index.ts", + "packages/cli/src/commands/analyze.ts", + "packages/cli/src/analyzers/projectMap.ts" + ]); + + const analysis = index.features.find((feature) => feature.id === "analysis-engine"); + assert.ok(analysis); + const featureMap = JSON.parse(await readFile( + join(outputRoot, analysis.map), + "utf8" + )) as { sourcePriority: string[]; flow?: string[] }; + assert.equal(featureMap.sourcePriority[0], "packages/cli/src/analyzers/projectMap.ts"); + assert.match(featureMap.flow?.join(" ") ?? "", /Scan project files/i); + assert.doesNotMatch(featureMap.flow?.join(" ") ?? "", /Follow dependency/i); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + await rm(outputRoot, { recursive: true, force: true }); + } +}); + +async function writeFixtureFile( + projectRoot: string, + path: string, + content: string +): Promise { + const target = join(projectRoot, path); + await mkdir(dirname(target), { recursive: true }); + await writeFile(target, content, "utf8"); +} diff --git a/packages/cli/test/ai-client.test.ts b/packages/cli/test/ai-client.test.ts index 57d925f..f834232 100644 --- a/packages/cli/test/ai-client.test.ts +++ b/packages/cli/test/ai-client.test.ts @@ -3,6 +3,7 @@ import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import test from "node:test"; import { + DEFAULT_AI_FALLBACKS, DEFAULT_AI_MODELS, GroqClient, type GroqClientDependencies @@ -190,6 +191,169 @@ test("Groq client falls back when the primary model is unavailable", async () => assert.equal(result.model, DEFAULT_AI_MODELS.fallback); }); +test("Groq client follows an ordered fallback chain after unavailable and rate-limited models", async () => { + const requestedModels: string[] = []; + const delays: number[] = []; + const [qwenModel, versatileModel] = DEFAULT_AI_FALLBACKS.ask; + const client = new GroqClient("gsk_test", { + fetch: async (_url, init) => { + const body = JSON.parse(String(init?.body)) as { model: string }; + requestedModels.push(body.model); + + if (body.model === DEFAULT_AI_MODELS.ask) { + return jsonResponse( + { error: { message: "The model is not available." } }, + 404 + ); + } + + if (body.model === qwenModel) { + return jsonResponse( + { error: { message: "Rate limit reached." } }, + 429 + ); + } + + return jsonResponse({ + model: versatileModel, + choices: [{ message: { content: "Recovered on the next model." } }] + }); + }, + sleep: async (milliseconds) => { + delays.push(milliseconds); + } + }); + + const result = await client.complete({ + messages: [{ role: "user", content: "Explain auth." }], + model: DEFAULT_AI_MODELS.ask, + fallbackModels: DEFAULT_AI_FALLBACKS.ask + }); + + assert.deepEqual(requestedModels, [ + DEFAULT_AI_MODELS.ask, + qwenModel, + qwenModel, + qwenModel, + qwenModel, + versatileModel + ]); + assert.deepEqual(delays, [1000, 2000, 4000]); + assert.equal(result.content, "Recovered on the next model."); + assert.equal(result.model, versatileModel); +}); + +test("Groq client removes duplicate models from the fallback chain", async () => { + const requestedModels: string[] = []; + const client = new GroqClient("gsk_test", { + fetch: async (_url, init) => { + const body = JSON.parse(String(init?.body)) as { model: string }; + requestedModels.push(body.model); + + if (body.model === DEFAULT_AI_MODELS.ask) { + return jsonResponse( + { error: { message: "The model is not available." } }, + 404 + ); + } + + return jsonResponse({ + model: DEFAULT_AI_MODELS.fallback, + choices: [{ message: { content: "Fallback answer." } }] + }); + } + }); + + await client.complete({ + messages: [{ role: "user", content: "Explain auth." }], + model: DEFAULT_AI_MODELS.ask, + fallbackModels: [ + DEFAULT_AI_MODELS.ask, + DEFAULT_AI_MODELS.fallback, + DEFAULT_AI_MODELS.fallback + ], + fallbackModel: DEFAULT_AI_MODELS.fallback + }); + + assert.deepEqual(requestedModels, [ + DEFAULT_AI_MODELS.ask, + DEFAULT_AI_MODELS.fallback + ]); +}); + +test("Groq streaming follows the fallback chain before emitting deltas", async () => { + const requestedModels: string[] = []; + const deltas: string[] = []; + const fallbackModel = DEFAULT_AI_FALLBACKS.ask[0]; + const encoder = new TextEncoder(); + const client = new GroqClient("gsk_test", { + fetch: async (_url, init) => { + const body = JSON.parse(String(init?.body)) as { + model: string; + stream?: boolean; + }; + requestedModels.push(body.model); + assert.equal(body.stream, true); + + if (body.model === DEFAULT_AI_MODELS.ask) { + return jsonResponse( + { error: { message: "The model is not available." } }, + 404 + ); + } + + return new Response(new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode( + `data: {"model":"${fallbackModel}","choices":[{"delta":{"content":"Fallback stream."}}]}\n\n` + )); + controller.enqueue(encoder.encode("data: [DONE]\n\n")); + controller.close(); + } + }), { + headers: { "content-type": "text/event-stream" } + }); + } + }); + + const result = await client.stream({ + messages: [{ role: "user", content: "Explain auth." }], + model: DEFAULT_AI_MODELS.ask, + fallbackModels: DEFAULT_AI_FALLBACKS.ask + }, (delta) => { + deltas.push(delta); + }); + + assert.deepEqual(requestedModels, [DEFAULT_AI_MODELS.ask, fallbackModel]); + assert.deepEqual(deltas, ["Fallback stream."]); + assert.equal(result.model, fallbackModel); +}); + +test("Groq client does not fall back after authentication errors", async () => { + let requestCount = 0; + const client = new GroqClient("invalid", { + fetch: async () => { + requestCount += 1; + return jsonResponse( + { error: { message: "Invalid API key." } }, + 401 + ); + } + }); + + await assert.rejects( + client.complete({ + messages: [{ role: "user", content: "Explain auth." }], + model: DEFAULT_AI_MODELS.ask, + fallbackModels: DEFAULT_AI_FALLBACKS.ask + }), + (error: unknown) => error instanceof DevmapError + && /API key is invalid/i.test(error.message) + ); + + assert.equal(requestCount, 1); +}); + test("Groq client maps invalid credentials to an actionable error", async () => { const client = new GroqClient("invalid", { fetch: async () => jsonResponse( diff --git a/packages/cli/test/analyze-ai.test.ts b/packages/cli/test/analyze-ai.test.ts index 7f9b4cd..07c4294 100644 --- a/packages/cli/test/analyze-ai.test.ts +++ b/packages/cli/test/analyze-ai.test.ts @@ -3,7 +3,10 @@ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; -import { DEFAULT_AI_MODELS } from "../src/ai/groq.js"; +import { + DEFAULT_AI_FALLBACKS, + DEFAULT_AI_MODELS +} from "../src/ai/groq.js"; import type { AiClient, AiCompletionRequest, @@ -228,8 +231,8 @@ test("analyze auto routing uses 20B normally and 120B for deep analysis", async assert.equal(requests[1]?.model, DEFAULT_AI_MODELS.analyze); assert.equal(requests[2]?.model, DEFAULT_AI_MODELS.deepAnalyze); assert.equal(requests[3]?.model, DEFAULT_AI_MODELS.deepAnalyze); - assert.equal(requests[0]?.fallbackModel, DEFAULT_AI_MODELS.fallback); - assert.equal(requests[2]?.fallbackModel, DEFAULT_AI_MODELS.fallback); + assert.deepEqual(requests[0]?.fallbackModels, DEFAULT_AI_FALLBACKS.analyze); + assert.deepEqual(requests[2]?.fallbackModels, DEFAULT_AI_FALLBACKS.deepAnalyze); } finally { await rm(projectRoot, { recursive: true, force: true }); } diff --git a/packages/cli/test/analyzers.test.ts b/packages/cli/test/analyzers.test.ts index 6a58526..c52123d 100644 --- a/packages/cli/test/analyzers.test.ts +++ b/packages/cli/test/analyzers.test.ts @@ -240,7 +240,9 @@ test("project map summarizes a Next.js fixture", async () => { root: nextFixture, framework: "nextjs", language: "typescript", - packageManager: "unknown" + packageManager: "unknown", + projectType: "web-app", + workspaceType: "single-package" }); assert.ok(projectMap.entryPoints.includes("app/page.tsx")); assert.ok(projectMap.entryPoints.includes("app/layout.tsx")); @@ -391,6 +393,24 @@ test("snapshot can be saved and read back", async () => { } }); +test("snapshot reader supplies project classification defaults for schema v1 snapshots", async () => { + const temporaryRoot = await mkdtemp(join(tmpdir(), "devmap-project-defaults-")); + + try { + const projectMap = await createProjectMap(nextFixture); + const legacyProject = projectMap.project as Partial; + delete legacyProject.projectType; + delete legacyProject.workspaceType; + await saveSnapshot(temporaryRoot, projectMap); + + const saved = await readSnapshot(temporaryRoot); + assert.equal(saved?.project.projectType, "web-app"); + assert.equal(saved?.project.workspaceType, "single-package"); + } finally { + await rm(temporaryRoot, { recursive: true, force: true }); + } +}); + test("project fingerprint is stable until source content changes", async () => { const projectRoot = await mkdtemp(join(tmpdir(), "devmap-fingerprint-test-")); diff --git a/packages/cli/test/ask-command.test.ts b/packages/cli/test/ask-command.test.ts index ae39b56..9633e9a 100644 --- a/packages/cli/test/ask-command.test.ts +++ b/packages/cli/test/ask-command.test.ts @@ -3,7 +3,10 @@ import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; -import { DEFAULT_AI_MODELS } from "../src/ai/groq.js"; +import { + DEFAULT_AI_FALLBACKS, + DEFAULT_AI_MODELS +} from "../src/ai/groq.js"; import type { AiClient, AiCompletionRequest, @@ -55,7 +58,7 @@ test("ask command uses configured AI client and prints token usage", async () => assert.equal(requests.length, 1); assert.equal(requests[0]?.model, DEFAULT_AI_MODELS.ask); - assert.equal(requests[0]?.fallbackModel, DEFAULT_AI_MODELS.fallback); + assert.deepEqual(requests[0]?.fallbackModels, DEFAULT_AI_FALLBACKS.ask); assert.match(requests[0]?.messages[1]?.content ?? "", /EXPANDED_TERMS: none/); assert.match(requests[0]?.messages[1]?.content ?? "", /auth\.ts/); const plainLogs = stripAnsi(logs);