diff --git a/packages/ipuaro/CHANGELOG.md b/packages/ipuaro/CHANGELOG.md index 4ac78ae..aad6689 100644 --- a/packages/ipuaro/CHANGELOG.md +++ b/packages/ipuaro/CHANGELOG.md @@ -5,6 +5,44 @@ 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.28.0] - 2025-12-05 - Circular Dependencies in Context + +### Added + +- **Circular Dependencies in Initial Context (v0.28.0)** + - New `## ⚠️ Circular Dependencies` section in initial context + - Shows cycle chains immediately without requiring tool calls + - Format: `- services/user → services/auth → services/user` + - Uses same path shortening as dependency graph (removes `src/`, extensions, `/index`) + +- **Configuration Option** + - `includeCircularDeps: boolean` in ContextConfigSchema (default: `true`) + - `includeCircularDeps` option in `BuildContextOptions` + - `circularDeps: string[][]` parameter to pass pre-computed cycles + - Users can disable to save tokens: `context.includeCircularDeps: false` + +- **New Helper Function in prompts.ts** + - `formatCircularDeps()` - formats circular dependency cycles for display + +### New Context Format + +``` +## ⚠️ Circular Dependencies + +- services/user → services/auth → services/user +- utils/a → utils/b → utils/c → utils/a +``` + +### Technical Details + +- Total tests: 1798 passed (was 1775, +23 new tests) + - 12 new tests for formatCircularDeps() + - 6 new tests for buildInitialContext with includeCircularDeps + - 5 new tests for includeCircularDeps config option +- Coverage: 97.48% lines, 91.13% branches, 98.63% functions +- 0 ESLint errors, 3 warnings (pre-existing complexity in ASTParser and prompts) +- Build successful + ## [0.27.0] - 2025-12-05 - Inline Dependency Graph ### Added diff --git a/packages/ipuaro/ROADMAP.md b/packages/ipuaro/ROADMAP.md index 6dbf625..c7a560a 100644 --- a/packages/ipuaro/ROADMAP.md +++ b/packages/ipuaro/ROADMAP.md @@ -1917,10 +1917,10 @@ Enhance initial context for LLM: add function signatures, interface field types, --- -## Version 0.28.0 - Circular Dependencies in Context 🔄 +## Version 0.28.0 - Circular Dependencies in Context 🔄 ✅ **Priority:** MEDIUM -**Status:** Planned +**Status:** Complete (v0.28.0 released) ### Description @@ -1936,9 +1936,15 @@ Enhance initial context for LLM: add function signatures, interface field types, ``` **Changes:** -- [ ] Add `formatCircularDeps()` to prompts.ts -- [ ] Get circular deps from IndexBuilder -- [ ] Store in Redis as separate key or in meta +- [x] Add `formatCircularDeps()` to prompts.ts +- [x] Add `includeCircularDeps: boolean` config option (default: true) +- [x] Add `circularDeps: string[][]` parameter to `BuildContextOptions` +- [x] Integrate into `buildInitialContext()` + +**Tests:** +- [x] Unit tests for formatCircularDeps() (12 tests) +- [x] Unit tests for buildInitialContext with includeCircularDeps (6 tests) +- [x] Unit tests for includeCircularDeps config option (5 tests) **Why:** LLM immediately sees architecture problems. @@ -2016,12 +2022,12 @@ interface FileMeta { - [x] Error handling complete ✅ (v0.16.0) - [ ] Performance optimized - [x] Documentation complete ✅ (v0.17.0) -- [x] Test coverage ≥91% branches, ≥95% lines/functions/statements ✅ (91.21% branches, 97.5% lines, 98.58% functions, 97.5% statements - 1687 tests) +- [x] Test coverage ≥91% branches, ≥95% lines/functions/statements ✅ (91.13% branches, 97.48% lines, 98.63% functions, 97.48% statements - 1798 tests) - [x] 0 ESLint errors ✅ - [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 --- @@ -2100,7 +2106,7 @@ sessions:list # List **Last Updated:** 2025-12-05 **Target Version:** 1.0.0 -**Current Version:** 0.27.0 -**Next Milestones:** v0.28.0 (Circular Deps), v0.29.0 (Impact Score), v0.30.0 (Transitive Deps) +**Current Version:** 0.28.0 +**Next Milestones:** v0.29.0 (Impact Score), v0.30.0 (Transitive Deps) -> **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 +> **Note:** Rich Initial Context complete ✅ (v0.24.0-v0.26.0). Graph Metrics in progress (v0.27.0 ✅, v0.28.0 ✅, v0.29.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 f21d5c3..db6a6e5 100644 --- a/packages/ipuaro/package.json +++ b/packages/ipuaro/package.json @@ -1,6 +1,6 @@ { "name": "@samiyev/ipuaro", - "version": "0.27.0", + "version": "0.28.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 d62b27a..48d3c7b 100644 --- a/packages/ipuaro/src/infrastructure/llm/prompts.ts +++ b/packages/ipuaro/src/infrastructure/llm/prompts.ts @@ -17,6 +17,8 @@ export interface ProjectStructure { export interface BuildContextOptions { includeSignatures?: boolean includeDepsGraph?: boolean + includeCircularDeps?: boolean + circularDeps?: string[][] } /** @@ -129,6 +131,7 @@ export function buildInitialContext( const sections: string[] = [] const includeSignatures = options?.includeSignatures ?? true const includeDepsGraph = options?.includeDepsGraph ?? true + const includeCircularDeps = options?.includeCircularDeps ?? true sections.push(formatProjectHeader(structure)) sections.push(formatDirectoryTree(structure)) @@ -141,6 +144,13 @@ export function buildInitialContext( } } + if (includeCircularDeps && options?.circularDeps && options.circularDeps.length > 0) { + const circularDepsSection = formatCircularDeps(options.circularDeps) + if (circularDepsSection) { + sections.push(circularDepsSection) + } + } + return sections.join("\n\n") } @@ -526,6 +536,38 @@ export function formatDependencyGraph(metas: Map): string | nu return lines.join("\n") } +/** + * Format circular dependencies for display in context. + * Shows warning section with cycle chains. + * + * Format: + * ## ⚠️ Circular Dependencies + * - services/user → services/auth → services/user + * - utils/a → utils/b → utils/c → utils/a + */ +export function formatCircularDeps(cycles: string[][]): string | null { + if (!cycles || cycles.length === 0) { + return null + } + + const lines: string[] = ["## ⚠️ Circular Dependencies", ""] + + for (const cycle of cycles) { + if (cycle.length === 0) { + continue + } + const formattedCycle = cycle.map(shortenPath).join(" → ") + lines.push(`- ${formattedCycle}`) + } + + // Return null if only header (no actual cycles) + 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 28a5053..365f0c9 100644 --- a/packages/ipuaro/src/shared/constants/config.ts +++ b/packages/ipuaro/src/shared/constants/config.ts @@ -116,6 +116,7 @@ export const ContextConfigSchema = z.object({ compressionMethod: z.enum(["llm-summary", "truncate"]).default("llm-summary"), includeSignatures: z.boolean().default(true), includeDepsGraph: z.boolean().default(true), + includeCircularDeps: 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 b406afc..943d92b 100644 --- a/packages/ipuaro/tests/unit/infrastructure/llm/prompts.test.ts +++ b/packages/ipuaro/tests/unit/infrastructure/llm/prompts.test.ts @@ -5,6 +5,7 @@ import { buildFileContext, truncateContext, formatDependencyGraph, + formatCircularDeps, type ProjectStructure, } from "../../../../src/infrastructure/llm/prompts.js" import type { FileAST } from "../../../../src/domain/value-objects/FileAST.js" @@ -2092,7 +2093,12 @@ describe("prompts", () => { [ "src/services/user.ts", { - complexity: { loc: 80, nesting: 3, cyclomaticComplexity: 10, score: 50 }, + 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, @@ -2117,15 +2123,7 @@ describe("prompts", () => { { 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", - ], + dependents: ["a.ts", "b.ts", "c.ts", "d.ts", "e.ts", "f.ts", "g.ts"], isHub: true, isEntryPoint: false, fileType: "source", @@ -2396,4 +2394,230 @@ describe("prompts", () => { }) }) }) + + describe("circular dependencies (0.28.0)", () => { + describe("formatCircularDeps", () => { + it("should return null for empty array", () => { + const result = formatCircularDeps([]) + + expect(result).toBeNull() + }) + + it("should return null for undefined", () => { + const result = formatCircularDeps(undefined as unknown as string[][]) + + expect(result).toBeNull() + }) + + it("should format a simple two-node cycle", () => { + const cycles = [["src/a.ts", "src/b.ts", "src/a.ts"]] + + const result = formatCircularDeps(cycles) + + expect(result).toContain("## ⚠️ Circular Dependencies") + expect(result).toContain("- a → b → a") + }) + + it("should format a three-node cycle", () => { + const cycles = [ + ["src/services/user.ts", "src/services/auth.ts", "src/services/user.ts"], + ] + + const result = formatCircularDeps(cycles) + + expect(result).toContain("## ⚠️ Circular Dependencies") + expect(result).toContain("- services/user → services/auth → services/user") + }) + + it("should format multiple cycles", () => { + const cycles = [ + ["src/a.ts", "src/b.ts", "src/a.ts"], + ["src/utils/x.ts", "src/utils/y.ts", "src/utils/z.ts", "src/utils/x.ts"], + ] + + const result = formatCircularDeps(cycles) + + expect(result).toContain("## ⚠️ Circular Dependencies") + expect(result).toContain("- a → b → a") + expect(result).toContain("- utils/x → utils/y → utils/z → utils/x") + }) + + it("should shorten paths (remove src/ prefix)", () => { + const cycles = [ + ["src/services/user.ts", "src/types/user.ts", "src/services/user.ts"], + ] + + const result = formatCircularDeps(cycles) + + expect(result).not.toContain("src/") + expect(result).toContain("services/user → types/user → services/user") + }) + + it("should remove file extensions", () => { + const cycles = [["lib/a.ts", "lib/b.tsx", "lib/c.js", "lib/a.ts"]] + + const result = formatCircularDeps(cycles) + + expect(result).not.toContain(".ts") + expect(result).not.toContain(".tsx") + expect(result).not.toContain(".js") + expect(result).toContain("lib/a → lib/b → lib/c → lib/a") + }) + + it("should remove /index suffix", () => { + const cycles = [ + ["src/components/index.ts", "src/utils/index.ts", "src/components/index.ts"], + ] + + const result = formatCircularDeps(cycles) + + expect(result).not.toContain("/index") + expect(result).toContain("components → utils → components") + }) + + it("should skip empty cycles", () => { + const cycles = [[], ["src/a.ts", "src/b.ts", "src/a.ts"], []] + + const result = formatCircularDeps(cycles) + + expect(result).toContain("- a → b → a") + const lines = result!.split("\n").filter((l) => l.startsWith("- ")) + expect(lines).toHaveLength(1) + }) + + it("should return null if all cycles are empty", () => { + const cycles = [[], [], []] + + const result = formatCircularDeps(cycles) + + expect(result).toBeNull() + }) + + it("should format self-referencing cycle", () => { + const cycles = [["src/self.ts", "src/self.ts"]] + + const result = formatCircularDeps(cycles) + + expect(result).toContain("- self → self") + }) + + it("should handle long cycles", () => { + const cycles = [ + [ + "src/a.ts", + "src/b.ts", + "src/c.ts", + "src/d.ts", + "src/e.ts", + "src/f.ts", + "src/a.ts", + ], + ] + + const result = formatCircularDeps(cycles) + + expect(result).toContain("- a → b → c → d → e → f → a") + }) + }) + + describe("buildInitialContext with includeCircularDeps", () => { + 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 circular deps when circularDeps provided", () => { + const circularDeps = [["src/a.ts", "src/b.ts", "src/a.ts"]] + + const context = buildInitialContext(structure, asts, undefined, { + circularDeps, + }) + + expect(context).toContain("## ⚠️ Circular Dependencies") + expect(context).toContain("- a → b → a") + }) + + it("should not include circular deps when includeCircularDeps is false", () => { + const circularDeps = [["src/a.ts", "src/b.ts", "src/a.ts"]] + + const context = buildInitialContext(structure, asts, undefined, { + circularDeps, + includeCircularDeps: false, + }) + + expect(context).not.toContain("## ⚠️ Circular Dependencies") + }) + + it("should not include circular deps when circularDeps is empty", () => { + const context = buildInitialContext(structure, asts, undefined, { + circularDeps: [], + includeCircularDeps: true, + }) + + expect(context).not.toContain("## ⚠️ Circular Dependencies") + }) + + it("should not include circular deps when circularDeps is undefined", () => { + const context = buildInitialContext(structure, asts, undefined, { + includeCircularDeps: true, + }) + + expect(context).not.toContain("## ⚠️ Circular Dependencies") + }) + + it("should include circular deps by default when circularDeps provided", () => { + const circularDeps = [["src/x.ts", "src/y.ts", "src/x.ts"]] + + const context = buildInitialContext(structure, asts, undefined, { + circularDeps, + }) + + expect(context).toContain("## ⚠️ Circular Dependencies") + expect(context).toContain("- x → y → x") + }) + + it("should include both dependency graph and circular deps", () => { + 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 circularDeps = [["src/a.ts", "src/b.ts", "src/a.ts"]] + + const context = buildInitialContext(structure, asts, metas, { + circularDeps, + includeDepsGraph: true, + includeCircularDeps: true, + }) + + expect(context).toContain("## Dependency Graph") + expect(context).toContain("## ⚠️ Circular Dependencies") + }) + }) + }) }) diff --git a/packages/ipuaro/tests/unit/shared/context-config.test.ts b/packages/ipuaro/tests/unit/shared/context-config.test.ts index d816e2f..b86d9ae 100644 --- a/packages/ipuaro/tests/unit/shared/context-config.test.ts +++ b/packages/ipuaro/tests/unit/shared/context-config.test.ts @@ -17,6 +17,7 @@ describe("ContextConfigSchema", () => { compressionMethod: "llm-summary", includeSignatures: true, includeDepsGraph: true, + includeCircularDeps: true, }) }) @@ -30,6 +31,7 @@ describe("ContextConfigSchema", () => { compressionMethod: "llm-summary", includeSignatures: true, includeDepsGraph: true, + includeCircularDeps: true, }) }) }) @@ -168,6 +170,7 @@ describe("ContextConfigSchema", () => { compressionMethod: "llm-summary", includeSignatures: true, includeDepsGraph: true, + includeCircularDeps: true, }) }) @@ -183,6 +186,7 @@ describe("ContextConfigSchema", () => { compressionMethod: "llm-summary", includeSignatures: true, includeDepsGraph: true, + includeCircularDeps: true, }) }) @@ -199,6 +203,7 @@ describe("ContextConfigSchema", () => { compressionMethod: "truncate", includeSignatures: true, includeDepsGraph: true, + includeCircularDeps: true, }) }) }) @@ -212,6 +217,7 @@ describe("ContextConfigSchema", () => { compressionMethod: "truncate" as const, includeSignatures: false, includeDepsGraph: false, + includeCircularDeps: false, } const result = ContextConfigSchema.parse(config) @@ -226,6 +232,7 @@ describe("ContextConfigSchema", () => { compressionMethod: "llm-summary" as const, includeSignatures: true, includeDepsGraph: true, + includeCircularDeps: true, } const result = ContextConfigSchema.parse(config) @@ -282,4 +289,29 @@ describe("ContextConfigSchema", () => { expect(() => ContextConfigSchema.parse({ includeDepsGraph: 1 })).toThrow() }) }) + + describe("includeCircularDeps", () => { + it("should accept true", () => { + const result = ContextConfigSchema.parse({ includeCircularDeps: true }) + expect(result.includeCircularDeps).toBe(true) + }) + + it("should accept false", () => { + const result = ContextConfigSchema.parse({ includeCircularDeps: false }) + expect(result.includeCircularDeps).toBe(false) + }) + + it("should default to true", () => { + const result = ContextConfigSchema.parse({}) + expect(result.includeCircularDeps).toBe(true) + }) + + it("should reject non-boolean", () => { + expect(() => ContextConfigSchema.parse({ includeCircularDeps: "true" })).toThrow() + }) + + it("should reject number", () => { + expect(() => ContextConfigSchema.parse({ includeCircularDeps: 1 })).toThrow() + }) + }) })