diff --git a/CHANGELOG.md b/CHANGELOG.md index 359fd8b..1cfb95d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,19 @@ All notable changes to DevMap are documented in this file. - Public benchmark results - Feedback-driven fixes from the `0.1.0` beta +### Added + +- `ts-morph` analysis for JavaScript and TypeScript behind a normalized + analyzer registry with heuristic and fallback analyzers +- Lightweight `.devmap/index.json` and per-feature navigation maps for agents + +### Changed + +- Feature detection now separates documentation, landing UI, CLI commands, + analysis, snapshot, and AI roles before assigning technical features +- Generated agent guidance now uses index-first navigation and treats the full + snapshot as a last-resort archive + ## [0.1.0] - 2026-06-15 Initial early beta release. diff --git a/PRD.md b/PRD.md index e9317a2..335df00 100644 --- a/PRD.md +++ b/PRD.md @@ -317,6 +317,7 @@ Run static analysis, generate project snapshot, and output a readable project ov - Detect database/schema - Detect entry points - Identify critical files +- Generate a lightweight agent index and per-feature navigation maps - Generate architecture overview - Save snapshot to `.devmap/snapshot.json` @@ -335,9 +336,11 @@ devmap analyze --deep | AI usage | Lower | Higher | | Best for | Quick mapping | Large/unfamiliar projects | -**Generated file:** +**Generated files:** ```txt +.devmap/index.json +.devmap/features/*.json .devmap/snapshot.json ``` @@ -493,6 +496,23 @@ Recommended header: --- +### `.devmap/index.json` and `.devmap/features/*.json` + +Generated by `devmap analyze` as the lightweight navigation layer for AI +agents. + +Preferred reading order: + +1. `.devmap/index.json` +2. the relevant `.devmap/features/*.json` map +3. source files listed in `sourcePriority` +4. `.devmap/snapshot.json` only when more detail is required + +The index must remain short and must not duplicate full dependency or change +impact data. + +--- + ### `.devmap/snapshot.json` Generated by `devmap analyze`. @@ -503,6 +523,7 @@ Generated by `devmap analyze`. - Source of truth for `devmap ask` - Reusable context for AI tools - Regenerated every time the project is re-analyzed +- Full analysis archive and backward-compatible source for DevMap commands **Important:** @@ -675,6 +696,11 @@ Static analysis detects: - Entry points - Critical files +JavaScript and TypeScript files use a normalized `ts-morph` analyzer for +imports, exports, symbols, and function metadata. Other source languages keep +the heuristic analyzer, with a low-confidence fallback for unknown file types. +The analyzer registry preserves the extension point for future parsers. + Results are converted into compact structured data before AI interpretation. ### MVP Principle @@ -704,8 +730,8 @@ interface DevMapSnapshot { version: string; generatedAt: string; agentInstructions: { - navigationPolicy: "snapshot-first"; - defaultMode: "minimal-exploration"; + navigationPolicy: "index-first"; + defaultMode: "feature-map-first"; maxInitialFiles: number; missingSnapshotAction: "run-devmap-analyze"; staleSnapshotAction: "run-devmap-analyze-fresh"; @@ -775,6 +801,8 @@ interface DevMapSnapshot { 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`. +- `.devmap/index.json` and feature maps are derived navigation artifacts, not a + replacement for the full versioned snapshot. - 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 3587c47..e3f3f76 100644 --- a/README.md +++ b/README.md @@ -45,7 +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 +* Index-first agent navigation policy with focused feature maps * Project relationships One analysis. Reusable context. Any codebase. @@ -104,7 +104,9 @@ snapshot.json | ----------------------- | -------------------- | | `DEVMAP.md` | DevMap instructions | | `AGENTS.md` | AI agent entry point | -| `.devmap/snapshot.json` | Core project context | +| `.devmap/index.json` | Lightweight agent navigation | +| `.devmap/features/*.json` | Focused feature maps | +| `.devmap/snapshot.json` | Full project context archive | | `ONBOARDING.md` | Optional onboarding guide | The snapshot is the primary output of DevMap. @@ -185,6 +187,10 @@ Snapshot saved: ## For AI Agents +Agents should read `.devmap/index.json` first, open the relevant feature map, +and inspect its `sourcePriority` files. `.devmap/snapshot.json` is the full +archive for cases where the lightweight navigation layer is insufficient. + If you use Claude Code, OpenAI Codex, Gemini CLI, Cursor, Windsurf, Aider, GitHub Copilot, or Amazon Q — DevMap provides reusable project context that works across all of them. Without DevMap: diff --git a/docs/architecture.md b/docs/architecture.md index ab1ba80..6a8745c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -128,8 +128,24 @@ The analyzer extracts useful structure from scanned files. * Detect external services * Detect database usage * Detect entry points -* Detect critical files -* Detect common features +* Detect critical files +* Detect common features + +### Analyzer Registry + +Scanned files pass through a normalized analyzer registry before `ProjectMap` +is built: + +```txt +Scanner -> TsMorphAnalyzer | HeuristicAnalyzer | FallbackAnalyzer -> FileAnalysis +``` + +- `.ts`, `.tsx`, `.js`, and `.jsx` use `ts-morph` with high confidence. +- Other recognized source files keep regex/heuristic extraction with medium + confidence. +- Unknown file types receive a low-confidence fallback result. +- Existing snapshot fields remain available; AST metadata enriches imports, + exports, symbols, and top functions for JavaScript and TypeScript. --- @@ -402,6 +418,10 @@ Feature metadata also stores a primary `entryPoint` and a short gives future generated docs a human-oriented path through the feature, not only a list of files. +Structural feature flows describe behavior such as scanning, analyzer +selection, project-map construction, snapshot persistence, and navigation-file +generation. They must not duplicate the feature file list as a second list. + ### Onboarding and Change Impact Snapshot schema includes two lightweight navigation aids: @@ -418,9 +438,9 @@ 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. +agents to read `.devmap/index.json`, open the relevant feature map, and inspect +its `sourcePriority` files before broad repository exploration. The full +`.devmap/snapshot.json` is used only when the lightweight maps are insufficient. The snapshot also stores a compact `agentInstructions` object for machine readers. This is intentionally small: policy fields live in JSON, while the @@ -709,9 +729,11 @@ Stores: └── snapshot.json ``` -Stores: - -* latest project snapshot +Stores: + +* latest project snapshot +* lightweight agent index in `.devmap/index.json` +* focused maps in `.devmap/features/*.json` Future: @@ -728,9 +750,11 @@ Future: DevMap may generate or update: ```txt -DEVMAP.md -AGENTS.md -.devmap/snapshot.json +DEVMAP.md +AGENTS.md +.devmap/index.json +.devmap/features/*.json +.devmap/snapshot.json ``` Detailed generated file behavior is documented in: diff --git a/docs/commands.md b/docs/commands.md index 27af6b3..53c65f7 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -146,12 +146,15 @@ devmap analyze --deep * Detect database usage * Detect entry points * Detect critical files +* Analyze JS/TS imports, exports, symbols, and functions with `ts-morph` +* Keep heuristic and fallback analysis for other file types * 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 +* Generate `.devmap/index.json` and `.devmap/features/*.json` for agents * Save snapshot to `.devmap/snapshot.json` ### Internal Flow @@ -172,11 +175,13 @@ Snapshot Terminal Output ``` -### Generated File - -```txt -.devmap/snapshot.json -``` +### Generated Files + +```txt +.devmap/index.json +.devmap/features/*.json +.devmap/snapshot.json +``` ### Output Example @@ -241,6 +246,8 @@ Shared utilities, database access, authentication logic, and helpers. * Do not send the entire project source to AI * Snapshot must be regenerated after analyze * Snapshot must remain compact and deterministic +* Agent index must remain small and must not duplicate full change-impact or + dependency data * AI metadata enrichment must be batched and optional * Analyze must continue if purpose or search-term enrichment fails * Raw provider errors must not be shown directly to users diff --git a/docs/development-testing.md b/docs/development-testing.md index 6e378b2..15d53c8 100644 --- a/docs/development-testing.md +++ b/docs/development-testing.md @@ -1,5 +1,23 @@ # DevMap Development Testing +## Analyzer Registry And Agent Navigation + +Focused verification: + +```bash +pnpm --filter devmap exec tsx --test test/file-analyzers.test.ts test/agent-navigation.test.ts test/analyzers.test.ts test/analyze-ai.test.ts +``` + +After `devmap analyze --fresh`, verify: + +- JS/TS `fileIndex` entries use `analyzer: "ts-morph"` and high confidence; +- non-JS source keeps heuristic analysis and unknown files use fallback; +- `.devmap/index.json` is short and contains no full `changeImpact` map; +- every index feature points to a readable `.devmap/features/*.json` file; +- docs and landing UI do not become evidence for Authentication or other + technical backend features; +- generated agent instructions use index-first navigation. + ## Agent JSON Output Packaged-command verification should include machine-readable output: diff --git a/docs/for-me-personal/DEBUG.md b/docs/for-me-personal/DEBUG.md index a843d4f..a130161 100644 --- a/docs/for-me-personal/DEBUG.md +++ b/docs/for-me-personal/DEBUG.md @@ -962,3 +962,40 @@ pnpm --filter devmap exec tsx --test test/analyzers.test.ts Folder dotfile yang dipakai alat development dapat berisi kata kunci AI, auth, atau service. Scanner harus membedakan metadata development dari source project agar snapshot tetap merepresentasikan aplikasi yang dianalisis. + +## 19. Authentication Palsu Dari Prompt Dan Dokumentasi Source + +**Tanggal:** 2026-06-20 +**Status:** Selesai + +### Gejala + +Setelah role filtering pertama, snapshot DevMap sendiri masih mendeteksi +Authentication dari `contextBuilder.ts`, `snapshotEnrichment.ts`, onboarding, +dan generated instructions. File tersebut hanya menyebut contoh auth di string. + +### Akar Masalah + +Fallback semantic auth role membaca seluruh content dan menganggap kombinasi +kata `auth`, `session`, `middleware`, atau `guard` sebagai runtime behavior. +Prompt dan dokumentasi embedded memenuhi pola itu tanpa implementasi auth. + +### Solusi + +- Technical features tidak memakai documentation, landing UI, atau test files. +- Auth consumer membutuhkan bukti path, import, atau symbol. +- Guard membutuhkan bukti auth dan guard pada path/symbol/import, bukan content + bebas. +- Provider membutuhkan auth import atau symbol yang kuat. +- Regression fixture memasukkan prompt-like strings agar bug tidak kembali. + +### Verifikasi + +Focused analyzer tests lulus dan fresh analysis pada root DevMap menghasilkan +AI Integration, Analysis Engine, CLI Commands, Documentation, Snapshot Engine, +serta Web Landing tanpa Authentication. + +### Pelajaran + +Kata teknis di prompt, docs, dan detector source bukan bukti capability runtime. +Feature attribution harus bertumpu pada struktur kode dan ownership file. diff --git a/docs/for-me-personal/PROGRESS.md b/docs/for-me-personal/PROGRESS.md index 6b94ffd..5916e4d 100644 --- a/docs/for-me-personal/PROGRESS.md +++ b/docs/for-me-personal/PROGRESS.md @@ -1,6 +1,37 @@ # Progress DevMap -Terakhir diperbarui: 2026-06-19 +Terakhir diperbarui: 2026-06-20 + +## Update 2026-06-20 + +### AST Analyzer Dan Agent Navigation + +- Menambahkan analyzer registry dengan output `FileAnalysis` yang konsisten. +- `.ts`, `.tsx`, `.js`, dan `.jsx` sekarang dianalisis memakai `ts-morph` + untuk imports, exports, symbols, line number, exported state, dan async state. +- File source lain tetap memakai heuristic analyzer; tipe yang tidak dikenal + memakai fallback low-confidence. +- Snapshot v1 tetap mempertahankan field lama dan menambah analyzer id, + analysis confidence, serta symbol metadata. +- `devmap analyze` sekarang menulis `.devmap/index.json` dan satu feature map + per fitur di `.devmap/features/`, termasuk saat snapshot cache dipakai ulang. +- Generated `DEVMAP.md` dan block `AGENTS.md` memakai urutan index, feature map, + source priority, lalu full snapshot sebagai last resort. +- Feature detector memisahkan documentation, web landing, CLI commands, + analysis engine, snapshot engine, dan AI integration sebelum technical + feature attribution. +- False-positive Authentication pada source DevMap sendiri dihapus dengan + mensyaratkan bukti path, import, atau symbol, bukan sekadar kata di content. +- Validasi manual pada root DevMap menghasilkan enam feature tanpa + Authentication palsu; index berukuran sekitar 4.5 KB. +- Structural flows sekarang menjelaskan aksi nyata seperti scan, analyzer + selection, ProjectMap build, snapshot persistence, dan index generation, + bukan mengulang daftar dependency. +- Critical files pada index memprioritaskan executable entry point, feature + entry point, dan behavioral support files; `ai/types.ts` tidak lagi masuk + hanya karena import count tinggi. +- Full CLI suite lulus 96/96, TypeScript typecheck dan production build lulus, + serta packed tarball E2E lulus untuk fixture Next.js dan Express. ## Update 2026-06-19 @@ -960,9 +991,10 @@ Tahap berikutnya menambahkan primary feature entry point, business flow ringkas, 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. +agent memakai snapshot-first pada implementasi saat itu. Kontrak ini kemudian +diganti pada 2026-06-20 menjadi index-first dan feature-map-first, dengan full +snapshot sebagai fallback. Agent tetap menjalankan `devmap analyze` saat output +hilang dan meminta user menjalankan `devmap init` jika belum terkonfigurasi. Verifikasi: diff --git a/docs/for-me-personal/TEST.md b/docs/for-me-personal/TEST.md index 1f2ba26..ea379fb 100644 --- a/docs/for-me-personal/TEST.md +++ b/docs/for-me-personal/TEST.md @@ -15,6 +15,48 @@ Ada beberapa versi DevMap yang dapat diuji: | npm link | CLI global sementara | Menguji command `devmap` dari folder mana pun | | CI/runtime | OS dan versi Node berbeda | Verifikasi lintas platform sebelum release | +## Ts-Morph Dan Agent Navigation + +Focused tests: + +```powershell +pnpm --filter devmap exec tsx --test test/file-analyzers.test.ts test/agent-navigation.test.ts test/analyzers.test.ts test/analyze-ai.test.ts +``` + +Tes source langsung pada root DevMap tanpa memakai Groq: + +```powershell +$root = (Get-Location).Path +$oldProfile = $env:USERPROFILE +$env:USERPROFILE = Join-Path $env:TEMP "devmap-static-validation" +pnpm dev:cli -- analyze "$root" --fresh --json +$env:USERPROFILE = $oldProfile +``` + +Periksa hasil berikut: + +```powershell +Get-Content .devmap\index.json -Raw | ConvertFrom-Json +Get-ChildItem .devmap\features\*.json +Get-Content .devmap\snapshot.json -Raw | ConvertFrom-Json +``` + +Expected: + +- file JS/TS memakai `ts-morph` dengan confidence `high`; +- file Vue/Astro dan source non-JS yang dikenali memakai `heuristic`; +- unknown file memakai `fallback`; +- index tidak memiliki full `changeImpact` atau dependency map; +- semua `features[].map` menunjuk file JSON yang ada; +- structural `flow` menjelaskan urutan perilaku dan tidak hanya berisi + `Follow dependency` atau salinan daftar feature files; +- `index.json.criticalFiles` dimulai dari executable/feature entry points dan + tidak mempromosikan type-only hub hanya karena import count; +- DevMap sendiri tidak mendeteksi Authentication dari README, prompt example, + onboarding text, atau landing page; +- feature anchor DevMap mengarah ke `projectMap.ts`, `analyze.ts`, dan landing + `index.astro`, bukan file dokumentasi acak. + ## Onboarding Command Focused automated test: @@ -45,7 +87,7 @@ Expected result: - Jika snapshot stale, human output memberi warning dan JSON berisi `snapshot.stale: true`. - JSON output menyertakan `agentInstructions` agar agent mengikuti policy - snapshot-first. + index-first dan feature-map-first. - Human output berfokus sebagai guide pemahaman, bukan file index: What This Project Does, Mental Model, Main Concepts, Important Areas to Understand, Key Flows, dan Where to Start. diff --git a/docs/generated-files.md b/docs/generated-files.md index f8aaeee..d1dd3e8 100644 --- a/docs/generated-files.md +++ b/docs/generated-files.md @@ -28,6 +28,13 @@ Generated `DEVMAP.md` tells AI agents to use command-level `--json` output instead of parsing decorated terminal text. This applies to `analyze`, `ask`, and `doctor`, while `init --json` is intended for non-interactive setup with an environment API key. + +Its navigation contract uses this order: + +1. `.devmap/index.json` +2. the relevant `.devmap/features/*.json` map +3. files listed in `sourcePriority` +4. `.devmap/snapshot.json` only when the lightweight maps are insufficient Rules: @@ -48,8 +55,35 @@ AGENTS.md exists. Append DevMap instructions? [y/N]: Only `y` or `yes` appends the block. Any other answer preserves the existing file unchanged. ---- - +--- + +## .devmap/index.json + +Generated during `devmap analyze`. + +This is the primary machine-readable entry point for AI coding agents. It +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. + +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 +files import them. + +--- + +## .devmap/features/*.json + +Generated during `devmap analyze`, one file per detected feature. Stale +generated feature maps are removed on the next analysis. + +Each map contains a summary, entry points, related files with roles, optional +behavior flow, keywords, confidence, and `sourcePriority`. These maps bridge +the compact index and the source code. + +--- + ## .devmap/snapshot.json Generated during: @@ -58,9 +92,9 @@ devmap analyze Purpose: -- Project snapshot -- Source of truth for ask -- Reusable AI context +- Project snapshot +- Source of truth for ask +- Full reusable AI context archive and debugging data Regenerated when project files change or when `devmap analyze --fresh` is used. An unchanged project reuses the existing snapshot. @@ -78,6 +112,7 @@ The snapshot contains: - dependencies and external services - database and feature evidence - compact per-file hashes, imports, exports, and line counts +- normalized analyzer id, confidence, symbols, and top-function metadata The snapshot does not store full source file content. diff --git a/docs/roadmap.md b/docs/roadmap.md index adbbec3..ebb1c65 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -9,12 +9,14 @@ **Goal:** Core analysis engine works. No AI yet. **Tasks:** -- File scanner with ignore list +- File scanner with ignore list +- Normalized analyzer registry with `ts-morph` for JS/TS and heuristic fallback - Framework detection (Next.js, Express) - Import/require parser → dependency graph - Entry point detection from graph topology - External service detection from imports -- Project map JSON generation +- Project map JSON generation +- Lightweight agent index and per-feature navigation maps - MD5 file hashing for cache - `devmap init` — setup wizard - `devmap doctor` — diagnostics diff --git a/packages/cli/README.md b/packages/cli/README.md index 33baeea..41d253d 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -93,7 +93,9 @@ devmap config model auto DevMap generates: -- `.devmap/snapshot.json` for structured project analysis +- `.devmap/index.json` for lightweight AI-agent navigation +- `.devmap/features/*.json` for focused feature maps and source priority +- `.devmap/snapshot.json` for the full structured project analysis archive - `DEVMAP.md` for human and AI-agent usage guidance - a small DevMap block in `AGENTS.md` only after confirmation when the file already exists @@ -102,6 +104,10 @@ Existing `AGENTS.md` and `DEVMAP.md` files are never overwritten. ## For AI Agents +Read `.devmap/index.json` first, open the relevant feature map, and inspect its +`sourcePriority` files. Use `.devmap/snapshot.json` only when the lightweight +navigation layer is insufficient. + Use `--json` for scripts, editors, CI, or AI agents: ```bash diff --git a/packages/cli/package.json b/packages/cli/package.json index 91bb053..4ad934d 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -48,7 +48,8 @@ }, "type": "module", "dependencies": { - "commander": "^12.0.0" + "commander": "^12.0.0", + "ts-morph": "^28.0.0" }, "devDependencies": { "@types/node": "^25.9.1", diff --git a/packages/cli/src/ai/contextBuilder.ts b/packages/cli/src/ai/contextBuilder.ts index 358df34..c764090 100644 --- a/packages/cli/src/ai/contextBuilder.ts +++ b/packages/cli/src/ai/contextBuilder.ts @@ -201,6 +201,14 @@ type SearchTermScoreInput = { }; }; +const STRUCTURAL_NAVIGATION_FEATURES = new Set([ + "Analysis Engine", + "CLI Commands", + "Documentation", + "Snapshot Engine", + "Web Landing" +]); + export async function buildQuestionContext( projectRoot: string, snapshot: ProjectMap, @@ -488,8 +496,13 @@ function scoreFeatureEvidence( const reasons: string[] = []; for (const feature of snapshot.features) { + const featureNameTerms = extractContextKeywords(feature.name); + if (isStructuralNavigationFeature(feature.name) + && !featureNameTerms.some((term) => keywords.includes(term))) { + continue; + } const featureTerms = [ - ...extractContextKeywords(feature.name), + ...featureNameTerms, ...(feature.searchTerms ?? []) ]; if ( @@ -504,6 +517,10 @@ function scoreFeatureEvidence( return { score, reasons }; } +function isStructuralNavigationFeature(name: string): boolean { + return STRUCTURAL_NAVIGATION_FEATURES.has(name); +} + function scoreFileIndexEvidence( metadata: ProjectMap["fileIndex"][string], keywords: string[], diff --git a/packages/cli/src/analyzers/analyzerRegistry.ts b/packages/cli/src/analyzers/analyzerRegistry.ts new file mode 100644 index 0000000..cbfeea9 --- /dev/null +++ b/packages/cli/src/analyzers/analyzerRegistry.ts @@ -0,0 +1,41 @@ +import type { + AnalyzerContext, + FileAnalysis, + FileAnalyzer +} from "./fileAnalysis.js"; +import type { ScannedFile } from "./fileScanner.js"; +import { FallbackAnalyzer, HeuristicAnalyzer } from "./heuristicAnalyzer.js"; +import { TsMorphAnalyzer } from "./tsMorphAnalyzer.js"; + +export class AnalyzerRegistry { + constructor(private readonly analyzers: FileAnalyzer[]) {} + + async analyze(file: ScannedFile, context: AnalyzerContext): Promise { + for (const analyzer of this.analyzers) { + if (!analyzer.supports(file)) continue; + + try { + return await analyzer.analyze(file, context); + } catch { + // Continue to the next analyzer so malformed source still receives metadata. + } + } + + throw new Error(`No analyzer supports ${file.path}.`); + } +} + +export async function analyzeFiles(files: ScannedFile[]): Promise> { + const registry = new AnalyzerRegistry([ + new TsMorphAnalyzer(), + new HeuristicAnalyzer(), + new FallbackAnalyzer() + ]); + const context = { files }; + const entries = await Promise.all(files.map(async (file) => [ + file.path, + await registry.analyze(file, context) + ] as const)); + + return Object.fromEntries(entries); +} diff --git a/packages/cli/src/analyzers/dependencyGraph.ts b/packages/cli/src/analyzers/dependencyGraph.ts index 9e5ad91..3138c72 100644 --- a/packages/cli/src/analyzers/dependencyGraph.ts +++ b/packages/cli/src/analyzers/dependencyGraph.ts @@ -1,17 +1,22 @@ +import type { FileAnalysis } from "./fileAnalysis.js"; import type { ScannedFile } from "./fileScanner.js"; export type FileGraph = Record; const IMPORT_RE = /(?:import\s+(?:[^'"]+\s+from\s+)?|export\s+[^'"]+\s+from\s+|require\()\s*['"]([^'"]+)['"]/g; -export function buildDependencyGraph(files: ScannedFile[]): FileGraph { +export function buildDependencyGraph( + files: ScannedFile[], + analyses: Record = {} +): FileGraph { const graph: FileGraph = {}; const localPaths = new Set(files.map((file) => file.path)); for (const file of files) { graph[file.path] = []; - for (const specifier of findImportSpecifiers(file.content)) { + const importSpecifiers = analyses[file.path]?.imports ?? findImportSpecifiers(file.content); + for (const specifier of importSpecifiers) { if (!specifier.startsWith(".")) { continue; } diff --git a/packages/cli/src/analyzers/featureDetector.ts b/packages/cli/src/analyzers/featureDetector.ts index 7bd8a30..ee902e0 100644 --- a/packages/cli/src/analyzers/featureDetector.ts +++ b/packages/cli/src/analyzers/featureDetector.ts @@ -1,6 +1,7 @@ import type { DatabaseInfo } from "./databaseDetector.js"; import type { ScannedFile } from "./fileScanner.js"; import type { RouteInfo } from "./routeDetector.js"; +import { classifyFileRole, isTechnicalFeatureSource, type FileRole } from "./fileRole.js"; import { isArchitectureSource } from "./sourceScope.js"; export type FeatureInfo = { @@ -20,15 +21,127 @@ export type AuthSemanticRole = "auth-config" | "guard" | "provider" | "consumer" const FEATURE_SIGNALS: Array<{ name: string; terms: string[]; + purpose: string; +}> = [ + { + name: "Authentication", + terms: ["auth", "login", "session", "jwt", "next-auth", "clerk"], + purpose: "Handles authentication, identity, sessions, login, and access control." + }, + { + name: "Payments", + terms: ["payment", "stripe", "midtrans"], + purpose: "Handles payment providers and transaction workflows." + }, + { + name: "File Upload", + terms: ["upload", "multer", "cloudinary"], + purpose: "Handles file ingestion, storage, and upload providers." + }, + { + name: "Email", + terms: ["email", "resend", "nodemailer"], + purpose: "Handles application email delivery and templates." + }, + { + name: "AI Integration", + terms: ["openai", "groq", "gemini", "generative-ai"], + purpose: "Handles AI providers, prompts, and model-facing context." + }, + { + name: "Notifications", + terms: ["notification", "web-push", "pusher"], + purpose: "Handles user notifications and push delivery." + } +]; + +const ROLE_FEATURES: Array<{ + role: FileRole; + name: string; + purpose: string; + terms: string[]; }> = [ - { name: "Authentication", terms: ["auth", "login", "session", "jwt", "next-auth", "clerk"] }, - { name: "Payments", terms: ["payment", "stripe", "midtrans"] }, - { name: "File Upload", terms: ["upload", "multer", "cloudinary"] }, - { name: "Email", terms: ["email", "resend", "nodemailer"] }, - { name: "AI Integration", terms: ["openai", "groq", "gemini", "generative-ai"] }, - { name: "Notifications", terms: ["notification", "web-push", "pusher"] } + { + role: "documentation", + name: "Documentation", + purpose: "Explains project behavior, setup, architecture, and contribution guidance.", + terms: ["documentation", "docs", "readme", "guide"] + }, + { + role: "landing-ui", + name: "Web Landing", + purpose: "Contains public landing and marketing user interface code.", + terms: ["web", "landing", "marketing", "hero", "ui"] + }, + { + role: "cli-command", + name: "CLI Commands", + purpose: "Contains command entry points that orchestrate DevMap behavior.", + terms: ["cli", "command", "analyze", "ask", "init", "doctor"] + }, + { + role: "snapshot-engine", + name: "Snapshot Engine", + purpose: "Builds, stores, validates, and reuses project snapshots.", + terms: ["snapshot", "projectmap", "analyze", "cache", "index"] + }, + { + role: "analysis-engine", + name: "Analysis Engine", + purpose: "Scans source files and extracts project structure and relationships.", + terms: ["analysis", "analyzer", "scanner", "detector", "dependency"] + }, + { + role: "ai-integration", + name: "AI Integration", + purpose: "Handles AI providers, prompts, and model-facing context.", + terms: ["ai", "groq", "prompt", "context", "model"] + } ]; +const FEATURE_FILE_PRIORITIES: Record = { + "AI Integration": [ + /\/ai\/groq\.[cm]?[jt]s$/, + /\/ai\/contextbuilder\.[cm]?[jt]s$/, + /\/ai\/prompts\.[cm]?[jt]s$/, + /\/ai\/completion\.[cm]?[jt]s$/ + ], + "Analysis Engine": [ + /\/analyzers\/projectmap\.[cm]?[jt]s$/, + /\/analyzers\/analyzerregistry\.[cm]?[jt]s$/, + /\/analyzers\/tsmorphanalyzer\.[cm]?[jt]s$/, + /\/analyzers\/heuristicanalyzer\.[cm]?[jt]s$/, + /\/analyzers\/fileanalysis\.[cm]?[jt]s$/, + /\/analyzers\/filescanner\.[cm]?[jt]s$/, + /\/analyzers\/dependencygraph\.[cm]?[jt]s$/, + /\/analyzers\/featuredetector\.[cm]?[jt]s$/ + ], + "Snapshot Engine": [ + /\/analyzers\/projectmap\.[cm]?[jt]s$/, + /\/cache\/snapshot\.[cm]?[jt]s$/, + /\/cache\/agentnavigation\.[cm]?[jt]s$/, + /\/cache\/filehash\.[cm]?[jt]s$/ + ], + "CLI Commands": [ + /\/commands\/analyze\.[cm]?[jt]s$/, + /\/commands\/ask\.[cm]?[jt]s$/, + /\/commands\/init\.[cm]?[jt]s$/, + /\/commands\/doctor\.[cm]?[jt]s$/, + /\/commands\/onboarding\.[cm]?[jt]s$/ + ], + Documentation: [ + /(^|\/)readme\.md$/, + /(^|\/)prd\.md$/, + /(^|\/)agents\.md$/, + /(^|\/)contributing\.md$/ + ], + "Web Landing": [ + /\/pages\/index\.astro$/, + /\/landing\/herosection\./, + /\/landing\/siteheader\./ + ] +}; + export function detectFeatures( files: ScannedFile[], routes: RouteInfo[], @@ -37,14 +150,48 @@ export function detectFeatures( const features: FeatureInfo[] = []; const scopedFiles = files.filter((file) => isArchitectureSource(file.path)); - for (const signal of FEATURE_SIGNALS) { + for (const definition of ROLE_FEATURES) { const evidence = scopedFiles + .filter((file) => + classifyFileRole(file.path) === definition.role + || (definition.name === "CLI Commands" + && /(^|\/)src\/index\.[cm]?[jt]s$/.test(file.path.toLowerCase())) + || (definition.name === "Snapshot Engine" + && /(^|\/)projectmap\.[cm]?[jt]sx?$/.test(file.path.toLowerCase())) + ) + .map((file) => file.path) + .sort((left, right) => + featureFilePriority(definition.name, left) - featureFilePriority(definition.name, right) + || left.localeCompare(right) + ) + .slice(0, 12); + + if (evidence.length > 0) { + features.push(createFeatureInfo( + definition.name, + evidence, + definition.terms, + definition.purpose + )); + } + } + + const technicalFiles = scopedFiles.filter((file) => isTechnicalFeatureSource(file.path)); + + for (const signal of FEATURE_SIGNALS) { + const evidence = technicalFiles .filter((file) => matchesSignal(file, signal.terms)) .map((file) => file.path) + .sort() .slice(0, 5); if (evidence.length > 0) { - features.push(createFeatureInfo(signal.name, evidence, signal.terms)); + mergeFeature(features, createFeatureInfo( + signal.name, + evidence, + signal.terms, + signal.purpose + )); } } @@ -77,13 +224,14 @@ export function detectFeatures( function createFeatureInfo( name: string, evidence: string[], - terms: string[] + terms: string[], + purpose = `Identifies ${name.toLowerCase()} capability in the project.` ): FeatureInfo { const files = evidence.filter((item) => item.includes("/") || /\.[A-Za-z0-9]+$/.test(item)); return { name, - purpose: `Identifies ${name.toLowerCase()} capability in the project.`, + purpose, files, businessFlow: [], entryPoints: [], @@ -93,6 +241,25 @@ function createFeatureInfo( }; } +function mergeFeature(features: FeatureInfo[], addition: FeatureInfo): void { + const existingIndex = features.findIndex((feature) => feature.name === addition.name); + if (existingIndex === -1) { + features.push(addition); + return; + } + + const existing = features[existingIndex]; + const files = [...new Set([...existing.files, ...addition.files])]; + const evidence = [...new Set([...existing.evidence, ...addition.evidence])]; + features[existingIndex] = { + ...existing, + files, + evidence, + searchTerms: [...new Set([...existing.searchTerms, ...addition.searchTerms])].slice(0, 8), + confidence: evidence.length >= 2 ? "high" : existing.confidence + }; +} + function matchesSignal(file: ScannedFile, terms: string[]): boolean { const path = file.path.toLowerCase(); const imports = readImportSpecifiers(file.content); @@ -139,6 +306,7 @@ function enrichAuthenticationFeature(features: FeatureInfo[], files: ScannedFile function collectAuthenticationFeatureFiles(files: ScannedFile[]): string[] { return orderAuthenticationFiles(files .filter((file) => isArchitectureSource(file.path)) + .filter((file) => isTechnicalFeatureSource(file.path)) .filter((file) => !isAnalyzerImplementationFile(file.path)) .filter((file) => { const imports = readImportSpecifiers(file.content); @@ -160,25 +328,36 @@ export function detectAuthenticationSemanticRole( ): AuthSemanticRole | null { const normalizedPath = path.toLowerCase(); const text = `${normalizedPath} ${symbols.join(" ")} ${imports.join(" ")} ${content}`.toLowerCase(); + const normalizedImports = imports.map((specifier) => specifier.toLowerCase()); + const hasAuthImport = normalizedImports.some((specifier) => + /(^|[/@-])(auth|next-auth|auth0|clerk)([/.-]|$)/.test(specifier) + || /supabase.*auth/.test(specifier) + ); + const hasAuthSymbol = symbols.some((symbol) => + /(auth|session|login|register|signin|signout|jwt|token)/i.test(symbol) + ); + const hasAuthPath = /(^|[/._-])(auth|session|login|register|signin|signout)([/._-]|$)/.test(normalizedPath); + const hasGuardPath = /(^|[/._-])(guard|middleware|proxy|protected)([/._-]|$)/.test(normalizedPath); + const hasGuardSymbol = symbols.some((symbol) => + /(guard|middleware|proxy|protected)/i.test(symbol) + ); if (/(^|\/)src\/auth\.[cm]?[jt]sx?$/.test(normalizedPath) || /(^|\/)auth\.[cm]?[jt]sx?$/.test(normalizedPath) || (hasSymbol(symbols, "auth") && hasSymbol(symbols, "handlers")) - || /\b(nextauth|getserversession|getsession|credentials)\b/.test(text) + || hasAuthImport && /\b(nextauth|getserversession|getsession|credentials)\b/.test(text) ) { return "auth-config"; } if (/(^|\/)(src\/)?(proxy|middleware)\.[cm]?[jt]sx?$/.test(normalizedPath) - || /\b(auth|session|token|jwt|redirect|unauthorized|authenticated)\b/.test(text) - && /\b(middleware|guard|proxy)\b/.test(text) + || (hasAuthPath || hasAuthImport || hasAuthSymbol) && (hasGuardPath || hasGuardSymbol) ) { return "guard"; } if (/providers?\.[cm]?[jt]sx?$/.test(normalizedPath) - && (imports.some((specifier) => specifier.includes("next-auth")) - || /\b(sessionprovider|usesession)\b/.test(text)) + && (hasAuthImport || hasAuthSymbol) ) { return "provider"; } @@ -189,13 +368,20 @@ export function detectAuthenticationSemanticRole( return "consumer"; } - if (/\b(auth|nextauth|getserversession|getsession|signin|signout|usesession|sessionprovider|handlelogin|handleregister|handlesignout)\b/.test(text)) { + if (hasAuthPath || hasAuthImport || hasAuthSymbol) { return "consumer"; } return null; } +function featureFilePriority(featureName: string, path: string): number { + const normalized = path.toLowerCase(); + const index = FEATURE_FILE_PRIORITIES[featureName] + ?.findIndex((pattern) => pattern.test(normalized)) ?? -1; + return index === -1 ? 100 : index; +} + export function orderAuthenticationFiles(files: string[]): string[] { return [...new Set(files)].sort((left, right) => authenticationFilePriority(left) - authenticationFilePriority(right) diff --git a/packages/cli/src/analyzers/fileAnalysis.ts b/packages/cli/src/analyzers/fileAnalysis.ts new file mode 100644 index 0000000..43b296d --- /dev/null +++ b/packages/cli/src/analyzers/fileAnalysis.ts @@ -0,0 +1,49 @@ +import type { ScannedFile } from "./fileScanner.js"; +import type { RouteInfo } from "./routeDetector.js"; + +export type AnalysisConfidence = "high" | "medium" | "low"; + +export type SymbolKind = + | "function" + | "class" + | "interface" + | "type" + | "enum" + | "const" + | "method"; + +export type SymbolInfo = { + name: string; + kind: SymbolKind; + line: number; + exported: boolean; + async?: boolean; +}; + +export type FunctionInfo = { + name: string; + kind: "function" | "const" | "class" | "method"; + line: number; + exported: boolean; + async: boolean; +}; + +export type FileAnalysis = { + analyzer: string; + confidence: AnalysisConfidence; + imports: string[]; + exports: string[]; + symbols: SymbolInfo[]; + topFunctions: FunctionInfo[]; + routes?: RouteInfo[]; +}; + +export type AnalyzerContext = { + files: ScannedFile[]; +}; + +export interface FileAnalyzer { + id: string; + supports(file: ScannedFile): boolean; + analyze(file: ScannedFile, context: AnalyzerContext): Promise; +} diff --git a/packages/cli/src/analyzers/fileRole.ts b/packages/cli/src/analyzers/fileRole.ts new file mode 100644 index 0000000..b894874 --- /dev/null +++ b/packages/cli/src/analyzers/fileRole.ts @@ -0,0 +1,60 @@ +export type FileRole = + | "documentation" + | "landing-ui" + | "ai-integration" + | "analysis-engine" + | "snapshot-engine" + | "cli-command" + | "test" + | "application-source"; + +export function classifyFileRole(path: string): FileRole { + const normalized = path.toLowerCase(); + const name = normalized.split("/").at(-1) ?? normalized; + + if (isTestPath(normalized)) return "test"; + if (name.endsWith(".md") || normalized.startsWith("docs/")) return "documentation"; + if ( + /(^|\/)(landing|marketing)(\/|$)/.test(normalized) + || /(^|\/)src\/pages\/index\.astro$/.test(normalized) + || /(^|\/)(hero|pricing|testimonials?|features?section)[^/]*\.(astro|tsx?|jsx?|vue|svelte)$/.test(normalized) + ) { + return "landing-ui"; + } + if (/(^|\/)src\/ai\//.test(normalized) || /(^|\/)ai\//.test(normalized)) { + return "ai-integration"; + } + if ( + /(^|\/)src\/cache\//.test(normalized) + || /(^|\/)(cache|snapshot)(\/|$)/.test(normalized) + || /(^|\/)snapshot\.[cm]?[jt]sx?$/.test(normalized) + ) { + return "snapshot-engine"; + } + if ( + /(^|\/)src\/analyzers?\//.test(normalized) + || /(^|\/)(analyzers?|detectors?|scanner)(\/|$)/.test(normalized) + ) { + return "analysis-engine"; + } + if ( + /(^|\/)src\/commands?\//.test(normalized) + || /(^|\/)(commands?|bin)(\/|$)/.test(normalized) + ) { + return "cli-command"; + } + + return "application-source"; +} + +export function isTechnicalFeatureSource(path: string): boolean { + const role = classifyFileRole(path); + return role !== "documentation" && role !== "landing-ui" && role !== "test"; +} + +function isTestPath(path: string): boolean { + return ( + /(^|\/)(__tests__|fixtures?|tests?)(\/|$)/.test(path) + || /\.(test|spec)\.[cm]?[jt]sx?$/.test(path) + ); +} diff --git a/packages/cli/src/analyzers/heuristicAnalyzer.ts b/packages/cli/src/analyzers/heuristicAnalyzer.ts new file mode 100644 index 0000000..a8b06d6 --- /dev/null +++ b/packages/cli/src/analyzers/heuristicAnalyzer.ts @@ -0,0 +1,177 @@ +import type { + AnalyzerContext, + FileAnalysis, + FileAnalyzer, + FunctionInfo, + SymbolInfo +} from "./fileAnalysis.js"; +import type { ScannedFile } from "./fileScanner.js"; + +const HEURISTIC_EXTENSIONS = new Set([ + ".astro", + ".cjs", + ".cs", + ".cts", + ".go", + ".java", + ".mjs", + ".mts", + ".php", + ".py", + ".rb", + ".svelte", + ".vue" +]); + +export class HeuristicAnalyzer implements FileAnalyzer { + readonly id = "heuristic"; + + supports(file: ScannedFile): boolean { + return HEURISTIC_EXTENSIONS.has(file.extension) + || [".ts", ".tsx", ".js", ".jsx"].includes(file.extension); + } + + async analyze(file: ScannedFile, _context: AnalyzerContext): Promise { + const imports = readImportSpecifiers(file.content); + const symbols = readSymbols(file.content); + const exports = symbols + .filter((symbol) => symbol.exported) + .map((symbol) => symbol.name) + .sort(); + const topFunctions = symbols + .filter((symbol): symbol is SymbolInfo & { kind: FunctionInfo["kind"] } => + ["function", "const", "class", "method"].includes(symbol.kind) + ) + .map((symbol) => ({ + name: symbol.name, + kind: symbol.kind, + line: symbol.line, + exported: symbol.exported, + async: symbol.async ?? false + })) + .sort(compareFunctions) + .slice(0, 8); + + return { + analyzer: this.id, + confidence: "medium", + imports, + exports, + symbols, + topFunctions + }; + } +} + +export class FallbackAnalyzer implements FileAnalyzer { + readonly id = "fallback"; + + supports(_file: ScannedFile): boolean { + return true; + } + + async analyze(_file: ScannedFile, _context: AnalyzerContext): Promise { + return { + analyzer: this.id, + confidence: "low", + imports: [], + exports: [], + symbols: [], + topFunctions: [] + }; + } +} + +function readImportSpecifiers(content: string): string[] { + const imports = new Set(); + const pattern = /(?:import\s+(?:[^'"]+\s+from\s+)?|export\s+[^'"]+\s+from\s+|require\()\s*['"]([^'"]+)['"]/g; + let match = pattern.exec(content); + + while (match) { + imports.add(match[1]); + match = pattern.exec(content); + } + + return [...imports]; +} + +function readSymbols(content: string): SymbolInfo[] { + const symbols = new Map(); + const patterns: Array<{ + kind: SymbolInfo["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 + }, + { + kind: "interface", + pattern: /(export\s+)?interface\s+([A-Za-z_$][A-Za-z0-9_$]*)/g, + nameIndex: 2, + exportedIndex: 1 + }, + { + kind: "type", + pattern: /(export\s+)?type\s+([A-Za-z_$][A-Za-z0-9_$]*)/g, + nameIndex: 2, + exportedIndex: 1 + }, + { + kind: "enum", + pattern: /(export\s+)?enum\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 candidate: SymbolInfo = { + name, + kind, + line: lineAt(content, match.index), + exported: exportedIndex !== undefined && Boolean(match[exportedIndex]), + ...(asyncIndex !== undefined ? { async: Boolean(match[asyncIndex]) } : {}) + }; + const existing = symbols.get(name); + if (!existing || Number(candidate.exported) > Number(existing.exported)) { + symbols.set(name, candidate); + } + match = pattern.exec(content); + } + } + + return [...symbols.values()].sort((left, right) => left.line - right.line || left.name.localeCompare(right.name)); +} + +function lineAt(content: string, index: number): number { + return content.slice(0, index).split(/\r?\n/).length; +} + +function compareFunctions(left: FunctionInfo, right: FunctionInfo): number { + return Number(right.exported) - Number(left.exported) + || left.line - right.line + || left.name.localeCompare(right.name); +} diff --git a/packages/cli/src/analyzers/projectMap.ts b/packages/cli/src/analyzers/projectMap.ts index f5e43d4..f884b68 100644 --- a/packages/cli/src/analyzers/projectMap.ts +++ b/packages/cli/src/analyzers/projectMap.ts @@ -1,4 +1,5 @@ import { hashContent } from "../cache/fileHash.js"; +import { analyzeFiles } from "./analyzerRegistry.js"; import { detectDatabase, type DatabaseInfo } from "./databaseDetector.js"; import { buildDependencyGraph, countReferences } from "./dependencyGraph.js"; import { detectEntryPoints } from "./entryPoints.js"; @@ -16,6 +17,7 @@ import { detectProjectMetadata, type ProjectMetadata } from "./projectMetadata.j import { detectRoutes, type RouteInfo } from "./routeDetector.js"; import { detectExternalServices } from "./serviceDetector.js"; import { isArchitectureSource } from "./sourceScope.js"; +import type { FileAnalysis, SymbolInfo } from "./fileAnalysis.js"; export const SNAPSHOT_SCHEMA_VERSION = "1"; @@ -45,9 +47,12 @@ export type FlowInfo = { }; export type FileIndexEntry = { + analyzer: string; + analysisConfidence: "high" | "medium" | "low"; hash: string; imports: string[]; exportedSymbols: string[]; + symbols: SymbolInfo[]; topFunctions: Array<{ name: string; kind: "function" | "const" | "class" | "method"; @@ -67,8 +72,8 @@ export type ProjectMap = { version: string; generatedAt: string; agentInstructions: { - navigationPolicy: "snapshot-first"; - defaultMode: "minimal-exploration"; + navigationPolicy: "index-first"; + defaultMode: "feature-map-first"; maxInitialFiles: number; missingSnapshotAction: "run-devmap-analyze"; staleSnapshotAction: "run-devmap-analyze-fresh"; @@ -121,7 +126,8 @@ export type ProjectMap = { export async function createProjectMap(projectRoot: string): Promise { const files = await scanFiles(projectRoot); - const graph = buildDependencyGraph(files); + const analyses = await analyzeFiles(files); + const graph = buildDependencyGraph(files, analyses); const references = countReferences(graph); const framework = detectFramework(files); const entryPoints = detectEntryPoints(graph); @@ -133,10 +139,18 @@ export async function createProjectMap(projectRoot: string): Promise entryPoints, graph ); - const criticalFiles = rankCriticalFiles(files, references, entryPoints); + const criticalFiles = rankCriticalFiles(files, analyses, references, entryPoints); const fileIndex = Object.fromEntries(files.map((file) => [ file.path, - createFileIndexEntry(file, graph[file.path] ?? [], references, entryPoints, criticalFiles, features) + createFileIndexEntry( + file, + analyses[file.path], + graph[file.path] ?? [], + references, + entryPoints, + criticalFiles, + features + ) ])); const flows = generateMinimalFlows(features, fileIndex, routes, graph); @@ -175,12 +189,12 @@ export async function createProjectMap(projectRoot: string): Promise function createAgentInstructions(): ProjectMap["agentInstructions"] { return { - navigationPolicy: "snapshot-first", - defaultMode: "minimal-exploration", + navigationPolicy: "index-first", + defaultMode: "feature-map-first", 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." + fallbackRule: "Read snapshot.json only when index.json and feature maps are insufficient; inspect extra source only when exact implementation is required." }; } @@ -196,6 +210,7 @@ export function createProjectFingerprint(files: ScannedFile[]): string { function rankCriticalFiles( files: ScannedFile[], + analyses: Record, references: Record, entryPoints: string[] ): ProjectMap["criticalFiles"] { @@ -224,7 +239,7 @@ function rankCriticalFiles( reasons.push("core project concern"); } - const semanticBonus = calculateCriticalSemanticBonus(file); + const semanticBonus = calculateCriticalSemanticBonus(file, analyses[file.path]); if (semanticBonus > 0) { score += semanticBonus; reasons.push("semantic feature anchor"); @@ -265,14 +280,15 @@ function readPackageDependencies(files: ScannedFile[]): Record function createFileIndexEntry( file: ScannedFile, + analysis: FileAnalysis, imports: string[], references: Record, entryPoints: string[], criticalFiles: ProjectMap["criticalFiles"], features: FeatureInfo[] ): FileIndexEntry { - const topFunctions = findTopFunctions(file.content); - const exportedSymbols = findExportedSymbols(file.content); + const topFunctions = analysis.topFunctions; + const exportedSymbols = analysis.exports; const scope = classifyFileScope(file, exportedSymbols, imports); const featureRefs = features .filter((feature) => feature.files.includes(file.path) || feature.evidence.includes(file.path)) @@ -293,9 +309,12 @@ function createFileIndexEntry( const purpose = inferFilePurpose(file.path, scope, exportedSymbols, topFunctions, featureRefs); return { + analyzer: analysis.analyzer, + analysisConfidence: analysis.confidence, hash: hashContent(file.content), imports, exportedSymbols, + symbols: analysis.symbols, topFunctions, lines: file.lines, ...(purpose ? { purpose } : {}), @@ -373,14 +392,11 @@ function calculateSemanticImportanceBonus( return featureRefs.length > 0 ? 20 : 0; } -function calculateCriticalSemanticBonus(file: ScannedFile): number { - const exportedSymbols = findExportedSymbols(file.content); - const topFunctions = findTopFunctions(file.content); - const imports = readImportSpecifiers(file.content); +function calculateCriticalSemanticBonus(file: ScannedFile, analysis: FileAnalysis): number { const role = detectAuthenticationSemanticRole( file.path, - [...exportedSymbols, ...topFunctions.map((item) => item.name)], - imports, + [...analysis.exports, ...analysis.symbols.map((item) => item.name)], + analysis.imports, file.content ); @@ -459,89 +475,11 @@ function inferFilePurpose( 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 isFeatureConfigFile(path: string, featureRefs: string[]): boolean { return featureRefs.length > 0 && /(^|\/)src\/(lib|utils)\/(config|constants)\.[cm]?[jt]sx?$/.test(path.toLowerCase()); } -function readImportSpecifiers(content: string): string[] { - const imports: string[] = []; - const pattern = /(?:import\s+(?:[^'"]+\s+from\s+)?|export\s+[^'"]+\s+from\s+|require\()\s*['"]([^'"]+)['"]/g; - let match = pattern.exec(content); - - while (match) { - imports.push(match[1].toLowerCase()); - match = pattern.exec(content); - } - - return imports; -} - function attachFeatureEntryPoints( features: FeatureInfo[], routes: RouteInfo[], @@ -616,6 +554,11 @@ function buildFeatureBusinessFlow( graph: Record, featureFiles: string[] ): string[] { + const structuralFlow = buildStructuralFeatureFlow(featureName, Object.keys(graph)); + if (structuralFlow.length > 0) { + return structuralFlow; + } + if (featureName === "Authentication" && featureFiles.length > 0) { const orderedFiles = entryPoint ? [entryPoint, ...featureFiles.filter((file) => file !== entryPoint)] @@ -642,6 +585,43 @@ function buildFeatureBusinessFlow( return steps; } +function buildStructuralFeatureFlow(featureName: string, files: string[]): string[] { + const find = (...patterns: RegExp[]) => files.find((file) => + patterns.some((pattern) => pattern.test(file.toLowerCase())) + ); + const steps: Array<[string, string | undefined]> = featureName === "Analysis Engine" + ? [ + ["Scan project files", find(/\/analyzers\/filescanner\.[cm]?[jt]s$/)], + ["Choose a compatible file analyzer", find(/\/analyzers\/analyzerregistry\.[cm]?[jt]s$/)], + ["Extract normalized AST or heuristic metadata", find(/\/analyzers\/tsmorphanalyzer\.[cm]?[jt]s$/)], + ["Build the normalized project map", find(/\/analyzers\/projectmap\.[cm]?[jt]s$/)] + ] + : featureName === "Snapshot Engine" + ? [ + ["Build the full project map", find(/\/analyzers\/projectmap\.[cm]?[jt]s$/)], + ["Persist and validate the snapshot", find(/\/cache\/snapshot\.[cm]?[jt]s$/)], + ["Generate the lightweight index and feature maps", find(/\/cache\/agentnavigation\.[cm]?[jt]s$/)] + ] + : featureName === "CLI Commands" + ? [ + ["Parse and dispatch the user command", find(/\/src\/index\.[cm]?[jt]s$/)], + ["Orchestrate project analysis", find(/\/commands\/analyze\.[cm]?[jt]s$/)], + ["Render human or machine-readable output", find(/\/utils\/output\.[cm]?[jt]s$/)] + ] + : featureName === "AI Integration" + ? [ + ["Build focused project context", find(/\/ai\/contextbuilder\.[cm]?[jt]s$/)], + ["Construct grounded model prompts", find(/\/ai\/prompts\.[cm]?[jt]s$/)], + ["Call Groq with retry and model fallback", find(/\/ai\/groq\.[cm]?[jt]s$/)], + ["Stream or return the completed response", find(/\/ai\/completion\.[cm]?[jt]s$/)] + ] + : []; + + return steps + .filter((step): step is [string, string] => Boolean(step[1])) + .map(([action, file]) => `${action} in ${file}.`); +} + function describeAuthenticationFlowStep(file: string, dependencies: string[]): string { const normalized = file.toLowerCase(); const dependencyText = dependencies.length > 0 @@ -690,20 +670,31 @@ function generateFeatureFlows( fileIndex: Record ): FlowInfo[] { return features - .filter((feature) => feature.confidence === "high" && feature.files.length > 0) + .filter((feature) => + feature.confidence === "high" + && feature.businessFlow.length > 1 + && !feature.businessFlow.some((step) => /^Identify files related to /i.test(step)) + ) .slice(0, 3) .map((feature) => { - const steps = feature.files.slice(0, 5).map((file, index) => ({ - label: renderFlowStepLabel(file, fileIndex[file], index === 0), - file, - purpose: fileIndex[file]?.purpose - })); + const candidateFiles = [...new Set([ + ...(feature.entryPoint ? [feature.entryPoint] : []), + ...feature.entryPoints, + ...feature.files + ])]; + const steps = feature.businessFlow.slice(0, 6).map((label) => { + const file = candidateFiles.find((candidate) => label.includes(candidate)); + return { + label, + ...(file ? { file, purpose: fileIndex[file]?.purpose } : {}) + }; + }); return { name: `${feature.name} flow`, - purpose: `Shows the main files related to ${feature.name.toLowerCase()}.`, + purpose: `Describes the inferred behavior for ${feature.name.toLowerCase()}.`, type: "feature" as const, - ...(feature.entryPoints[0] ? { entryPoint: feature.entryPoints[0] } : {}), + ...(feature.entryPoint ? { entryPoint: feature.entryPoint } : {}), steps, ...(steps.length > 3 ? { mermaid: renderMermaidFlow(steps) } : {}), confidence: "high" as const @@ -926,26 +917,6 @@ function detectAnalysisWarnings(files: ScannedFile[]): string[] { } } -function findExportedSymbols(content: string): string[] { - const symbols = new Set(); - const patterns = [ - /export\s+(?:async\s+)?function\s+([A-Za-z0-9_]+)/g, - /export\s+const\s+([A-Za-z0-9_]+)/g, - /export\s+class\s+([A-Za-z0-9_]+)/g, - /export\s+type\s+([A-Za-z0-9_]+)/g - ]; - - for (const pattern of patterns) { - let match = pattern.exec(content); - while (match) { - symbols.add(match[1]); - match = pattern.exec(content); - } - } - - return [...symbols].sort(); -} - function splitSearchTerms(value: string): string[] { const spaced = value .replace(/([a-z0-9])([A-Z])/g, "$1 $2") diff --git a/packages/cli/src/analyzers/tsMorphAnalyzer.ts b/packages/cli/src/analyzers/tsMorphAnalyzer.ts new file mode 100644 index 0000000..71ae6dc --- /dev/null +++ b/packages/cli/src/analyzers/tsMorphAnalyzer.ts @@ -0,0 +1,168 @@ +import { + Node, + Project, + ScriptTarget, + SyntaxKind, + VariableDeclarationKind +} from "ts-morph"; +import type { + AnalyzerContext, + FileAnalysis, + FileAnalyzer, + FunctionInfo, + SymbolInfo +} from "./fileAnalysis.js"; +import type { ScannedFile } from "./fileScanner.js"; + +const SUPPORTED_EXTENSIONS = new Set([".ts", ".tsx", ".js", ".jsx"]); + +export class TsMorphAnalyzer implements FileAnalyzer { + readonly id = "ts-morph"; + private readonly project = new Project({ + useInMemoryFileSystem: true, + skipAddingFilesFromTsConfig: true, + compilerOptions: { + allowJs: true, + target: ScriptTarget.ES2022 + } + }); + + supports(file: ScannedFile): boolean { + return SUPPORTED_EXTENSIONS.has(file.extension); + } + + async analyze(file: ScannedFile, _context: AnalyzerContext): Promise { + const sourceFile = this.project.createSourceFile(file.path, file.content, { overwrite: true }); + const imports = new Set( + sourceFile.getImportDeclarations().map((declaration) => declaration.getModuleSpecifierValue()) + ); + + for (const declaration of sourceFile.getExportDeclarations()) { + const specifier = declaration.getModuleSpecifierValue(); + if (specifier) imports.add(specifier); + } + + for (const call of sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression)) { + if (call.getExpression().getText() !== "require") continue; + const argument = call.getArguments()[0]; + if (argument && Node.isStringLiteral(argument)) { + imports.add(argument.getLiteralValue()); + } + } + + const exports = [...sourceFile.getExportedDeclarations().keys()].sort(); + const symbols: SymbolInfo[] = []; + + for (const statement of sourceFile.getStatements()) { + if (Node.isFunctionDeclaration(statement) && statement.getName()) { + symbols.push({ + name: statement.getNameOrThrow(), + kind: "function", + line: statement.getStartLineNumber(), + exported: statement.isExported() || statement.isDefaultExport(), + async: statement.isAsync() + }); + continue; + } + + if (Node.isClassDeclaration(statement) && statement.getName()) { + const classExported = statement.isExported() || statement.isDefaultExport(); + symbols.push({ + name: statement.getNameOrThrow(), + kind: "class", + line: statement.getStartLineNumber(), + exported: classExported + }); + for (const method of statement.getMethods()) { + symbols.push({ + name: method.getName(), + kind: "method", + line: method.getStartLineNumber(), + exported: false, + async: method.isAsync() + }); + } + continue; + } + + if (Node.isInterfaceDeclaration(statement)) { + symbols.push(createDeclarationSymbol(statement, "interface")); + continue; + } + + if (Node.isTypeAliasDeclaration(statement)) { + symbols.push(createDeclarationSymbol(statement, "type")); + continue; + } + + if (Node.isEnumDeclaration(statement)) { + symbols.push(createDeclarationSymbol(statement, "enum")); + continue; + } + + if (Node.isVariableStatement(statement) + && statement.getDeclarationKind() === VariableDeclarationKind.Const) { + for (const declaration of statement.getDeclarations()) { + const initializer = declaration.getInitializer(); + symbols.push({ + name: declaration.getName(), + kind: "const", + line: declaration.getStartLineNumber(), + exported: statement.isExported(), + async: Boolean( + initializer + && (Node.isArrowFunction(initializer) || Node.isFunctionExpression(initializer)) + && initializer.isAsync() + ) + }); + } + } + } + + const topFunctions = symbols + .filter((symbol): symbol is SymbolInfo & { kind: FunctionInfo["kind"] } => + ["function", "const", "class", "method"].includes(symbol.kind) + ) + .map((symbol) => ({ + name: symbol.name, + kind: symbol.kind, + line: symbol.line, + exported: symbol.exported, + async: symbol.async ?? false + })) + .sort(compareFunctions) + .slice(0, 8); + + return { + analyzer: this.id, + confidence: "high", + imports: [...imports], + exports, + symbols, + topFunctions + }; + } +} + +function createDeclarationSymbol( + declaration: { + getName(): string; + getStartLineNumber(): number; + isExported(): boolean; + isDefaultExport(): boolean; + }, + kind: "interface" | "type" | "enum" +): SymbolInfo { + return { + name: declaration.getName(), + kind, + line: declaration.getStartLineNumber(), + exported: declaration.isExported() || declaration.isDefaultExport() + }; +} + +function compareFunctions(left: FunctionInfo, right: FunctionInfo): number { + return Number(right.exported) - Number(left.exported) + || left.line - right.line + || left.name.localeCompare(right.name); +} diff --git a/packages/cli/src/cache/agentNavigation.ts b/packages/cli/src/cache/agentNavigation.ts new file mode 100644 index 0000000..0aa4cd5 --- /dev/null +++ b/packages/cli/src/cache/agentNavigation.ts @@ -0,0 +1,204 @@ +import { mkdir, readdir, unlink, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import type { FeatureInfo } from "../analyzers/featureDetector.js"; +import type { ProjectMap } from "../analyzers/projectMap.js"; + +export type AgentNavigationWriteResult = { + indexPath: string; + featurePaths: string[]; +}; + +type AgentFeatureIndex = { + id: string; + name: string; + summary: string; + keywords: string[]; + criticalFiles: string[]; + map: string; +}; + +export async function writeAgentNavigationFiles( + projectRoot: string, + snapshot: ProjectMap +): Promise { + const devmapDirectory = join(projectRoot, ".devmap"); + const featureDirectory = join(devmapDirectory, "features"); + await mkdir(featureDirectory, { recursive: true }); + await removeStaleFeatureMaps(featureDirectory); + + const featureIndexes = snapshot.features.map((feature) => + createFeatureIndex(feature, snapshot) + ); + const featurePaths: string[] = []; + + for (const feature of snapshot.features) { + const id = featureId(feature.name); + const path = join(featureDirectory, `${id}.json`); + await writeJson(path, createFeatureMap(id, feature, snapshot)); + featurePaths.push(path); + } + + const indexPath = join(devmapDirectory, "index.json"); + await writeJson(indexPath, { + project: { + name: snapshot.project.name, + framework: snapshot.project.framework, + language: snapshot.project.language, + packageManager: snapshot.project.packageManager, + summary: createProjectSummary(snapshot) + }, + generatedAt: snapshot.generatedAt, + entryPoints: snapshot.entryPoints.slice(0, 8), + criticalFiles: selectIndexCriticalFiles(snapshot), + features: featureIndexes, + snapshot: { + path: ".devmap/snapshot.json", + usage: "last_resort_or_web_ai_copy_context" + }, + agentInstructions: "Read this file first. Pick the relevant feature by keywords, open its feature map, then inspect only source files listed in sourcePriority. Do not read snapshot.json unless index.json and feature maps are insufficient." + }); + + return { indexPath, featurePaths }; +} + +function createFeatureIndex(feature: FeatureInfo, snapshot: ProjectMap): AgentFeatureIndex { + const id = featureId(feature.name); + return { + id, + name: feature.name, + summary: feature.purpose, + keywords: feature.searchTerms.slice(0, 8), + criticalFiles: selectCriticalFiles(feature, snapshot), + map: `.devmap/features/${id}.json` + }; +} + +function createFeatureMap(id: string, feature: FeatureInfo, snapshot: ProjectMap) { + const entryPoints = [...new Set([ + ...(feature.entryPoint ? [feature.entryPoint] : []), + ...feature.entryPoints + ])]; + const relatedFiles = feature.files + .filter((path) => snapshot.fileIndex[path]) + .map((path) => ({ + path, + role: snapshot.fileIndex[path]?.purpose ?? `Supports ${feature.name}.` + })); + const featureOrder = new Map(feature.files.map((path, index) => [path, index])); + const sourcePriority = [...relatedFiles] + .sort((left, right) => { + const leftEntry = Number(entryPoints.includes(left.path)); + const rightEntry = Number(entryPoints.includes(right.path)); + return rightEntry - leftEntry + || (featureOrder.get(left.path) ?? Number.MAX_SAFE_INTEGER) + - (featureOrder.get(right.path) ?? Number.MAX_SAFE_INTEGER) + || (snapshot.fileIndex[right.path]?.importance ?? 0) + - (snapshot.fileIndex[left.path]?.importance ?? 0) + || left.path.localeCompare(right.path); + }) + .map((file) => file.path) + .slice(0, 8); + const flow = feature.businessFlow.filter((step) => + !/^Identify files related to /i.test(step) + ); + + return { + id, + name: feature.name, + summary: feature.purpose, + entryPoints, + criticalFiles: selectCriticalFiles(feature, snapshot), + relatedFiles, + ...(flow.length > 1 ? { flow } : {}), + keywords: feature.searchTerms.slice(0, 12), + sourcePriority, + confidence: feature.confidence + }; +} + +function selectIndexCriticalFiles(snapshot: ProjectMap): string[] { + const selected = new Set(); + + for (const path of snapshot.entryPoints) { + selected.add(path); + } + + for (const feature of snapshot.features) { + if (feature.name === "Documentation") continue; + if (feature.entryPoint) selected.add(feature.entryPoint); + + const supportingFile = feature.files.find((path) => { + if (path === feature.entryPoint) return false; + const metadata = snapshot.fileIndex[path]; + return metadata + && metadata.scope !== "docs" + && metadata.scope !== "test" + && (metadata.topFunctions.length > 0 || ["api", "cli", "ui"].includes(metadata.scope)); + }); + if (supportingFile) selected.add(supportingFile); + } + + for (const critical of snapshot.criticalFiles) { + const metadata = snapshot.fileIndex[critical.path]; + if (!metadata || metadata.scope === "docs" || metadata.scope === "test") continue; + if (metadata.topFunctions.length === 0 && metadata.scope !== "api" && metadata.scope !== "cli") { + continue; + } + selected.add(critical.path); + } + + return [...selected].slice(0, 8); +} + +function selectCriticalFiles(feature: FeatureInfo, snapshot: ProjectMap): string[] { + const featureFiles = new Set(feature.files); + const critical = snapshot.criticalFiles + .filter((file) => featureFiles.has(file.path)) + .map((file) => file.path); + + if (critical.length > 0) { + return critical.slice(0, 5); + } + + return [...feature.files] + .filter((path) => snapshot.fileIndex[path]) + .sort((left, right) => + (snapshot.fileIndex[right]?.importance ?? 0) + - (snapshot.fileIndex[left]?.importance ?? 0) + || left.localeCompare(right) + ) + .slice(0, 5); +} + +function createProjectSummary(snapshot: ProjectMap): string { + const stack = snapshot.project.framework === "unknown" + ? snapshot.project.language + : `${snapshot.project.framework} ${snapshot.project.language}`; + const featureNames = snapshot.features + .filter((feature) => feature.confidence !== "low") + .map((feature) => feature.name) + .slice(0, 4); + const featureText = featureNames.length > 0 + ? ` Main concerns: ${featureNames.join(", ")}.` + : ""; + + return `${snapshot.project.name} is a ${stack} project with ${snapshot.stats.relevantFiles} analyzed files.${featureText}`; +} + +function featureId(name: string): string { + return name + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, "") || "feature"; +} + +async function removeStaleFeatureMaps(directory: string): Promise { + const entries = await readdir(directory, { withFileTypes: true }); + await Promise.all(entries + .filter((entry) => entry.isFile() && entry.name.endsWith(".json")) + .map((entry) => unlink(join(directory, entry.name)))); +} + +async function writeJson(path: string, value: unknown): Promise { + await writeFile(path, `${JSON.stringify(value, null, 2)}\n`, "utf8"); +} diff --git a/packages/cli/src/cache/snapshot.ts b/packages/cli/src/cache/snapshot.ts index d9e8211..2527ebe 100644 --- a/packages/cli/src/cache/snapshot.ts +++ b/packages/cli/src/cache/snapshot.ts @@ -117,13 +117,17 @@ export async function inspectSnapshot(projectRoot: string): Promise): void { if (!isRecord(snapshot.agentInstructions)) { snapshot.agentInstructions = { - navigationPolicy: "snapshot-first", - defaultMode: "minimal-exploration", + navigationPolicy: "index-first", + defaultMode: "feature-map-first", 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." + fallbackRule: "Read snapshot.json only when index.json and feature maps are insufficient; inspect extra source only when exact implementation is required." }; + } else if (snapshot.agentInstructions.navigationPolicy === "snapshot-first") { + snapshot.agentInstructions.navigationPolicy = "index-first"; + snapshot.agentInstructions.defaultMode = "feature-map-first"; + snapshot.agentInstructions.fallbackRule = "Read snapshot.json only when index.json and feature maps are insufficient; inspect extra source only when exact implementation is required."; } if (!Array.isArray(snapshot.flows)) { @@ -138,6 +142,11 @@ function normalizeSnapshotDefaults(snapshot: Record): void { const fileIndex = snapshot.fileIndex as Record>; for (const entry of Object.values(fileIndex)) { + if (typeof entry.analyzer !== "string") entry.analyzer = "heuristic"; + if (!["high", "medium", "low"].includes(String(entry.analysisConfidence))) { + entry.analysisConfidence = "medium"; + } + if (!Array.isArray(entry.symbols)) entry.symbols = []; if (typeof entry.scope !== "string") entry.scope = "unknown"; if (!Array.isArray(entry.featureRefs)) entry.featureRefs = []; if (!Array.isArray(entry.searchTerms)) entry.searchTerms = []; diff --git a/packages/cli/src/commands/analyze.ts b/packages/cli/src/commands/analyze.ts index 420c09e..a4fbf63 100644 --- a/packages/cli/src/commands/analyze.ts +++ b/packages/cli/src/commands/analyze.ts @@ -6,6 +6,7 @@ import { enrichSnapshotWithAi } from "../ai/snapshotEnrichment.js"; import type { AiClient } from "../ai/types.js"; import { createProjectMap } from "../analyzers/projectMap.js"; import { inspectSnapshot, saveSnapshot } from "../cache/snapshot.js"; +import { writeAgentNavigationFiles } from "../cache/agentNavigation.js"; import { readConfig, type DevmapConfig } from "../utils/config.js"; import { DevmapError } from "../utils/errors.js"; import { output, withJsonOutput } from "../utils/output.js"; @@ -51,6 +52,7 @@ async function runAnalyze( const previous = options.fresh ? { status: "missing" as const } : await inspectSnapshot(projectRoot); if (previous.status === "valid" && previous.snapshot.fingerprint === snapshot.fingerprint) { + await writeAgentNavigationFiles(projectRoot, previous.snapshot); printSnapshot(previous.snapshot, options.deep); output.success("Project is unchanged. Reused existing snapshot."); return printOrGenerateInterpretation( @@ -63,9 +65,11 @@ async function runAnalyze( snapshot = await enrichSnapshot(snapshot, options, dependencies); await saveSnapshot(projectRoot, snapshot); + await writeAgentNavigationFiles(projectRoot, snapshot); printSnapshot(snapshot, options.deep); output.success("Snapshot saved to .devmap/snapshot.json"); + output.success("Agent navigation saved to .devmap/index.json and .devmap/features/"); if (options.fresh) { output.success("Fresh analysis completed"); } diff --git a/packages/cli/src/utils/agentsFile.ts b/packages/cli/src/utils/agentsFile.ts index fffdbae..2bbe419 100644 --- a/packages/cli/src/utils/agentsFile.ts +++ b/packages/cli/src/utils/agentsFile.ts @@ -9,7 +9,11 @@ export const DEVMAP_AGENTS_BLOCK = `${DEVMAP_AGENTS_MARKER} ## DevMap Context Before working in this repository, read \`DEVMAP.md\` first. -For current project structure, use \`.devmap/snapshot.json\` if available. +Read \`.devmap/index.json\` first, then the relevant +\`.devmap/features/*.json\` map. Inspect files from \`sourcePriority\` before +exploring broadly. Use \`.devmap/snapshot.json\` only when those lightweight +navigation files are insufficient. If the navigation files are missing, run +\`devmap analyze\`. `; export type AgentsFileStatus = "missing" | "existing" | "integrated"; diff --git a/packages/cli/src/utils/devmapFile.ts b/packages/cli/src/utils/devmapFile.ts index 53c2652..4fe48a7 100644 --- a/packages/cli/src/utils/devmapFile.ts +++ b/packages/cli/src/utils/devmapFile.ts @@ -24,7 +24,9 @@ This repository uses DevMap to create reusable project context for developers an ## Project Context - Detected framework: ${stack} -- Generated analysis: \`.devmap/snapshot.json\` +- Agent navigation index: \`.devmap/index.json\` +- Feature maps: \`.devmap/features/*.json\` +- Full analysis archive: \`.devmap/snapshot.json\` - DevMap config: \`~/.devmap/config.json\` ## Recommended Workflow @@ -48,19 +50,17 @@ devmap doctor --json ## Agent Navigation Contract -This repository uses DevMap as the primary navigation source. Use snapshot-first, -not repository-scan-first. +This repository uses DevMap as the primary navigation source. Use the lightweight +navigation files before broad repository exploration. -Before exploring files, read \`.devmap/snapshot.json\` and prefer these sections: +Preferred reading order: -1. \`features\` -2. \`features.entryPoint\` -3. \`features.businessFlow\` -4. \`flows\` -5. \`onboarding.recommendedPath\` -6. \`changeImpact\` -7. \`criticalFiles\` -8. \`fileIndex\` +1. Read \`.devmap/index.json\`. +2. Pick the relevant feature using its name and keywords. +3. Open the matching \`.devmap/features/*.json\` map. +4. Inspect only the files listed in \`sourcePriority\` first. +5. Read \`.devmap/snapshot.json\` only when the index and feature maps are + insufficient or full archive/debug context is required. Do not scan the whole repository first. @@ -79,18 +79,17 @@ 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\`. +2. Read \`.devmap/index.json\`. +3. Open the relevant feature map. +4. Inspect at most the smallest relevant source-file set from \`sourcePriority\`. +5. Explain which navigation entry guided the decision when giving advice. +6. Avoid unrelated files unless the navigation data is incomplete or exact + code verification is required. + +If \`.devmap/index.json\` or \`.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. diff --git a/packages/cli/test/agent-navigation.test.ts b/packages/cli/test/agent-navigation.test.ts new file mode 100644 index 0000000..1f39807 --- /dev/null +++ b/packages/cli/test/agent-navigation.test.ts @@ -0,0 +1,60 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import { createProjectMap } from "../src/analyzers/projectMap.js"; +import { writeAgentNavigationFiles } from "../src/cache/agentNavigation.js"; + +test("agent navigation writer creates a compact index and feature maps", async () => { + const fixtureRoot = join( + dirname(fileURLToPath(import.meta.url)), + "fixtures", + "nextjs-project" + ); + const outputRoot = await mkdtemp(join(tmpdir(), "devmap-agent-navigation-")); + + try { + const snapshot = await createProjectMap(fixtureRoot); + const result = await writeAgentNavigationFiles(outputRoot, snapshot); + const index = JSON.parse(await readFile(result.indexPath, "utf8")) as Record; + const features = index.features as Array<{ + id: string; + map: string; + criticalFiles: string[]; + }>; + + assert.equal(index.generatedAt, snapshot.generatedAt); + assert.deepEqual(index.entryPoints, snapshot.entryPoints); + assert.deepEqual( + (index.criticalFiles as string[]).slice(0, snapshot.entryPoints.length), + snapshot.entryPoints + ); + assert.ok(Array.isArray(features)); + assert.ok(features.length > 0); + assert.equal("changeImpact" in index, false); + assert.deepEqual(index.snapshot, { + path: ".devmap/snapshot.json", + usage: "last_resort_or_web_ai_copy_context" + }); + assert.match(String(index.agentInstructions), /Read this file first/); + assert.match(String(index.agentInstructions), /Do not read snapshot\.json unless/); + + const authentication = features.find((feature) => feature.id === "authentication"); + assert.ok(authentication); + assert.ok(authentication.map.endsWith("authentication.json")); + assert.ok(authentication.criticalFiles.length <= 5); + + const featureMap = JSON.parse(await readFile( + join(outputRoot, authentication.map), + "utf8" + )) as Record; + assert.equal(featureMap.id, "authentication"); + assert.ok(Array.isArray(featureMap.relatedFiles)); + assert.ok(Array.isArray(featureMap.sourcePriority)); + assert.equal("changeImpact" in featureMap, false); + } finally { + await rm(outputRoot, { recursive: true, force: true }); + } +}); diff --git a/packages/cli/test/analyze-ai.test.ts b/packages/cli/test/analyze-ai.test.ts index 01ef979..7f9b4cd 100644 --- a/packages/cli/test/analyze-ai.test.ts +++ b/packages/cli/test/analyze-ai.test.ts @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; @@ -349,6 +349,44 @@ test("analyze continues when snapshot enrichment AI fails", async () => { } }); +test("analyze writes lightweight agent navigation alongside the snapshot", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "devmap-navigation-output-")); + + try { + await writeFile( + join(projectRoot, "package.json"), + JSON.stringify({ name: "navigation-output" }), + "utf8" + ); + await writeFile( + join(projectRoot, "index.ts"), + "export async function start() { return true; }\n", + "utf8" + ); + + await captureOutput(() => analyzeCommand( + projectRoot, + { fresh: true }, + { loadConfig: async () => null } + )); + + const index = JSON.parse(await readFile( + join(projectRoot, ".devmap", "index.json"), + "utf8" + )) as { snapshot: { path: string }; agentInstructions: string }; + const snapshot = JSON.parse(await readFile( + join(projectRoot, ".devmap", "snapshot.json"), + "utf8" + )) as { fileIndex: Record }; + + assert.equal(index.snapshot.path, ".devmap/snapshot.json"); + assert.match(index.agentInstructions, /feature map/); + assert.equal(snapshot.fileIndex["index.ts"]?.analyzer, "ts-morph"); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } +}); + function stripAnsi(value: string): string { return value.replace(/\u001B\[[0-9;]*m/g, ""); } diff --git a/packages/cli/test/analyzers.test.ts b/packages/cli/test/analyzers.test.ts index af4c21d..6a58526 100644 --- a/packages/cli/test/analyzers.test.ts +++ b/packages/cli/test/analyzers.test.ts @@ -8,6 +8,7 @@ import { buildDependencyGraph, countReferences } from "../src/analyzers/dependen import { scanFiles } from "../src/analyzers/fileScanner.js"; import { shouldIgnorePath } from "../src/analyzers/filterEngine.js"; import { detectFramework } 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"; import { @@ -125,17 +126,112 @@ test("service detector detects HTTP API providers without package dependencies", } }); +test("feature detection keeps documentation and landing UI out of technical features", () => { + const files = [ + createScannedFile("README.md", "Authentication login session Groq Stripe upload email"), + createScannedFile("PRD.md", "Authentication roadmap and payment requirements"), + createScannedFile("docs/auth.md", "How login and sessions should work"), + createScannedFile( + "apps/web/src/components/landing/HeroSection.astro", + "

