Compare commits

..

5 Commits

Author SHA1 Message Date
imfozilbek
d63d85d850 feat(ipuaro): add inline dependency graph to initial context
- 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
2025-12-05 14:38:45 +05:00
imfozilbek
41cfc21f20 docs(ipuaro): align roadmap versions with package versions 2025-12-05 14:20:14 +05:00
imfozilbek
eeaa223436 chore(ipuaro): release v0.26.0 2025-12-05 13:51:13 +05:00
imfozilbek
36768c06d1 feat(ipuaro): add decorator extraction to initial context
Extract decorators from classes and methods for NestJS/Angular support.
Decorators are now shown in initial context:
- @Controller('users') class UserController
- @Get(':id') async getUser(id: string): Promise<User>

Changes:
- Add decorators field to FunctionInfo, MethodInfo, ClassInfo
- Update ASTParser to extract decorators from tree-sitter nodes
- Update formatFileSummary to display decorators prefix
- Add 18 unit tests for decorator extraction and formatting
2025-12-05 13:38:46 +05:00
imfozilbek
5a22cd5c9b feat(ipuaro): add enum value definitions to initial context
Extract enum declarations with member names and values from TypeScript
AST and display them in the initial LLM context. This allows the LLM
to know valid enum values without making tool calls.

Features:
- Numeric values (Active=1)
- String values (Admin="admin")
- Implicit values (Up, Down)
- Negative numbers (Cold=-10)
- const enum modifier
- export enum modifier
- Long enum truncation (>100 chars)

Adds EnumInfo and EnumMemberInfo interfaces, extractEnum() method in
ASTParser, formatEnumSignature() in prompts.ts, and 17 new unit tests.
2025-12-05 13:14:51 +05:00
11 changed files with 1740 additions and 46 deletions

View File

@@ -5,6 +5,118 @@ 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
- **Decorator Extraction (0.24.4)**
- Functions now show their decorators in initial context
- Classes now show their decorators in initial context
- Methods show decorators per-method
- New format: `@Controller('users') class UserController`
- Function format: `@Get(':id') async getUser(id: string): Promise<User>`
- Supports NestJS decorators: `@Controller`, `@Get`, `@Post`, `@Injectable`, `@UseGuards`, etc.
- Supports Angular decorators: `@Component`, `@Injectable`, `@Input`, `@Output`, etc.
- **FileAST.ts Enhancements**
- `decorators?: string[]` field on `FunctionInfo`
- `decorators?: string[]` field on `MethodInfo`
- `decorators?: string[]` field on `ClassInfo`
- **ASTParser.ts Enhancements**
- `formatDecorator()` - formats decorator node to string (e.g., `@Get(':id')`)
- `extractNodeDecorators()` - extracts decorators that are direct children of a node
- `extractDecoratorsFromSiblings()` - extracts decorators before the declaration in export statements
- Decorators are extracted for classes, methods, and exported functions
- **prompts.ts Enhancements**
- `formatDecoratorsPrefix()` - formats decorators as a prefix string for display
- Used in `formatFunctionSignature()` for function decorators
- Used in `formatFileSummary()` for class decorators
### New Context Format
```
### src/controllers/user.controller.ts
- @Controller('users') class UserController extends BaseController
- @Get(':id') @Auth() async getUser(id: string): Promise<User>
- @Post() @ValidateBody() async createUser(data: UserDTO): Promise<User>
```
### Technical Details
- Total tests: 1754 passed (was 1720, +34 new tests)
- 14 new tests for ASTParser decorator extraction
- 6 new tests for prompts decorator formatting
- +14 other tests from internal improvements
- Coverage: 97.49% lines, 91.14% branches, 98.61% functions
- 0 ESLint errors, 2 warnings (pre-existing complexity in ASTParser and prompts)
- Build successful
### Notes
This completes the v0.24.0 Rich Initial Context milestone:
- ✅ 0.24.1 - Function Signatures with Types
- ✅ 0.24.2 - Interface/Type Field Definitions
- ✅ 0.24.3 - Enum Value Definitions
- ✅ 0.24.4 - Decorator Extraction
Next milestone: v0.25.0 - Graph Metrics in Context
---
## [0.25.0] - 2025-12-04 - Rich Initial Context: Interface Fields & Type Definitions
### Added

View File

