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") + }) + }) })