feat(ipuaro): implement v0.1.0 foundation

- Project setup with tsup, vitest, ESM support
- Domain entities: Session, Project
- Value objects: FileData, FileAST, FileMeta, ChatMessage, ToolCall, ToolResult, UndoEntry
- Service interfaces: IStorage, ILLMClient, ITool, IIndexer, IToolRegistry
- Shared: Config (zod), IpuaroError, utils (hash, tokens), Result type
- CLI with placeholder commands (start, init, index)
- 91 unit tests with 100% coverage
- Fix package scope @puaros -> @samiyev in CLAUDE.md
This commit is contained in:
imfozilbek
2025-11-29 23:08:38 +05:00
parent 7f6180df37
commit 130a8c4f24
62 changed files with 4629 additions and 6 deletions

View File

@@ -0,0 +1,80 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
import { loadConfig, validateConfig, getConfigErrors } from "../../../../src/shared/config/loader.js"
import { DEFAULT_CONFIG } from "../../../../src/shared/constants/config.js"
import * as fs from "node:fs"
vi.mock("node:fs")
describe("config loader", () => {
beforeEach(() => {
vi.resetAllMocks()
})
afterEach(() => {
vi.restoreAllMocks()
})
describe("loadConfig", () => {
it("should return default config when no files exist", () => {
vi.mocked(fs.existsSync).mockReturnValue(false)
const config = loadConfig("/project")
expect(config).toEqual(DEFAULT_CONFIG)
})
it("should merge project config with defaults", () => {
vi.mocked(fs.existsSync).mockImplementation((path) => {
return path === "/project/.ipuaro.json"
})
vi.mocked(fs.readFileSync).mockReturnValue(
JSON.stringify({ llm: { model: "custom-model" } })
)
const config = loadConfig("/project")
expect(config.llm.model).toBe("custom-model")
expect(config.redis.host).toBe("localhost")
})
it("should handle invalid JSON gracefully", () => {
vi.mocked(fs.existsSync).mockReturnValue(true)
vi.mocked(fs.readFileSync).mockReturnValue("invalid json")
const config = loadConfig("/project")
expect(config).toEqual(DEFAULT_CONFIG)
})
})
describe("validateConfig", () => {
it("should return true for valid config", () => {
expect(validateConfig(DEFAULT_CONFIG)).toBe(true)
})
it("should return true for partial valid config", () => {
expect(validateConfig({ redis: { host: "redis.local" } })).toBe(true)
})
it("should return false for invalid config", () => {
expect(validateConfig({ redis: { port: "not a number" } })).toBe(false)
})
})
describe("getConfigErrors", () => {
it("should return empty array for valid config", () => {
const errors = getConfigErrors(DEFAULT_CONFIG)
expect(errors).toHaveLength(0)
})
it("should return errors for invalid config", () => {
const errors = getConfigErrors({
redis: { port: "invalid" },
})
expect(errors.length).toBeGreaterThan(0)
expect(errors[0]).toContain("redis.port")
})
})
})

View File