@@ -1779,10 +1779,10 @@ export interface ScanResult {
---
## Version 0.24.0 - Rich Initial Context 📋
## Version 0.24.0 - Rich Initial Context 📋
**Priority:** HIGH
**Status:** In Progress (2/4 complete)
**Status:** Complete (v0.24.0 released)
Enhance initial context for LLM: add function signatures, interface field types, and enum values. This allows LLM to answer questions about types and parameters without tool calls.
@@ -1836,7 +1836,7 @@ Enhance initial context for LLM: add function signatures, interface field types,
**Why:** LLM knows data structure, won't invent fields.
### 0.24.3 - Enum Value Definitions
### 0.24.3 - Enum Value Definitions ⭐ ✅
**Problem:** LLM only sees `type: Status`
**Solution:** Show values: `Status { Active=1, Inactive=0, Pending=2 }`
@@ -1852,13 +1852,13 @@ Enhance initial context for LLM: add function signatures, interface field types,
```
**Changes:**
- [ ] Add `EnumInfo` to FileAST with members and values
- [ ] Update `ASTParser.ts` to extract enum members
- [ ] Update `formatFileSummary()` to output enum values
- [x] Add `EnumInfo` to FileAST with members and values
- [x] Update `ASTParser.ts` to extract enum members
- [x] Update `formatFileSummary()` to output enum values
**Why:** LLM knows valid enum values.
### 0.24.4 - Decorator Extraction
### 0.24.4 - Decorator Extraction ⭐ ✅
**Problem:** LLM doesn't see decorators (important for NestJS, Angular)
**Solution:** Show decorators in context
@@ -1872,27 +1872,24 @@ Enhance initial context for LLM: add function signatures, interface field types,
```
**Changes:**
- [ ] Add `decorators: string[]` to FunctionInfo and ClassInfo
- [ ] Update `ASTParser.ts` to extract decorators
- [ ] Update context to display decorators
- [x] Add `decorators: string[]` to FunctionInfo, MethodInfo, and ClassInfo
- [x] Update `ASTParser.ts` to extract decorators via `extractNodeDecorators()` and `extractDecoratorsFromSiblings()`
- [x] Update `prompts.ts` to display decorators via `formatDecoratorsPrefix()`
**Why:** LLM understands routing, DI, guards in NestJS/Angular.
**Tests:**
- [ ] Unit tests for enhanced ASTParser
- [ ] Unit tests for new context format
- [ ] Integration tests for full flow
- [x] Unit tests for ASTParser decorator extraction (14 tests)
- [x] Unit tests for prompts decorator formatting (6 tests)
---
## Version 0.25.0 - Graph Metrics in Context 📊
## Version 0.27.0 - Inline Dependency Graph 📊 ✅
**Priority:** MEDIUM
**Status:** Planned
**Status:** Complete (v0.27.0 released)
Add graph metrics to initial context: dependency graph, circular dependencies, impact score.
### 0.25.1 - Inline Dependency Graph
### Description
**Problem:** LLM doesn't see file relationships without tool calls
**Solution:** Show dependency graph in context
@@ -1907,14 +1904,25 @@ Add graph metrics to initial context: dependency graph, circular dependencies, i
```
**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.
### 0.25.2 - Circular Dependencies in Context
---
## Version 0.28.0 - Circular Dependencies in Context 🔄
**Priority:** MEDIUM
**Status:** Planned
### Description
**Problem:** Circular deps are computed but not shown in context
**Solution:** Show cycles immediately
@@ -1934,7 +1942,14 @@ Add graph metrics to initial context: dependency graph, circular dependencies, i
**Why:** LLM immediately sees architecture problems.
### 0.25.3 - Impact Score
---
## Version 0.29.0 - Impact Score 📈
**Priority:** MEDIUM
**Status:** Planned
### Description
**Problem:** LLM doesn't know which files are critical
**Solution:** Show impact score (% of codebase that depends on file)
@@ -1958,7 +1973,14 @@ Add graph metrics to initial context: dependency graph, circular dependencies, i
**Why:** LLM understands which files are critical for changes.
### 0.25.4 - Transitive Dependencies Count
---
## Version 0.30.0 - Transitive Dependencies Count 🔢
**Priority:** MEDIUM
**Status:** Planned
### Description
**Problem:** Currently only counting direct dependencies
**Solution:** Add transitive dependencies to meta
@@ -1978,8 +2000,7 @@ interface FileMeta {
- [ ] Store in FileMeta
**Tests:**
- [ ] Unit tests for graph metrics computation
- [ ] Unit tests for new context sections
- [ ] Unit tests for transitive dependencies computation
- [ ] Performance tests for large codebases
---
@@ -1999,8 +2020,8 @@ interface FileMeta {
- [x] 0 ESLint errors ✅
- [x] Examples working ✅ (v0.18.0)
- [x] CHANGELOG.md up to date ✅
- [ ] Rich initial context (v0.24.0) — function signatures, interface fields, enum values
- [ ] Graph metrics in context (v0.25.0) — dependency graph, circular deps, impact score
- [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
---
@@ -2077,9 +2098,9 @@ sessions:list # List<session_id>
---
**Last Updated:** 2025-12-04
**Last Updated:** 2025-12-05
**Target Version:** 1.0.0
**Current Version:** 0.25.0
**Next Milestones:** v0.24.0 (Rich Context - 2/4 complete), v0.25.0 (Graph Metrics)
**Current Version:** 0.27.0
**Next Milestones:** v0.28.0 (Circular Deps), v0.29.0 (Impact Score), v0.30.0 (Transitive Deps)
> **Note:** v0.24.0 and v0.25.0 are required for 1.0.0 release. They enable LLM to answer questions about types, signatures, and architecture without tool calls.
> **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.

View File

@@ -1,6 +1,6 @@
{
"name": "@samiyev/ipuaro",
"version": "0.25.0",
"version": "0.27.0",
"description": "Local AI agent for codebase operations with infinite context feeling",
"author": "Fozilbek Samiyev <fozilbek.samiyev@gmail.com>",
"license": "MIT",

View File

@@ -52,6 +52,8 @@ export interface FunctionInfo {
isExported: boolean
/** Return type (if available) */
returnType?: string
/** Decorators applied to the function (e.g., ["@Get(':id')", "@Auth()"]) */
decorators?: string[]
}
export interface MethodInfo {
@@ -69,6 +71,8 @@ export interface MethodInfo {
visibility: "public" | "private" | "protected"
/** Whether it's static */
isStatic: boolean
/** Decorators applied to the method (e.g., ["@Get(':id')", "@UseGuards(AuthGuard)"]) */
decorators?: string[]
}
export interface PropertyInfo {
@@ -105,6 +109,8 @@ export interface ClassInfo {
isExported: boolean
/** Whether class is abstract */
isAbstract: boolean
/** Decorators applied to the class (e.g., ["@Controller('users')", "@Injectable()"]) */
decorators?: string[]
}
export interface InterfaceInfo {
@@ -133,6 +139,28 @@ export interface TypeAliasInfo {
definition?: string
}
export interface EnumMemberInfo {
/** Member name */
name: string
/** Member value (string or number, if specified) */
value?: string | number
}
export interface EnumInfo {
/** Enum name */
name: string
/** Start line number */
lineStart: number
/** End line number */
lineEnd: number
/** Enum members with values */
members: EnumMemberInfo[]
/** Whether it's exported */
isExported: boolean
/** Whether it's a const enum */
isConst: boolean
}
export interface FileAST {
/** Import statements */
imports: ImportInfo[]
@@ -146,6 +174,8 @@ export interface FileAST {
interfaces: InterfaceInfo[]
/** Type alias declarations */
typeAliases: TypeAliasInfo[]
/** Enum declarations */
enums: EnumInfo[]
/** Whether parsing encountered errors */
parseError: boolean
/** Parse error message if any */
@@ -160,6 +190,7 @@ export function createEmptyFileAST(): FileAST {
classes: [],
interfaces: [],
typeAliases: [],
enums: [],
parseError: false,
}
}

View File

@@ -6,6 +6,7 @@ import JSON from "tree-sitter-json"
import * as yamlParser from "yaml"
import {
createEmptyFileAST,
type EnumMemberInfo,
type ExportInfo,
type FileAST,
type ImportInfo,
@@ -192,6 +193,11 @@ export class ASTParser {
this.extractTypeAlias(node, ast, false)
}
break
case NodeType.ENUM_DECLARATION:
if (isTypeScript) {
this.extractEnum(node, ast, false)
}
break
}
}
@@ -258,13 +264,15 @@ export class ASTParser {
const declaration = node.childForFieldName(FieldName.DECLARATION)
if (declaration) {
const decorators = this.extractDecoratorsFromSiblings(declaration)
switch (declaration.type) {
case NodeType.FUNCTION_DECLARATION:
this.extractFunction(declaration, ast, true)
this.extractFunction(declaration, ast, true, decorators)
this.addExportInfo(ast, declaration, "function", isDefault)
break
case NodeType.CLASS_DECLARATION:
this.extractClass(declaration, ast, true)
this.extractClass(declaration, ast, true, decorators)
this.addExportInfo(ast, declaration, "class", isDefault)
break
case NodeType.INTERFACE_DECLARATION:
@@ -275,6 +283,10 @@ export class ASTParser {
this.extractTypeAlias(declaration, ast, true)
this.addExportInfo(ast, declaration, "type", isDefault)
break
case NodeType.ENUM_DECLARATION:
this.extractEnum(declaration, ast, true)
this.addExportInfo(ast, declaration, "type", isDefault)
break
case NodeType.LEXICAL_DECLARATION:
this.extractLexicalDeclaration(declaration, ast, true)
break
@@ -299,7 +311,12 @@ export class ASTParser {
}
}
private extractFunction(node: SyntaxNode, ast: FileAST, isExported: boolean): void {
private extractFunction(
node: SyntaxNode,
ast: FileAST,
isExported: boolean,
externalDecorators: string[] = [],
): void {
const nameNode = node.childForFieldName(FieldName.NAME)
if (!nameNode) {
return
@@ -309,6 +326,9 @@ export class ASTParser {
const isAsync = node.children.some((c) => c.type === NodeType.ASYNC)
const returnTypeNode = node.childForFieldName(FieldName.RETURN_TYPE)
const nodeDecorators = this.extractNodeDecorators(node)
const decorators = [...externalDecorators, ...nodeDecorators]
ast.functions.push({
name: nameNode.text,
lineStart: node.startPosition.row + 1,
@@ -317,6 +337,7 @@ export class ASTParser {
isAsync,
isExported,
returnType: returnTypeNode?.text?.replace(/^:\s*/, ""),
decorators,
})
}
@@ -342,6 +363,7 @@ export class ASTParser {
isAsync,
isExported,
returnType: returnTypeNode?.text?.replace(/^:\s*/, ""),
decorators: [],
})
if (isExported) {
@@ -364,7 +386,12 @@ export class ASTParser {
}
}
private extractClass(node: SyntaxNode, ast: FileAST, isExported: boolean): void {
private extractClass(
node: SyntaxNode,
ast: FileAST,
isExported: boolean,
externalDecorators: string[] = [],
): void {
const nameNode = node.childForFieldName(FieldName.NAME)
if (!nameNode) {
return
@@ -375,14 +402,19 @@ export class ASTParser {
const properties: PropertyInfo[] = []
if (body) {
let pendingDecorators: string[] = []
for (const member of body.children) {
if (member.type === NodeType.METHOD_DEFINITION) {
methods.push(this.extractMethod(member))
if (member.type === NodeType.DECORATOR) {
pendingDecorators.push(this.formatDecorator(member))
} else if (member.type === NodeType.METHOD_DEFINITION) {
methods.push(this.extractMethod(member, pendingDecorators))
pendingDecorators = []
} else if (
member.type === NodeType.PUBLIC_FIELD_DEFINITION ||
member.type === NodeType.FIELD_DEFINITION
) {
properties.push(this.extractProperty(member))
pendingDecorators = []
}
}
}
@@ -390,6 +422,9 @@ export class ASTParser {
const { extendsName, implementsList } = this.extractClassHeritage(node)
const isAbstract = node.children.some((c) => c.type === NodeType.ABSTRACT)
const nodeDecorators = this.extractNodeDecorators(node)
const decorators = [...externalDecorators, ...nodeDecorators]
ast.classes.push({
name: nameNode.text,
lineStart: node.startPosition.row + 1,
@@ -400,6 +435,7 @@ export class ASTParser {
implements: implementsList,
isExported,
isAbstract,
decorators,
})
}
@@ -453,7 +489,7 @@ export class ASTParser {
}
}
private extractMethod(node: SyntaxNode): MethodInfo {
private extractMethod(node: SyntaxNode, decorators: string[] = []): MethodInfo {
const nameNode = node.childForFieldName(FieldName.NAME)
const params = this.extractParameters(node)
const isAsync = node.children.some((c) => c.type === NodeType.ASYNC)
@@ -475,6 +511,7 @@ export class ASTParser {
isAsync,
visibility,
isStatic,
decorators,
}
}
@@ -565,6 +602,75 @@ export class ASTParser {
})
}
private extractEnum(node: SyntaxNode, ast: FileAST, isExported: boolean): void {
const nameNode = node.childForFieldName(FieldName.NAME)
if (!nameNode) {
return
}
const body = node.childForFieldName(FieldName.BODY)
const members: EnumMemberInfo[] = []
if (body) {
for (const child of body.children) {
if (child.type === NodeType.ENUM_ASSIGNMENT) {
const memberName = child.childForFieldName(FieldName.NAME)
const memberValue = child.childForFieldName(FieldName.VALUE)
if (memberName) {
members.push({
name: memberName.text,
value: this.parseEnumValue(memberValue),
})
}
} else if (
child.type === NodeType.IDENTIFIER ||
child.type === NodeType.PROPERTY_IDENTIFIER
) {
members.push({
name: child.text,
value: undefined,
})
}
}
}
const isConst = node.children.some((c) => c.text === "const")
ast.enums.push({
name: nameNode.text,
lineStart: node.startPosition.row + 1,
lineEnd: node.endPosition.row + 1,
members,
isExported,
isConst,
})
}
private parseEnumValue(valueNode: SyntaxNode | null): string | number | undefined {
if (!valueNode) {
return undefined
}
const text = valueNode.text
if (valueNode.type === "number") {
return Number(text)
}
if (valueNode.type === "string") {
return this.getStringValue(valueNode)
}
if (valueNode.type === "unary_expression" && text.startsWith("-")) {
const num = Number(text)
if (!isNaN(num)) {
return num
}
}
return text
}
private extractParameters(node: SyntaxNode): ParameterInfo[] {
const params: ParameterInfo[] = []
const paramsNode = node.childForFieldName(FieldName.PARAMETERS)
@@ -613,6 +719,49 @@ export class ASTParser {
}
}
/**
* Format a decorator node to a string like "@Get(':id')" or "@Injectable()".
*/
private formatDecorator(node: SyntaxNode): string {
return node.text.replace(/\s+/g, " ").trim()
}
/**
* Extract decorators that are direct children of a node.
* In tree-sitter, decorators are children of the class/function declaration.
*/
private extractNodeDecorators(node: SyntaxNode): string[] {
const decorators: string[] = []
for (const child of node.children) {
if (child.type === NodeType.DECORATOR) {
decorators.push(this.formatDecorator(child))
}
}
return decorators
}
/**
* Extract decorators from sibling nodes before the current node.
* Decorators appear as children before the declaration in export statements.
*/
private extractDecoratorsFromSiblings(node: SyntaxNode): string[] {
const decorators: string[] = []
const parent = node.parent
if (!parent) {
return decorators
}
for (const sibling of parent.children) {
if (sibling.type === NodeType.DECORATOR) {
decorators.push(this.formatDecorator(sibling))
} else if (sibling === node) {
break
}
}
return decorators
}
private classifyImport(from: string): ImportInfo["type"] {
if (from.startsWith(".") || from.startsWith("/")) {
return "internal"

View File

@@ -16,6 +16,7 @@ export const NodeType = {
CLASS_DECLARATION: "class_declaration",
INTERFACE_DECLARATION: "interface_declaration",
TYPE_ALIAS_DECLARATION: "type_alias_declaration",
ENUM_DECLARATION: "enum_declaration",
// Clauses
IMPORT_CLAUSE: "import_clause",
@@ -37,6 +38,11 @@ export const NodeType = {
FIELD_DEFINITION: "field_definition",
PROPERTY_SIGNATURE: "property_signature",
// Enum members
ENUM_BODY: "enum_body",
ENUM_ASSIGNMENT: "enum_assignment",
PROPERTY_IDENTIFIER: "property_identifier",
// Parameters
REQUIRED_PARAMETER: "required_parameter",
OPTIONAL_PARAMETER: "optional_parameter",
@@ -57,6 +63,9 @@ export const NodeType = {
DEFAULT: "default",
ACCESSIBILITY_MODIFIER: "accessibility_modifier",
READONLY: "readonly",
// Decorators
DECORATOR: "decorator",
} as const
export type NodeTypeValue = (typeof NodeType)[keyof typeof NodeType]

View File

@@ -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")
}
@@ -187,10 +196,22 @@ function formatFileOverview(
return lines.join("\n")
}
/**
* Format decorators as a prefix string.
* Example: "@Get(':id') @Auth() "
*/
function formatDecoratorsPrefix(decorators: string[] | undefined): string {
if (!decorators || decorators.length === 0) {
return ""
}
return `${decorators.join(" ")} `
}
/**
* Format a function signature.
*/
function formatFunctionSignature(fn: FileAST["functions"][0]): string {
const decoratorsPrefix = formatDecoratorsPrefix(fn.decorators)
const asyncPrefix = fn.isAsync ? "async " : ""
const params = fn.params
.map((p) => {
@@ -200,7 +221,7 @@ function formatFunctionSignature(fn: FileAST["functions"][0]): string {
})
.join(", ")
const returnType = fn.returnType ? `: ${fn.returnType}` : ""
return `${asyncPrefix}${fn.name}(${params})${returnType}`
return `${decoratorsPrefix}${asyncPrefix}${fn.name}(${params})${returnType}`
}
/**
@@ -240,6 +261,37 @@ function formatTypeAliasSignature(type: FileAST["typeAliases"][0]): string {
return `type ${type.name} = ${definition}`
}
/**
* Format an enum signature with members and values.
* Example: "enum Status { Active=1, Inactive=0, Pending=2 }"
* Example: "const enum Role { Admin="admin", User="user" }"
*/
function formatEnumSignature(enumInfo: FileAST["enums"][0]): string {
const constPrefix = enumInfo.isConst ? "const " : ""
if (enumInfo.members.length === 0) {
return `${constPrefix}enum ${enumInfo.name}`
}
const membersStr = enumInfo.members
.map((m) => {
if (m.value === undefined) {
return m.name
}
const valueStr = typeof m.value === "string" ? `"${m.value}"` : String(m.value)
return `${m.name}=${valueStr}`
})
.join(", ")
const result = `${constPrefix}enum ${enumInfo.name} { ${membersStr} }`
if (result.length > 100) {
return truncateDefinition(result, 100)
}
return result
}
/**
* Truncate long type definitions for display.
*/
@@ -279,9 +331,10 @@ function formatFileSummary(
if (ast.classes.length > 0) {
for (const cls of ast.classes) {
const decoratorsPrefix = formatDecoratorsPrefix(cls.decorators)
const ext = cls.extends ? ` extends ${cls.extends}` : ""
const impl = cls.implements.length > 0 ? ` implements ${cls.implements.join(", ")}` : ""
lines.push(`- class ${cls.name}${ext}${impl}`)
lines.push(`- ${decoratorsPrefix}class ${cls.name}${ext}${impl}`)
}
}
@@ -297,6 +350,12 @@ function formatFileSummary(
}
}
if (ast.enums && ast.enums.length > 0) {
for (const enumInfo of ast.enums) {
lines.push(`- ${formatEnumSignature(enumInfo)}`)
}
}
if (lines.length === 1) {
return `- ${path}${flags}`
}
@@ -330,6 +389,11 @@ function formatFileSummaryCompact(path: string, ast: FileAST, flags: string): st
parts.push(`type: ${names}`)
}
if (ast.enums && ast.enums.length > 0) {
const names = ast.enums.map((e) => e.name).join(", ")
parts.push(`enum: ${names}`)
}
const summary = parts.length > 0 ? ` [${parts.join(" | ")}]` : ""
return `- ${path}${summary}${flags}`
}
@@ -359,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, FileMeta>): 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.
*/

View File

@@ -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),
})
/**

View File

@@ -562,4 +562,274 @@ third: value3`
expect(ast.exports[2].line).toBe(3)
})
})
describe("enums (0.24.3)", () => {
it("should extract enum with numeric values", () => {
const code = `enum Status {
Active = 1,
Inactive = 0,
Pending = 2
}`
const ast = parser.parse(code, "ts")
expect(ast.enums).toHaveLength(1)
expect(ast.enums[0]).toMatchObject({
name: "Status",
isExported: false,
isConst: false,
})
expect(ast.enums[0].members).toHaveLength(3)
expect(ast.enums[0].members[0]).toMatchObject({ name: "Active", value: 1 })
expect(ast.enums[0].members[1]).toMatchObject({ name: "Inactive", value: 0 })
expect(ast.enums[0].members[2]).toMatchObject({ name: "Pending", value: 2 })
})
it("should extract enum with string values", () => {
const code = `enum Role {
Admin = "admin",
User = "user",
Guest = "guest"
}`
const ast = parser.parse(code, "ts")
expect(ast.enums).toHaveLength(1)
expect(ast.enums[0].members).toHaveLength(3)
expect(ast.enums[0].members[0]).toMatchObject({ name: "Admin", value: "admin" })
expect(ast.enums[0].members[1]).toMatchObject({ name: "User", value: "user" })
expect(ast.enums[0].members[2]).toMatchObject({ name: "Guest", value: "guest" })
})
it("should extract enum without explicit values", () => {
const code = `enum Direction {
Up,
Down,
Left,
Right
}`
const ast = parser.parse(code, "ts")
expect(ast.enums).toHaveLength(1)
expect(ast.enums[0].members).toHaveLength(4)
expect(ast.enums[0].members[0]).toMatchObject({ name: "Up", value: undefined })
expect(ast.enums[0].members[1]).toMatchObject({ name: "Down", value: undefined })
})
it("should extract exported enum", () => {
const code = `export enum Color {
Red = "#FF0000",
Green = "#00FF00",
Blue = "#0000FF"
}`
const ast = parser.parse(code, "ts")
expect(ast.enums).toHaveLength(1)
expect(ast.enums[0].isExported).toBe(true)
expect(ast.exports).toHaveLength(1)
expect(ast.exports[0].kind).toBe("type")
})
it("should extract const enum", () => {
const code = `const enum HttpStatus {
OK = 200,
NotFound = 404,
InternalError = 500
}`
const ast = parser.parse(code, "ts")
expect(ast.enums).toHaveLength(1)
expect(ast.enums[0].isConst).toBe(true)
expect(ast.enums[0].members[0]).toMatchObject({ name: "OK", value: 200 })
})
it("should extract exported const enum", () => {
const code = `export const enum LogLevel {
Debug = 0,
Info = 1,
Warn = 2,
Error = 3
}`
const ast = parser.parse(code, "ts")
expect(ast.enums).toHaveLength(1)
expect(ast.enums[0].isExported).toBe(true)
expect(ast.enums[0].isConst).toBe(true)
})
it("should extract line range for enum", () => {
const code = `enum Test {
A = 1,
B = 2
}`
const ast = parser.parse(code, "ts")
expect(ast.enums[0].lineStart).toBe(1)
expect(ast.enums[0].lineEnd).toBe(4)
})
it("should handle enum with negative values", () => {
const code = `enum Temperature {
Cold = -10,
Freezing = -20,
Hot = 40
}`
const ast = parser.parse(code, "ts")
expect(ast.enums).toHaveLength(1)
expect(ast.enums[0].members[0]).toMatchObject({ name: "Cold", value: -10 })
expect(ast.enums[0].members[1]).toMatchObject({ name: "Freezing", value: -20 })
expect(ast.enums[0].members[2]).toMatchObject({ name: "Hot", value: 40 })
})
it("should handle empty enum", () => {
const code = `enum Empty {}`
const ast = parser.parse(code, "ts")
expect(ast.enums).toHaveLength(1)
expect(ast.enums[0].name).toBe("Empty")
expect(ast.enums[0].members).toHaveLength(0)
})
it("should not extract enum from JavaScript", () => {
const code = `enum Status { Active = 1 }`
const ast = parser.parse(code, "js")
expect(ast.enums).toHaveLength(0)
})
})
describe("decorators (0.24.4)", () => {
it("should extract class decorator", () => {
const code = `@Controller('users')
class UserController {}`
const ast = parser.parse(code, "ts")
expect(ast.classes).toHaveLength(1)
expect(ast.classes[0].decorators).toHaveLength(1)
expect(ast.classes[0].decorators[0]).toBe("@Controller('users')")
})
it("should extract multiple class decorators", () => {
const code = `@Controller('api')
@Injectable()
@UseGuards(AuthGuard)
class ApiController {}`
const ast = parser.parse(code, "ts")
expect(ast.classes).toHaveLength(1)
expect(ast.classes[0].decorators).toHaveLength(3)
expect(ast.classes[0].decorators[0]).toBe("@Controller('api')")
expect(ast.classes[0].decorators[1]).toBe("@Injectable()")
expect(ast.classes[0].decorators[2]).toBe("@UseGuards(AuthGuard)")
})
it("should extract method decorators", () => {
const code = `class UserController {
@Get(':id')
@Auth()
async getUser() {}
}`
const ast = parser.parse(code, "ts")
expect(ast.classes).toHaveLength(1)
expect(ast.classes[0].methods).toHaveLength(1)
expect(ast.classes[0].methods[0].decorators).toHaveLength(2)
expect(ast.classes[0].methods[0].decorators[0]).toBe("@Get(':id')")
expect(ast.classes[0].methods[0].decorators[1]).toBe("@Auth()")
})
it("should extract exported decorated class", () => {
const code = `@Injectable()
export class UserService {}`
const ast = parser.parse(code, "ts")
expect(ast.classes).toHaveLength(1)
expect(ast.classes[0].isExported).toBe(true)
expect(ast.classes[0].decorators).toHaveLength(1)
expect(ast.classes[0].decorators[0]).toBe("@Injectable()")
})
it("should extract decorator with complex arguments", () => {
const code = `@Module({
imports: [UserModule],
controllers: [AppController],
providers: [AppService]
})
class AppModule {}`
const ast = parser.parse(code, "ts")
expect(ast.classes).toHaveLength(1)
expect(ast.classes[0].decorators).toHaveLength(1)
expect(ast.classes[0].decorators[0]).toContain("@Module")
expect(ast.classes[0].decorators[0]).toContain("imports")
})
it("should extract decorated class with extends", () => {
const code = `@Entity()
class User extends BaseEntity {}`
const ast = parser.parse(code, "ts")
expect(ast.classes).toHaveLength(1)
expect(ast.classes[0].extends).toBe("BaseEntity")
expect(ast.classes[0].decorators).toHaveLength(1)
expect(ast.classes[0].decorators![0]).toBe("@Entity()")
})
it("should handle class without decorators", () => {
const code = `class SimpleClass {}`
const ast = parser.parse(code, "ts")
expect(ast.classes).toHaveLength(1)
expect(ast.classes[0].decorators).toHaveLength(0)
})
it("should handle method without decorators", () => {
const code = `class SimpleClass {
simpleMethod() {}
}`
const ast = parser.parse(code, "ts")
expect(ast.classes).toHaveLength(1)
expect(ast.classes[0].methods).toHaveLength(1)
expect(ast.classes[0].methods[0].decorators).toHaveLength(0)
})
it("should handle function without decorators", () => {
const code = `function simpleFunc() {}`
const ast = parser.parse(code, "ts")
expect(ast.functions).toHaveLength(1)
expect(ast.functions[0].decorators).toHaveLength(0)
})
it("should handle arrow function without decorators", () => {
const code = `const arrowFn = () => {}`
const ast = parser.parse(code, "ts")
expect(ast.functions).toHaveLength(1)
expect(ast.functions[0].decorators).toHaveLength(0)
})
it("should extract NestJS controller pattern", () => {
const code = `@Controller('users')
export class UserController {
@Get()
findAll() {}
@Get(':id')
findOne() {}
@Post()
@Body()
create() {}
}`
const ast = parser.parse(code, "ts")
expect(ast.classes).toHaveLength(1)
expect(ast.classes[0].decorators).toContain("@Controller('users')")
expect(ast.classes[0].methods).toHaveLength(3)
expect(ast.classes[0].methods[0].decorators).toContain("@Get()")
expect(ast.classes[0].methods[1].decorators).toContain("@Get(':id')")
expect(ast.classes[0].methods[2].decorators).toContain("@Post()")
})
})
})

