Files
pi-map/tests/llm-integration.test.ts
alex 2f198ea0d2 feat: remove heuristics, go LLM-only with mocks + restructure
BREAKING: Heuristic fallback removed — LLM client now required

Changes:
- Remove extractFileHeuristic, extractPackageHeuristic, all helpers
- extractFileLLM / extractPackageLLM now throw LLMError when client missing
- Add hybrid binary detection: extension blacklist + content sniffing
- Increase file size limit: 50KB → 500KB (text files only)
- Restructure src/ into subdirectories:
  - src/llm/ — all LLM clients, extract, batch, cache, error
  - src/ast/ — AST extraction
  - src/cli/ — CLI entry point
- Update all imports across codebase and tests
- Add tests/mock-llm.ts helper for deterministic mock clients
- Update all tests to use mock LLM clients (no heuristics dependency)
- All 52 tests passing (including 8 real LLM integration tests)
2026-06-10 17:34:37 +02:00

179 lines
5.8 KiB
TypeScript

import { describe, it, expect } from "vitest";
import { writeFileSync, mkdtempSync, readFileSync } from "fs";
import { join } from "path";
import { tmpdir } from "os";
import { createLLMClient } from "../src/llm/llm-client.js";
import { extractFileLLM, extractPackageLLM } from "../src/llm/llm-extract.js";
import { processFiles } from "../src/llm/llm-batch.js";
// Load .env file manually (no dotenv dependency needed)
function loadEnv(): Record<string, string> {
const env: Record<string, string> = {};
try {
const content = readFileSync(".env", "utf8");
for (const line of content.split("\n")) {
const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
if (match) env[match[1]] = match[2];
}
} catch {
// No .env file
}
return env;
}
const env = loadEnv();
const kimiKey = env.KIMI_API_KEY || process.env.KIMI_API_KEY;
const kimiModel =
env.KIMI_MODEL ||
env.LLM_MODEL ||
process.env.KIMI_MODEL ||
process.env.LLM_MODEL ||
"kimi-k2-6";
const hasKimiKey = !!kimiKey;
// Set env vars so the clients can pick them up
if (env.KIMI_API_KEY) process.env.KIMI_API_KEY = env.KIMI_API_KEY;
if (env.LLM_MODEL) process.env.LLM_MODEL = env.LLM_MODEL;
describe.skipIf(!hasKimiKey)("LLM integration with Kimi", () => {
it("creates kimi client and calls complete", async () => {
const client = createLLMClient("kimi", { model: kimiModel });
const response = await client.complete(
"PURPOSE: test\nAnalyze this: export const x = 1;",
);
expect(typeof response).toBe("string");
expect(response.length).toBeGreaterThan(0);
console.log(" complete() response:", response.slice(0, 120));
}, 30000);
it("extracts file purpose with real LLM", async () => {
const dir = mkdtempSync(join(tmpdir(), "pi-map-llm-"));
const file = join(dir, "config.ts");
writeFileSync(
file,
`export const API_URL = "https://api.example.com";\nexport const TIMEOUT = 5000;`,
);
const client = createLLMClient("kimi", { model: kimiModel });
const result = await extractFileLLM(file, client, dir);
expect(result.purpose).toBeTruthy();
expect(result.purpose.length).toBeGreaterThan(5);
expect(Array.isArray(result.deps)).toBe(true);
expect(Array.isArray(result.concepts)).toBe(true);
console.log(" File purpose:", result.purpose);
console.log(" Concepts:", result.concepts.join(", ") || "none");
}, 30000);
it("extracts package role with real LLM", async () => {
const client = createLLMClient("kimi", { model: kimiModel });
const result = await extractPackageLLM(
"src/utils",
[
{ name: "http.ts", purpose: "HTTP client wrapper" },
{ name: "cache.ts", purpose: "In-memory cache" },
{ name: "retry.ts", purpose: "Retry logic with backoff" },
],
client,
);
expect(result.role).toBeTruthy();
expect(result.role.length).toBeGreaterThan(5);
expect(result.arch).toBeTruthy();
console.log(" Package role:", result.role);
console.log(" Package arch:", result.arch);
}, 30000);
it("caches LLM results on disk", async () => {
const dir = mkdtempSync(join(tmpdir(), "pi-map-llm-"));
const file = join(dir, "test.ts");
writeFileSync(file, `export const version = "1.0.0";`);
const client = createLLMClient("kimi", { model: kimiModel });
const result1 = await extractFileLLM(file, client, dir);
expect(result1.purpose).toBeTruthy();
// Second call should hit cache — much faster
const start = Date.now();
const result2 = await extractFileLLM(file, client, dir);
const elapsed = Date.now() - start;
expect(result2.purpose).toBe(result1.purpose);
expect(elapsed).toBeLessThan(500); // Cache hit should be fast
console.log(" Cache hit time:", elapsed, "ms");
}, 30000);
it("processes multiple files in parallel", async () => {
const dir = mkdtempSync(join(tmpdir(), "pi-map-llm-"));
const files: string[] = [];
for (let i = 0; i < 3; i++) {
const f = join(dir, `file${i}.ts`);
writeFileSync(
f,
`export const val${i} = ${i};\n// Some logic here\nexport function helper${i}() { return val${i}; }`,
);
files.push(f);
}
const client = createLLMClient("kimi", { model: kimiModel });
const start = Date.now();
const results = await processFiles(
files,
async (f) => extractFileLLM(f, client, dir),
{ concurrency: 3, maxRetries: 1, retryDelaysMs: [2000] },
);
const elapsed = Date.now() - start;
expect(results.length).toBe(3);
for (const r of results) {
expect(r.purpose).toBeTruthy();
expect(r.purpose.length).toBeGreaterThan(5);
}
console.log(" Parallel processing:", elapsed, "ms for 3 files");
}, 60000);
it("skips large files without calling LLM", async () => {
const dir = mkdtempSync(join(tmpdir(), "pi-map-llm-"));
const file = join(dir, "big.ts");
writeFileSync(file, "x".repeat(600 * 1024));
let calls = 0;
const trackingClient = createLLMClient("kimi", { model: kimiModel });
const originalComplete = trackingClient.complete.bind(trackingClient);
trackingClient.complete = async (...args) => {
calls++;
return originalComplete(...args);
};
const result = await extractFileLLM(file, trackingClient, dir);
expect(result.purpose).toBe("Large file");
expect(calls).toBe(0); // Should never call LLM for large files
});
});
describe("LLM integration without env vars", () => {
it("throws clear error when API key is missing", () => {
const saved = process.env.KIMI_API_KEY;
delete process.env.KIMI_API_KEY;
try {
expect(() => createLLMClient("kimi", { apiKey: undefined })).toThrow(
"No Kimi API key",
);
} finally {
if (saved) process.env.KIMI_API_KEY = saved;
}
});
it("throws clear error for OpenAI without key", () => {
const saved = process.env.OPENAI_API_KEY;
delete process.env.OPENAI_API_KEY;
try {
expect(() => createLLMClient("openai", { apiKey: undefined })).toThrow(
"No OpenAI API key",
);
} finally {
if (saved) process.env.OPENAI_API_KEY = saved;
}
});
});