@@ -0,0 +1,48 @@
import { describe, it, expect } from "vitest"
import { MESSAGES } from "../../../../src/shared/constants/messages.js"
describe("MESSAGES", () => {
it("should have status messages", () => {
expect(MESSAGES.STATUS_READY).toBe("Ready")
expect(MESSAGES.STATUS_THINKING).toBe("Thinking...")
expect(MESSAGES.STATUS_INDEXING).toBe("Indexing...")
expect(MESSAGES.STATUS_ERROR).toBe("Error")
})
it("should have error messages", () => {
expect(MESSAGES.ERROR_REDIS_UNAVAILABLE).toContain("Redis")
expect(MESSAGES.ERROR_OLLAMA_UNAVAILABLE).toContain("Ollama")
expect(MESSAGES.ERROR_MODEL_NOT_FOUND).toContain("Model")
expect(MESSAGES.ERROR_FILE_NOT_FOUND).toBe("File not found")
expect(MESSAGES.ERROR_PARSE_FAILED).toContain("parse")
expect(MESSAGES.ERROR_TOOL_FAILED).toContain("Tool")
expect(MESSAGES.ERROR_COMMAND_BLACKLISTED).toContain("blacklisted")
expect(MESSAGES.ERROR_PATH_OUTSIDE_PROJECT).toContain("outside")
})
it("should have confirmation messages", () => {
expect(MESSAGES.CONFIRM_APPLY_EDIT).toContain("Apply")
expect(MESSAGES.CONFIRM_DELETE_FILE).toContain("Delete")
expect(MESSAGES.CONFIRM_RUN_COMMAND).toContain("Run")
expect(MESSAGES.CONFIRM_CREATE_FILE).toContain("Create")
expect(MESSAGES.CONFIRM_GIT_COMMIT).toContain("commit")
})
it("should have info messages", () => {
expect(MESSAGES.INFO_SESSION_LOADED).toContain("loaded")
expect(MESSAGES.INFO_SESSION_CREATED).toContain("created")
expect(MESSAGES.INFO_INDEXING_COMPLETE).toContain("complete")
expect(MESSAGES.INFO_EDIT_APPLIED).toContain("applied")
expect(MESSAGES.INFO_EDIT_CANCELLED).toContain("cancelled")
expect(MESSAGES.INFO_UNDO_SUCCESS).toContain("reverted")
expect(MESSAGES.INFO_UNDO_EMPTY).toContain("Nothing")
})
it("should have help text", () => {
expect(MESSAGES.HELP_COMMANDS).toContain("/help")
expect(MESSAGES.HELP_COMMANDS).toContain("/clear")
expect(MESSAGES.HELP_COMMANDS).toContain("/undo")
expect(MESSAGES.HELP_HOTKEYS).toContain("Ctrl+C")
expect(MESSAGES.HELP_HOTKEYS).toContain("Ctrl+D")
})
})

View File

@@ -0,0 +1,86 @@
import { describe, it, expect } from "vitest"
import { IpuaroError } from "../../../../src/shared/errors/IpuaroError.js"
describe("IpuaroError", () => {
describe("constructor", () => {
it("should create error with all fields", () => {
const error = new IpuaroError("file", "Not found", true, "Check path")
expect(error.name).toBe("IpuaroError")
expect(error.type).toBe("file")
expect(error.message).toBe("Not found")
expect(error.recoverable).toBe(true)
expect(error.suggestion).toBe("Check path")
})
it("should default recoverable to true", () => {
const error = new IpuaroError("parse", "Parse failed")
expect(error.recoverable).toBe(true)
})
})
describe("static factories", () => {
it("should create redis error", () => {
const error = IpuaroError.redis("Connection failed")
expect(error.type).toBe("redis")
expect(error.recoverable).toBe(false)
expect(error.suggestion).toContain("Redis")
})
it("should create parse error", () => {
const error = IpuaroError.parse("Syntax error", "test.ts")
expect(error.type).toBe("parse")
expect(error.message).toContain("test.ts")
expect(error.recoverable).toBe(true)
})
it("should create parse error without file", () => {
const error = IpuaroError.parse("Syntax error")
expect(error.message).toBe("Syntax error")
})
it("should create llm error", () => {
const error = IpuaroError.llm("Timeout")
expect(error.type).toBe("llm")
expect(error.recoverable).toBe(true)
expect(error.suggestion).toContain("Ollama")
})
it("should create file error", () => {
const error = IpuaroError.file("Not found")
expect(error.type).toBe("file")
})
it("should create command error", () => {
const error = IpuaroError.command("Blacklisted")
expect(error.type).toBe("command")
})
it("should create conflict error", () => {
const error = IpuaroError.conflict("File changed")
expect(error.type).toBe("conflict")
expect(error.suggestion).toContain("Regenerate")
})
it("should create validation error", () => {
const error = IpuaroError.validation("Invalid param")
expect(error.type).toBe("validation")
})
it("should create timeout error", () => {
const error = IpuaroError.timeout("Request timeout")
expect(error.type).toBe("timeout")
expect(error.suggestion).toContain("timeout")
})
})
})

View File

