diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fa2a463..8fab83c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - node-version: [18, 20] + node-version: [20, 22] steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 diff --git a/package.json b/package.json index 141cc1b..9cdabf2 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "preflight-dev": "./bin/cli.js" }, "engines": { - "node": ">=18" + "node": ">=20" }, "scripts": { "build": "tsc", diff --git a/src/lib/config.ts b/src/lib/config.ts index fc9d8f2..5d68c9a 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -78,7 +78,7 @@ function loadConfig(): PreflightConfig { if (existsSync(configPath)) { try { const configYaml = readFileSync(configPath, "utf-8"); - const configData = yamlLoad(configYaml) as any; + const configData = yamlLoad(configYaml) as Partial | undefined; if (configData) { // Merge config data with defaults @@ -96,7 +96,7 @@ function loadConfig(): PreflightConfig { if (existsSync(triagePath)) { try { const triageYaml = readFileSync(triagePath, "utf-8"); - const triageData = yamlLoad(triageYaml) as any; + const triageData = yamlLoad(triageYaml) as Partial | undefined; if (triageData) { if (triageData.rules) config.triage.rules = { ...config.triage.rules, ...triageData.rules }; diff --git a/src/lib/contracts.ts b/src/lib/contracts.ts index e126805..2216c60 100644 --- a/src/lib/contracts.ts +++ b/src/lib/contracts.ts @@ -161,9 +161,13 @@ function extractOpenApiContracts(content: string, filePath: string, projectDir: const now = new Date().toISOString(); try { - const spec = filePath.endsWith(".json") ? JSON.parse(content) : yamlLoad(content) as any; + interface OpenApiSpec { + paths?: Record>; + components?: { schemas?: Record }; + } + const spec: OpenApiSpec = filePath.endsWith(".json") ? JSON.parse(content) : yamlLoad(content) as OpenApiSpec; if (spec?.paths) { - for (const [path, methods] of Object.entries(spec.paths as Record)) { + for (const [path, methods] of Object.entries(spec.paths)) { for (const method of Object.keys(methods)) { if (["get", "post", "put", "delete", "patch"].includes(method)) { const op = methods[method]; diff --git a/src/lib/embeddings.ts b/src/lib/embeddings.ts index 69b5883..2e06563 100644 --- a/src/lib/embeddings.ts +++ b/src/lib/embeddings.ts @@ -26,11 +26,16 @@ export function preprocessText(text: string): string { // --- Local Provider (Xenova/transformers) --- -let extractor: any = null; +/** Xenova feature-extraction pipeline instance */ +interface FeatureExtractor { + (text: string, options: { pooling: string; normalize: boolean }): Promise<{ data: Float32Array }>; +} + +let extractor: FeatureExtractor | null = null; -async function getExtractor(): Promise { +async function getExtractor(): Promise { if (!extractor) { - extractor = await pipeline("feature-extraction", "Xenova/all-MiniLM-L6-v2"); + extractor = await pipeline("feature-extraction", "Xenova/all-MiniLM-L6-v2") as unknown as FeatureExtractor; } return extractor; } @@ -90,9 +95,9 @@ class OpenAIEmbeddingProvider implements EmbeddingProvider { throw new Error(`OpenAI embeddings API error ${resp.status}: ${err}`); } - const data = await resp.json(); + const data = await resp.json() as { data: Array<{ index: number; embedding: number[] }> }; // Sort by index to preserve order - const sorted = data.data.sort((a: any, b: any) => a.index - b.index); + const sorted = data.data.sort((a, b) => a.index - b.index); for (const item of sorted) { results.push(item.embedding); } diff --git a/src/lib/git-extractor.ts b/src/lib/git-extractor.ts index 3caec97..79956fc 100644 --- a/src/lib/git-extractor.ts +++ b/src/lib/git-extractor.ts @@ -62,9 +62,10 @@ export function extractGitHistory( maxBuffer: 50 * 1024 * 1024, stdio: ["pipe", "pipe", "pipe"], }); - } catch (err: any) { + } catch (err: unknown) { // No commits or other git error - if (err.stdout) output = err.stdout; + const execErr = err as { stdout?: string }; + if (execErr.stdout) output = execErr.stdout; else return []; } diff --git a/src/lib/session-parser.ts b/src/lib/session-parser.ts index 76bc02f..93c8f24 100644 --- a/src/lib/session-parser.ts +++ b/src/lib/session-parser.ts @@ -36,20 +36,41 @@ const CORRECTION_PATTERNS = [ // ── Helpers ──────────────────────────────────────────────────────────────── +/** A single record from a Claude Code JSONL session file */ +interface SessionRecord { + type?: string; + subtype?: string; + timestamp?: string | number; + message?: { content?: unknown }; + content?: unknown; + model?: string; + gitBranch?: string; + sessionId?: string; + is_error?: boolean; + tool_use_id?: string; +} + +interface ContentBlock { + type: string; + text?: string; + name?: string; + input?: unknown; +} + function extractText(content: unknown): string { if (typeof content === "string") return content; if (Array.isArray(content)) { - return content - .filter((b: any) => b.type === "text" && typeof b.text === "string") - .map((b: any) => b.text) + return (content as ContentBlock[]) + .filter((b) => b.type === "text" && typeof b.text === "string") + .map((b) => b.text!) .join("\n"); } return ""; } -function extractToolUseBlocks(content: unknown): any[] { +function extractToolUseBlocks(content: unknown): ContentBlock[] { if (!Array.isArray(content)) return []; - return content.filter((b: any) => b.type === "tool_use"); + return (content as ContentBlock[]).filter((b) => b.type === "tool_use"); } function normalizeTimestamp(ts: unknown, fallback: string): string { @@ -185,9 +206,9 @@ export async function parseSessionAsync( for await (const line of rl) { lineNum++; if (!line.trim()) continue; - let obj: any; + let obj: SessionRecord; try { - obj = JSON.parse(line); + obj = JSON.parse(line) as SessionRecord; } catch { process.stderr.write(`[session-parser] malformed line ${lineNum} in ${filePath}\n`); continue; @@ -220,9 +241,9 @@ function parseLinesSync( for (let i = 0; i < lines.length; i++) { const line = lines[i].trim(); if (!line) continue; - let obj: any; + let obj: SessionRecord; try { - obj = JSON.parse(line); + obj = JSON.parse(line) as SessionRecord; } catch { process.stderr.write(`[session-parser] malformed line ${i + 1} in ${filePath}\n`); continue; @@ -242,7 +263,7 @@ function parseLinesSync( } function processRecord( - obj: any, + obj: SessionRecord, filePath: string, project: string, projectName: string, diff --git a/src/lib/state.ts b/src/lib/state.ts index 062ed69..baa8576 100644 --- a/src/lib/state.ts +++ b/src/lib/state.ts @@ -2,6 +2,9 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync, appendFileSync, sta import { join } from "path"; import { PROJECT_DIR } from "./files.js"; +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- generic JSON state needs flexible value types +export type JsonRecord = Record; + export const STATE_DIR = join(PROJECT_DIR, ".claude", "preflight-state"); /** Max log file size in bytes (5 MB). Triggers rotation. */ @@ -18,7 +21,7 @@ function ensureStateDir(): void { * Load a JSON state file by name (without extension). * Returns empty object if missing or corrupt. */ -export function loadState(name: string): Record { +export function loadState(name: string): JsonRecord { const p = join(STATE_DIR, `${name}.json`); if (!existsSync(p)) return {}; try { @@ -31,7 +34,7 @@ export function loadState(name: string): Record { /** * Save a JSON state file by name (without extension). */ -export function saveState(name: string, data: Record): void { +export function saveState(name: string, data: JsonRecord): void { ensureStateDir(); writeFileSync(join(STATE_DIR, `${name}.json`), JSON.stringify(data, null, 2)); } @@ -39,7 +42,7 @@ export function saveState(name: string, data: Record): void { /** * Append a JSONL entry to a log file. Rotates if file exceeds MAX_LOG_SIZE. */ -export function appendLog(filename: string, entry: Record): void { +export function appendLog(filename: string, entry: JsonRecord): void { ensureStateDir(); const logFile = join(STATE_DIR, filename); @@ -62,7 +65,7 @@ export function appendLog(filename: string, entry: Record): void { * Read a JSONL log file. Pass `lastN` to only return the last N entries * (still reads the file, but avoids allocating all parsed objects). */ -export function readLog(filename: string, lastN?: number): Record[] { +export function readLog(filename: string, lastN?: number): JsonRecord[] { const logFile = join(STATE_DIR, filename); if (!existsSync(logFile)) return []; try { @@ -70,7 +73,7 @@ export function readLog(filename: string, lastN?: number): Record[] if (!raw) return []; const lines = raw.split("\n"); const subset = lastN != null && lastN > 0 ? lines.slice(-lastN) : lines; - const results: Record[] = []; + const results: JsonRecord[] = []; for (const line of subset) { try { results.push(JSON.parse(line)); } catch { /* skip corrupt line */ } } diff --git a/src/tools/clarify-intent.ts b/src/tools/clarify-intent.ts index 32efa3a..95a4cf9 100644 --- a/src/tools/clarify-intent.ts +++ b/src/tools/clarify-intent.ts @@ -36,10 +36,10 @@ function getTestFailures(): string { const fp = join(PROJECT_DIR, p); if (existsSync(fp)) { try { - const data = JSON.parse(readFileSync(fp, "utf-8")); + const data = JSON.parse(readFileSync(fp, "utf-8")) as { testResults?: Array<{ status: string; name: string }> }; const failed = data.testResults - ?.filter((t: any) => t.status === "failed") - ?.map((t: any) => t.name) || []; + ?.filter((t) => t.status === "failed") + ?.map((t) => t.name) || []; return failed.length ? failed.join("\n") : "all passing"; } catch { continue; } } diff --git a/src/tools/estimate-cost.ts b/src/tools/estimate-cost.ts index 327491a..8d8d4a7 100644 --- a/src/tools/estimate-cost.ts +++ b/src/tools/estimate-cost.ts @@ -35,12 +35,19 @@ function estimateTokens(text: string): number { return Math.ceil(text.length / 4); } +interface ContentBlock { + type?: string; + text?: string; + name?: string; + input?: unknown; +} + function extractText(content: unknown): string { if (typeof content === "string") return content; if (Array.isArray(content)) { - return content - .filter((b: any) => typeof b.text === "string") - .map((b: any) => b.text) + return (content as ContentBlock[]) + .filter((b) => typeof b.text === "string") + .map((b) => b.text!) .join("\n"); } return ""; @@ -48,9 +55,9 @@ function extractText(content: unknown): string { function extractToolNames(content: unknown): string[] { if (!Array.isArray(content)) return []; - return content - .filter((b: any) => b.type === "tool_use" && b.name) - .map((b: any) => b.name as string); + return (content as ContentBlock[]) + .filter((b) => b.type === "tool_use" && b.name) + .map((b) => b.name!); } function formatTokens(n: number): string { @@ -106,7 +113,7 @@ function analyzeSessionFile(filePath: string): SessionAnalysis { let lastAssistantTokens = 0; for (const line of lines) { - let obj: any; + let obj: { type?: string; timestamp?: string | number; message?: { content?: unknown }; content?: unknown; model?: string; is_error?: boolean; tool_use_id?: string }; try { obj = JSON.parse(line); } catch { @@ -148,8 +155,8 @@ function analyzeSessionFile(filePath: string): SessionAnalysis { if (PREFLIGHT_TOOLS.has(name)) { result.preflightCalls++; // Estimate tool call tokens (name + args) - const toolBlocks = (msgContent as any[]).filter( - (b: any) => b.type === "tool_use" && b.name === name, + const toolBlocks = (msgContent as ContentBlock[]).filter( + (b) => b.type === "tool_use" && b.name === name, ); for (const tb of toolBlocks) { result.preflightTokens += estimateTokens( diff --git a/src/tools/search-history.ts b/src/tools/search-history.ts index 33f0f17..d028df8 100644 --- a/src/tools/search-history.ts +++ b/src/tools/search-history.ts @@ -3,6 +3,14 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { searchSemantic, listIndexedProjects } from "../lib/timeline-db.js"; import { getRelatedProjects } from "../lib/config.js"; import type { SearchScope } from "../types.js"; +import type { TimelineEvent } from "../lib/session-parser.js"; + +/** Search result with optional distance score from vector search */ +interface SearchResult extends TimelineEvent { + _distance?: number; + summary?: string; + commit_hash?: string; +} const RELATIVE_DATE_RE = /^(\d+)(days?|weeks?|months?|years?)$/; @@ -101,7 +109,7 @@ export function registerSearchHistory(server: McpServer) { // Post-filter by author (stored in metadata JSON) if (params.author) { const authorLower = params.author.toLowerCase(); - results = results.filter((r: any) => { + results = results.filter((r: SearchResult) => { try { const meta = JSON.parse(r.metadata || "{}"); return (meta.author || "").toLowerCase().includes(authorLower); @@ -113,14 +121,14 @@ export function registerSearchHistory(server: McpServer) { return { content: [{ type: "text", text: `## Search Results for "${params.query}"\n_No results found._` }] }; } - const projects = new Set(results.map((r: any) => r.project || "unknown")); + const projects = new Set(results.map((r: SearchResult) => r.project || "unknown")); const lines: string[] = [ `## Search Results for "${params.query}"`, `_${results.length} result${results.length !== 1 ? "s" : ""} across ${projects.size} project${projects.size !== 1 ? "s" : ""}_`, "", ]; - results.forEach((event: any, i: number) => { + results.forEach((event: SearchResult, i: number) => { const badge = TYPE_BADGES[event.type] || event.type; const ts = event.timestamp ? new Date(event.timestamp).toISOString().replace("T", " ").slice(0, 16) : "unknown"; const proj = event.project || "unknown"; diff --git a/src/tools/timeline-view.ts b/src/tools/timeline-view.ts index c4c4d14..b9bd8b4 100644 --- a/src/tools/timeline-view.ts +++ b/src/tools/timeline-view.ts @@ -3,6 +3,14 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { getTimeline, listIndexedProjects } from "../lib/timeline-db.js"; import { getRelatedProjects } from "../lib/config.js"; import type { SearchScope } from "../types.js"; +import type { TimelineEvent } from "../lib/session-parser.js"; + +/** Extended timeline event with optional fields populated by the DB layer */ +interface TimelineRecord extends TimelineEvent { + summary?: string; + commit_hash?: string; + tool_name?: string; +} const RELATIVE_DATE_RE = /^(\d+)(days?|weeks?|months?|years?)$/; @@ -102,7 +110,7 @@ export function registerTimeline(server: McpServer) { // Post-filter by author if (params.author) { const authorLower = params.author.toLowerCase(); - events = events.filter((e: any) => { + events = events.filter((e: TimelineRecord) => { if (e.type !== "commit") return true; // only filter commits try { const meta = JSON.parse(e.metadata || "{}"); @@ -116,7 +124,7 @@ export function registerTimeline(server: McpServer) { } // Group by day - const days = new Map(); + const days = new Map(); for (const event of events) { const day = event.timestamp ? new Date(event.timestamp).toISOString().slice(0, 10) : "unknown"; if (!days.has(day)) days.set(day, []); @@ -141,7 +149,7 @@ export function registerTimeline(server: McpServer) { lines.push(`### ${day}`); const dayEvents = days.get(day)!; // Sort by timestamp within day - dayEvents.sort((a: any, b: any) => { + dayEvents.sort((a: TimelineRecord, b: TimelineRecord) => { const ta = a.timestamp ? new Date(a.timestamp).getTime() : 0; const tb = b.timestamp ? new Date(b.timestamp).getTime() : 0; return ta - tb;