diff --git a/AGENTS.md b/AGENTS.md index 81f54c7..7cf48ff 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,7 +20,7 @@ Use an issue before opening a pull request for: Workflow: 1. Read the PRD and relevant documentation. -2. Create a `codex/` branch and prepare the implementation locally. +2. If you Codex agent = Create a `codex/` branch and prepare the implementation locally, if not codex dont create a branch with prefix `codex/` and prepare the implementation locally. 3. Add or update tests, documentation, and personal development notes. 4. Run the relevant verification gates. 5. Before committing, pushing, or opening the PR, stop and ask the user to diff --git a/CHANGELOG.md b/CHANGELOG.md index 04c7ad6..6172cde 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,9 @@ All notable changes to DevMap are documented in this file. ### Changed +- Snapshot metadata now separates the primary framework from frameworks found + in workspace packages, prioritizes root documentation, and emits semantic + file purposes and provider-neutral AI flows - `devmap init` defaults OpenRouter to `openrouter/free` on Enter and explains how to change the stored model with `devmap config model ` - Feature detection now separates documentation, landing UI, CLI commands, diff --git a/PRD.md b/PRD.md index 78be629..02c3642 100644 --- a/PRD.md +++ b/PRD.md @@ -514,12 +514,14 @@ 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. +The index project header includes `framework`, `frameworks`, `projectType`, and +`workspaceType`. `framework` is the primary project framework and remains +`unknown` for a CLI/library workspace without one primary web framework. +`frameworks` lists frameworks detected in workspace packages, such as Astro in +a landing app. 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 diff --git a/README.md b/README.md index 39a4157..c7739f5 100644 --- a/README.md +++ b/README.md @@ -242,6 +242,9 @@ Use your preferred AI tool to change it. * Laravel * Nuxt +Workspace classification can identify Astro packages, but deep Astro analysis +is not part of the current MVP support promise. + --- ## AI Provider Setup diff --git a/docs/architecture.md b/docs/architecture.md index e4e288d..357efe1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -168,11 +168,17 @@ Framework detection should use: | `express` dependency | Express | | `server.ts` or `server.js` | Node/Express entry point | | `react` plus browser runtime/tooling and JSX/TSX source | Standalone React | +| `astro` dependency or `src/pages/*.astro` | Astro workspace framework | Next.js detection runs before React because Next projects also depend on React. A generic `src/app/` folder is not enough to infer Next.js; source-only fallback requires Next conventions such as `app/page`, `app/layout`, `app/route`, or a Next config file. + +Snapshots distinguish the primary `framework` from the additive `frameworks` +list. A Node CLI monorepo with an Astro landing site has `framework: unknown`, +`projectType: node-cli`, and `frameworks: [astro]`. Astro detection here is +classification only; deep Astro route/component analysis is future work. --- diff --git a/docs/for-me-personal/PROGRESS.md b/docs/for-me-personal/PROGRESS.md index c59a0d2..6a9f5bf 100644 --- a/docs/for-me-personal/PROGRESS.md +++ b/docs/for-me-personal/PROGRESS.md @@ -1,6 +1,25 @@ # Progress DevMap -Terakhir diperbarui: 2026-06-20 +Terakhir diperbarui: 2026-06-21 + +## Update 2026-06-21 + +### Snapshot Accuracy For Mixed Workspaces + +- Project metadata sekarang memisahkan primary `framework` dari daftar + `frameworks` yang ditemukan pada package workspace. +- DevMap sendiri diklasifikasikan sebagai `node-cli` monorepo dengan primary + framework `unknown` dan workspace framework `astro`, bukan Next.js. +- Astro detection saat ini hanya memakai dependency dan pola page `.astro`; + belum ada deep Astro analyzer. +- Documentation feature map memprioritaskan root README, AGENTS, CONTRIBUTING, + dan PRD sebelum README internal di assets/package. +- Static file purpose menjelaskan tanggung jawab file dan tidak lagi memakai + template daftar export yang diawali `exposes`. +- AI flow menyebut Groq dan OpenRouter sebagai adapter provider alternatif. +- Critical scoring memberi bobot lebih besar pada execution owners seperti + project map dan file scanner, serta menurunkan shared types/constants. +- Regression fixture mixed CLI/Astro dan full suite lulus dengan 118 test. ## Update 2026-06-20 diff --git a/docs/for-me-personal/TEST.md b/docs/for-me-personal/TEST.md index 26b5aab..f396d09 100644 --- a/docs/for-me-personal/TEST.md +++ b/docs/for-me-personal/TEST.md @@ -52,6 +52,28 @@ Remove-Item Env:OPENROUTER_API_KEY Jangan simpan atau menyalin API key nyata ke repository, snapshot, output test, atau dokumentasi debugging. +## Mixed Workspace Snapshot Accuracy + +Jalankan static analyze pada root DevMap dengan config AI terisolasi: + +```powershell +$oldProfile = $env:USERPROFILE +$env:USERPROFILE = Join-Path $env:TEMP "devmap-mixed-workspace-test" +pnpm dev:cli -- analyze . --fresh --json +$env:USERPROFILE = $oldProfile +``` + +Periksa `.devmap/index.json` dan feature maps. Expected: + +- `projectType` adalah `node-cli`; +- primary `framework` adalah `unknown`; +- `frameworks` memuat `astro`; +- Documentation dimulai dari root `README.md`, bukan README dalam assets; +- purpose `ai/provider.ts` menjelaskan pemilihan provider/model routing dan + tidak memakai kata `exposes`; +- AI flow menyebut `groq.ts` dan `openrouter.ts`; +- main critical list tetap dimulai dari CLI entry/analyze/project map. + ## Ts-Morph Dan Agent Navigation Focused tests: diff --git a/docs/generated-files.md b/docs/generated-files.md index 7e5521c..874fb02 100644 --- a/docs/generated-files.md +++ b/docs/generated-files.md @@ -66,6 +66,14 @@ contains project identity, entry points, a short critical-file list, and compact feature descriptors that link to focused feature maps. It intentionally omits full dependency and change-impact data. +Project identity separates the primary `framework` from `frameworks` detected +across workspace packages. This prevents an Astro or Next.js child app from +being presented as the framework of a CLI-centered monorepo. + +Documentation feature maps prioritize root `README.md`, `AGENTS.md`, and +contributor guidance before nested package or asset README files. File roles +describe responsibilities rather than repeating export names. + Its critical-file list prioritizes executable entry points, feature entry points, and one behavioral support file per feature before falling back to global importance scores. Type-only hubs are not promoted solely because many diff --git a/packages/cli/src/ai/prompts.ts b/packages/cli/src/ai/prompts.ts index 85e91d8..2de989a 100644 --- a/packages/cli/src/ai/prompts.ts +++ b/packages/cli/src/ai/prompts.ts @@ -2,7 +2,8 @@ import type { ProjectMap } from "../analyzers/projectMap.js"; import type { QuestionContext } from "./contextBuilder.js"; import type { AiMessage } from "./types.js"; -export type AskProjectSummary = Pick; +export type AskProjectSummary = Pick + & { frameworks?: ProjectMap["project"]["frameworks"] }; export function buildQueryExpansionMessages(query: string): AiMessage[] { return [ @@ -127,6 +128,7 @@ export function buildAskMessages( content: [ `PROJECT: ${project.name}`, `FRAMEWORK: ${project.framework}`, + `WORKSPACE_FRAMEWORKS: ${project.frameworks?.join(", ") || "none"}`, `INTENT: ${context.intent}`, `KEYWORDS: ${context.keywords.length > 0 ? context.keywords.join(", ") : "none"}`, `EXPANDED_TERMS: ${context.expandedTerms.length > 0 ? context.expandedTerms.join(", ") : "none"}`, diff --git a/packages/cli/src/analyzers/featureDetector.ts b/packages/cli/src/analyzers/featureDetector.ts index 1b529dc..727bfa0 100644 --- a/packages/cli/src/analyzers/featureDetector.ts +++ b/packages/cli/src/analyzers/featureDetector.ts @@ -379,6 +379,16 @@ export function detectAuthenticationSemanticRole( function featureFilePriority(featureName: string, path: string): number { const normalized = path.toLowerCase(); + if (featureName === "Documentation") { + if (normalized === "readme.md") return 0; + if (normalized === "agents.md") return 1; + if (normalized === "contributing.md") return 2; + if (normalized === "prd.md") return 3; + if (/^packages\/[^/]+\/readme\.md$/.test(normalized)) return 10; + if (/^docs\//.test(normalized)) return 20; + return 30 + normalized.split("/").length; + } + const index = FEATURE_FILE_PRIORITIES[featureName] ?.findIndex((pattern) => pattern.test(normalized)) ?? -1; return index === -1 ? 100 : index; diff --git a/packages/cli/src/analyzers/frameworkDetector.ts b/packages/cli/src/analyzers/frameworkDetector.ts index 7a34d12..9a09f09 100644 --- a/packages/cli/src/analyzers/frameworkDetector.ts +++ b/packages/cli/src/analyzers/frameworkDetector.ts @@ -1,53 +1,66 @@ import type { ScannedFile } from "./fileScanner.js"; import { isArchitectureSource } from "./sourceScope.js"; -export type Framework = "nextjs" | "react" | "express" | "unknown"; +export type Framework = "nextjs" | "react" | "express" | "astro" | "unknown"; +export type DetectedFramework = Exclude; export function detectFramework(files: ScannedFile[]): Framework { - const dependencies = readAllDependencies(files); - const sourceFiles = files.filter((file) => isArchitectureSource(file.path)); + return detectFrameworks(files)[0] ?? "unknown"; +} + +export function detectFrameworks(files: ScannedFile[]): DetectedFramework[] { + const detected = new Set(); + for (const dependencies of readManifestDependencies(files)) { + if ("next" in dependencies) detected.add("nextjs"); + if ("express" in dependencies) detected.add("express"); + if ("astro" in dependencies) detected.add("astro"); + + const hasReactRuntime = "react-dom" in dependencies + || "react-scripts" in dependencies + || "@vitejs/plugin-react" in dependencies + || "@vitejs/plugin-react-swc" in dependencies; + if ( + !("next" in dependencies) + && !("astro" in dependencies) + && "react" in dependencies + && hasReactRuntime + ) { + detected.add("react"); + } + } + const sourceFiles = files.filter((file) => isArchitectureSource(file.path)); if ( - "next" in dependencies - || files.some((file) => /^next\.config\.[cm]?[jt]s$/.test(file.path)) + files.some((file) => /(^|\/)next\.config\.[cm]?[jt]s$/.test(file.path)) || sourceFiles.some((file) => - /^(?:src\/)?app\/(?:.+\/)?(?:page|layout|route)\.[jt]sx?$/.test(file.path) - || /^(?:src\/)?pages\/(?:_app|_document|api\/)/.test(file.path) + /(^|\/)(?:src\/)?app\/(?:.+\/)?(?:page|layout|route)\.[jt]sx?$/.test(file.path) + || /(^|\/)(?:src\/)?pages\/(?:_app|_document|api\/)/.test(file.path) ) ) { - return "nextjs"; + detected.add("nextjs"); } if ( - "express" in dependencies - || sourceFiles.some((file) => /^(?:src\/)?(?:server|app)\.[cm]?[jt]s$/.test(file.path)) + sourceFiles.some((file) => /(^|\/)(?:src\/)?(?:server|app)\.[cm]?[jt]s$/.test(file.path)) ) { - return "express"; + detected.add("express"); } - const hasReactRuntime = "react-dom" in dependencies - || "react-scripts" in dependencies - || "@vitejs/plugin-react" in dependencies - || "@vitejs/plugin-react-swc" in dependencies; - const hasReactSource = sourceFiles.some((file) => - /\.[jt]sx$/.test(file.path) - || /(?:from\s+["']react["']|from\s+["']react-dom(?:\/client)?["']|require\(["']react["']\))/.test(file.content) - ); - - if ("react" in dependencies && hasReactRuntime && hasReactSource) { - return "react"; + if (sourceFiles.some((file) => /(^|\/)src\/pages\/.+\.astro$/.test(file.path))) { + detected.add("astro"); } - return "unknown"; + return FRAMEWORK_ORDER.filter((framework) => detected.has(framework)); } -function readAllDependencies(files: ScannedFile[]): Record { +const FRAMEWORK_ORDER: DetectedFramework[] = ["nextjs", "express", "react", "astro"]; + +function readManifestDependencies(files: ScannedFile[]): Array> { return files - .filter((file) => file.path.endsWith("package.json")) - .reduce((dependencies, file) => ({ - ...dependencies, - ...readDependencies(file.content) - }), {} as Record); + .filter((file) => + file.path.endsWith("package.json") && isArchitectureSource(file.path) + ) + .map((file) => readDependencies(file.content)); } function readDependencies(content: string): Record { diff --git a/packages/cli/src/analyzers/projectMap.ts b/packages/cli/src/analyzers/projectMap.ts index 6b836f8..da7cefc 100644 --- a/packages/cli/src/analyzers/projectMap.ts +++ b/packages/cli/src/analyzers/projectMap.ts @@ -12,7 +12,11 @@ import { } from "./featureDetector.js"; import type { ScannedFile } from "./fileScanner.js"; import { scanFiles } from "./fileScanner.js"; -import { detectFramework, type Framework } from "./frameworkDetector.js"; +import { + detectFramework, + detectFrameworks, + type Framework +} from "./frameworkDetector.js"; import { detectProjectMetadata, type ProjectMetadata } from "./projectMetadata.js"; import { detectRoutes, type RouteInfo } from "./routeDetector.js"; import { detectExternalServices } from "./serviceDetector.js"; @@ -129,7 +133,15 @@ export async function createProjectMap(projectRoot: string): Promise const analyses = await analyzeFiles(files); const graph = buildDependencyGraph(files, analyses); const references = countReferences(graph); - const framework = detectFramework(files); + const detectedFramework = detectFramework(files); + const frameworks = detectFrameworks(files); + const project = detectProjectMetadata( + projectRoot, + detectedFramework, + files, + frameworks + ); + const framework = project.framework; const entryPoints = detectEntryPoints(graph); const routes = detectRoutes(files, framework); const database = detectDatabase(files); @@ -162,7 +174,7 @@ export async function createProjectMap(projectRoot: string): Promise fingerprint: createProjectFingerprint(files), projectRoot, framework, - project: detectProjectMetadata(projectRoot, framework, files), + project, stats: { // A pre-filter filesystem count is not collected in schema v1. totalFiles: files.length, @@ -230,10 +242,20 @@ function rankCriticalFiles( } if (entryPointSet.has(file.path)) { - score += 4; + score += 12; reasons.push("application entry point"); } + const executionBonus = calculateExecutionResponsibilityBonus(file.path); + if (executionBonus > 0) { + score += executionBonus; + reasons.push("core execution responsibility"); + } + + if (/(^|\/)(types?|constants?)\.[cm]?[jt]sx?$/.test(file.path)) { + score = Math.max(0, score - 8); + } + if (/(^|\/)(auth|session|db|database|middleware|schema|config)([./-]|$)/i.test(file.path)) { score += 3; reasons.push("core project concern"); @@ -257,6 +279,13 @@ function rankCriticalFiles( .slice(0, 10); } +function calculateExecutionResponsibilityBonus(path: string): number { + return /(^|\/)(projectmap|filescanner|analyzerregistry|router|controller|orchestrator|engine)\.[cm]?[jt]sx?$/i.test(path) + || /(^|\/)commands?\/[^/]+\.[cm]?[jt]sx?$/i.test(path) + ? 16 + : 0; +} + function readPackageDependencies(files: ScannedFile[]): Record { const packageJson = files.find((file) => file.path === "package.json"); if (!packageJson) { @@ -306,7 +335,7 @@ function createFileIndexEntry( topFunctions ); const searchTerms = buildFileSearchTerms(file.path, scope, exportedSymbols, topFunctions, featureRefs); - const purpose = inferFilePurpose(file.path, scope, exportedSymbols, topFunctions, featureRefs); + const purpose = inferFilePurpose(file.path, scope, featureRefs); return { analyzer: analysis.analyzer, @@ -450,29 +479,60 @@ function buildFileSearchTerms( function inferFilePurpose( path: string, scope: FileScope, - exportedSymbols: string[], - topFunctions: FileIndexEntry["topFunctions"], featureRefs: string[] ): string | undefined { - 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 ")}` - : ""; - if (scope === "docs" || scope === "test") { return undefined; } - if (scope === "unknown" && primarySymbols.length === 0 && featureRefs.length === 0) { + const normalized = path.toLowerCase(); + const knownPurpose = inferKnownFilePurpose(normalized); + if (knownPurpose) { + return `${path} ${knownPurpose}`; + } + + if (scope === "unknown" && featureRefs.length === 0) { return undefined; } - return `${path} ${subject}${featureText}.`; + const fileName = normalized.split("/").at(-1)?.replace(/\.[^.]+$/, "") ?? "file"; + const responsibility = splitSearchTerms(fileName).join(" ") || fileName; + const featureText = featureRefs.length > 0 + ? ` for ${featureRefs.slice(0, 2).join(" and ")}` + : ""; + const scopeText = scope === "unknown" ? "project" : scope; + return `${path} implements ${responsibility} ${scopeText} responsibilities${featureText}.`; +} + +function inferKnownFilePurpose(path: string): string | undefined { + if (/\/ai\/provider\.[cm]?[jt]s$/.test(path)) { + return "selects the configured AI provider and resolves model routing for AI-powered commands."; + } + if (/\/ai\/groq\.[cm]?[jt]s$/.test(path)) { + return "implements Groq requests, retries, streaming, and provider-specific errors."; + } + if (/\/ai\/openrouter\.[cm]?[jt]s$/.test(path)) { + return "implements OpenRouter requests, model selection, streaming, and provider-specific errors."; + } + if (/\/ai\/contextbuilder\.[cm]?[jt]s$/.test(path)) { + return "selects and bounds repository context before an AI request."; + } + if (/\/ai\/prompts\.[cm]?[jt]s$/.test(path)) { + return "constructs grounded prompts from snapshot and retrieval context."; + } + if (/\/ai\/completion\.[cm]?[jt]s$/.test(path)) { + return "coordinates streaming and non-streaming AI completion output."; + } + if (/\/analyzers\/projectmap\.[cm]?[jt]s$/.test(path)) { + return "orchestrates scanning, analysis, feature mapping, flows, and snapshot metadata."; + } + if (/\/analyzers\/filescanner\.[cm]?[jt]s$/.test(path)) { + return "scans eligible project files while applying ignore and safety rules."; + } + if (/(^|\/)types?\.[cm]?[jt]s$/.test(path)) { + return "defines shared type contracts used by neighboring modules."; + } + return undefined; } function isFeatureConfigFile(path: string, featureRefs: string[]): boolean { @@ -613,7 +673,13 @@ function buildStructuralFeatureFlow(featureName: string, files: string[]): strin ["Build focused project context", find(/\/ai\/contextbuilder\.[cm]?[jt]s$/)], ["Construct grounded model prompts", find(/\/ai\/prompts\.[cm]?[jt]s$/)], ["Select the configured AI provider", find(/\/ai\/provider\.[cm]?[jt]s$/)], - ["Call the provider with its model policy", find(/\/ai\/(groq|openrouter)\.[cm]?[jt]s$/)], + [ + "Call the configured provider adapter", + [ + find(/\/ai\/groq\.[cm]?[jt]s$/), + find(/\/ai\/openrouter\.[cm]?[jt]s$/) + ].filter((file): file is string => Boolean(file)).join(" or ") || undefined + ], ["Stream or return the completed response", find(/\/ai\/completion\.[cm]?[jt]s$/)] ] : []; diff --git a/packages/cli/src/analyzers/projectMetadata.ts b/packages/cli/src/analyzers/projectMetadata.ts index 220adcd..1cd2217 100644 --- a/packages/cli/src/analyzers/projectMetadata.ts +++ b/packages/cli/src/analyzers/projectMetadata.ts @@ -1,7 +1,7 @@ import { existsSync } from "node:fs"; import { basename, join } from "node:path"; import type { ScannedFile } from "./fileScanner.js"; -import type { Framework } from "./frameworkDetector.js"; +import type { DetectedFramework, Framework } from "./frameworkDetector.js"; export type ProjectLanguage = "typescript" | "javascript" | "mixed" | "unknown"; export type PackageManager = "pnpm" | "npm" | "yarn" | "bun" | "unknown"; @@ -12,6 +12,7 @@ export type ProjectMetadata = { name: string; root: string; framework: Framework; + frameworks: DetectedFramework[]; language: ProjectLanguage; packageManager: PackageManager; projectType: ProjectType; @@ -22,20 +23,26 @@ export type ProjectMetadata = { export function detectProjectMetadata( projectRoot: string, framework: Framework, - files: ScannedFile[] + files: ScannedFile[], + frameworks: DetectedFramework[] = framework === "unknown" ? [] : [framework] ): ProjectMetadata { const manifests = readPackageManifests(files); const projectType = detectProjectType(framework, manifests); const primaryManifest = selectPrimaryManifest(manifests, projectType); + const workspaceType = detectWorkspaceType(projectRoot, manifests); + const primaryFramework = projectType === "node-cli" || projectType === "library" + ? "unknown" + : framework; return { name: readProjectName(manifests) ?? basename(projectRoot), root: projectRoot, - framework, + framework: primaryFramework, + frameworks, language: detectLanguage(files), packageManager: detectPackageManager(projectRoot), projectType, - workspaceType: detectWorkspaceType(projectRoot, manifests), + workspaceType, ...(primaryManifest?.description ? { description: primaryManifest.description } : {}) }; } @@ -85,7 +92,7 @@ function detectProjectType( manifests: PackageManifest[] ): ProjectType { if (manifests.some((manifest) => manifest.bin)) return "node-cli"; - if (["nextjs", "react"].includes(framework) || hasDependency(manifests, "astro")) { + if (["nextjs", "react", "astro"].includes(framework) || hasDependency(manifests, "astro")) { return "web-app"; } if (framework === "express") return "api-service"; diff --git a/packages/cli/src/cache/agentNavigation.ts b/packages/cli/src/cache/agentNavigation.ts index 4c81816..84de3ee 100644 --- a/packages/cli/src/cache/agentNavigation.ts +++ b/packages/cli/src/cache/agentNavigation.ts @@ -43,6 +43,7 @@ export async function writeAgentNavigationFiles( project: { name: snapshot.project.name, framework: snapshot.project.framework, + frameworks: snapshot.project.frameworks, language: snapshot.project.language, packageManager: snapshot.project.packageManager, projectType: snapshot.project.projectType, diff --git a/packages/cli/src/cache/snapshot.ts b/packages/cli/src/cache/snapshot.ts index 48606fb..bd3916a 100644 --- a/packages/cli/src/cache/snapshot.ts +++ b/packages/cli/src/cache/snapshot.ts @@ -151,6 +151,11 @@ function normalizeSnapshotDefaults(snapshot: Record): void { if (typeof snapshot.project.workspaceType !== "string") { snapshot.project.workspaceType = "single-package"; } + if (!Array.isArray(snapshot.project.frameworks)) { + snapshot.project.frameworks = snapshot.project.framework === "unknown" + ? [] + : [snapshot.project.framework]; + } } const fileIndex = snapshot.fileIndex as Record>; diff --git a/packages/cli/src/commands/analyze.ts b/packages/cli/src/commands/analyze.ts index abdaceb..a7aa4ac 100644 --- a/packages/cli/src/commands/analyze.ts +++ b/packages/cli/src/commands/analyze.ts @@ -119,6 +119,10 @@ function printSnapshot( ): void { output.keyValue("Project", snapshot.project.name); output.keyValue("Framework", snapshot.project.framework); + output.keyValue( + "Workspace Frameworks", + snapshot.project.frameworks.join(", ") || "none detected" + ); output.keyValue("Language", snapshot.project.language); output.keyValue("Package Manager", snapshot.project.packageManager); output.keyValue("Files", snapshot.stats.relevantFiles); diff --git a/packages/cli/src/commands/doctor.ts b/packages/cli/src/commands/doctor.ts index 2786639..bc875d6 100644 --- a/packages/cli/src/commands/doctor.ts +++ b/packages/cli/src/commands/doctor.ts @@ -6,7 +6,10 @@ import { type ProviderInspection } from "../ai/provider.js"; import { scanFiles } from "../analyzers/fileScanner.js"; -import { detectFramework } from "../analyzers/frameworkDetector.js"; +import { + detectFramework, + detectFrameworks +} from "../analyzers/frameworkDetector.js"; import { detectProjectMetadata } from "../analyzers/projectMetadata.js"; import { inspectSnapshot } from "../cache/snapshot.js"; import { readConfig, type DevmapConfig } from "../utils/config.js"; @@ -55,7 +58,8 @@ async function runDoctor( scanFiles(projectRoot) ]); const framework = detectFramework(files); - const project = detectProjectMetadata(projectRoot, framework, files); + const frameworks = detectFrameworks(files); + const project = detectProjectMetadata(projectRoot, framework, files, frameworks); const selectedModel = config ? resolveAiRouting(config, "ask").model : undefined; @@ -69,7 +73,8 @@ async function runDoctor( output.keyValue("Node.js", `${process.version} (${nodeSupported ? "supported" : "unsupported"})`); output.keyValue("OS", `${platform()}/${arch()}`); output.keyValue("Project", project.name); - output.keyValue("Framework", framework); + output.keyValue("Framework", project.framework); + output.keyValue("Workspace Frameworks", project.frameworks.join(", ") || "none detected"); output.keyValue("Package Manager", project.packageManager); output.keyValue("Provider", config?.provider ?? "not configured"); output.keyValue("Config", config ? "exists" : "missing"); diff --git a/packages/cli/src/commands/onboarding.ts b/packages/cli/src/commands/onboarding.ts index 1a9ef28..65941c0 100644 --- a/packages/cli/src/commands/onboarding.ts +++ b/packages/cli/src/commands/onboarding.ts @@ -203,9 +203,7 @@ type ReadingItem = { function renderProjectIntroduction(snapshot: ProjectMap, language: OnboardingLanguage): string[] { const name = snapshot.project.name || (language === "id" ? "Project ini" : "This project"); - const framework = snapshot.project.framework !== "unknown" - ? language === "id" ? `berbasis ${snapshot.project.framework}` : `built with ${snapshot.project.framework}` - : null; + const framework = describeProjectFrameworks(snapshot, language); const projectLanguage = snapshot.project.language !== "unknown" ? language === "id" ? `menggunakan ${snapshot.project.language}` : snapshot.project.language : null; @@ -248,6 +246,26 @@ function formatEnglishProjectDescriptor(language: string | null, framework: stri return language ?? framework ?? "software"; } +function describeProjectFrameworks( + snapshot: ProjectMap, + language: OnboardingLanguage +): string | null { + if (snapshot.project.framework !== "unknown") { + return language === "id" + ? `berbasis ${snapshot.project.framework}` + : `built with ${snapshot.project.framework}`; + } + + if (snapshot.project.frameworks.length > 0) { + const frameworks = snapshot.project.frameworks.join(", "); + return language === "id" + ? `dengan framework workspace ${frameworks}` + : `with workspace frameworks ${frameworks}`; + } + + return null; +} + function renderMentalModel(snapshot: ProjectMap, language: OnboardingLanguage): string[] { const hasCli = hasScope(snapshot, "cli") || hasPathSegment(snapshot, "commands"); const hasRoutes = snapshot.routes.length > 0 || snapshot.apiRoutes.length > 0; @@ -848,7 +866,7 @@ function renderProjectNarrative(snapshot: ProjectMap, language: OnboardingLangua ? [ `${snapshot.project.name} adalah project ${snapshot.project.language}`, `yang memakai ${snapshot.project.packageManager}`, - snapshot.project.framework !== "unknown" ? `dengan ${snapshot.project.framework}` : null, + describeProjectFrameworks(snapshot, "id"), entryPoints.length > 0 ? `dan mulai dari ${entryPoints[0]}` : null, featureNames.length > 0 ? `dengan area fitur terdeteksi seperti ${formatInlineList(featureNames, "id")}` : null, services.length > 0 ? `serta external service seperti ${formatInlineList(services, "id")}` : null @@ -856,7 +874,7 @@ function renderProjectNarrative(snapshot: ProjectMap, language: OnboardingLangua : [ `${snapshot.project.name} is a ${snapshot.project.language} project`, `using ${snapshot.project.packageManager}`, - snapshot.project.framework !== "unknown" ? `with ${snapshot.project.framework}` : null, + describeProjectFrameworks(snapshot, "en"), entryPoints.length > 0 ? `starting from ${entryPoints[0]}` : null, featureNames.length > 0 ? `with detected feature areas such as ${formatInlineList(featureNames, "en")}` : null, services.length > 0 ? `and external services such as ${formatInlineList(services, "en")}` : null diff --git a/packages/cli/test/agent-navigation.test.ts b/packages/cli/test/agent-navigation.test.ts index 9e7b1f1..ea89f26 100644 --- a/packages/cli/test/agent-navigation.test.ts +++ b/packages/cli/test/agent-navigation.test.ts @@ -138,6 +138,116 @@ test("agent navigation identifies a CLI monorepo and prioritizes its main flow", } }); +test("agent navigation describes mixed CLI workspaces without misleading agents", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "devmap-mixed-navigation-")); + const outputRoot = await mkdtemp(join(tmpdir(), "devmap-mixed-navigation-output-")); + + try { + await writeFixtureFile(projectRoot, "package.json", JSON.stringify({ + name: "mixed-workspace", + private: true + })); + await writeFixtureFile(projectRoot, "pnpm-workspace.yaml", "packages:\n - apps/*\n - packages/*\n"); + await writeFixtureFile(projectRoot, "README.md", "# Mixed workspace\n"); + await writeFixtureFile(projectRoot, "AGENTS.md", "# Agent guidance\n"); + await writeFixtureFile(projectRoot, "apps/web/package.json", JSON.stringify({ + name: "web", + devDependencies: { astro: "^5.0.0" } + })); + await writeFixtureFile(projectRoot, "apps/web/src/pages/index.astro", "

