Files
puaros/packages/ipuaro/tests/unit/infrastructure/indexer/ASTParser.test.ts
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

700 lines
24 KiB
TypeScript

import { describe, it, expect, beforeAll } from "vitest"
import { ASTParser } from "../../../../src/infrastructure/indexer/ASTParser.js"
describe("ASTParser", () => {
let parser: ASTParser
beforeAll(() => {
parser = new ASTParser()
})
describe("parse", () => {
it("should parse empty file", () => {
const ast = parser.parse("", "ts")
expect(ast.parseError).toBe(false)
expect(ast.imports).toHaveLength(0)
expect(ast.exports).toHaveLength(0)
expect(ast.functions).toHaveLength(0)
expect(ast.classes).toHaveLength(0)
})
it("should handle syntax errors gracefully", () => {
const code = "export function {{{ invalid"
const ast = parser.parse(code, "ts")
expect(ast.parseError).toBe(true)
expect(ast.parseErrorMessage).toBeDefined()
})
it("should return error for unsupported language", () => {
const ast = parser.parse("const x = 1", "py" as never)
expect(ast.parseError).toBe(true)
expect(ast.parseErrorMessage).toContain("Unsupported language")
})
})
describe("imports", () => {
it("should extract default import", () => {
const code = `import React from "react"`
const ast = parser.parse(code, "ts")
expect(ast.imports).toHaveLength(1)
expect(ast.imports[0]).toMatchObject({
name: "React",
from: "react",
isDefault: true,
type: "external",
})
})
it("should extract named imports", () => {
const code = `import { useState, useEffect } from "react"`
const ast = parser.parse(code, "ts")
expect(ast.imports).toHaveLength(2)
expect(ast.imports[0].name).toBe("useState")
expect(ast.imports[1].name).toBe("useEffect")
expect(ast.imports[0].isDefault).toBe(false)
})
it("should extract namespace import", () => {
const code = `import * as path from "path"`
const ast = parser.parse(code, "ts")
expect(ast.imports).toHaveLength(1)
expect(ast.imports[0].name).toBe("path")
expect(ast.imports[0].isDefault).toBe(false)
})
it("should classify internal imports", () => {
const code = `import { foo } from "./utils"`
const ast = parser.parse(code, "ts")
expect(ast.imports[0].type).toBe("internal")
})
it("should classify builtin imports", () => {
const code = `import * as fs from "node:fs"`
const ast = parser.parse(code, "ts")
expect(ast.imports[0].type).toBe("builtin")
})
it("should classify external imports", () => {
const code = `import lodash from "lodash"`
const ast = parser.parse(code, "ts")
expect(ast.imports[0].type).toBe("external")
})
})
describe("functions", () => {
it("should extract function declaration", () => {
const code = `function add(a: number, b: number): number {
return a + b
}`
const ast = parser.parse(code, "ts")
expect(ast.functions).toHaveLength(1)
expect(ast.functions[0]).toMatchObject({
name: "add",
isAsync: false,
isExported: false,
})
expect(ast.functions[0].lineStart).toBe(1)
expect(ast.functions[0].lineEnd).toBe(3)
})
it("should extract async function", () => {
const code = `async function fetchData() { return null }`
const ast = parser.parse(code, "ts")
expect(ast.functions[0].isAsync).toBe(true)
})
it("should extract exported function", () => {
const code = `export function main() {}`
const ast = parser.parse(code, "ts")
expect(ast.functions[0].isExported).toBe(true)
expect(ast.exports).toHaveLength(1)
expect(ast.exports[0].kind).toBe("function")
})
it("should extract arrow function", () => {
const code = `const add = (a: number, b: number) => a + b`
const ast = parser.parse(code, "ts")
expect(ast.functions).toHaveLength(1)
expect(ast.functions[0].name).toBe("add")
})
it("should extract function parameters", () => {
const code = `function test(a: string, b?: number, c = 10) {}`
const ast = parser.parse(code, "ts")
expect(ast.functions[0].params).toHaveLength(3)
expect(ast.functions[0].params[0]).toMatchObject({
name: "a",
optional: false,
})
expect(ast.functions[0].params[1]).toMatchObject({
name: "b",
optional: true,
})
})
})
describe("classes", () => {
it("should extract class declaration", () => {
const code = `class MyClass {
value: number
constructor() {}
getValue() {
return this.value
}
}`
const ast = parser.parse(code, "ts")
expect(ast.classes).toHaveLength(1)
expect(ast.classes[0]).toMatchObject({
name: "MyClass",
isExported: false,
isAbstract: false,
})
})
it("should extract exported class", () => {
const code = `export class Service {}`
const ast = parser.parse(code, "ts")
expect(ast.classes[0].isExported).toBe(true)
expect(ast.exports).toHaveLength(1)
expect(ast.exports[0].kind).toBe("class")
})
it("should extract class methods", () => {
const code = `class Service {
async fetch() {}
private process() {}
static create() {}
}`
const ast = parser.parse(code, "ts")
expect(ast.classes[0].methods.length).toBeGreaterThanOrEqual(1)
})
it("should extract class extends", () => {
const code = `class Child extends Parent {}`
const ast = parser.parse(code, "ts")
expect(ast.classes[0].extends).toBe("Parent")
})
})
describe("interfaces", () => {
it("should extract interface declaration", () => {
const code = `interface User {
name: string
age: number
}`
const ast = parser.parse(code, "ts")
expect(ast.interfaces).toHaveLength(1)
expect(ast.interfaces[0]).toMatchObject({
name: "User",
isExported: false,
})
})
it("should extract exported interface", () => {
const code = `export interface Config {}`
const ast = parser.parse(code, "ts")
expect(ast.interfaces[0].isExported).toBe(true)
})
it("should extract interface properties", () => {
const code = `interface Props {
value: string
onChange: (v: string) => void
}`
const ast = parser.parse(code, "ts")
expect(ast.interfaces[0].properties.length).toBeGreaterThanOrEqual(1)
})
})
describe("type aliases", () => {
it("should extract type alias", () => {
const code = `type ID = string | number`
const ast = parser.parse(code, "ts")
expect(ast.typeAliases).toHaveLength(1)
expect(ast.typeAliases[0]).toMatchObject({
name: "ID",
isExported: false,
})
})
it("should extract exported type alias", () => {
const code = `export type Status = "pending" | "done"`
const ast = parser.parse(code, "ts")
expect(ast.typeAliases[0].isExported).toBe(true)
})
it("should extract type alias definition (simple)", () => {
const code = `type UserId = string`
const ast = parser.parse(code, "ts")
expect(ast.typeAliases).toHaveLength(1)
expect(ast.typeAliases[0].definition).toBe("string")
})
it("should extract type alias definition (union)", () => {
const code = `type Status = "pending" | "active" | "done"`
const ast = parser.parse(code, "ts")
expect(ast.typeAliases).toHaveLength(1)
expect(ast.typeAliases[0].definition).toBe('"pending" | "active" | "done"')
})
it("should extract type alias definition (intersection)", () => {
const code = `type AdminUser = User & Admin`
const ast = parser.parse(code, "ts")
expect(ast.typeAliases).toHaveLength(1)
expect(ast.typeAliases[0].definition).toBe("User & Admin")
})
it("should extract type alias definition (object type)", () => {
const code = `type Point = { x: number; y: number }`
const ast = parser.parse(code, "ts")
expect(ast.typeAliases).toHaveLength(1)
expect(ast.typeAliases[0].definition).toBe("{ x: number; y: number }")
})
it("should extract type alias definition (function type)", () => {
const code = `type Handler = (event: Event) => void`
const ast = parser.parse(code, "ts")
expect(ast.typeAliases).toHaveLength(1)
expect(ast.typeAliases[0].definition).toBe("(event: Event) => void")
})
it("should extract type alias definition (generic)", () => {
const code = `type Result<T> = { success: boolean; data: T }`
const ast = parser.parse(code, "ts")
expect(ast.typeAliases).toHaveLength(1)
expect(ast.typeAliases[0].definition).toBe("{ success: boolean; data: T }")
})
it("should extract type alias definition (array)", () => {
const code = `type UserIds = string[]`
const ast = parser.parse(code, "ts")
expect(ast.typeAliases).toHaveLength(1)
expect(ast.typeAliases[0].definition).toBe("string[]")
})
it("should extract type alias definition (tuple)", () => {
const code = `type Pair = [string, number]`
const ast = parser.parse(code, "ts")
expect(ast.typeAliases).toHaveLength(1)
expect(ast.typeAliases[0].definition).toBe("[string, number]")
})
})
describe("exports", () => {
it("should extract named exports", () => {
const code = `
const foo = 1
const bar = 2
export { foo, bar }
`
const ast = parser.parse(code, "ts")
expect(ast.exports).toHaveLength(2)
})
it("should extract export default", () => {
const code = `export default function main() {}`
const ast = parser.parse(code, "ts")
expect(ast.exports.some((e) => e.isDefault)).toBe(true)
})
it("should extract exported const", () => {
const code = `export const VERSION = "1.0.0"`
const ast = parser.parse(code, "ts")
expect(ast.exports).toHaveLength(1)
expect(ast.exports[0].kind).toBe("variable")
})
})
describe("JavaScript support", () => {
it("should parse JavaScript file", () => {
const code = `
import React from "react"
function Component() {
return null
}
export default Component
`
const ast = parser.parse(code, "js")
expect(ast.parseError).toBe(false)
expect(ast.imports).toHaveLength(1)
expect(ast.functions).toHaveLength(1)
})
it("should parse JSX file", () => {
const code = `
import React from "react"
function App() {
return <div>Hello</div>
}
`
const ast = parser.parse(code, "jsx")
expect(ast.parseError).toBe(false)
})
})
describe("TSX support", () => {
it("should parse TSX file", () => {
const code = `
import React from "react"
interface Props {
name: string
}
export function Greeting({ name }: Props) {
return <h1>Hello, {name}!</h1>
}
`
const ast = parser.parse(code, "tsx")
expect(ast.parseError).toBe(false)
expect(ast.interfaces).toHaveLength(1)
expect(ast.functions).toHaveLength(1)
})
})
describe("import string formats", () => {
it("should handle single-quoted imports", () => {
const code = `import { foo } from './module'`
const ast = parser.parse(code, "ts")
expect(ast.imports).toHaveLength(1)
expect(ast.imports[0].from).toBe("./module")
})
it("should handle double-quoted imports", () => {
const code = `import { bar } from "./other"`
const ast = parser.parse(code, "ts")
expect(ast.imports).toHaveLength(1)
expect(ast.imports[0].from).toBe("./other")
})
})
describe("parameter types", () => {
it("should handle simple identifier parameters", () => {
const code = `const fn = (x) => x * 2`
const ast = parser.parse(code, "ts")
expect(ast.functions.length).toBeGreaterThanOrEqual(0)
})
it("should handle optional parameters with defaults", () => {
const code = `function greet(name: string = "World"): string { return name }`
const ast = parser.parse(code, "ts")
expect(ast.functions).toHaveLength(1)
const fn = ast.functions[0]
expect(fn.params.some((p) => p.hasDefault)).toBe(true)
})
it("should handle arrow function with untyped params", () => {
const code = `const add = (a, b) => a + b`
const ast = parser.parse(code, "ts")
expect(ast.functions.length).toBeGreaterThanOrEqual(0)
})
it("should handle multiple parameter types", () => {
const code = `
function mix(
required: string,
optional?: number,
withDefault: boolean = true
) {}
`
const ast = parser.parse(code, "ts")
expect(ast.functions).toHaveLength(1)
const fn = ast.functions[0]
expect(fn.params).toHaveLength(3)
expect(fn.params.some((p) => p.optional)).toBe(true)
expect(fn.params.some((p) => p.hasDefault)).toBe(true)
})
})
describe("complex file", () => {
it("should parse complex TypeScript file", () => {
const code = `
import * as fs from "node:fs"
import { join } from "node:path"
import type { Config } from "./types"
export interface Options {
root: string
verbose?: boolean
}
export type Result = { success: boolean }
export class Scanner {
private options: Options
constructor(options: Options) {
this.options = options
}
async scan(): Promise<string[]> {
return []
}
}
export function createScanner(options: Options): Scanner {
return new Scanner(options)
}
export const VERSION = "1.0.0"
`
const ast = parser.parse(code, "ts")
expect(ast.parseError).toBe(false)
expect(ast.imports.length).toBeGreaterThanOrEqual(2)
expect(ast.interfaces).toHaveLength(1)
expect(ast.typeAliases).toHaveLength(1)
expect(ast.classes).toHaveLength(1)
expect(ast.functions.length).toBeGreaterThanOrEqual(1)
expect(ast.exports.length).toBeGreaterThanOrEqual(4)
})
})
describe("JSON parsing", () => {
it("should extract top-level keys from JSON object", () => {
const json = `{
"name": "test",
"version": "1.0.0",
"dependencies": {},
"scripts": {}
}`
const ast = parser.parse(json, "json")
expect(ast.parseError).toBe(false)
expect(ast.exports).toHaveLength(4)
expect(ast.exports.map((e) => e.name)).toEqual([
"name",
"version",
"dependencies",
"scripts",
])
expect(ast.exports.every((e) => e.kind === "variable")).toBe(true)
})
it("should handle empty JSON object", () => {
const json = `{}`
const ast = parser.parse(json, "json")
expect(ast.parseError).toBe(false)
expect(ast.exports).toHaveLength(0)
})
})
describe("YAML parsing", () => {
it("should extract top-level keys from YAML", () => {
const yaml = `name: test
version: 1.0.0
dependencies:
foo: ^1.0.0
scripts:
test: vitest`
const ast = parser.parse(yaml, "yaml")
expect(ast.parseError).toBe(false)
expect(ast.exports.length).toBeGreaterThanOrEqual(4)
expect(ast.exports.map((e) => e.name)).toContain("name")
expect(ast.exports.map((e) => e.name)).toContain("version")
expect(ast.exports.every((e) => e.kind === "variable")).toBe(true)
})
it("should handle YAML array at root", () => {
const yaml = `- item1
- item2
- item3`
const ast = parser.parse(yaml, "yaml")
expect(ast.parseError).toBe(false)
expect(ast.exports).toHaveLength(1)
expect(ast.exports[0].name).toBe("(array)")
})
it("should handle empty YAML", () => {
const yaml = ``
const ast = parser.parse(yaml, "yaml")
expect(ast.parseError).toBe(false)
expect(ast.exports).toHaveLength(0)
})
it("should handle YAML with null content", () => {
const yaml = `null`
const ast = parser.parse(yaml, "yaml")
expect(ast.parseError).toBe(false)
expect(ast.exports).toHaveLength(0)
})
it("should handle invalid YAML with parse error", () => {
const yaml = `{invalid: yaml: syntax: [}`
const ast = parser.parse(yaml, "yaml")
expect(ast.parseError).toBe(true)
expect(ast.parseErrorMessage).toBeDefined()
})
it("should track correct line numbers for YAML keys", () => {
const yaml = `first: value1
second: value2
third: value3`
const ast = parser.parse(yaml, "yaml")
expect(ast.parseError).toBe(false)
expect(ast.exports).toHaveLength(3)
expect(ast.exports[0].line).toBe(1)
expect(ast.exports[1].line).toBe(2)
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)
})
})
})