diff --git a/PRD.md b/PRD.md index d78dd4c..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; @@ -719,10 +727,24 @@ interface DevMapSnapshot { database?: DatabaseInfo; features: FeatureInfo[]; flows: FlowInfo[]; + onboarding: { + recommendedPath: string[]; + }; + changeImpact: Record; fileIndex: Record; lines: number; purpose?: string; scope: "api" | "ui" | "database" | "config" | "service" | "cli" | "test" | "docs" | "unknown"; @@ -741,9 +763,18 @@ 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. +- Flow metadata should include compact high-confidence feature flows and + request/API flows derived from routes and local dependency edges. +- Feature metadata should expose a primary entry point and a short business + 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 c893389..23e5354 100644 --- a/README.md +++ b/README.md @@ -37,12 +37,15 @@ 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 * Detected features -* Minimal high-confidence feature flows +* 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 0182e3c..ab1ba80 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 | @@ -391,9 +392,39 @@ 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 the feature file order 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. + +Feature metadata also stores a primary `entryPoint` and a short +`businessFlow` when DevMap can infer them from routes or dependencies. This +gives future generated docs a human-oriented path through the feature, not only +a list of files. + +### Onboarding and Change Impact + +Snapshot schema includes two lightweight navigation aids: + +| Field | Purpose | +| ----- | ------- | +| `onboarding.recommendedPath` | Ordered files a new developer or AI agent should read first | +| `changeImpact` | File-level impacted features, flows, and direct dependents | + +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`. --- diff --git a/docs/commands.md b/docs/commands.md index 27c6443..d36000d 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -144,9 +144,11 @@ 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 -* Generate minimal high-confidence feature flows +* Build a compact file index with purpose, scope, top functions/code symbols, + search terms, feature references, and importance +* Generate minimal high-confidence feature and request/API flows +* Infer feature entry points and short business flows where possible +* Build a lightweight onboarding path and file-level change impact map * 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..fd3c222 100644 --- a/docs/for-me-personal/PROGRESS.md +++ b/docs/for-me-personal/PROGRESS.md @@ -917,3 +917,34 @@ 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. + +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`. + +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 +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..1dfb3df 100644 --- a/docs/for-me-personal/TEST.md +++ b/docs/for-me-personal/TEST.md @@ -391,7 +391,17 @@ 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; +- `flows` mencakup feature flow dan request/API flow dari route ke dependency + lokalnya; +- `features[*].entryPoint` dan `features[*].businessFlow` terisi ketika bisa + diinfer dari route/dependency; +- `onboarding.recommendedPath` dan `changeImpact` tersedia sebagai metadata + navigasi lanjutan; +- `agentInstructions` tersedia di snapshot sebagai policy machine-readable + ringkas. 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/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 cd16df8..1623c5a 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; @@ -53,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; @@ -76,6 +91,13 @@ export type ProjectMap = { database?: DatabaseInfo; features: FeatureInfo[]; flows: FlowInfo[]; + onboarding: { + recommendedPath: string[]; + }; + changeImpact: Record; warnings?: string[]; dependencies: Record; ai?: { @@ -102,7 +124,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) => [ @@ -110,9 +133,12 @@ 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(), + agentInstructions: createAgentInstructions(), fingerprint: createProjectFingerprint(files), projectRoot, framework, @@ -130,13 +156,28 @@ export async function createProjectMap(projectRoot: string): Promise externalServices: detectExternalServices(files), ...(database ? { database } : {}), features, - flows: generateMinimalFlows(features, fileIndex), + flows, + onboarding: { + recommendedPath: buildOnboardingPath(files, entryPoints, criticalFiles, fileIndex) + }, + changeImpact: buildChangeImpact(fileIndex, features, flows, graph), warnings: detectAnalysisWarnings(files), dependencies: readPackageDependencies(files), fileIndex }; } +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) @@ -218,6 +259,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 +274,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 +337,7 @@ function buildFileSearchTerms( path: string, scope: FileScope, exportedSymbols: string[], + topFunctions: FileIndexEntry["topFunctions"], featureRefs: string[] ): string[] { const terms = new Set(); @@ -312,6 +356,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 +377,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,17 +394,83 @@ 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[], - entryPoints: string[] + entryPoints: string[], + graph: Record ): FeatureInfo[] { return features.map((feature) => { const relatedRouteEntries = routes @@ -360,10 +480,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 @@ -371,7 +495,61 @@ 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, + routes: RouteInfo[], + graph: Record +): FlowInfo[] { + return [ + ...generateFeatureFlows(features, fileIndex), + ...generateRequestFlows(routes, fileIndex, graph) + ]; +} + +function generateFeatureFlows( features: FeatureInfo[], fileIndex: Record ): FlowInfo[] { @@ -380,7 +558,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 +575,157 @@ 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 && !fileIndex[file])) { + continue; + } + + visited.add(file); + files.push(file); + + for (const next of graph[file] ?? []) { + if (!visited.has(next) && (!fileIndex || fileIndex[next])) { + queue.push(next); + } + } + } + + 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, + 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/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/src/cache/snapshot.ts b/packages/cli/src/cache/snapshot.ts index 27564ee..d9e8211 100644 --- a/packages/cli/src/cache/snapshot.ts +++ b/packages/cli/src/cache/snapshot.ts @@ -115,15 +115,33 @@ 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 = []; } + 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)) { 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; } @@ -139,6 +157,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/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 1210263..af4c21d 100644 --- a/packages/cli/test/analyzers.test.ts +++ b/packages/cli/test/analyzers.test.ts @@ -95,10 +95,48 @@ 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); 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, { @@ -135,6 +173,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"); @@ -150,13 +198,32 @@ 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" && 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.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); }); @@ -169,6 +236,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", @@ -177,6 +254,19 @@ 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.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")); }); 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")); 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/);