From 9c966e8d683a5ec7c50a41d373bbcc8357f7a962 Mon Sep 17 00:00:00 2001 From: Mufacoderz Date: Thu, 18 Jun 2026 16:41:00 +0800 Subject: [PATCH 1/5] feat: add snapshot function metadata Co-authored-by: devmap-agent <238585242+devmap-agent@users.noreply.github.com> --- PRD.md | 14 ++- README.md | 2 +- docs/architecture.md | 5 +- docs/commands.md | 4 +- docs/for-me-personal/PROGRESS.md | 18 ++++ docs/for-me-personal/TEST.md | 4 +- packages/cli/src/ai/contextBuilder.ts | 2 +- packages/cli/src/analyzers/projectMap.ts | 112 ++++++++++++++++++++-- packages/cli/src/cache/snapshot.ts | 1 + packages/cli/test/analyzers.test.ts | 20 ++++ packages/cli/test/context-builder.test.ts | 5 + 11 files changed, 171 insertions(+), 16 deletions(-) diff --git a/PRD.md b/PRD.md index d78dd4c..4355c84 100644 --- a/PRD.md +++ b/PRD.md @@ -723,6 +723,13 @@ interface DevMapSnapshot { hash: string; imports: string[]; exportedSymbols: string[]; + topFunctions: Array<{ + name: string; + kind: "function" | "const" | "class" | "method"; + line: number; + exported: boolean; + async: boolean; + }>; lines: number; purpose?: string; scope: "api" | "ui" | "database" | "config" | "service" | "cli" | "test" | "docs" | "unknown"; @@ -741,9 +748,10 @@ interface DevMapSnapshot { - Snapshot must include a schema version - Future schema changes must be versioned - File index entries should include compact navigation metadata such as - purpose, responsibility scope, feature references, search terms, and - importance. These fields help `devmap ask`, future onboarding output, and - future flow generation without storing full raw source. + purpose, responsibility scope, exported symbols, top functions/code symbols, + feature references, search terms, and importance. These fields help + `devmap ask`, future onboarding output, and future flow generation without + storing full raw source. - AI-generated file purpose and search terms must be batched and optional. Analyze must continue if enrichment fails. diff --git a/README.md b/README.md index c893389..eb7fba1 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ The snapshot contains: * Architecture overview * Entry points * Critical files -* File purpose, scope, search terms, and importance +* File purpose, scope, top functions, search terms, and importance * Routes and APIs * External services * Database information diff --git a/docs/architecture.md b/docs/architecture.md index 0182e3c..1e65bfe 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -377,6 +377,7 @@ Each `fileIndex` entry stores compact navigation metadata: | ----- | ------- | | `purpose` | One-sentence description of what the file does when available | | `scope` | Responsibility classification: API, UI, database, config, service, CLI, test, docs, or unknown | +| `topFunctions` | Compact list of important functions or exported code symbols with line numbers | | `featureRefs` | Feature names that reference this file | | `searchTerms` | Retrieval-focused terms used by `devmap ask` | | `importance` | Static importance score from references, entry point status, critical-file score, and feature ownership | @@ -392,8 +393,8 @@ and snapshot generation must still complete. Snapshot schema includes `flows` as a foundation for future `FLOW.md` generation. Phase 1 only creates small feature flows for high-confidence -features, using the feature file order as steps. It does not build a full call -graph or separate flow analyzer. +features, using feature files and their important exported symbols as steps. It +does not build a full call graph or separate flow analyzer. --- diff --git a/docs/commands.md b/docs/commands.md index 27c6443..0b90dff 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -144,8 +144,8 @@ devmap analyze --deep * Detect database usage * Detect entry points * Detect critical files -* Build a compact file index with purpose, scope, search terms, feature - references, and importance +* Build a compact file index with purpose, scope, top functions/code symbols, + search terms, feature references, and importance * Generate minimal high-confidence feature flows * Generate architecture overview * Save snapshot to `.devmap/snapshot.json` diff --git a/docs/for-me-personal/PROGRESS.md b/docs/for-me-personal/PROGRESS.md index cc74209..c703213 100644 --- a/docs/for-me-personal/PROGRESS.md +++ b/docs/for-me-personal/PROGRESS.md @@ -917,3 +917,21 @@ Verifikasi: ```powershell pnpm --filter devmap exec tsx --test test/analyzers.test.ts ``` + +## Snapshot Function Metadata + +**Tanggal:** 2026-06-18 + +Snapshot `fileIndex` sekarang menyimpan `topFunctions`, yaitu daftar ringkas +fungsi atau symbol kode penting beserta line number, tipe symbol, status export, +dan status async. Metadata ini menjadi fondasi untuk jawaban `ask`, onboarding, +dan flow document tanpa harus membaca raw source terlalu banyak. + +Flow minimal juga mulai memakai symbol penting pada label step, sehingga flow +lebih informatif daripada sekadar daftar file. + +Verifikasi: + +```powershell +pnpm --filter devmap exec tsx --test test/analyzers.test.ts test/context-builder.test.ts +``` diff --git a/docs/for-me-personal/TEST.md b/docs/for-me-personal/TEST.md index 8e31b76..24f2c65 100644 --- a/docs/for-me-personal/TEST.md +++ b/docs/for-me-personal/TEST.md @@ -391,7 +391,9 @@ Expected tambahan: - `.agent/` dan `.agents/` tidak masuk hasil scan; - `.agents/skills/*/SKILL.md` tidak terdeteksi sebagai fitur AI project; -- snapshot tetap hanya merepresentasikan source project yang dianalisis. +- snapshot tetap hanya merepresentasikan source project yang dianalisis; +- `fileIndex[*].topFunctions` berisi fungsi atau symbol kode penting dengan + line number, status export, dan status async. AI client: diff --git a/packages/cli/src/ai/contextBuilder.ts b/packages/cli/src/ai/contextBuilder.ts index b719a06..358df34 100644 --- a/packages/cli/src/ai/contextBuilder.ts +++ b/packages/cli/src/ai/contextBuilder.ts @@ -804,7 +804,7 @@ async function readContextFile( return { ...rankedFile, exports: snapshot.fileIndex[rankedFile.path]?.exportedSymbols ?? [], - topFunctions: [], + topFunctions: snapshot.fileIndex[rankedFile.path]?.topFunctions ?? [], purpose: snapshot.fileIndex[rankedFile.path]?.purpose, startLine: window.start + 1, endLine: window.end, diff --git a/packages/cli/src/analyzers/projectMap.ts b/packages/cli/src/analyzers/projectMap.ts index cd16df8..9c1dfd9 100644 --- a/packages/cli/src/analyzers/projectMap.ts +++ b/packages/cli/src/analyzers/projectMap.ts @@ -42,6 +42,13 @@ export type FileIndexEntry = { hash: string; imports: string[]; exportedSymbols: string[]; + topFunctions: Array<{ + name: string; + kind: "function" | "const" | "class" | "method"; + line: number; + exported: boolean; + async: boolean; + }>; lines: number; purpose?: string; scope: FileScope; @@ -218,6 +225,7 @@ function createFileIndexEntry( criticalFiles: ProjectMap["criticalFiles"], features: FeatureInfo[] ): FileIndexEntry { + const topFunctions = findTopFunctions(file.content); const exportedSymbols = findExportedSymbols(file.content); const scope = classifyFileScope(file, exportedSymbols, imports); const featureRefs = features @@ -232,13 +240,14 @@ function createFileIndexEntry( criticalFile?.score ?? 0, featureRefs.length ); - const searchTerms = buildFileSearchTerms(file.path, scope, exportedSymbols, featureRefs); - const purpose = inferFilePurpose(file.path, scope, exportedSymbols, featureRefs); + const searchTerms = buildFileSearchTerms(file.path, scope, exportedSymbols, topFunctions, featureRefs); + const purpose = inferFilePurpose(file.path, scope, exportedSymbols, topFunctions, featureRefs); return { hash: hashContent(file.content), imports, exportedSymbols, + topFunctions, lines: file.lines, ...(purpose ? { purpose } : {}), scope, @@ -294,6 +303,7 @@ function buildFileSearchTerms( path: string, scope: FileScope, exportedSymbols: string[], + topFunctions: FileIndexEntry["topFunctions"], featureRefs: string[] ): string[] { const terms = new Set(); @@ -312,6 +322,12 @@ function buildFileSearchTerms( } } + for (const item of topFunctions) { + for (const part of splitSearchTerms(item.name)) { + terms.add(part); + } + } + for (const feature of featureRefs) { for (const part of splitSearchTerms(feature)) { terms.add(part); @@ -327,10 +343,14 @@ function inferFilePurpose( path: string, scope: FileScope, exportedSymbols: string[], + topFunctions: FileIndexEntry["topFunctions"], featureRefs: string[] ): string | undefined { - const subject = exportedSymbols[0] - ? `exports ${exportedSymbols.slice(0, 3).join(", ")}` + const primarySymbols = exportedSymbols.length > 0 + ? exportedSymbols + : topFunctions.map((item) => item.name); + const subject = primarySymbols[0] + ? `exposes ${primarySymbols.slice(0, 3).join(", ")}` : `contains ${scope === "unknown" ? "project" : scope} code`; const featureText = featureRefs.length > 0 ? ` for ${featureRefs.slice(0, 2).join(" and ")}` @@ -340,13 +360,78 @@ function inferFilePurpose( return undefined; } - if (scope === "unknown" && exportedSymbols.length === 0 && featureRefs.length === 0) { + if (scope === "unknown" && primarySymbols.length === 0 && featureRefs.length === 0) { return undefined; } return `${path} ${subject}${featureText}.`; } +function findTopFunctions(content: string): FileIndexEntry["topFunctions"] { + const functions = new Map(); + const patterns: Array<{ + kind: FileIndexEntry["topFunctions"][number]["kind"]; + pattern: RegExp; + nameIndex: number; + exportedIndex?: number; + asyncIndex?: number; + }> = [ + { + kind: "function", + pattern: /(export\s+)?(async\s+)?function\s+([A-Za-z_$][A-Za-z0-9_$]*)/g, + nameIndex: 3, + exportedIndex: 1, + asyncIndex: 2 + }, + { + kind: "const", + pattern: /(export\s+)?const\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*=\s*(async\s*)?/g, + nameIndex: 2, + exportedIndex: 1, + asyncIndex: 3 + }, + { + kind: "class", + pattern: /(export\s+)?class\s+([A-Za-z_$][A-Za-z0-9_$]*)/g, + nameIndex: 2, + exportedIndex: 1 + } + ]; + + for (const { kind, pattern, nameIndex, exportedIndex, asyncIndex } of patterns) { + let match = pattern.exec(content); + while (match) { + const name = match[nameIndex]; + const existing = functions.get(name); + const candidate = { + name, + kind, + line: getLineNumber(content, match.index), + exported: exportedIndex !== undefined && Boolean(match[exportedIndex]), + async: asyncIndex !== undefined && Boolean(match[asyncIndex]) + }; + + if (!existing || Number(candidate.exported) > Number(existing.exported)) { + functions.set(name, candidate); + } + + match = pattern.exec(content); + } + } + + return [...functions.values()] + .sort((left, right) => + Number(right.exported) - Number(left.exported) + || left.line - right.line + || left.name.localeCompare(right.name) + ) + .slice(0, 8); +} + +function getLineNumber(content: string, index: number): number { + return content.slice(0, index).split(/\r?\n/).length; +} + function attachFeatureEntryPoints( features: FeatureInfo[], routes: RouteInfo[], @@ -380,7 +465,7 @@ function generateMinimalFlows( .slice(0, 3) .map((feature) => { const steps = feature.files.slice(0, 5).map((file, index) => ({ - label: index === 0 ? `Start with ${file}` : `Review related file ${file}`, + label: renderFlowStepLabel(file, fileIndex[file], index === 0), file, purpose: fileIndex[file]?.purpose })); @@ -397,6 +482,21 @@ function generateMinimalFlows( }); } +function renderFlowStepLabel( + file: string, + metadata: FileIndexEntry | undefined, + isFirstStep: boolean +): string { + const prefix = isFirstStep ? "Start with" : "Review"; + const symbols = metadata?.topFunctions + .filter((item) => item.exported) + .map((item) => item.name) + .slice(0, 2) ?? []; + const symbolText = symbols.length > 0 ? ` (${symbols.join(", ")})` : ""; + + return `${prefix} ${file}${symbolText}`; +} + function renderMermaidFlow(steps: FlowInfo["steps"]): string { const lines = ["graph TD"]; steps.forEach((step, index) => { diff --git a/packages/cli/src/cache/snapshot.ts b/packages/cli/src/cache/snapshot.ts index 27564ee..c95a66f 100644 --- a/packages/cli/src/cache/snapshot.ts +++ b/packages/cli/src/cache/snapshot.ts @@ -124,6 +124,7 @@ function normalizeSnapshotDefaults(snapshot: Record): void { if (typeof entry.scope !== "string") entry.scope = "unknown"; if (!Array.isArray(entry.featureRefs)) entry.featureRefs = []; if (!Array.isArray(entry.searchTerms)) entry.searchTerms = []; + if (!Array.isArray(entry.topFunctions)) entry.topFunctions = []; if (typeof entry.importance !== "number") entry.importance = 0; } diff --git a/packages/cli/test/analyzers.test.ts b/packages/cli/test/analyzers.test.ts index 1210263..1970112 100644 --- a/packages/cli/test/analyzers.test.ts +++ b/packages/cli/test/analyzers.test.ts @@ -135,6 +135,16 @@ test("project map summarizes a Next.js fixture", async () => { assert.ok(projectMap.features.some((feature) => feature.name === "API Routes")); assert.deepEqual(projectMap.fileIndex["app/page.tsx"].imports, ["lib/auth.ts"]); assert.ok(projectMap.fileIndex["lib/auth.ts"].exportedSymbols.includes("getSession")); + assert.deepEqual( + projectMap.fileIndex["lib/auth.ts"].topFunctions.find((item) => item.name === "getSession"), + { + name: "getSession", + kind: "function", + line: 6, + exported: true, + async: true + } + ); assert.equal(projectMap.fileIndex["app/api/session/route.ts"].scope, "api"); assert.equal(projectMap.fileIndex["prisma/schema.prisma"].scope, "database"); assert.equal(projectMap.fileIndex["lib/auth.ts"].scope, "service"); @@ -169,6 +179,16 @@ test("project map summarizes an Express fixture", async () => { assert.equal(projectMap.fileIndex["src/server.ts"].scope, "api"); assert.ok(projectMap.fileIndex["src/server.ts"].searchTerms.includes("server")); assert.deepEqual(projectMap.fileIndex["src/server.ts"].imports, ["src/routes/payments.ts"]); + assert.deepEqual( + projectMap.fileIndex["src/routes/payments.ts"].topFunctions.find((item) => item.name === "paymentsRouter"), + { + name: "paymentsRouter", + kind: "const", + line: 4, + exported: true, + async: false + } + ); assert.deepEqual(projectMap.apiRoutes, [ { path: "/payments", diff --git a/packages/cli/test/context-builder.test.ts b/packages/cli/test/context-builder.test.ts index b7013bc..b334962 100644 --- a/packages/cli/test/context-builder.test.ts +++ b/packages/cli/test/context-builder.test.ts @@ -24,6 +24,11 @@ test("context builder ranks feature evidence and expands local dependencies", as ); assert.equal(context.files[0]?.path, "lib/auth.ts"); + assert.ok(context.files[0]?.topFunctions.some((item) => + item.name === "getSession" + && item.exported === true + && item.async === true + )); assert.equal(context.confidence, "high"); assert.ok(context.topScore >= 70); assert.ok(context.files.some((file) => file.path === "lib/db.ts")); From 56c456d4ba9b778068bb5e3ca8aba2ec1a09f64f Mon Sep 17 00:00:00 2001 From: Mufacoderz Date: Thu, 18 Jun 2026 16:45:48 +0800 Subject: [PATCH 2/5] feat: add snapshot request flows Co-authored-by: devmap-agent <238585242+devmap-agent@users.noreply.github.com> --- PRD.md | 2 + README.md | 2 +- docs/architecture.md | 7 ++- docs/commands.md | 2 +- docs/for-me-personal/PROGRESS.md | 4 ++ docs/for-me-personal/TEST.md | 4 +- packages/cli/src/analyzers/projectMap.ts | 70 +++++++++++++++++++++++- packages/cli/test/analyzers.test.ts | 15 +++++ 8 files changed, 99 insertions(+), 7 deletions(-) diff --git a/PRD.md b/PRD.md index 4355c84..6aaa3ef 100644 --- a/PRD.md +++ b/PRD.md @@ -752,6 +752,8 @@ interface DevMapSnapshot { feature references, search terms, and importance. These fields help `devmap ask`, future onboarding output, and future flow generation without storing full raw source. +- Flow metadata should include compact high-confidence feature flows and + request/API flows derived from routes and local dependency edges. - AI-generated file purpose and search terms must be batched and optional. Analyze must continue if enrichment fails. diff --git a/README.md b/README.md index eb7fba1..c44cb34 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ The snapshot contains: * External services * Database information * Detected features -* Minimal high-confidence feature flows +* Minimal high-confidence feature and request flows * Project relationships One analysis. Reusable context. Any codebase. diff --git a/docs/architecture.md b/docs/architecture.md index 1e65bfe..285728d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -392,9 +392,10 @@ and snapshot generation must still complete. ### Minimal Flows Snapshot schema includes `flows` as a foundation for future `FLOW.md` -generation. Phase 1 only creates small feature flows for high-confidence -features, using feature files and their important exported symbols as steps. It -does not build a full call graph or separate flow analyzer. +generation. Phase 1 creates small feature flows for high-confidence features +and request/API flows from detected routes plus local dependency edges. Flow +steps may include important exported symbols, but DevMap still does not build a +full call graph or separate flow analyzer. --- diff --git a/docs/commands.md b/docs/commands.md index 0b90dff..fa9f56e 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -146,7 +146,7 @@ devmap analyze --deep * Detect critical files * Build a compact file index with purpose, scope, top functions/code symbols, search terms, feature references, and importance -* Generate minimal high-confidence feature flows +* Generate minimal high-confidence feature and request/API flows * Generate architecture overview * Save snapshot to `.devmap/snapshot.json` diff --git a/docs/for-me-personal/PROGRESS.md b/docs/for-me-personal/PROGRESS.md index c703213..d37e1cb 100644 --- a/docs/for-me-personal/PROGRESS.md +++ b/docs/for-me-personal/PROGRESS.md @@ -930,6 +930,10 @@ dan flow document tanpa harus membaca raw source terlalu banyak. Flow minimal juga mulai memakai symbol penting pada label step, sehingga flow lebih informatif daripada sekadar daftar file. +Tahap lanjutannya menambahkan request/API flows dari route yang terdeteksi ke +dependency lokalnya. Contoh: route API dapat menghasilkan flow +`route.ts -> auth.ts -> db.ts`, yang nanti bisa menjadi bahan awal `FLOW.md`. + Verifikasi: ```powershell diff --git a/docs/for-me-personal/TEST.md b/docs/for-me-personal/TEST.md index 24f2c65..a433356 100644 --- a/docs/for-me-personal/TEST.md +++ b/docs/for-me-personal/TEST.md @@ -393,7 +393,9 @@ Expected tambahan: - `.agents/skills/*/SKILL.md` tidak terdeteksi sebagai fitur AI project; - snapshot tetap hanya merepresentasikan source project yang dianalisis; - `fileIndex[*].topFunctions` berisi fungsi atau symbol kode penting dengan - line number, status export, dan status async. + line number, status export, dan status async; +- `flows` mencakup feature flow dan request/API flow dari route ke dependency + lokalnya. AI client: diff --git a/packages/cli/src/analyzers/projectMap.ts b/packages/cli/src/analyzers/projectMap.ts index 9c1dfd9..c33adf5 100644 --- a/packages/cli/src/analyzers/projectMap.ts +++ b/packages/cli/src/analyzers/projectMap.ts @@ -137,7 +137,7 @@ export async function createProjectMap(projectRoot: string): Promise externalServices: detectExternalServices(files), ...(database ? { database } : {}), features, - flows: generateMinimalFlows(features, fileIndex), + flows: generateMinimalFlows(features, fileIndex, routes, graph), warnings: detectAnalysisWarnings(files), dependencies: readPackageDependencies(files), fileIndex @@ -457,6 +457,18 @@ function attachFeatureEntryPoints( } function generateMinimalFlows( + features: FeatureInfo[], + fileIndex: Record, + routes: RouteInfo[], + graph: Record +): FlowInfo[] { + return [ + ...generateFeatureFlows(features, fileIndex), + ...generateRequestFlows(routes, fileIndex, graph) + ]; +} + +function generateFeatureFlows( features: FeatureInfo[], fileIndex: Record ): FlowInfo[] { @@ -482,6 +494,62 @@ function generateMinimalFlows( }); } +function generateRequestFlows( + routes: RouteInfo[], + fileIndex: Record, + graph: Record +): FlowInfo[] { + return routes + .filter((route) => route.kind === "api") + .slice(0, 5) + .map((route) => { + const files = collectFlowFiles(route.file, graph, fileIndex); + const steps = files.map((file, index) => ({ + label: renderFlowStepLabel(file, fileIndex[file], index === 0), + file, + purpose: fileIndex[file]?.purpose + })); + + return { + name: `Request ${route.path}`, + purpose: `Shows the main files involved in the ${route.path} request path.`, + type: "request" as const, + entryPoint: route.file, + steps, + ...(steps.length > 2 ? { mermaid: renderMermaidFlow(steps) } : {}), + confidence: steps.length > 1 ? "high" as const : "medium" as const + }; + }); +} + +function collectFlowFiles( + entryFile: string, + graph: Record, + fileIndex: Record +): string[] { + const files: string[] = []; + const visited = new Set(); + const queue = [entryFile]; + + while (queue.length > 0 && files.length < 5) { + const file = queue.shift(); + if (!file || visited.has(file) || !fileIndex[file]) { + continue; + } + + visited.add(file); + files.push(file); + + for (const next of graph[file] ?? []) { + if (!visited.has(next) && fileIndex[next]) { + queue.push(next); + } + } + } + + return files; +} + function renderFlowStepLabel( file: string, metadata: FileIndexEntry | undefined, diff --git a/packages/cli/test/analyzers.test.ts b/packages/cli/test/analyzers.test.ts index 1970112..01b6b7d 100644 --- a/packages/cli/test/analyzers.test.ts +++ b/packages/cli/test/analyzers.test.ts @@ -167,6 +167,14 @@ test("project map summarizes a Next.js fixture", async () => { && flow.confidence === "high" && flow.steps.length > 0 )); + const sessionFlow = projectMap.flows.find((flow) => flow.name === "Request /api/session"); + assert.ok(sessionFlow); + assert.equal(sessionFlow.type, "request"); + assert.equal(sessionFlow.entryPoint, "app/api/session/route.ts"); + assert.deepEqual( + sessionFlow.steps.map((step) => step.file), + ["app/api/session/route.ts", "lib/auth.ts", "lib/db.ts"] + ); assert.ok(projectMap.stats.relevantFiles >= 5); }); @@ -197,6 +205,13 @@ test("project map summarizes an Express fixture", async () => { methods: ["USE"] } ]); + const paymentsFlow = projectMap.flows.find((flow) => flow.name === "Request /payments"); + assert.ok(paymentsFlow); + assert.equal(paymentsFlow.type, "request"); + assert.deepEqual( + paymentsFlow.steps.map((step) => step.file), + ["src/server.ts", "src/routes/payments.ts"] + ); assert.ok(projectMap.features.some((feature) => feature.name === "Payments")); }); From 45be9f853c3e480296e236815d7ba13d04b4393f Mon Sep 17 00:00:00 2001 From: Mufacoderz Date: Thu, 18 Jun 2026 16:58:28 +0800 Subject: [PATCH 3/5] feat: add snapshot navigation metadata Co-authored-by: devmap-agent <238585242+devmap-agent@users.noreply.github.com> --- PRD.md | 11 ++ README.md | 2 + docs/architecture.md | 18 +++ docs/commands.md | 2 + docs/for-me-personal/PROGRESS.md | 4 + docs/for-me-personal/TEST.md | 6 +- packages/cli/src/analyzers/featureDetector.ts | 3 + packages/cli/src/analyzers/projectMap.ts | 153 +++++++++++++++++- packages/cli/src/cache/snapshot.ts | 8 + packages/cli/test/analyzers.test.ts | 19 ++- 10 files changed, 218 insertions(+), 8 deletions(-) diff --git a/PRD.md b/PRD.md index 6aaa3ef..6bb2557 100644 --- a/PRD.md +++ b/PRD.md @@ -719,6 +719,13 @@ interface DevMapSnapshot { database?: DatabaseInfo; features: FeatureInfo[]; flows: FlowInfo[]; + onboarding: { + recommendedPath: string[]; + }; + changeImpact: Record; fileIndex: Record auth.ts -> db.ts`, yang nanti bisa menjadi bahan awal `FLOW.md`. +Tahap berikutnya menambahkan primary feature entry point, business flow ringkas, +`onboarding.recommendedPath`, dan `changeImpact` file-level. Ini sengaja masih +shallow agar snapshot lebih memahami project tanpa masuk ke symbol graph penuh. + Verifikasi: ```powershell diff --git a/docs/for-me-personal/TEST.md b/docs/for-me-personal/TEST.md index a433356..8d5c481 100644 --- a/docs/for-me-personal/TEST.md +++ b/docs/for-me-personal/TEST.md @@ -395,7 +395,11 @@ Expected tambahan: - `fileIndex[*].topFunctions` berisi fungsi atau symbol kode penting dengan line number, status export, dan status async; - `flows` mencakup feature flow dan request/API flow dari route ke dependency - lokalnya. + lokalnya; +- `features[*].entryPoint` dan `features[*].businessFlow` terisi ketika bisa + diinfer dari route/dependency; +- `onboarding.recommendedPath` dan `changeImpact` tersedia sebagai metadata + navigasi lanjutan. AI client: diff --git a/packages/cli/src/analyzers/featureDetector.ts b/packages/cli/src/analyzers/featureDetector.ts index d593ebd..3b6ee79 100644 --- a/packages/cli/src/analyzers/featureDetector.ts +++ b/packages/cli/src/analyzers/featureDetector.ts @@ -7,7 +7,9 @@ export type FeatureInfo = { name: string; purpose: string; files: string[]; + entryPoint?: string; entryPoints: string[]; + businessFlow: string[]; searchTerms: string[]; confidence: "high" | "medium" | "low"; evidence: string[]; @@ -80,6 +82,7 @@ function createFeatureInfo( name, purpose: `Identifies ${name.toLowerCase()} capability in the project.`, files, + businessFlow: [], entryPoints: [], searchTerms: [...new Set(terms.map((term) => term.toLowerCase()))].slice(0, 8), confidence: evidence.length >= 2 ? "high" : "medium", diff --git a/packages/cli/src/analyzers/projectMap.ts b/packages/cli/src/analyzers/projectMap.ts index c33adf5..8e223f7 100644 --- a/packages/cli/src/analyzers/projectMap.ts +++ b/packages/cli/src/analyzers/projectMap.ts @@ -83,6 +83,13 @@ export type ProjectMap = { database?: DatabaseInfo; features: FeatureInfo[]; flows: FlowInfo[]; + onboarding: { + recommendedPath: string[]; + }; + changeImpact: Record; warnings?: string[]; dependencies: Record; ai?: { @@ -109,7 +116,8 @@ export async function createProjectMap(projectRoot: string): Promise const features = attachFeatureEntryPoints( detectFeatures(files, routes, database), routes, - entryPoints + entryPoints, + graph ); const criticalFiles = rankCriticalFiles(files, references, entryPoints); const fileIndex = Object.fromEntries(files.map((file) => [ @@ -117,6 +125,8 @@ export async function createProjectMap(projectRoot: string): Promise createFileIndexEntry(file, graph[file.path] ?? [], references, entryPoints, criticalFiles, features) ])); + const flows = generateMinimalFlows(features, fileIndex, routes, graph); + return { version: SNAPSHOT_SCHEMA_VERSION, generatedAt: new Date().toISOString(), @@ -137,7 +147,11 @@ export async function createProjectMap(projectRoot: string): Promise externalServices: detectExternalServices(files), ...(database ? { database } : {}), features, - flows: generateMinimalFlows(features, fileIndex, routes, graph), + flows, + onboarding: { + recommendedPath: buildOnboardingPath(files, entryPoints, criticalFiles, fileIndex) + }, + changeImpact: buildChangeImpact(fileIndex, features, flows, graph), warnings: detectAnalysisWarnings(files), dependencies: readPackageDependencies(files), fileIndex @@ -435,7 +449,8 @@ function getLineNumber(content: string, index: number): number { function attachFeatureEntryPoints( features: FeatureInfo[], routes: RouteInfo[], - entryPoints: string[] + entryPoints: string[], + graph: Record ): FeatureInfo[] { return features.map((feature) => { const relatedRouteEntries = routes @@ -445,10 +460,14 @@ 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); return { ...feature, + ...(entryPoint ? { entryPoint } : {}), entryPoints: relatedEntries, + businessFlow, confidence: feature.files.length >= 2 || relatedEntries.length > 0 ? "high" : feature.confidence @@ -456,6 +475,48 @@ function attachFeatureEntryPoints( }); } +function chooseFeatureEntryPoint( + files: string[], + relatedEntries: string[], + routes: RouteInfo[], + graph: Record +): string | undefined { + if (relatedEntries.length > 0) { + return relatedEntries[0]; + } + + const route = routes.find((candidate) => + files.includes(candidate.file) + || (graph[candidate.file] ?? []).some((dependency) => files.includes(dependency)) + ); + + if (route) { + return route.file; + } + + return files[0]; +} + +function buildFeatureBusinessFlow( + featureName: string, + entryPoint: string | undefined, + graph: Record +): string[] { + if (!entryPoint) { + return [`Identify files related to ${featureName}.`]; + } + + const chain = collectFlowFiles(entryPoint, graph); + const steps = [`Start at ${entryPoint}.`]; + + for (const file of chain.slice(1, 4)) { + steps.push(`Follow dependency ${file}.`); + } + + steps.push(`Review related files for ${featureName}.`); + return steps; +} + function generateMinimalFlows( features: FeatureInfo[], fileIndex: Record, @@ -525,7 +586,7 @@ function generateRequestFlows( function collectFlowFiles( entryFile: string, graph: Record, - fileIndex: Record + fileIndex?: Record ): string[] { const files: string[] = []; const visited = new Set(); @@ -533,7 +594,7 @@ function collectFlowFiles( while (queue.length > 0 && files.length < 5) { const file = queue.shift(); - if (!file || visited.has(file) || !fileIndex[file]) { + if (!file || visited.has(file) || (fileIndex && !fileIndex[file])) { continue; } @@ -541,7 +602,7 @@ function collectFlowFiles( files.push(file); for (const next of graph[file] ?? []) { - if (!visited.has(next) && fileIndex[next]) { + if (!visited.has(next) && (!fileIndex || fileIndex[next])) { queue.push(next); } } @@ -550,6 +611,86 @@ function collectFlowFiles( return files; } +function buildOnboardingPath( + files: ScannedFile[], + entryPoints: string[], + criticalFiles: ProjectMap["criticalFiles"], + fileIndex: Record +): string[] { + const availableFiles = new Set(files.map((file) => file.path)); + const path = new Set(); + + for (const candidate of ["README.md", "readme.md", "AGENTS.md", "DEVMAP.md", "package.json"]) { + if (availableFiles.has(candidate)) { + path.add(candidate); + } + } + + for (const entryPoint of entryPoints) { + path.add(entryPoint); + } + + for (const file of criticalFiles.map((item) => item.path)) { + path.add(file); + } + + for (const [file, metadata] of Object.entries(fileIndex) + .sort(([, left], [, right]) => right.importance - left.importance)) { + if (metadata.scope !== "test" && metadata.scope !== "docs") { + path.add(file); + } + } + + return [...path].slice(0, 12); +} + +function buildChangeImpact( + fileIndex: Record, + features: FeatureInfo[], + flows: FlowInfo[], + graph: Record +): ProjectMap["changeImpact"] { + const reverseDependencies = buildReverseDependencies(graph); + const impacts: ProjectMap["changeImpact"] = {}; + + for (const file of Object.keys(fileIndex)) { + const impactedFeatures = features + .filter((feature) => feature.files.includes(file) || feature.entryPoint === file) + .map((feature) => feature.name); + const impactedFlows = flows + .filter((flow) => flow.steps.some((step) => step.file === file)) + .map((flow) => flow.name); + const dependents = reverseDependencies[file] ?? []; + const impactNames = [...new Set([...impactedFeatures, ...impactedFlows])].sort(); + + if (impactNames.length > 0 || dependents.length > 0) { + impacts[file] = { + impacts: impactNames, + dependents + }; + } + } + + return impacts; +} + +function buildReverseDependencies(graph: Record): Record { + const reverse: Record = {}; + + for (const [file, dependencies] of Object.entries(graph)) { + for (const dependency of dependencies) { + reverse[dependency] ??= []; + reverse[dependency].push(file); + } + } + + for (const dependents of Object.values(reverse)) { + dependents.sort(); + } + + return reverse; +} + function renderFlowStepLabel( file: string, metadata: FileIndexEntry | undefined, diff --git a/packages/cli/src/cache/snapshot.ts b/packages/cli/src/cache/snapshot.ts index c95a66f..923de15 100644 --- a/packages/cli/src/cache/snapshot.ts +++ b/packages/cli/src/cache/snapshot.ts @@ -118,6 +118,12 @@ function normalizeSnapshotDefaults(snapshot: Record): void { if (!Array.isArray(snapshot.flows)) { snapshot.flows = []; } + if (!isRecord(snapshot.onboarding)) { + snapshot.onboarding = { recommendedPath: [] }; + } + if (!isRecord(snapshot.changeImpact)) { + snapshot.changeImpact = {}; + } const fileIndex = snapshot.fileIndex as Record>; for (const entry of Object.values(fileIndex)) { @@ -140,6 +146,8 @@ function normalizeSnapshotDefaults(snapshot: Record): void { } if (!Array.isArray(feature.files)) feature.files = Array.isArray(feature.evidence) ? feature.evidence : []; if (!Array.isArray(feature.entryPoints)) feature.entryPoints = []; + if (typeof feature.entryPoint !== "string") delete feature.entryPoint; + if (!Array.isArray(feature.businessFlow)) feature.businessFlow = []; if (!Array.isArray(feature.searchTerms)) feature.searchTerms = []; if (!["high", "medium", "low"].includes(String(feature.confidence))) { feature.confidence = "medium"; diff --git a/packages/cli/test/analyzers.test.ts b/packages/cli/test/analyzers.test.ts index 01b6b7d..cbaf7cf 100644 --- a/packages/cli/test/analyzers.test.ts +++ b/packages/cli/test/analyzers.test.ts @@ -160,8 +160,11 @@ test("project map summarizes a Next.js fixture", async () => { const authentication = projectMap.features.find((feature) => feature.name === "Authentication"); assert.ok(authentication); assert.equal(authentication.confidence, "high"); - assert.ok(authentication.purpose.includes("authentication")); + assert.equal(authentication.entryPoint, "app/api/session/route.ts"); + assert.ok(authentication.purpose.toLowerCase().includes("authentication")); assert.ok(authentication.searchTerms.includes("auth")); + assert.ok(authentication.businessFlow.length >= 3); + assert.equal(authentication.businessFlow[0], "Start at app/api/session/route.ts."); assert.ok(projectMap.flows.some((flow) => flow.name === "Authentication flow" && flow.confidence === "high" @@ -175,6 +178,14 @@ test("project map summarizes a Next.js fixture", async () => { sessionFlow.steps.map((step) => step.file), ["app/api/session/route.ts", "lib/auth.ts", "lib/db.ts"] ); + assert.deepEqual(projectMap.changeImpact["lib/auth.ts"].impacts.sort(), [ + "Authentication", + "Authentication flow", + "Request /api/session" + ]); + assert.ok(projectMap.onboarding.recommendedPath.includes("package.json")); + assert.ok(projectMap.onboarding.recommendedPath.includes("app/page.tsx")); + assert.ok(projectMap.onboarding.recommendedPath.includes("lib/auth.ts")); assert.ok(projectMap.stats.relevantFiles >= 5); }); @@ -212,6 +223,12 @@ test("project map summarizes an Express fixture", async () => { paymentsFlow.steps.map((step) => step.file), ["src/server.ts", "src/routes/payments.ts"] ); + assert.deepEqual(projectMap.changeImpact["src/routes/payments.ts"].impacts.sort(), [ + "Payments", + "Payments flow", + "Request /payments" + ]); + assert.ok(projectMap.onboarding.recommendedPath.includes("src/server.ts")); assert.ok(projectMap.features.some((feature) => feature.name === "Payments")); }); From c486074d43f4691b3e547ab75d78f705bae26db7 Mon Sep 17 00:00:00 2001 From: Mufacoderz Date: Thu, 18 Jun 2026 17:09:12 +0800 Subject: [PATCH 4/5] add agent contract --- PRD.md | 10 ++++ README.md | 1 + docs/architecture.md | 11 ++++ docs/for-me-personal/PROGRESS.md | 5 ++ docs/for-me-personal/TEST.md | 4 +- packages/cli/src/analyzers/projectMap.ts | 20 ++++++++ packages/cli/src/cache/snapshot.ts | 11 ++++ packages/cli/src/utils/devmapFile.ts | 62 +++++++++++++++++++---- packages/cli/test/analyzers.test.ts | 8 +++ packages/cli/test/init-and-errors.test.ts | 9 +++- 10 files changed, 130 insertions(+), 11 deletions(-) diff --git a/PRD.md b/PRD.md index 6bb2557..c2263b2 100644 --- a/PRD.md +++ b/PRD.md @@ -703,6 +703,14 @@ Recommended MVP schema: interface DevMapSnapshot { version: string; generatedAt: string; + agentInstructions: { + navigationPolicy: "snapshot-first"; + defaultMode: "minimal-exploration"; + maxInitialFiles: number; + missingSnapshotAction: "run-devmap-analyze"; + staleSnapshotAction: "run-devmap-analyze-fresh"; + fallbackRule: string; + }; project: { name?: string; root: string; @@ -765,6 +773,8 @@ interface DevMapSnapshot { flow when DevMap can infer one from routes or dependency edges. - Snapshot should include a lightweight onboarding path and file-level change impact map for future generated docs and safer AI-assisted edits. +- Snapshot should include compact machine-readable agent instructions. The + complete agent navigation contract belongs in generated `DEVMAP.md`. - AI-generated file purpose and search terms must be batched and optional. Analyze must continue if enrichment fails. diff --git a/README.md b/README.md index 1caab38..23e5354 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,7 @@ The snapshot contains: * Minimal high-confidence feature and request flows * Feature entry points and lightweight business flows * Onboarding path and file-level change impact +* Snapshot-first agent navigation policy * Project relationships One analysis. Reusable context. Any codebase. diff --git a/docs/architecture.md b/docs/architecture.md index 8cbafca..ab1ba80 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -415,6 +415,17 @@ These fields are static-first and intentionally shallow. They are meant to guide future `ONBOARDING.md`, `FLOW.md`, and safer edit planning without building a full symbol graph. +### Agent Contract + +Generated `DEVMAP.md` contains the complete agent navigation contract. It tells +agents to use `.devmap/snapshot.json` before broad repository exploration, to +prefer feature entry points and flows, and to run `devmap analyze` when the +snapshot is missing. + +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`. + --- ## Context Builder diff --git a/docs/for-me-personal/PROGRESS.md b/docs/for-me-personal/PROGRESS.md index ecea54e..fd3c222 100644 --- a/docs/for-me-personal/PROGRESS.md +++ b/docs/for-me-personal/PROGRESS.md @@ -938,6 +938,11 @@ Tahap berikutnya menambahkan primary feature entry point, business flow ringkas, `onboarding.recommendedPath`, dan `changeImpact` file-level. Ini sengaja masih shallow agar snapshot lebih memahami project tanpa masuk ke symbol graph penuh. +Generated `DEVMAP.md` sekarang memiliki Agent Navigation Contract yang meminta +agent memakai snapshot-first, menjalankan `devmap analyze` saat snapshot hilang, +dan meminta user menjalankan `devmap init` jika DevMap belum terkonfigurasi. +Snapshot juga menyimpan `agentInstructions` kecil untuk machine reader. + Verifikasi: ```powershell diff --git a/docs/for-me-personal/TEST.md b/docs/for-me-personal/TEST.md index 8d5c481..1dfb3df 100644 --- a/docs/for-me-personal/TEST.md +++ b/docs/for-me-personal/TEST.md @@ -399,7 +399,9 @@ Expected tambahan: - `features[*].entryPoint` dan `features[*].businessFlow` terisi ketika bisa diinfer dari route/dependency; - `onboarding.recommendedPath` dan `changeImpact` tersedia sebagai metadata - navigasi lanjutan. + navigasi lanjutan; +- `agentInstructions` tersedia di snapshot sebagai policy machine-readable + ringkas. AI client: diff --git a/packages/cli/src/analyzers/projectMap.ts b/packages/cli/src/analyzers/projectMap.ts index 8e223f7..1623c5a 100644 --- a/packages/cli/src/analyzers/projectMap.ts +++ b/packages/cli/src/analyzers/projectMap.ts @@ -60,6 +60,14 @@ export type FileIndexEntry = { export type ProjectMap = { version: string; generatedAt: string; + agentInstructions: { + navigationPolicy: "snapshot-first"; + defaultMode: "minimal-exploration"; + maxInitialFiles: number; + missingSnapshotAction: "run-devmap-analyze"; + staleSnapshotAction: "run-devmap-analyze-fresh"; + fallbackRule: string; + }; fingerprint: string; projectRoot: string; framework: Framework; @@ -130,6 +138,7 @@ export async function createProjectMap(projectRoot: string): Promise return { version: SNAPSHOT_SCHEMA_VERSION, generatedAt: new Date().toISOString(), + agentInstructions: createAgentInstructions(), fingerprint: createProjectFingerprint(files), projectRoot, framework, @@ -158,6 +167,17 @@ export async function createProjectMap(projectRoot: string): Promise }; } +function createAgentInstructions(): ProjectMap["agentInstructions"] { + return { + navigationPolicy: "snapshot-first", + defaultMode: "minimal-exploration", + maxInitialFiles: 3, + missingSnapshotAction: "run-devmap-analyze", + staleSnapshotAction: "run-devmap-analyze-fresh", + fallbackRule: "Inspect source files only when the snapshot is missing details, stale, or exact implementation is required." + }; +} + export function createProjectFingerprint(files: ScannedFile[]): string { const content = files .map((file) => [file.path, hashContent(file.content)] as const) diff --git a/packages/cli/src/cache/snapshot.ts b/packages/cli/src/cache/snapshot.ts index 923de15..d9e8211 100644 --- a/packages/cli/src/cache/snapshot.ts +++ b/packages/cli/src/cache/snapshot.ts @@ -115,6 +115,17 @@ export async function inspectSnapshot(projectRoot: string): Promise): void { + if (!isRecord(snapshot.agentInstructions)) { + snapshot.agentInstructions = { + navigationPolicy: "snapshot-first", + defaultMode: "minimal-exploration", + maxInitialFiles: 3, + missingSnapshotAction: "run-devmap-analyze", + staleSnapshotAction: "run-devmap-analyze-fresh", + fallbackRule: "Inspect source files only when the snapshot is missing details, stale, or exact implementation is required." + }; + } + if (!Array.isArray(snapshot.flows)) { snapshot.flows = []; } diff --git a/packages/cli/src/utils/devmapFile.ts b/packages/cli/src/utils/devmapFile.ts index 0381f8c..53c2652 100644 --- a/packages/cli/src/utils/devmapFile.ts +++ b/packages/cli/src/utils/devmapFile.ts @@ -46,15 +46,59 @@ devmap doctor devmap doctor --json \`\`\` -## Guidance For AI Agents - -- Read this file before exploring the repository. -- Prefer the DevMap snapshot to blind repository-wide exploration. -- Use \`--json\` when calling DevMap programmatically so stdout remains one - parseable JSON document without ANSI or terminal decoration. -- Start from entry points and critical files reported by DevMap. -- Do not edit generated files inside \`.devmap/\`. -- Re-run analysis when the snapshot may be stale. +## Agent Navigation Contract + +This repository uses DevMap as the primary navigation source. Use snapshot-first, +not repository-scan-first. + +Before exploring files, read \`.devmap/snapshot.json\` and prefer these sections: + +1. \`features\` +2. \`features.entryPoint\` +3. \`features.businessFlow\` +4. \`flows\` +5. \`onboarding.recommendedPath\` +6. \`changeImpact\` +7. \`criticalFiles\` +8. \`fileIndex\` + +Do not scan the whole repository first. + +Open source files only when: + +- the snapshot is missing; +- the snapshot is stale; +- the snapshot does not contain enough detail; +- exact implementation is required; +- the task is edit, debug, or refactor; +- the user explicitly asks for code changes. + +When source inspection is needed, inspect the smallest relevant set first. +Prefer feature entry points and flow steps over broad folder exploration. + +## Required Agent Workflow + +1. Read \`DEVMAP.md\`. +2. Read \`.devmap/snapshot.json\`. +3. Identify the matching feature, flow, entry point, onboarding path, or change + impact entry. +4. Inspect at most the smallest relevant source-file set first. +5. Explain which snapshot section guided the decision when giving navigation + advice. +6. Avoid unrelated files unless the snapshot is incomplete or exact code + verification is required. + +If \`.devmap/snapshot.json\` is missing, run \`devmap analyze\` when DevMap is +available and configured. If analyze fails because DevMap is not initialized, +ask the user to run \`devmap init\` and then \`devmap analyze\`. + +If the snapshot may be stale, run \`devmap analyze --fresh\` before relying on +it. + +Use \`--json\` when calling DevMap programmatically so stdout remains one +parseable JSON document without ANSI or terminal decoration. + +Do not edit generated files inside \`.devmap/\`. ## Repository Safety diff --git a/packages/cli/test/analyzers.test.ts b/packages/cli/test/analyzers.test.ts index cbaf7cf..91e9170 100644 --- a/packages/cli/test/analyzers.test.ts +++ b/packages/cli/test/analyzers.test.ts @@ -99,6 +99,14 @@ test("project map summarizes a Next.js fixture", async () => { const projectMap = await createProjectMap(nextFixture); assert.equal(projectMap.version, "1"); + assert.deepEqual(projectMap.agentInstructions, { + navigationPolicy: "snapshot-first", + defaultMode: "minimal-exploration", + maxInitialFiles: 3, + missingSnapshotAction: "run-devmap-analyze", + staleSnapshotAction: "run-devmap-analyze-fresh", + fallbackRule: "Inspect source files only when the snapshot is missing details, stale, or exact implementation is required." + }); assert.match(projectMap.fingerprint, /^[a-f0-9]{32}$/); assert.equal(projectMap.framework, "nextjs"); assert.deepEqual(projectMap.project, { diff --git a/packages/cli/test/init-and-errors.test.ts b/packages/cli/test/init-and-errors.test.ts index d519a0f..deebb6e 100644 --- a/packages/cli/test/init-and-errors.test.ts +++ b/packages/cli/test/init-and-errors.test.ts @@ -23,7 +23,14 @@ test("DEVMAP.md contains workflow and AI-agent guidance", () => { assert.match(content, /Detected framework: nextjs/); assert.match(content, /devmap analyze/); - assert.match(content, /Guidance For AI Agents/); + assert.match(content, /Agent Navigation Contract/); + assert.match(content, /Required Agent Workflow/); + assert.match(content, /snapshot-first/); + assert.match(content, /features\.entryPoint/); + assert.match(content, /onboarding\.recommendedPath/); + assert.match(content, /changeImpact/); + assert.match(content, /Do not scan the whole repository first/); + assert.match(content, /devmap init/); assert.match(content, /--json/); assert.match(content, /\.devmap\/snapshot\.json/); assert.match(content, /Never commit API keys/); From 8aefe125c5a1eb9b4ab82e7c9a8db8add601987e Mon Sep 17 00:00:00 2001 From: Mufacoderz Date: Fri, 19 Jun 2026 09:20:23 +0800 Subject: [PATCH 5/5] fix: detect Groq service from source signals Co-authored-by: devmap-agent <238585242+devmap-agent@users.noreply.github.com> --- packages/cli/src/analyzers/serviceDetector.ts | 45 ++++++++++++++++++- packages/cli/test/analyzers.test.ts | 30 +++++++++++++ 2 files changed, 74 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/analyzers/serviceDetector.ts b/packages/cli/src/analyzers/serviceDetector.ts index be44aee..3b70299 100644 --- a/packages/cli/src/analyzers/serviceDetector.ts +++ b/packages/cli/src/analyzers/serviceDetector.ts @@ -14,6 +14,11 @@ const SERVICES: Array<[string[], string]> = [ [["groq"], "Groq"] ]; +const SOURCE_SERVICE_SIGNALS: Array<[string[], string]> = [ + [["api.groq.com", "console.groq.com", "groq api key", "groqclient"], "Groq"], + [["api.openai.com", "openai api key", "openaiclient"], "OpenAI"] +]; + export function detectExternalServices(files: ScannedFile[]): string[] { const services = new Set(); const scopedFiles = files.filter((file) => isArchitectureSource(file.path)); @@ -27,6 +32,10 @@ export function detectExternalServices(files: ScannedFile[]): string[] { } } + for (const service of readSourceServiceNames(scopedFiles)) { + services.add(service); + } + return [...services].sort(); } @@ -60,7 +69,10 @@ function readImportedPackageNames(files: ScannedFile[]): string[] { const names = new Set(); const importPattern = /(?:import\s+(?:[^'"]+\s+from\s+)?|export\s+[^'"]+\s+from\s+|require\()\s*['"]([^'"]+)['"]/g; - for (const file of files.filter((item) => [".ts", ".tsx", ".js", ".jsx"].includes(item.extension))) { + for (const file of files.filter((item) => + [".ts", ".tsx", ".js", ".jsx"].includes(item.extension) + && !isServiceSignalDefinitionFile(item.path) + )) { let match = importPattern.exec(file.content); while (match) { const specifier = match[1].toLowerCase(); @@ -73,3 +85,34 @@ function readImportedPackageNames(files: ScannedFile[]): string[] { return [...names]; } + +function readSourceServiceNames(files: ScannedFile[]): string[] { + const services = new Set(); + + for (const file of files.filter((item) => + [".ts", ".tsx", ".js", ".jsx"].includes(item.extension) + && !isServiceSignalDefinitionFile(item.path) + )) { + const content = file.content.toLowerCase(); + + for (const [signals, service] of SOURCE_SERVICE_SIGNALS) { + if (signals.some((signal) => content.includes(signal))) { + services.add(service); + } + } + } + + return [...services]; +} + +function isServiceSignalDefinitionFile(path: string): boolean { + const fileName = path.toLowerCase().split("/").at(-1) ?? ""; + return fileName === "servicedetector.ts" + || fileName === "servicedetector.tsx" + || fileName === "servicedetector.js" + || fileName === "servicedetector.jsx" + || fileName === "featuredetector.ts" + || fileName === "featuredetector.tsx" + || fileName === "featuredetector.js" + || fileName === "featuredetector.jsx"; +} diff --git a/packages/cli/test/analyzers.test.ts b/packages/cli/test/analyzers.test.ts index 91e9170..af4c21d 100644 --- a/packages/cli/test/analyzers.test.ts +++ b/packages/cli/test/analyzers.test.ts @@ -95,6 +95,36 @@ test("service detector only reports dependencies that are actually present", asy assert.deepEqual(expressServices, ["Stripe"]); }); +test("service detector detects HTTP API providers without package dependencies", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "devmap-service-signal-test-")); + + try { + await writeFile( + join(projectRoot, "package.json"), + JSON.stringify({ name: "service-signal-test", dependencies: { commander: "^12.0.0" } }) + ); + await mkdir(join(projectRoot, "src", "ai"), { recursive: true }); + await writeFile( + join(projectRoot, "src", "ai", "groq.ts"), + [ + "const GROQ_MODELS_URL = 'https://api.groq.com/openai/v1/models';", + "export class GroqClient {}" + ].join("\n") + ); + await mkdir(join(projectRoot, "src", "analyzers"), { recursive: true }); + await writeFile( + join(projectRoot, "src", "analyzers", "serviceDetector.ts"), + "const SOURCE_SERVICE_SIGNALS = ['https://api.openai.com/v1/chat/completions'];\n" + ); + await mkdir(join(projectRoot, "docs"), { recursive: true }); + await writeFile(join(projectRoot, "docs", "notes.md"), "Groq mentioned in docs only.\n"); + + assert.deepEqual(detectExternalServices(await scanFiles(projectRoot)), ["Groq"]); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } +}); + test("project map summarizes a Next.js fixture", async () => { const projectMap = await createProjectMap(nextFixture);