Landing

\n"); + await writeFixtureFile(projectRoot, "apps/web/src/assets/README.md", "# Asset notes\n"); + await writeFixtureFile(projectRoot, "packages/cli/package.json", JSON.stringify({ + name: "navigator", + bin: { navigator: "./dist/index.js" } + })); + await writeFixtureFile( + projectRoot, + "packages/cli/src/index.ts", + 'import { analyzeCommand } from "./commands/analyze.js"; analyzeCommand();\n' + ); + await writeFixtureFile( + projectRoot, + "packages/cli/src/commands/analyze.ts", + 'import { createProjectMap } from "../analyzers/projectMap.js"; import type { Project } from "../ai/types.js"; export function analyzeCommand(): Project { return createProjectMap() as Project; }\n' + ); + await writeFixtureFile( + projectRoot, + "packages/cli/src/analyzers/projectMap.ts", + 'import { scanFiles } from "./fileScanner.js"; import type { Project } from "../ai/types.js"; export function createProjectMap(): Project { return scanFiles() as Project; }\n' + ); + await writeFixtureFile( + projectRoot, + "packages/cli/src/analyzers/fileScanner.ts", + "export function scanFiles() { return []; }\n" + ); + await writeFixtureFile( + projectRoot, + "packages/cli/src/ai/types.ts", + "export type Project = { ready: boolean };\n" + ); + await writeFixtureFile( + projectRoot, + "packages/cli/src/ai/provider.ts", + 'import type { Project } from "./types.js"; export function createAiClient() { return {} as Project; } export function resolveAiRouting() { return "auto"; }\n' + ); + await writeFixtureFile( + projectRoot, + "packages/cli/src/ai/groq.ts", + 'import type { Project } from "./types.js"; export class GroqClient { project?: Project; }\n' + ); + await writeFixtureFile( + projectRoot, + "packages/cli/src/ai/openrouter.ts", + 'import type { Project } from "./types.js"; export class OpenRouterClient { project?: Project; }\n' + ); + await writeFixtureFile(projectRoot, "packages/cli/src/ai/contextBuilder.ts", "export function buildQuestionContext() {}\n"); + await writeFixtureFile(projectRoot, "packages/cli/src/ai/prompts.ts", "export function buildAskMessages() {}\n"); + await writeFixtureFile(projectRoot, "packages/cli/src/ai/completion.ts", "export function completeWithOptionalStreaming() {}\n"); + + const snapshot = await createProjectMap(projectRoot); + const result = await writeAgentNavigationFiles(outputRoot, snapshot); + const index = JSON.parse(await readFile(result.indexPath, "utf8")) as { + project: { framework: string; frameworks: string[]; projectType: string }; + features: Array<{ id: string; map: string }>; + }; + + assert.equal(index.project.projectType, "node-cli"); + assert.equal(index.project.framework, "unknown"); + assert.deepEqual(index.project.frameworks, ["astro"]); + + const documentation = index.features.find((feature) => feature.id === "documentation"); + assert.ok(documentation); + const documentationMap = JSON.parse(await readFile( + join(outputRoot, documentation.map), + "utf8" + )) as { entryPoints: string[]; sourcePriority: string[] }; + assert.equal(documentationMap.entryPoints[0], "README.md"); + assert.equal(documentationMap.sourcePriority[0], "README.md"); + + const providerPurpose = snapshot.fileIndex["packages/cli/src/ai/provider.ts"]?.purpose ?? ""; + assert.match(providerPurpose, /selects the configured AI provider/i); + assert.doesNotMatch(providerPurpose, /\bexposes\b/i); + + const aiIntegration = snapshot.features.find((feature) => feature.name === "AI Integration"); + assert.ok(aiIntegration); + assert.match(aiIntegration.businessFlow.join(" "), /groq\.ts/i); + assert.match(aiIntegration.businessFlow.join(" "), /openrouter\.ts/i); + + const scanner = snapshot.criticalFiles.find((file) => + file.path.endsWith("analyzers/fileScanner.ts") + ); + const sharedTypes = snapshot.criticalFiles.find((file) => + file.path.endsWith("ai/types.ts") + ); + assert.ok(scanner); + assert.ok(sharedTypes); + assert.ok(scanner.score > sharedTypes.score); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + await rm(outputRoot, { recursive: true, force: true }); + } +}); + async function writeFixtureFile( projectRoot: string, path: string, diff --git a/packages/cli/test/analyzers.test.ts b/packages/cli/test/analyzers.test.ts index 59cfc0c..372af2b 100644 --- a/packages/cli/test/analyzers.test.ts +++ b/packages/cli/test/analyzers.test.ts @@ -7,7 +7,10 @@ import test from "node:test"; import { buildDependencyGraph, countReferences } from "../src/analyzers/dependencyGraph.js"; import { scanFiles } from "../src/analyzers/fileScanner.js"; import { shouldIgnorePath } from "../src/analyzers/filterEngine.js"; -import { detectFramework } from "../src/analyzers/frameworkDetector.js"; +import { + detectFramework, + detectFrameworks +} from "../src/analyzers/frameworkDetector.js"; import { detectFeatures } from "../src/analyzers/featureDetector.js"; import { createProjectMap } from "../src/analyzers/projectMap.js"; import { detectExternalServices } from "../src/analyzers/serviceDetector.js"; @@ -109,6 +112,25 @@ test("framework detector recognizes standalone React without downgrading Next.js assert.equal(detectFramework(reactLibraryFiles), "unknown"); }); +test("framework detector reports Astro in a mixed workspace", () => { + const files = [ + createScannedFile("package.json", JSON.stringify({ name: "workspace" })), + createScannedFile("apps/web/package.json", JSON.stringify({ + devDependencies: { astro: "^5.0.0" } + })), + createScannedFile("apps/web/src/pages/index.astro", "