AI authentication and payment project mapping

" + ), + createScannedFile( + "packages/cli/src/ai/groq.ts", + 'import type { AiClient } from "./types.js"; export class GroqClient {}' + ), + createScannedFile( + "packages/cli/src/ai/contextBuilder.ts", + 'const aliases = ["auth", "login", "session", "jwt"]; export function buildContext() {}' + ), + createScannedFile( + "packages/cli/src/ai/snapshotEnrichment.ts", + 'const prompt = "Prefer auth provider config, middleware guard, session provider, and nextauth examples"; export function enrich() {}' + ), + createScannedFile( + "packages/cli/src/commands/onboarding.ts", + 'const example = "Authentication flow"; export function onboardingCommand() {}' + ), + createScannedFile( + "packages/cli/src/analyzers/projectMap.ts", + 'import { scanFiles } from "./fileScanner.js"; export function createProjectMap() {}' + ), + createScannedFile( + "packages/cli/src/cache/snapshot.ts", + 'export async function saveSnapshot() {}' + ), + createScannedFile( + "packages/cli/src/commands/analyze.ts", + 'export async function analyzeCommand() {}' + ) + ]; + + const features = detectFeatures(files, []); + const names = features.map((feature) => feature.name); + + assert.ok(names.includes("AI Integration")); + assert.ok(names.includes("Analysis Engine")); + assert.ok(names.includes("Snapshot Engine")); + assert.ok(names.includes("CLI Commands")); + assert.ok(names.includes("Documentation")); + assert.ok(names.includes("Web Landing")); + assert.ok(!names.includes("Authentication")); + assert.ok(!names.includes("Payments")); + assert.ok(!names.includes("File Upload")); + + const aiFeature = features.find((feature) => feature.name === "AI Integration"); + assert.ok(aiFeature?.files.includes("packages/cli/src/ai/groq.ts")); + assert.ok(aiFeature?.files.every((path) => path.startsWith("packages/cli/src/ai/"))); +}); + +test("structural feature flows describe behavior instead of repeating file lists", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "devmap-structural-flow-test-")); + const files = { + "package.json": JSON.stringify({ name: "structural-flow-test" }), + "src/index.ts": 'export { analyzeCommand } from "./commands/analyze.js";\n', + "src/commands/analyze.ts": 'import { createProjectMap } from "../analyzers/projectMap.js"; export async function analyzeCommand() { return createProjectMap(); }\n', + "src/analyzers/fileScanner.ts": "export async function scanFiles() { return []; }\n", + "src/analyzers/analyzerRegistry.ts": "export async function analyzeFiles() { return {}; }\n", + "src/analyzers/tsMorphAnalyzer.ts": "export class TsMorphAnalyzer {}\n", + "src/analyzers/projectMap.ts": 'import { scanFiles } from "./fileScanner.js"; import { analyzeFiles } from "./analyzerRegistry.js"; export async function createProjectMap() { await scanFiles(); return analyzeFiles(); }\n', + "src/cache/snapshot.ts": "export async function saveSnapshot() {}\n", + "src/cache/agentNavigation.ts": "export async function writeAgentNavigationFiles() {}\n", + "src/utils/output.ts": "export const output = {};\n" + }; + + try { + for (const [path, content] of Object.entries(files)) { + const target = join(projectRoot, path); + await mkdir(dirname(target), { recursive: true }); + await writeFile(target, content, "utf8"); + } + + const projectMap = await createProjectMap(projectRoot); + const analysis = projectMap.features.find((feature) => feature.name === "Analysis Engine"); + const snapshot = projectMap.features.find((feature) => feature.name === "Snapshot Engine"); + + assert.ok(analysis); + assert.match(analysis.businessFlow.join(" "), /Scan project files/); + assert.match(analysis.businessFlow.join(" "), /Choose a compatible file analyzer/); + assert.doesNotMatch(analysis.businessFlow.join(" "), /Follow dependency/); + assert.ok(snapshot); + assert.match(snapshot.businessFlow.join(" "), /Persist and validate the snapshot/); + assert.match(snapshot.businessFlow.join(" "), /lightweight index and feature maps/); + } 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", + navigationPolicy: "index-first", + defaultMode: "feature-map-first", 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." + fallbackRule: "Read snapshot.json only when index.json and feature maps are insufficient; inspect extra source only when exact implementation is required." }); assert.match(projectMap.fingerprint, /^[a-f0-9]{32}$/); assert.equal(projectMap.framework, "nextjs"); @@ -227,6 +323,17 @@ test("project map summarizes a Next.js fixture", async () => { assert.ok(projectMap.stats.relevantFiles >= 5); }); +function createScannedFile(path: string, content: string) { + return { + path, + absolutePath: `C:/fixture/${path}`, + extension: path.slice(path.lastIndexOf(".")), + size: Buffer.byteLength(content), + lines: content.split(/\r?\n/).length, + content + }; +} + test("project map summarizes an Express fixture", async () => { const projectMap = await createProjectMap(expressFixture); diff --git a/packages/cli/test/file-analyzers.test.ts b/packages/cli/test/file-analyzers.test.ts new file mode 100644 index 0000000..56339f8 --- /dev/null +++ b/packages/cli/test/file-analyzers.test.ts @@ -0,0 +1,109 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { analyzeFiles } from "../src/analyzers/analyzerRegistry.js"; +import type { ScannedFile } from "../src/analyzers/fileScanner.js"; + +test("ts-morph analyzer extracts JavaScript and TypeScript structure", async () => { + const file = createScannedFile("src/service.ts", [ + 'import { readFile } from "node:fs/promises";', + 'export { join } from "node:path";', + "export interface ServiceOptions { enabled: boolean }", + "export type ServiceResult = string;", + "export enum ServiceState { Ready }", + "export class ServiceClient {", + " async run(): Promise { return readFile.toString(); }", + "}", + "export const DEFAULT_SERVICE = 'devmap';", + "export async function createService(): Promise {", + " return new ServiceClient();", + "}" + ].join("\n")); + + const analysis = (await analyzeFiles([file]))[file.path]; + + assert.equal(analysis.analyzer, "ts-morph"); + assert.equal(analysis.confidence, "high"); + assert.deepEqual(analysis.imports, ["node:fs/promises", "node:path"]); + assert.deepEqual(analysis.exports, [ + "DEFAULT_SERVICE", + "ServiceClient", + "ServiceOptions", + "ServiceResult", + "ServiceState", + "createService", + "join" + ]); + assert.deepEqual( + analysis.symbols.map((symbol) => [symbol.name, symbol.kind, symbol.exported]), + [ + ["ServiceOptions", "interface", true], + ["ServiceResult", "type", true], + ["ServiceState", "enum", true], + ["ServiceClient", "class", true], + ["run", "method", false], + ["DEFAULT_SERVICE", "const", true], + ["createService", "function", true] + ] + ); + assert.deepEqual( + analysis.topFunctions.find((item) => item.name === "createService"), + { + name: "createService", + kind: "function", + line: 10, + exported: true, + async: true + } + ); +}); + +test("registry keeps heuristic analysis for non-JS source files", async () => { + const file = createScannedFile("src/App.vue", [ + "" + ].join("\n")); + + const analysis = (await analyzeFiles([file]))[file.path]; + + assert.equal(analysis.analyzer, "heuristic"); + assert.equal(analysis.confidence, "medium"); + assert.deepEqual(analysis.imports, ["./Header.vue"]); + assert.deepEqual(analysis.exports, ["pageTitle"]); +}); + +test("ts-morph analyzer preserves CommonJS require dependencies", async () => { + const file = createScannedFile( + "src/server.js", + 'const express = require("express");\nmodule.exports = express();\n' + ); + + const analysis = (await analyzeFiles([file]))[file.path]; + + assert.equal(analysis.analyzer, "ts-morph"); + assert.deepEqual(analysis.imports, ["express"]); +}); + +test("registry uses a low-confidence fallback for unknown file types", async () => { + const file = createScannedFile("notes.md", "# Notes\n\nNo source declarations here.\n"); + + const analysis = (await analyzeFiles([file]))[file.path]; + + assert.equal(analysis.analyzer, "fallback"); + assert.equal(analysis.confidence, "low"); + assert.deepEqual(analysis.imports, []); + assert.deepEqual(analysis.exports, []); + assert.deepEqual(analysis.symbols, []); +}); + +function createScannedFile(path: string, content: string): ScannedFile { + return { + path, + absolutePath: `C:/fixture/${path}`, + extension: path.slice(path.lastIndexOf(".")), + size: Buffer.byteLength(content), + lines: content.split(/\r?\n/).length, + content + }; +} diff --git a/packages/cli/test/init-and-errors.test.ts b/packages/cli/test/init-and-errors.test.ts index deebb6e..22b36d3 100644 --- a/packages/cli/test/init-and-errors.test.ts +++ b/packages/cli/test/init-and-errors.test.ts @@ -25,10 +25,10 @@ test("DEVMAP.md contains workflow and AI-agent guidance", () => { assert.match(content, /devmap analyze/); 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, /\.devmap\/index\.json/); + assert.match(content, /\.devmap\/features\/\*\.json/); + assert.match(content, /sourcePriority/); + assert.match(content, /snapshot\.json.*only when/is); assert.match(content, /Do not scan the whole repository first/); assert.match(content, /devmap init/); assert.match(content, /--json/); diff --git a/packages/cli/test/json-output.test.ts b/packages/cli/test/json-output.test.ts index 333c292..d7c5759 100644 --- a/packages/cli/test/json-output.test.ts +++ b/packages/cli/test/json-output.test.ts @@ -141,7 +141,7 @@ test("onboarding --json emits guide metadata and markdown", async () => { assert.equal(payload.project.name, "json-onboarding"); assert.equal(payload.overview, null); assert.equal(payload.snapshot.stale, false); - assert.equal(payload.agentInstructions.navigationPolicy, "snapshot-first"); + assert.equal(payload.agentInstructions.navigationPolicy, "index-first"); assert.ok(Array.isArray(payload.entryPoints)); assert.ok(Array.isArray(payload.criticalFiles)); assert.ok(Array.isArray(payload.externalServices)); diff --git a/packages/cli/test/package-e2e.mjs b/packages/cli/test/package-e2e.mjs index 09753fc..16a746e 100644 --- a/packages/cli/test/package-e2e.mjs +++ b/packages/cli/test/package-e2e.mjs @@ -93,6 +93,17 @@ try { const snapshotPath = join(projectRoot, ".devmap", "snapshot.json"); const snapshot = JSON.parse(await readFile(snapshotPath, "utf8")); assert.equal(snapshot.project.framework, expectedFramework); + const agentIndex = JSON.parse(await readFile( + join(projectRoot, ".devmap", "index.json"), + "utf8" + )); + assert.equal(agentIndex.snapshot.path, ".devmap/snapshot.json"); + assert.ok(Array.isArray(agentIndex.features)); + for (const feature of agentIndex.features) { + const featureMap = JSON.parse(await readFile(join(projectRoot, feature.map), "utf8")); + assert.equal(featureMap.id, feature.id); + assert.ok(Array.isArray(featureMap.sourcePriority)); + } const analyzeJson = parseJsonOutput(await runDevmap(projectRoot, [ "analyze", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5bc1fdf..c01e578 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -34,6 +34,9 @@ importers: commander: specifier: ^12.0.0 version: 12.1.0 + ts-morph: + specifier: ^28.0.0 + version: 28.0.0 devDependencies: '@types/node': specifier: ^25.9.1 @@ -761,6 +764,9 @@ packages: '@swc/helpers@0.5.23': resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==} + '@ts-morph/common@0.29.0': + resolution: {integrity: sha512-35oUmphHbJvQ/+UTwFNme/t2p3FoKiGJ5auTjjpNTop2dyREspirjMy82PLSC1pnDJ8ah1GU98hwpVt64YXQsg==} + '@types/debug@4.1.13': resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} @@ -891,6 +897,10 @@ packages: bail@2.0.2: resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + base-64@1.0.0: resolution: {integrity: sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg==} @@ -913,6 +923,10 @@ packages: resolution: {integrity: sha512-F3PH5k5juxom4xktynS7MoFY+NUWH5LC4CnH11YB8NPew+HLpmBLCybSAEyb2F+4pRXhuhWqFesoQd6DAyc2hw==} engines: {node: '>=18'} + brace-expansion@5.0.6: + resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} + engines: {node: 18 || 20 || >=22} + braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} @@ -984,6 +998,9 @@ packages: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} + code-block-writer@13.0.3: + resolution: {integrity: sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -1485,6 +1502,10 @@ packages: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + mrmime@2.0.1: resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} engines: {node: '>=10'} @@ -1864,6 +1885,9 @@ packages: ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + ts-morph@28.0.0: + resolution: {integrity: sha512-Wp3tnZ2bzwxyTZMtgWVzXDfm7lB1Drz+y9DmmYH/L702PQhPyVrp3pkou3yIz4qjS14GY9kcpmLiOOMvl8oG1g==} + tsconfck@3.1.6: resolution: {integrity: sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==} engines: {node: ^18 || >=20} @@ -2785,6 +2809,12 @@ snapshots: dependencies: tslib: 2.8.1 + '@ts-morph/common@0.29.0': + dependencies: + minimatch: 10.2.5 + path-browserify: 1.0.1 + tinyglobby: 0.2.17 + '@types/debug@4.1.13': dependencies: '@types/ms': 2.1.0 @@ -3023,6 +3053,8 @@ snapshots: bail@2.0.2: {} + balanced-match@4.0.4: {} + base-64@1.0.0: {} base64-js@1.5.1: {} @@ -3044,6 +3076,10 @@ snapshots: widest-line: 5.0.0 wrap-ansi: 9.0.2 + brace-expansion@5.0.6: + dependencies: + balanced-match: 4.0.4 + braces@3.0.3: dependencies: fill-range: 7.1.1 @@ -3110,6 +3146,8 @@ snapshots: clsx@2.1.1: {} + code-block-writer@13.0.3: {} + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -3849,6 +3887,10 @@ snapshots: braces: 3.0.3 picomatch: 2.3.2 + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.6 + mrmime@2.0.1: {} ms@2.1.3: {} @@ -4311,6 +4353,11 @@ snapshots: ts-interface-checker@0.1.13: {} + ts-morph@28.0.0: + dependencies: + '@ts-morph/common': 0.29.0 + code-block-writer: 13.0.3 + tsconfck@3.1.6(typescript@5.9.3): optionalDependencies: typescript: 5.9.3