@@ -0,0 +1,51 @@
import { describe, it, expect } from "vitest"
import { ok, err, isOk, isErr, type Result } from "../../../../src/shared/types/index.js"
describe("Result type", () => {
describe("ok", () => {
it("should create success result", () => {
const result = ok("data")
expect(result.success).toBe(true)
expect(result.data).toBe("data")
})
})
describe("err", () => {
it("should create error result", () => {
const error = new Error("failed")
const result = err(error)
expect(result.success).toBe(false)
expect(result.error).toBe(error)
})
})
describe("isOk", () => {
it("should return true for success", () => {
const result: Result<string> = ok("data")
expect(isOk(result)).toBe(true)
})
it("should return false for error", () => {
const result: Result<string> = err(new Error("fail"))
expect(isOk(result)).toBe(false)
})
})
describe("isErr", () => {
it("should return true for error", () => {
const result: Result<string> = err(new Error("fail"))
expect(isErr(result)).toBe(true)
})
it("should return false for success", () => {
const result: Result<string> = ok("data")
expect(isErr(result)).toBe(false)
})
})
})

View File

@@ -0,0 +1,56 @@
import { describe, it, expect } from "vitest"
import { md5, hashLines, shortHash } from "../../../../src/shared/utils/hash.js"
describe("hash utils", () => {
describe("md5", () => {
it("should return consistent hash for same input", () => {
const hash1 = md5("hello")
const hash2 = md5("hello")
expect(hash1).toBe(hash2)
})
it("should return different hash for different input", () => {
const hash1 = md5("hello")
const hash2 = md5("world")
expect(hash1).not.toBe(hash2)
})
it("should return 32 character hex string", () => {
const hash = md5("test")
expect(hash).toHaveLength(32)
expect(hash).toMatch(/^[a-f0-9]+$/)
})
})
describe("hashLines", () => {
it("should hash joined lines", () => {
const lines = ["line1", "line2", "line3"]
const hash = hashLines(lines)
expect(hash).toBe(md5("line1\nline2\nline3"))
})
it("should handle empty array", () => {
const hash = hashLines([])
expect(hash).toBe(md5(""))
})
})
describe("shortHash", () => {
it("should return truncated hash", () => {
const hash = shortHash("test")
expect(hash).toHaveLength(8)
})
it("should accept custom length", () => {
const hash = shortHash("test", 12)
expect(hash).toHaveLength(12)
})
})
})

View File

@@ -0,0 +1,61 @@
import { describe, it, expect } from "vitest"
import {
estimateTokens,
estimateTokensForLines,
truncateToTokens,
formatTokenCount,
} from "../../../../src/shared/utils/tokens.js"
describe("tokens utils", () => {
describe("estimateTokens", () => {
it("should estimate ~4 chars per token", () => {
expect(estimateTokens("")).toBe(0)
expect(estimateTokens("test")).toBe(1)
expect(estimateTokens("12345678")).toBe(2)
})
it("should round up", () => {
expect(estimateTokens("12345")).toBe(2)
})
})
describe("estimateTokensForLines", () => {
it("should estimate tokens for array of lines", () => {
const lines = ["line1", "line2"]
const expected = estimateTokens("line1\nline2")
expect(estimateTokensForLines(lines)).toBe(expected)
})
it("should handle empty array", () => {
expect(estimateTokensForLines([])).toBe(0)
})
})
describe("truncateToTokens", () => {
it("should not truncate short text", () => {
const text = "short"
expect(truncateToTokens(text, 10)).toBe(text)
})
it("should truncate long text", () => {
const text = "a".repeat(100)
const result = truncateToTokens(text, 10)
expect(result).toBe("a".repeat(40) + "...")
})
})
describe("formatTokenCount", () => {
it("should format small numbers as-is", () => {
expect(formatTokenCount(500)).toBe("500")
expect(formatTokenCount(999)).toBe("999")
})
it("should format thousands with k suffix", () => {
expect(formatTokenCount(1000)).toBe("1.0k")
expect(formatTokenCount(1500)).toBe("1.5k")
expect(formatTokenCount(12345)).toBe("12.3k")
})
})
})