Home

\n"), + createScannedFile("test/fixtures/next/package.json", JSON.stringify({ + dependencies: { next: "^15.0.0", react: "^19.0.0", "react-dom": "^19.0.0" } + })), + createScannedFile("test/fixtures/express/package.json", JSON.stringify({ + dependencies: { express: "^5.0.0" } + })) + ]; + + assert.equal(detectFramework(files), "astro"); + assert.deepEqual(detectFrameworks(files), ["astro"]); +}); + test("project map classifies a standalone React app and finds its browser entry", async () => { const projectRoot = await mkdtemp(join(tmpdir(), "devmap-react-project-")); @@ -324,6 +346,7 @@ test("project map summarizes a Next.js fixture", async () => { name: "nextjs-fixture", root: nextFixture, framework: "nextjs", + frameworks: ["nextjs"], language: "typescript", packageManager: "unknown", projectType: "web-app", @@ -486,11 +509,13 @@ test("snapshot reader supplies project classification defaults for schema v1 sna const legacyProject = projectMap.project as Partial; delete legacyProject.projectType; delete legacyProject.workspaceType; + delete legacyProject.frameworks; await saveSnapshot(temporaryRoot, projectMap); const saved = await readSnapshot(temporaryRoot); assert.equal(saved?.project.projectType, "web-app"); assert.equal(saved?.project.workspaceType, "single-package"); + assert.deepEqual(saved?.project.frameworks, ["nextjs"]); } finally { await rm(temporaryRoot, { recursive: true, force: true }); } diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 84e141c..b799e5f 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -3,5 +3,6 @@ packages: - "packages/*" allowBuilds: esbuild: set this to true or false + sharp: set this to true or false onlyBuiltDependencies: - esbuild