From 2dcb22812cdcbe8b19c5f7e4d0339f35342aa343 Mon Sep 17 00:00:00 2001 From: imfozilbek Date: Thu, 4 Dec 2025 22:29:02 +0500 Subject: [PATCH] feat(ipuaro): add function signatures to initial context - Add full function signatures with parameter types and return types - Arrow functions now extract returnType in ASTParser - New formatFunctionSignature() helper in prompts.ts - Add includeSignatures config option (default: true) - Support compact format when includeSignatures: false - 15 new tests, coverage 91.14% branches --- packages/ipuaro/CHANGELOG.md | 71 ++++ packages/ipuaro/ROADMAP.md | 16 +- .../src/infrastructure/indexer/ASTParser.ts | 2 + .../ipuaro/src/infrastructure/llm/prompts.ts | 96 ++++- .../ipuaro/src/shared/constants/config.ts | 1 + .../unit/infrastructure/llm/prompts.test.ts | 386 +++++++++++++++++- .../tests/unit/shared/context-config.test.ts | 32 ++ 7 files changed, 582 insertions(+), 22 deletions(-) diff --git a/packages/ipuaro/CHANGELOG.md b/packages/ipuaro/CHANGELOG.md index 3338d3d..ad2efe2 100644 --- a/packages/ipuaro/CHANGELOG.md +++ b/packages/ipuaro/CHANGELOG.md @@ -5,6 +5,77 @@ 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.24.0] - 2025-12-04 - Rich Initial Context: Function Signatures + +### Added + +- **Function Signatures in Context (0.24.1)** + - Full function signatures with parameter types and return types in initial context + - New format: `async getUser(id: string): Promise` instead of `fn: getUser` + - Classes show inheritance: `class UserService extends BaseService implements IService` + - Interfaces show extends: `interface AdminUser extends User, Admin` + - Optional parameters marked with `?`: `format(value: string, options?: FormatOptions)` + +- **BuildContextOptions Interface** + - New `includeSignatures?: boolean` option for `buildInitialContext()` + - Controls signature vs compact format (default: `true` for signatures) + +- **Configuration** + - Added `includeSignatures: boolean` to `ContextConfigSchema` (default: `true`) + - Users can disable signatures to save tokens: `context.includeSignatures: false` + +### Changed + +- **ASTParser** + - Arrow functions now extract `returnType` in `extractLexicalDeclaration()` + - Return type format normalized (strips leading `: `) + +- **prompts.ts** + - New `formatFunctionSignature()` helper function + - `formatFileSummary()` now shows full signatures by default + - Added `formatFileSummaryCompact()` for legacy format + - `formatFileOverview()` accepts `includeSignatures` parameter + - Defensive handling for missing interface `extends` array + +### New Context Format (default) + +``` +### src/services/user.ts +- async getUser(id: string): Promise +- async createUser(data: UserDTO): Promise +- validateEmail(email: string): boolean +- class UserService extends BaseService +- interface IUserService extends IService +- type UserId +``` + +### Compact Format (includeSignatures: false) + +``` +- src/services/user.ts [fn: getUser, createUser | class: UserService | interface: IUserService | type: UserId] +``` + +### Technical Details + +- Total tests: 1702 passed (was 1687, +15 new tests) + - 8 new tests for function signature formatting + - 5 new tests for `includeSignatures` configuration + - 1 new test for compact format + - 1 new test for undefined AST entries +- Coverage: 97.54% lines, 91.14% branches, 98.59% functions +- 0 ESLint errors, 2 warnings (complexity in ASTParser and prompts) +- Build successful + +### Notes + +This is the first part of 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 + +--- + ## [0.23.0] - 2025-12-04 - JSON/YAML & Symlinks ### Added diff --git a/packages/ipuaro/ROADMAP.md b/packages/ipuaro/ROADMAP.md index 5c6565c..79c2d8c 100644 --- a/packages/ipuaro/ROADMAP.md +++ b/packages/ipuaro/ROADMAP.md @@ -1782,11 +1782,11 @@ export interface ScanResult { ## Version 0.24.0 - Rich Initial Context 📋 **Priority:** HIGH -**Status:** Planned +**Status:** In Progress (1/4 complete) Улучшение initial context для LLM: добавление сигнатур функций, типов интерфейсов и значений enum. Это позволит LLM отвечать на вопросы о типах и параметрах без tool calls. -### 0.24.1 - Function Signatures with Types ⭐ +### 0.24.1 - Function Signatures with Types ⭐ ✅ **Проблема:** Сейчас LLM видит только имена функций: `fn: getUser, createUser` **Решение:** Показать полные сигнатуры: `async getUser(id: string): Promise` @@ -1805,10 +1805,10 @@ export interface ScanResult { ``` **Изменения:** -- [ ] Расширить `FunctionInfo` в FileAST для хранения типов параметров и return type -- [ ] Обновить `ASTParser.ts` для извлечения типов параметров и return types -- [ ] Обновить `formatFileSummary()` в prompts.ts для вывода сигнатур -- [ ] Добавить опцию `includeSignatures: boolean` в config +- [x] Расширить `FunctionInfo` в FileAST для хранения типов параметров и return type (already existed) +- [x] Обновить `ASTParser.ts` для извлечения типов параметров и return types (arrow functions fixed) +- [x] Обновить `formatFileSummary()` в prompts.ts для вывода сигнатур +- [x] Добавить опцию `includeSignatures: boolean` в config **Зачем:** LLM не будет галлюцинировать параметры и return types. @@ -2079,7 +2079,7 @@ sessions:list # List **Last Updated:** 2025-12-04 **Target Version:** 1.0.0 -**Current Version:** 0.23.0 -**Next Milestones:** v0.24.0 (Rich Context), v0.25.0 (Graph Metrics) +**Current Version:** 0.24.0 +**Next Milestones:** v0.24.0 (Rich Context - 1/4 complete), v0.25.0 (Graph Metrics) > **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. \ No newline at end of file diff --git a/packages/ipuaro/src/infrastructure/indexer/ASTParser.ts b/packages/ipuaro/src/infrastructure/indexer/ASTParser.ts index b7643fc..66759e8 100644 --- a/packages/ipuaro/src/infrastructure/indexer/ASTParser.ts +++ b/packages/ipuaro/src/infrastructure/indexer/ASTParser.ts @@ -332,6 +332,7 @@ export class ASTParser { ) { const params = this.extractParameters(valueNode) const isAsync = valueNode.children.some((c) => c.type === NodeType.ASYNC) + const returnTypeNode = valueNode.childForFieldName(FieldName.RETURN_TYPE) ast.functions.push({ name: nameNode?.text ?? "", @@ -340,6 +341,7 @@ export class ASTParser { params, isAsync, isExported, + returnType: returnTypeNode?.text?.replace(/^:\s*/, ""), }) if (isExported) { diff --git a/packages/ipuaro/src/infrastructure/llm/prompts.ts b/packages/ipuaro/src/infrastructure/llm/prompts.ts index 3c40855..f07564b 100644 --- a/packages/ipuaro/src/infrastructure/llm/prompts.ts +++ b/packages/ipuaro/src/infrastructure/llm/prompts.ts @@ -11,6 +11,13 @@ export interface ProjectStructure { directories: string[] } +/** + * Options for building initial context. + */ +export interface BuildContextOptions { + includeSignatures?: boolean +} + /** * System prompt for the ipuaro AI agent. */ @@ -116,12 +123,14 @@ export function buildInitialContext( structure: ProjectStructure, asts: Map, metas?: Map, + options?: BuildContextOptions, ): string { const sections: string[] = [] + const includeSignatures = options?.includeSignatures ?? true sections.push(formatProjectHeader(structure)) sections.push(formatDirectoryTree(structure)) - sections.push(formatFileOverview(asts, metas)) + sections.push(formatFileOverview(asts, metas, includeSignatures)) return sections.join("\n\n") } @@ -157,7 +166,11 @@ function formatDirectoryTree(structure: ProjectStructure): string { /** * Format file overview with AST summaries. */ -function formatFileOverview(asts: Map, metas?: Map): string { +function formatFileOverview( + asts: Map, + metas?: Map, + includeSignatures = true, +): string { const lines: string[] = ["## Files", ""] const sortedPaths = [...asts.keys()].sort() @@ -168,16 +181,87 @@ function formatFileOverview(asts: Map, metas?: Map { + const optional = p.optional ? "?" : "" + const type = p.type ? `: ${p.type}` : "" + return `${p.name}${optional}${type}` + }) + .join(", ") + const returnType = fn.returnType ? `: ${fn.returnType}` : "" + return `${asyncPrefix}${fn.name}(${params})${returnType}` +} + +/** + * Format a single file's AST summary. + * When includeSignatures is true, shows full function signatures. + * When false, shows compact format with just names. + */ +function formatFileSummary( + path: string, + ast: FileAST, + meta?: FileMeta, + includeSignatures = true, +): string { + const flags = formatFileFlags(meta) + + if (!includeSignatures) { + return formatFileSummaryCompact(path, ast, flags) + } + + const lines: string[] = [] + lines.push(`### ${path}${flags}`) + + if (ast.functions.length > 0) { + for (const fn of ast.functions) { + lines.push(`- ${formatFunctionSignature(fn)}`) + } + } + + if (ast.classes.length > 0) { + for (const cls of ast.classes) { + const ext = cls.extends ? ` extends ${cls.extends}` : "" + const impl = cls.implements.length > 0 ? ` implements ${cls.implements.join(", ")}` : "" + lines.push(`- class ${cls.name}${ext}${impl}`) + } + } + + if (ast.interfaces.length > 0) { + for (const iface of ast.interfaces) { + const extList = iface.extends ?? [] + const ext = extList.length > 0 ? ` extends ${extList.join(", ")}` : "" + lines.push(`- interface ${iface.name}${ext}`) + } + } + + if (ast.typeAliases.length > 0) { + for (const type of ast.typeAliases) { + lines.push(`- type ${type.name}`) + } + } + + if (lines.length === 1) { + return `- ${path}${flags}` + } + + return lines.join("\n") +} + +/** + * Format file summary in compact mode (just names, no signatures). + */ +function formatFileSummaryCompact(path: string, ast: FileAST, flags: string): string { const parts: string[] = [] if (ast.functions.length > 0) { @@ -201,8 +285,6 @@ function formatFileSummary(path: string, ast: FileAST, meta?: FileMeta): string } const summary = parts.length > 0 ? ` [${parts.join(" | ")}]` : "" - const flags = formatFileFlags(meta) - return `- ${path}${summary}${flags}` } diff --git a/packages/ipuaro/src/shared/constants/config.ts b/packages/ipuaro/src/shared/constants/config.ts index 5309c08..a08bba1 100644 --- a/packages/ipuaro/src/shared/constants/config.ts +++ b/packages/ipuaro/src/shared/constants/config.ts @@ -114,6 +114,7 @@ export const ContextConfigSchema = z.object({ maxContextUsage: z.number().min(0).max(1).default(0.8), autoCompressAt: z.number().min(0).max(1).default(0.8), compressionMethod: z.enum(["llm-summary", "truncate"]).default("llm-summary"), + includeSignatures: 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 2303e94..44e5b55 100644 --- a/packages/ipuaro/tests/unit/infrastructure/llm/prompts.test.ts +++ b/packages/ipuaro/tests/unit/infrastructure/llm/prompts.test.ts @@ -108,13 +108,23 @@ describe("prompts", () => { expect(context).toContain("tests/") }) - it("should include file overview with AST summaries", () => { + it("should include file overview with AST summaries (signatures format)", () => { const context = buildInitialContext(structure, asts) expect(context).toContain("## Files") - expect(context).toContain("src/index.ts") + expect(context).toContain("### src/index.ts") + expect(context).toContain("- main()") + expect(context).toContain("### src/utils.ts") + expect(context).toContain("- class Helper") + }) + + it("should use compact format when includeSignatures is false", () => { + const context = buildInitialContext(structure, asts, undefined, { + includeSignatures: false, + }) + + expect(context).toContain("## Files") expect(context).toContain("fn: main") - expect(context).toContain("src/utils.ts") expect(context).toContain("class: Helper") }) @@ -506,7 +516,16 @@ describe("prompts", () => { exports: [], functions: [], classes: [], - interfaces: [{ name: "IFoo", lineStart: 1, lineEnd: 5, isExported: true }], + interfaces: [ + { + name: "IFoo", + lineStart: 1, + lineEnd: 5, + properties: [], + extends: [], + isExported: true, + }, + ], typeAliases: [], parseError: false, }, @@ -515,6 +534,44 @@ describe("prompts", () => { const context = buildInitialContext(structure, asts) + expect(context).toContain("- interface IFoo") + }) + + it("should handle file with only interfaces (compact format)", () => { + const structure: ProjectStructure = { + name: "test", + rootPath: "/test", + files: ["types.ts"], + directories: [], + } + const asts = new Map([ + [ + "types.ts", + { + imports: [], + exports: [], + functions: [], + classes: [], + interfaces: [ + { + name: "IFoo", + lineStart: 1, + lineEnd: 5, + properties: [], + extends: [], + isExported: true, + }, + ], + typeAliases: [], + parseError: false, + }, + ], + ]) + + const context = buildInitialContext(structure, asts, undefined, { + includeSignatures: false, + }) + expect(context).toContain("interface: IFoo") }) @@ -534,9 +591,7 @@ describe("prompts", () => { functions: [], classes: [], interfaces: [], - typeAliases: [ - { name: "MyType", lineStart: 1, lineEnd: 1, isExported: true }, - ], + typeAliases: [{ name: "MyType", line: 1, isExported: true }], parseError: false, }, ], @@ -544,6 +599,35 @@ describe("prompts", () => { const context = buildInitialContext(structure, asts) + expect(context).toContain("- type MyType") + }) + + it("should handle file with only type aliases (compact format)", () => { + const structure: ProjectStructure = { + name: "test", + rootPath: "/test", + files: ["types.ts"], + directories: [], + } + const asts = new Map([ + [ + "types.ts", + { + imports: [], + exports: [], + functions: [], + classes: [], + interfaces: [], + typeAliases: [{ name: "MyType", line: 1, isExported: true }], + parseError: false, + }, + ], + ]) + + const context = buildInitialContext(structure, asts, undefined, { + includeSignatures: false, + }) + expect(context).toContain("type: MyType") }) @@ -686,6 +770,22 @@ describe("prompts", () => { expect(context).toContain("exists.ts") expect(context).not.toContain("missing.ts") }) + + it("should skip undefined AST entries", () => { + const structure: ProjectStructure = { + name: "test", + rootPath: "/test", + files: ["file.ts"], + directories: [], + } + const asts = new Map() + asts.set("file.ts", undefined as unknown as FileAST) + + const context = buildInitialContext(structure, asts) + + expect(context).toContain("## Files") + expect(context).not.toContain("file.ts") + }) }) describe("truncateContext", () => { @@ -714,4 +814,276 @@ describe("prompts", () => { expect(result).toContain("truncated") }) }) + + describe("function signatures with types", () => { + it("should format function with typed parameters", () => { + const structure: ProjectStructure = { + name: "test", + rootPath: "/test", + files: ["user.ts"], + directories: [], + } + const asts = new Map([ + [ + "user.ts", + { + imports: [], + exports: [], + functions: [ + { + name: "getUser", + lineStart: 1, + lineEnd: 5, + params: [ + { + name: "id", + type: "string", + optional: false, + hasDefault: false, + }, + ], + isAsync: false, + isExported: true, + }, + ], + classes: [], + interfaces: [], + typeAliases: [], + parseError: false, + }, + ], + ]) + + const context = buildInitialContext(structure, asts) + + expect(context).toContain("- getUser(id: string)") + }) + + it("should format async function with return type", () => { + const structure: ProjectStructure = { + name: "test", + rootPath: "/test", + files: ["user.ts"], + directories: [], + } + const asts = new Map([ + [ + "user.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", + }, + ], + classes: [], + interfaces: [], + typeAliases: [], + parseError: false, + }, + ], + ]) + + const context = buildInitialContext(structure, asts) + + expect(context).toContain("- async getUser(id: string): Promise") + }) + + it("should format function with optional parameters", () => { + const structure: ProjectStructure = { + name: "test", + rootPath: "/test", + files: ["utils.ts"], + directories: [], + } + const asts = new Map([ + [ + "utils.ts", + { + imports: [], + exports: [], + functions: [ + { + name: "format", + lineStart: 1, + lineEnd: 5, + params: [ + { + name: "value", + type: "string", + optional: false, + hasDefault: false, + }, + { + name: "options", + type: "FormatOptions", + optional: true, + hasDefault: false, + }, + ], + isAsync: false, + isExported: true, + returnType: "string", + }, + ], + classes: [], + interfaces: [], + typeAliases: [], + parseError: false, + }, + ], + ]) + + const context = buildInitialContext(structure, asts) + + expect(context).toContain("- format(value: string, options?: FormatOptions): string") + }) + + it("should format function with multiple typed parameters", () => { + const structure: ProjectStructure = { + name: "test", + rootPath: "/test", + files: ["api.ts"], + directories: [], + } + const asts = new Map([ + [ + "api.ts", + { + imports: [], + exports: [], + functions: [ + { + name: "createUser", + lineStart: 1, + lineEnd: 10, + params: [ + { + name: "name", + type: "string", + optional: false, + hasDefault: false, + }, + { + name: "email", + type: "string", + optional: false, + hasDefault: false, + }, + { + name: "age", + type: "number", + optional: true, + hasDefault: false, + }, + ], + isAsync: true, + isExported: true, + returnType: "Promise", + }, + ], + classes: [], + interfaces: [], + typeAliases: [], + parseError: false, + }, + ], + ]) + + const context = buildInitialContext(structure, asts) + + expect(context).toContain( + "- async createUser(name: string, email: string, age?: number): Promise", + ) + }) + + it("should format function without types (JavaScript style)", () => { + const structure: ProjectStructure = { + name: "test", + rootPath: "/test", + files: ["legacy.js"], + directories: [], + } + const asts = new Map([ + [ + "legacy.js", + { + imports: [], + exports: [], + functions: [ + { + name: "doSomething", + lineStart: 1, + lineEnd: 5, + params: [ + { name: "x", optional: false, hasDefault: false }, + { name: "y", optional: false, hasDefault: false }, + ], + isAsync: false, + isExported: true, + }, + ], + classes: [], + interfaces: [], + typeAliases: [], + parseError: false, + }, + ], + ]) + + const context = buildInitialContext(structure, asts) + + expect(context).toContain("- doSomething(x, y)") + }) + + it("should format interface with extends", () => { + const structure: ProjectStructure = { + name: "test", + rootPath: "/test", + files: ["types.ts"], + directories: [], + } + const asts = new Map([ + [ + "types.ts", + { + imports: [], + exports: [], + functions: [], + classes: [], + interfaces: [ + { + name: "AdminUser", + lineStart: 1, + lineEnd: 5, + properties: [], + extends: ["User", "Admin"], + isExported: true, + }, + ], + typeAliases: [], + parseError: false, + }, + ], + ]) + + const context = buildInitialContext(structure, asts) + + expect(context).toContain("- interface AdminUser extends User, Admin") + }) + }) }) diff --git a/packages/ipuaro/tests/unit/shared/context-config.test.ts b/packages/ipuaro/tests/unit/shared/context-config.test.ts index 8815e11..0b023ed 100644 --- a/packages/ipuaro/tests/unit/shared/context-config.test.ts +++ b/packages/ipuaro/tests/unit/shared/context-config.test.ts @@ -15,6 +15,7 @@ describe("ContextConfigSchema", () => { maxContextUsage: 0.8, autoCompressAt: 0.8, compressionMethod: "llm-summary", + includeSignatures: true, }) }) @@ -26,6 +27,7 @@ describe("ContextConfigSchema", () => { maxContextUsage: 0.8, autoCompressAt: 0.8, compressionMethod: "llm-summary", + includeSignatures: true, }) }) }) @@ -162,6 +164,7 @@ describe("ContextConfigSchema", () => { maxContextUsage: 0.8, autoCompressAt: 0.8, compressionMethod: "llm-summary", + includeSignatures: true, }) }) @@ -175,6 +178,7 @@ describe("ContextConfigSchema", () => { maxContextUsage: 0.8, autoCompressAt: 0.9, compressionMethod: "llm-summary", + includeSignatures: true, }) }) @@ -189,6 +193,7 @@ describe("ContextConfigSchema", () => { maxContextUsage: 0.7, autoCompressAt: 0.8, compressionMethod: "truncate", + includeSignatures: true, }) }) }) @@ -200,6 +205,7 @@ describe("ContextConfigSchema", () => { maxContextUsage: 0.9, autoCompressAt: 0.85, compressionMethod: "truncate" as const, + includeSignatures: false, } const result = ContextConfigSchema.parse(config) @@ -212,10 +218,36 @@ describe("ContextConfigSchema", () => { maxContextUsage: 0.8, autoCompressAt: 0.8, compressionMethod: "llm-summary" as const, + includeSignatures: true, } const result = ContextConfigSchema.parse(config) expect(result).toEqual(config) }) }) + + describe("includeSignatures", () => { + it("should accept true", () => { + const result = ContextConfigSchema.parse({ includeSignatures: true }) + expect(result.includeSignatures).toBe(true) + }) + + it("should accept false", () => { + const result = ContextConfigSchema.parse({ includeSignatures: false }) + expect(result.includeSignatures).toBe(false) + }) + + it("should default to true", () => { + const result = ContextConfigSchema.parse({}) + expect(result.includeSignatures).toBe(true) + }) + + it("should reject non-boolean", () => { + expect(() => ContextConfigSchema.parse({ includeSignatures: "true" })).toThrow() + }) + + it("should reject number", () => { + expect(() => ContextConfigSchema.parse({ includeSignatures: 1 })).toThrow() + }) + }) })