From d63d85d85051b0314918e407cf995cff754e32f5 Mon Sep 17 00:00:00 2001 From: imfozilbek Date: Fri, 5 Dec 2025 14:38:45 +0500 Subject: [PATCH] feat(ipuaro): add inline dependency graph to initial context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add formatDependencyGraph() to show file relationships in LLM context - Add includeDepsGraph option to ContextConfigSchema (default: true) - Format: "services/user: → types/user ← controllers/user" - Hub files shown first, sorted by total connections - 21 new tests for dependency graph functionality --- packages/ipuaro/CHANGELOG.md | 52 +++ packages/ipuaro/ROADMAP.md | 24 +- packages/ipuaro/package.json | 2 +- .../ipuaro/src/infrastructure/llm/prompts.ts | 112 +++++ .../ipuaro/src/shared/constants/config.ts | 1 + .../unit/infrastructure/llm/prompts.test.ts | 383 ++++++++++++++++++ .../tests/unit/shared/context-config.test.ts | 32 ++ 7 files changed, 595 insertions(+), 11 deletions(-) diff --git a/packages/ipuaro/CHANGELOG.md b/packages/ipuaro/CHANGELOG.md index deca2cb..4ac78ae 100644 --- a/packages/ipuaro/CHANGELOG.md +++ b/packages/ipuaro/CHANGELOG.md @@ -5,6 +5,58 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.27.0] - 2025-12-05 - Inline Dependency Graph + +### Added + +- **Dependency Graph in Initial Context (v0.27.0)** + - New `## Dependency Graph` section in initial context + - Shows file relationships without requiring tool calls + - Format: `services/user: → types/user, utils/validation ← controllers/user` + - `→` indicates files this file imports (dependencies) + - `←` indicates files that import this file (dependents) + - Hub files (>5 dependents) shown first + - Files sorted by total connections (descending) + +- **Configuration Option** + - `includeDepsGraph: boolean` in ContextConfigSchema (default: `true`) + - `includeDepsGraph` option in `BuildContextOptions` + - Users can disable to save tokens: `context.includeDepsGraph: false` + +- **New Helper Functions in prompts.ts** + - `formatDependencyGraph()` - formats entire dependency graph from metas + - `formatDepsEntry()` - formats single file's dependencies/dependents + - `shortenPath()` - shortens paths (removes `src/`, extensions, `/index`) + +### New Context Format + +``` +## Dependency Graph + +utils/validation: ← services/user, services/auth, controllers/api +services/user: → types/user, utils/validation ← controllers/user, api/routes +services/auth: → services/user, utils/jwt ← controllers/auth +types/user: ← services/user, services/auth +``` + +### Technical Details + +- Total tests: 1775 passed (was 1754, +21 new tests) + - 16 new tests for formatDependencyGraph() + - 5 new tests for includeDepsGraph config option +- Coverage: 97.48% lines, 91.07% branches, 98.62% functions +- 0 ESLint errors, 2 warnings (pre-existing complexity in ASTParser and prompts) +- Build successful + +### Notes + +This completes v0.27.0 of the Graph Metrics milestone: +- ✅ 0.27.0 - Inline Dependency Graph + +Next milestone: v0.28.0 - Circular Dependencies in Context + +--- + ## [0.26.0] - 2025-12-05 - Rich Initial Context: Decorator Extraction ### Added diff --git a/packages/ipuaro/ROADMAP.md b/packages/ipuaro/ROADMAP.md index 759e3d1..6dbf625 100644 --- a/packages/ipuaro/ROADMAP.md +++ b/packages/ipuaro/ROADMAP.md @@ -1884,10 +1884,10 @@ Enhance initial context for LLM: add function signatures, interface field types, --- -## Version 0.27.0 - Inline Dependency Graph 📊 +## Version 0.27.0 - Inline Dependency Graph 📊 ✅ **Priority:** MEDIUM -**Status:** Planned +**Status:** Complete (v0.27.0 released) ### Description @@ -1904,10 +1904,14 @@ Enhance initial context for LLM: add function signatures, interface field types, ``` **Changes:** -- [ ] Add `formatDependencyGraph()` to prompts.ts -- [ ] Use data from `FileMeta.dependencies` and `FileMeta.dependents` -- [ ] Group by hub files (many connections) -- [ ] Add `includeDepsGraph: boolean` option to config +- [x] Add `formatDependencyGraph()` to prompts.ts +- [x] Use data from `FileMeta.dependencies` and `FileMeta.dependents` +- [x] Group by hub files (many connections) +- [x] Add `includeDepsGraph: boolean` option to config + +**Tests:** +- [x] Unit tests for formatDependencyGraph() (16 tests) +- [x] Unit tests for includeDepsGraph config option (5 tests) **Why:** LLM sees architecture without tool call. @@ -2017,7 +2021,7 @@ interface FileMeta { - [x] Examples working ✅ (v0.18.0) - [x] CHANGELOG.md up to date ✅ - [x] Rich initial context (v0.24.0-v0.26.0) — function signatures, interface fields, enum values, decorators ✅ -- [ ] Graph metrics in context (v0.27.0-v0.30.0) — dependency graph, circular deps, impact score, transitive deps +- [ ] Graph metrics in context (v0.27.0-v0.30.0) — dependency graph ✅, circular deps, impact score, transitive deps --- @@ -2096,7 +2100,7 @@ sessions:list # List **Last Updated:** 2025-12-05 **Target Version:** 1.0.0 -**Current Version:** 0.26.0 -**Next Milestones:** v0.27.0 (Dependency Graph), v0.28.0 (Circular Deps), v0.29.0 (Impact Score), v0.30.0 (Transitive Deps) +**Current Version:** 0.27.0 +**Next Milestones:** v0.28.0 (Circular Deps), v0.29.0 (Impact Score), v0.30.0 (Transitive Deps) -> **Note:** Rich Initial Context complete ✅ (v0.24.0-v0.26.0). Graph Metrics (v0.27.0-v0.30.0) required for 1.0.0 release. \ No newline at end of file +> **Note:** Rich Initial Context complete ✅ (v0.24.0-v0.26.0). Graph Metrics in progress (v0.27.0 ✅, v0.28.0-v0.30.0 pending) for 1.0.0 release. \ No newline at end of file diff --git a/packages/ipuaro/package.json b/packages/ipuaro/package.json index 693b269..f21d5c3 100644 --- a/packages/ipuaro/package.json +++ b/packages/ipuaro/package.json @@ -1,6 +1,6 @@ { "name": "@samiyev/ipuaro", - "version": "0.26.0", + "version": "0.27.0", "description": "Local AI agent for codebase operations with infinite context feeling", "author": "Fozilbek Samiyev ", "license": "MIT", diff --git a/packages/ipuaro/src/infrastructure/llm/prompts.ts b/packages/ipuaro/src/infrastructure/llm/prompts.ts index ea462cb..d62b27a 100644 --- a/packages/ipuaro/src/infrastructure/llm/prompts.ts +++ b/packages/ipuaro/src/infrastructure/llm/prompts.ts @@ -16,6 +16,7 @@ export interface ProjectStructure { */ export interface BuildContextOptions { includeSignatures?: boolean + includeDepsGraph?: boolean } /** @@ -127,11 +128,19 @@ export function buildInitialContext( ): string { const sections: string[] = [] const includeSignatures = options?.includeSignatures ?? true + const includeDepsGraph = options?.includeDepsGraph ?? true sections.push(formatProjectHeader(structure)) sections.push(formatDirectoryTree(structure)) sections.push(formatFileOverview(asts, metas, includeSignatures)) + if (includeDepsGraph && metas && metas.size > 0) { + const depsGraph = formatDependencyGraph(metas) + if (depsGraph) { + sections.push(depsGraph) + } + } + return sections.join("\n\n") } @@ -414,6 +423,109 @@ function formatFileFlags(meta?: FileMeta): string { return flags.length > 0 ? ` (${flags.join(", ")})` : "" } +/** + * Shorten a file path for display in dependency graph. + * Removes common prefixes like "src/" and file extensions. + */ +function shortenPath(path: string): string { + let short = path + if (short.startsWith("src/")) { + short = short.slice(4) + } + // Remove common extensions + short = short.replace(/\.(ts|tsx|js|jsx)$/, "") + // Remove /index suffix + short = short.replace(/\/index$/, "") + return short +} + +/** + * Format a single dependency graph entry. + * Format: "path: → dep1, dep2 ← dependent1, dependent2" + */ +function formatDepsEntry(path: string, dependencies: string[], dependents: string[]): string { + const parts: string[] = [] + const shortPath = shortenPath(path) + + if (dependencies.length > 0) { + const deps = dependencies.map(shortenPath).join(", ") + parts.push(`→ ${deps}`) + } + + if (dependents.length > 0) { + const deps = dependents.map(shortenPath).join(", ") + parts.push(`← ${deps}`) + } + + if (parts.length === 0) { + return "" + } + + return `${shortPath}: ${parts.join(" ")}` +} + +/** + * Format dependency graph for all files. + * Shows hub files first, then files with dependencies/dependents. + * + * Format: + * ## Dependency Graph + * services/user: → types/user, utils/validation ← controllers/user + * services/auth: → services/user, utils/jwt ← controllers/auth + */ +export function formatDependencyGraph(metas: Map): string | null { + if (metas.size === 0) { + return null + } + + const entries: { path: string; deps: string[]; dependents: string[]; isHub: boolean }[] = [] + + for (const [path, meta] of metas) { + // Only include files that have connections + if (meta.dependencies.length > 0 || meta.dependents.length > 0) { + entries.push({ + path, + deps: meta.dependencies, + dependents: meta.dependents, + isHub: meta.isHub, + }) + } + } + + if (entries.length === 0) { + return null + } + + // Sort: hubs first, then by total connections (desc), then by path + entries.sort((a, b) => { + if (a.isHub !== b.isHub) { + return a.isHub ? -1 : 1 + } + const aTotal = a.deps.length + a.dependents.length + const bTotal = b.deps.length + b.dependents.length + if (aTotal !== bTotal) { + return bTotal - aTotal + } + return a.path.localeCompare(b.path) + }) + + const lines: string[] = ["## Dependency Graph", ""] + + for (const entry of entries) { + const line = formatDepsEntry(entry.path, entry.deps, entry.dependents) + if (line) { + lines.push(line) + } + } + + // Return null if only header (no actual entries) + if (lines.length <= 2) { + return null + } + + return lines.join("\n") +} + /** * Format line range for display. */ diff --git a/packages/ipuaro/src/shared/constants/config.ts b/packages/ipuaro/src/shared/constants/config.ts index a08bba1..28a5053 100644 --- a/packages/ipuaro/src/shared/constants/config.ts +++ b/packages/ipuaro/src/shared/constants/config.ts @@ -115,6 +115,7 @@ export const ContextConfigSchema = z.object({ autoCompressAt: z.number().min(0).max(1).default(0.8), compressionMethod: z.enum(["llm-summary", "truncate"]).default("llm-summary"), includeSignatures: z.boolean().default(true), + includeDepsGraph: z.boolean().default(true), }) /** diff --git a/packages/ipuaro/tests/unit/infrastructure/llm/prompts.test.ts b/packages/ipuaro/tests/unit/infrastructure/llm/prompts.test.ts index 277e4f9..b406afc 100644 --- a/packages/ipuaro/tests/unit/infrastructure/llm/prompts.test.ts +++ b/packages/ipuaro/tests/unit/infrastructure/llm/prompts.test.ts @@ -4,6 +4,7 @@ import { buildInitialContext, buildFileContext, truncateContext, + formatDependencyGraph, type ProjectStructure, } from "../../../../src/infrastructure/llm/prompts.js" import type { FileAST } from "../../../../src/domain/value-objects/FileAST.js" @@ -2013,4 +2014,386 @@ describe("prompts", () => { expect(context).not.toContain("@") }) }) + + describe("dependency graph (0.27.0)", () => { + describe("formatDependencyGraph", () => { + it("should return null for empty metas", () => { + const metas = new Map() + + const result = formatDependencyGraph(metas) + + expect(result).toBeNull() + }) + + it("should return null when no files have dependencies or dependents", () => { + const metas = new Map([ + [ + "src/isolated.ts", + { + complexity: { loc: 10, nesting: 1, cyclomaticComplexity: 1, score: 10 }, + dependencies: [], + dependents: [], + isHub: false, + isEntryPoint: true, + fileType: "source", + }, + ], + ]) + + const result = formatDependencyGraph(metas) + + expect(result).toBeNull() + }) + + it("should format file with only dependencies", () => { + const metas = new Map([ + [ + "src/services/user.ts", + { + complexity: { loc: 50, nesting: 2, cyclomaticComplexity: 5, score: 30 }, + dependencies: ["src/types/user.ts", "src/utils/validation.ts"], + dependents: [], + isHub: false, + isEntryPoint: false, + fileType: "source", + }, + ], + ]) + + const result = formatDependencyGraph(metas) + + expect(result).toContain("## Dependency Graph") + expect(result).toContain("services/user: → types/user, utils/validation") + }) + + it("should format file with only dependents", () => { + const metas = new Map([ + [ + "src/types/user.ts", + { + complexity: { loc: 20, nesting: 1, cyclomaticComplexity: 1, score: 10 }, + dependencies: [], + dependents: ["src/services/user.ts", "src/controllers/user.ts"], + isHub: false, + isEntryPoint: false, + fileType: "types", + }, + ], + ]) + + const result = formatDependencyGraph(metas) + + expect(result).toContain("## Dependency Graph") + expect(result).toContain("types/user: ← services/user, controllers/user") + }) + + it("should format file with both dependencies and dependents", () => { + const metas = new Map([ + [ + "src/services/user.ts", + { + complexity: { loc: 80, nesting: 3, cyclomaticComplexity: 10, score: 50 }, + dependencies: ["src/types/user.ts", "src/utils/validation.ts"], + dependents: ["src/controllers/user.ts", "src/api/routes.ts"], + isHub: false, + isEntryPoint: false, + fileType: "source", + }, + ], + ]) + + const result = formatDependencyGraph(metas) + + expect(result).toContain("## Dependency Graph") + expect(result).toContain( + "services/user: → types/user, utils/validation ← controllers/user, api/routes", + ) + }) + + it("should sort hub files first", () => { + const metas = new Map([ + [ + "src/utils/helpers.ts", + { + complexity: { loc: 30, nesting: 1, cyclomaticComplexity: 3, score: 20 }, + dependencies: [], + dependents: [ + "a.ts", + "b.ts", + "c.ts", + "d.ts", + "e.ts", + "f.ts", + "g.ts", + ], + isHub: true, + isEntryPoint: false, + fileType: "source", + }, + ], + [ + "src/services/user.ts", + { + complexity: { loc: 50, nesting: 2, cyclomaticComplexity: 5, score: 30 }, + dependencies: ["src/types/user.ts"], + dependents: ["src/controllers/user.ts"], + isHub: false, + isEntryPoint: false, + fileType: "source", + }, + ], + ]) + + const result = formatDependencyGraph(metas) + + expect(result).not.toBeNull() + const lines = result!.split("\n") + const hubIndex = lines.findIndex((l) => l.includes("utils/helpers")) + const serviceIndex = lines.findIndex((l) => l.includes("services/user")) + expect(hubIndex).toBeLessThan(serviceIndex) + }) + + it("should sort by total connections (descending) for non-hubs", () => { + const metas = new Map([ + [ + "src/a.ts", + { + complexity: { loc: 10, nesting: 1, cyclomaticComplexity: 1, score: 10 }, + dependencies: ["x.ts"], + dependents: [], + isHub: false, + isEntryPoint: false, + fileType: "source", + }, + ], + [ + "src/b.ts", + { + complexity: { loc: 10, nesting: 1, cyclomaticComplexity: 1, score: 10 }, + dependencies: ["x.ts", "y.ts"], + dependents: ["z.ts"], + isHub: false, + isEntryPoint: false, + fileType: "source", + }, + ], + ]) + + const result = formatDependencyGraph(metas) + + expect(result).not.toBeNull() + const lines = result!.split("\n") + const aIndex = lines.findIndex((l) => l.startsWith("a:")) + const bIndex = lines.findIndex((l) => l.startsWith("b:")) + expect(bIndex).toBeLessThan(aIndex) + }) + + it("should shorten src/ prefix", () => { + const metas = new Map([ + [ + "src/index.ts", + { + complexity: { loc: 10, nesting: 1, cyclomaticComplexity: 1, score: 10 }, + dependencies: ["src/utils/helpers.ts"], + dependents: [], + isHub: false, + isEntryPoint: true, + fileType: "source", + }, + ], + ]) + + const result = formatDependencyGraph(metas) + + expect(result).toContain("index: → utils/helpers") + expect(result).not.toContain("src/") + }) + + it("should remove file extensions", () => { + const metas = new Map([ + [ + "lib/utils.ts", + { + complexity: { loc: 10, nesting: 1, cyclomaticComplexity: 1, score: 10 }, + dependencies: ["lib/helpers.tsx", "lib/types.js"], + dependents: [], + isHub: false, + isEntryPoint: false, + fileType: "source", + }, + ], + ]) + + const result = formatDependencyGraph(metas) + + expect(result).toContain("lib/utils: → lib/helpers, lib/types") + expect(result).not.toContain(".ts") + expect(result).not.toContain(".tsx") + expect(result).not.toContain(".js") + }) + + it("should remove /index suffix", () => { + const metas = new Map([ + [ + "src/components/index.ts", + { + complexity: { loc: 10, nesting: 1, cyclomaticComplexity: 1, score: 10 }, + dependencies: ["src/utils/index.ts"], + dependents: [], + isHub: false, + isEntryPoint: true, + fileType: "source", + }, + ], + ]) + + const result = formatDependencyGraph(metas) + + expect(result).toContain("components: → utils") + expect(result).not.toContain("/index") + }) + + it("should handle multiple files in graph", () => { + const metas = new Map([ + [ + "src/services/user.ts", + { + complexity: { loc: 50, nesting: 2, cyclomaticComplexity: 5, score: 30 }, + dependencies: ["src/types/user.ts"], + dependents: ["src/controllers/user.ts"], + isHub: false, + isEntryPoint: false, + fileType: "source", + }, + ], + [ + "src/services/auth.ts", + { + complexity: { loc: 40, nesting: 2, cyclomaticComplexity: 4, score: 25 }, + dependencies: ["src/services/user.ts", "src/utils/jwt.ts"], + dependents: ["src/controllers/auth.ts"], + isHub: false, + isEntryPoint: false, + fileType: "source", + }, + ], + ]) + + const result = formatDependencyGraph(metas) + + expect(result).toContain("## Dependency Graph") + expect(result).toContain("services/user: → types/user ← controllers/user") + expect(result).toContain( + "services/auth: → services/user, utils/jwt ← controllers/auth", + ) + }) + }) + + describe("buildInitialContext with includeDepsGraph", () => { + const structure: ProjectStructure = { + name: "test-project", + rootPath: "/test", + files: ["src/index.ts"], + directories: ["src"], + } + + const asts = new Map([ + [ + "src/index.ts", + { + imports: [], + exports: [], + functions: [], + classes: [], + interfaces: [], + typeAliases: [], + parseError: false, + }, + ], + ]) + + it("should include dependency graph by default", () => { + const metas = new Map([ + [ + "src/index.ts", + { + complexity: { loc: 10, nesting: 1, cyclomaticComplexity: 1, score: 10 }, + dependencies: ["src/utils.ts"], + dependents: [], + isHub: false, + isEntryPoint: true, + fileType: "source", + }, + ], + ]) + + const context = buildInitialContext(structure, asts, metas) + + expect(context).toContain("## Dependency Graph") + expect(context).toContain("index: → utils") + }) + + it("should exclude dependency graph when includeDepsGraph is false", () => { + const metas = new Map([ + [ + "src/index.ts", + { + complexity: { loc: 10, nesting: 1, cyclomaticComplexity: 1, score: 10 }, + dependencies: ["src/utils.ts"], + dependents: [], + isHub: false, + isEntryPoint: true, + fileType: "source", + }, + ], + ]) + + const context = buildInitialContext(structure, asts, metas, { + includeDepsGraph: false, + }) + + expect(context).not.toContain("## Dependency Graph") + }) + + it("should not include dependency graph when metas is undefined", () => { + const context = buildInitialContext(structure, asts, undefined, { + includeDepsGraph: true, + }) + + expect(context).not.toContain("## Dependency Graph") + }) + + it("should not include dependency graph when metas is empty", () => { + const emptyMetas = new Map() + + const context = buildInitialContext(structure, asts, emptyMetas, { + includeDepsGraph: true, + }) + + expect(context).not.toContain("## Dependency Graph") + }) + + it("should not include dependency graph when no files have connections", () => { + const metas = new Map([ + [ + "src/index.ts", + { + complexity: { loc: 10, nesting: 1, cyclomaticComplexity: 1, score: 10 }, + dependencies: [], + dependents: [], + isHub: false, + isEntryPoint: true, + fileType: "source", + }, + ], + ]) + + const context = buildInitialContext(structure, asts, metas, { + includeDepsGraph: true, + }) + + expect(context).not.toContain("## Dependency Graph") + }) + }) + }) }) diff --git a/packages/ipuaro/tests/unit/shared/context-config.test.ts b/packages/ipuaro/tests/unit/shared/context-config.test.ts index 0b023ed..d816e2f 100644 --- a/packages/ipuaro/tests/unit/shared/context-config.test.ts +++ b/packages/ipuaro/tests/unit/shared/context-config.test.ts @@ -16,6 +16,7 @@ describe("ContextConfigSchema", () => { autoCompressAt: 0.8, compressionMethod: "llm-summary", includeSignatures: true, + includeDepsGraph: true, }) }) @@ -28,6 +29,7 @@ describe("ContextConfigSchema", () => { autoCompressAt: 0.8, compressionMethod: "llm-summary", includeSignatures: true, + includeDepsGraph: true, }) }) }) @@ -165,6 +167,7 @@ describe("ContextConfigSchema", () => { autoCompressAt: 0.8, compressionMethod: "llm-summary", includeSignatures: true, + includeDepsGraph: true, }) }) @@ -179,6 +182,7 @@ describe("ContextConfigSchema", () => { autoCompressAt: 0.9, compressionMethod: "llm-summary", includeSignatures: true, + includeDepsGraph: true, }) }) @@ -194,6 +198,7 @@ describe("ContextConfigSchema", () => { autoCompressAt: 0.8, compressionMethod: "truncate", includeSignatures: true, + includeDepsGraph: true, }) }) }) @@ -206,6 +211,7 @@ describe("ContextConfigSchema", () => { autoCompressAt: 0.85, compressionMethod: "truncate" as const, includeSignatures: false, + includeDepsGraph: false, } const result = ContextConfigSchema.parse(config) @@ -219,6 +225,7 @@ describe("ContextConfigSchema", () => { autoCompressAt: 0.8, compressionMethod: "llm-summary" as const, includeSignatures: true, + includeDepsGraph: true, } const result = ContextConfigSchema.parse(config) @@ -250,4 +257,29 @@ describe("ContextConfigSchema", () => { expect(() => ContextConfigSchema.parse({ includeSignatures: 1 })).toThrow() }) }) + + describe("includeDepsGraph", () => { + it("should accept true", () => { + const result = ContextConfigSchema.parse({ includeDepsGraph: true }) + expect(result.includeDepsGraph).toBe(true) + }) + + it("should accept false", () => { + const result = ContextConfigSchema.parse({ includeDepsGraph: false }) + expect(result.includeDepsGraph).toBe(false) + }) + + it("should default to true", () => { + const result = ContextConfigSchema.parse({}) + expect(result.includeDepsGraph).toBe(true) + }) + + it("should reject non-boolean", () => { + expect(() => ContextConfigSchema.parse({ includeDepsGraph: "true" })).toThrow() + }) + + it("should reject number", () => { + expect(() => ContextConfigSchema.parse({ includeDepsGraph: 1 })).toThrow() + }) + }) })