View File

@@ -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"
@@ -1191,9 +1192,7 @@ describe("prompts", () => {
const context = buildInitialContext(structure, asts)
expect(context).toContain(
"interface AdminUser extends User { role: string }",
)
expect(context).toContain("interface AdminUser extends User { role: string }")
})
it("should format interface with readonly fields", () => {
@@ -1494,4 +1493,907 @@ describe("prompts", () => {
expect(context).toContain("- type Handler = (event: Event) => void")
})
})
describe("enum definitions (0.24.3)", () => {
it("should format enum with numeric values", () => {
const structure: ProjectStructure = {
name: "test",
rootPath: "/test",
files: ["enums.ts"],
directories: [],
}
const asts = new Map<string, FileAST>([
[
"enums.ts",
{
imports: [],
exports: [],
functions: [],
classes: [],
interfaces: [],
typeAliases: [],
enums: [
{
name: "Status",
lineStart: 1,
lineEnd: 5,
members: [
{ name: "Active", value: 1 },
{ name: "Inactive", value: 0 },
{ name: "Pending", value: 2 },
],
isExported: true,
isConst: false,
},
],
parseError: false,
},
],
])
const context = buildInitialContext(structure, asts)
expect(context).toContain("- enum Status { Active=1, Inactive=0, Pending=2 }")
})
it("should format enum with string values", () => {
const structure: ProjectStructure = {
name: "test",
rootPath: "/test",
files: ["enums.ts"],
directories: [],
}
const asts = new Map<string, FileAST>([
[
"enums.ts",
{
imports: [],
exports: [],
functions: [],
classes: [],
interfaces: [],
typeAliases: [],
enums: [
{
name: "Role",
lineStart: 1,
lineEnd: 5,
members: [
{ name: "Admin", value: "admin" },
{ name: "User", value: "user" },
],
isExported: true,
isConst: false,
},
],
parseError: false,
},
],
])
const context = buildInitialContext(structure, asts)
expect(context).toContain('- enum Role { Admin="admin", User="user" }')
})
it("should format const enum", () => {
const structure: ProjectStructure = {
name: "test",
rootPath: "/test",
files: ["enums.ts"],
directories: [],
}
const asts = new Map<string, FileAST>([
[
"enums.ts",
{
imports: [],
exports: [],
functions: [],
classes: [],
interfaces: [],
typeAliases: [],
enums: [
{
name: "HttpStatus",
lineStart: 1,
lineEnd: 5,
members: [
{ name: "OK", value: 200 },
{ name: "NotFound", value: 404 },
],
isExported: true,
isConst: true,
},
],
parseError: false,
},
],
])
const context = buildInitialContext(structure, asts)
expect(context).toContain("- const enum HttpStatus { OK=200, NotFound=404 }")
})
it("should format enum without explicit values", () => {
const structure: ProjectStructure = {
name: "test",
rootPath: "/test",
files: ["enums.ts"],
directories: [],
}
const asts = new Map<string, FileAST>([
[
"enums.ts",
{
imports: [],
exports: [],
functions: [],
classes: [],
interfaces: [],
typeAliases: [],
enums: [
{
name: "Direction",
lineStart: 1,
lineEnd: 5,
members: [
{ name: "Up", value: undefined },
{ name: "Down", value: undefined },
],
isExported: true,
isConst: false,
},
],
parseError: false,
},
],
])
const context = buildInitialContext(structure, asts)
expect(context).toContain("- enum Direction { Up, Down }")
})
it("should format empty enum", () => {
const structure: ProjectStructure = {
name: "test",
rootPath: "/test",
files: ["enums.ts"],
directories: [],
}
const asts = new Map<string, FileAST>([
[
"enums.ts",
{
imports: [],
exports: [],
functions: [],
classes: [],
interfaces: [],
typeAliases: [],
enums: [
{
name: "Empty",
lineStart: 1,
lineEnd: 1,
members: [],
isExported: true,
isConst: false,
},
],
parseError: false,
},
],
])
const context = buildInitialContext(structure, asts)
expect(context).toContain("- enum Empty")
})
it("should include enum in compact format", () => {
const structure: ProjectStructure = {
name: "test",
rootPath: "/test",
files: ["enums.ts"],
directories: [],
}
const asts = new Map<string, FileAST>([
[
"enums.ts",
{
imports: [],
exports: [],
functions: [],
classes: [],
interfaces: [],
typeAliases: [],
enums: [
{
name: "Status",
lineStart: 1,
lineEnd: 5,
members: [{ name: "Active", value: 1 }],
isExported: true,
isConst: false,
},
],
parseError: false,
},
],
])
const context = buildInitialContext(structure, asts, undefined, {
includeSignatures: false,
})
expect(context).toContain("enum: Status")
})
it("should truncate long enum definitions", () => {
const structure: ProjectStructure = {
name: "test",
rootPath: "/test",
files: ["enums.ts"],
directories: [],
}
const asts = new Map<string, FileAST>([
[
"enums.ts",
{
imports: [],
exports: [],
functions: [],
classes: [],
interfaces: [],
typeAliases: [],
enums: [
{
name: "VeryLongEnumName",
lineStart: 1,
lineEnd: 20,
members: [
{ name: "FirstVeryLongMemberName", value: "first_value" },
{ name: "SecondVeryLongMemberName", value: "second_value" },
{ name: "ThirdVeryLongMemberName", value: "third_value" },
],
isExported: true,
isConst: false,
},
],
parseError: false,
},
],
])
const context = buildInitialContext(structure, asts)
expect(context).toContain("...")
expect(context).toContain("- enum VeryLongEnumName")
})
})
describe("decorators (0.24.4)", () => {
it("should format function with decorators", () => {
const structure: ProjectStructure = {
name: "test",
rootPath: "/test",
files: ["controller.ts"],
directories: [],
}
const asts = new Map<string, FileAST>([
[
"controller.ts",
{
imports: [],
exports: [],
functions: [
{
name: "getUser",
lineStart: 1,
lineEnd: 5,
params: [
{
name: "id",
type: "string",
optional: false,
hasDefault: false,
},
],
isAsync: true,
isExported: true,
returnType: "Promise<User>",
decorators: ["@Get(':id')"],
},
],
classes: [],
interfaces: [],
typeAliases: [],
parseError: false,
},
],
])
const context = buildInitialContext(structure, asts)
expect(context).toContain("- @Get(':id') async getUser(id: string): Promise<User>")
})
it("should format class with decorators", () => {
const structure: ProjectStructure = {
name: "test",
rootPath: "/test",
files: ["controller.ts"],
directories: [],
}
const asts = new Map<string, FileAST>([
[
"controller.ts",
{
imports: [],
exports: [],
functions: [],
classes: [
{
name: "UserController",
lineStart: 1,
lineEnd: 20,
methods: [],
properties: [],
implements: [],
isExported: true,
isAbstract: false,
decorators: ["@Controller('users')"],
},
],
interfaces: [],
typeAliases: [],
parseError: false,
},
],
])
const context = buildInitialContext(structure, asts)
expect(context).toContain("- @Controller('users') class UserController")
})
it("should format class with multiple decorators", () => {
const structure: ProjectStructure = {
name: "test",
rootPath: "/test",
files: ["service.ts"],
directories: [],
}
const asts = new Map<string, FileAST>([
[
"service.ts",
{
imports: [],
exports: [],
functions: [],
classes: [
{
name: "UserService",
lineStart: 1,
lineEnd: 30,
methods: [],
properties: [],
implements: ["IUserService"],
isExported: true,
isAbstract: false,
decorators: ["@Injectable()", "@Singleton()"],
},
],
interfaces: [],
typeAliases: [],
parseError: false,
},
],
])
const context = buildInitialContext(structure, asts)
expect(context).toContain(
"- @Injectable() @Singleton() class UserService implements IUserService",
)
})
it("should format function with multiple decorators", () => {
const structure: ProjectStructure = {
name: "test",
rootPath: "/test",
files: ["controller.ts"],
directories: [],
}
const asts = new Map<string, FileAST>([
[
"controller.ts",
{
imports: [],
exports: [],
functions: [
{
name: "createUser",
lineStart: 1,
lineEnd: 10,
params: [],
isAsync: true,
isExported: true,
decorators: ["@Post()", "@Auth()", "@ValidateBody()"],
},
],
classes: [],
interfaces: [],
typeAliases: [],
parseError: false,
},
],
])
const context = buildInitialContext(structure, asts)
expect(context).toContain("- @Post() @Auth() @ValidateBody() async createUser()")
})
it("should handle function without decorators", () => {
const structure: ProjectStructure = {
name: "test",
rootPath: "/test",
files: ["utils.ts"],
directories: [],
}
const asts = new Map<string, FileAST>([
[
"utils.ts",
{
imports: [],
exports: [],
functions: [
{
name: "helper",
lineStart: 1,
lineEnd: 5,
params: [],
isAsync: false,
isExported: true,
},
],
classes: [],
interfaces: [],
typeAliases: [],
parseError: false,
},
],
])
const context = buildInitialContext(structure, asts)
expect(context).toContain("- helper()")
expect(context).not.toContain("@")
})
it("should handle class without decorators", () => {
const structure: ProjectStructure = {
name: "test",
rootPath: "/test",
files: ["simple.ts"],
directories: [],
}
const asts = new Map<string, FileAST>([
[
"simple.ts",
{
imports: [],
exports: [],
functions: [],
classes: [
{
name: "SimpleClass",
lineStart: 1,
lineEnd: 10,
methods: [],
properties: [],
implements: [],
isExported: true,
isAbstract: false,
},
],
interfaces: [],
typeAliases: [],
parseError: false,
},
],
])
const context = buildInitialContext(structure, asts)
expect(context).toContain("- class SimpleClass")
expect(context).not.toContain("@")
})
})
describe("dependency graph (0.27.0)", () => {
describe("formatDependencyGraph", () => {
it("should return null for empty metas", () => {
const metas = new Map<string, FileMeta>()
const result = formatDependencyGraph(metas)
expect(result).toBeNull()
})
it("should return null when no files have dependencies or dependents", () => {
const metas = new Map<string, FileMeta>([
[
"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<string, FileMeta>([
[
"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<string, FileMeta>([
[
"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<string, FileMeta>([
[
"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<string, FileMeta>([
[
"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<string, FileMeta>([
[
"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<string, FileMeta>([
[
"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<string, FileMeta>([
[
"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<string, FileMeta>([
[
"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<string, FileMeta>([
[
"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<string, FileAST>([
[
"src/index.ts",
{
imports: [],
exports: [],
functions: [],
classes: [],
interfaces: [],
typeAliases: [],
parseError: false,
},
],
])
it("should include dependency graph by default", () => {
const metas = new Map<string, FileMeta>([
[
"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<string, FileMeta>([
[
"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<string, FileMeta>()
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<string, FileMeta>([
[
"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")
})
})
})
})

View File

@@ -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()
})
})
})