From 5a22cd5c9bc0ab7e320731b6d1668913a82de520 Mon Sep 17 00:00:00 2001 From: imfozilbek Date: Fri, 5 Dec 2025 13:14:51 +0500 Subject: [PATCH] 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. --- packages/ipuaro/ROADMAP.md | 14 +- .../src/domain/value-objects/FileAST.ts | 25 ++ .../src/infrastructure/indexer/ASTParser.ts | 79 +++++ .../indexer/tree-sitter-types.ts | 6 + .../ipuaro/src/infrastructure/llm/prompts.ts | 42 +++ .../infrastructure/indexer/ASTParser.test.ts | 134 ++++++++ .../unit/infrastructure/llm/prompts.test.ts | 285 +++++++++++++++++- 7 files changed, 575 insertions(+), 10 deletions(-) diff --git a/packages/ipuaro/ROADMAP.md b/packages/ipuaro/ROADMAP.md index ac72a10..dbbf882 100644 --- a/packages/ipuaro/ROADMAP.md +++ b/packages/ipuaro/ROADMAP.md @@ -1782,7 +1782,7 @@ export interface ScanResult { ## Version 0.24.0 - Rich Initial Context 📋 **Priority:** HIGH -**Status:** In Progress (2/4 complete) +**Status:** In Progress (3/4 complete) 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,9 +1852,9 @@ 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. @@ -2077,9 +2077,9 @@ sessions:list # List --- -**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) +**Next Milestones:** v0.24.0 (Rich Context - 3/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/domain/value-objects/FileAST.ts b/packages/ipuaro/src/domain/value-objects/FileAST.ts index 46f1d4c..2d3d8b8 100644 --- a/packages/ipuaro/src/domain/value-objects/FileAST.ts +++ b/packages/ipuaro/src/domain/value-objects/FileAST.ts @@ -133,6 +133,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 +168,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 +184,7 @@ export function createEmptyFileAST(): FileAST { classes: [], interfaces: [], typeAliases: [], + enums: [], parseError: false, } } diff --git a/packages/ipuaro/src/infrastructure/indexer/ASTParser.ts b/packages/ipuaro/src/infrastructure/indexer/ASTParser.ts index 6cc8377..820eb82 100644 --- a/packages/ipuaro/src/infrastructure/indexer/ASTParser.ts +++ b/packages/ipuaro/src/infrastructure/indexer/ASTParser.ts @@ -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 } } @@ -275,6 +281,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 @@ -565,6 +575,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) diff --git a/packages/ipuaro/src/infrastructure/indexer/tree-sitter-types.ts b/packages/ipuaro/src/infrastructure/indexer/tree-sitter-types.ts index e02be69..fd2bdee 100644 --- a/packages/ipuaro/src/infrastructure/indexer/tree-sitter-types.ts +++ b/packages/ipuaro/src/infrastructure/indexer/tree-sitter-types.ts @@ -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", diff --git a/packages/ipuaro/src/infrastructure/llm/prompts.ts b/packages/ipuaro/src/infrastructure/llm/prompts.ts index 5e3decb..39a8a15 100644 --- a/packages/ipuaro/src/infrastructure/llm/prompts.ts +++ b/packages/ipuaro/src/infrastructure/llm/prompts.ts @@ -240,6 +240,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. */ @@ -297,6 +328,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 +367,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}` } diff --git a/packages/ipuaro/tests/unit/infrastructure/indexer/ASTParser.test.ts b/packages/ipuaro/tests/unit/infrastructure/indexer/ASTParser.test.ts index 0f470de..1fa5421 100644 --- a/packages/ipuaro/tests/unit/infrastructure/indexer/ASTParser.test.ts +++ b/packages/ipuaro/tests/unit/infrastructure/indexer/ASTParser.test.ts @@ -562,4 +562,138 @@ 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) + }) + }) }) diff --git a/packages/ipuaro/tests/unit/infrastructure/llm/prompts.test.ts b/packages/ipuaro/tests/unit/infrastructure/llm/prompts.test.ts index 9113d48..9ed40e8 100644 --- a/packages/ipuaro/tests/unit/infrastructure/llm/prompts.test.ts +++ b/packages/ipuaro/tests/unit/infrastructure/llm/prompts.test.ts @@ -1191,9 +1191,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 +1492,285 @@ 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([ + [ + "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([ + [ + "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([ + [ + "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([ + [ + "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([ + [ + "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([ + [ + "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([ + [ + "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") + }) + }) })