UNPKG

@reliverse/rse

Version:

@reliverse/rse is your all-in-one companion for bootstrapping and improving any kind of projects (especially web apps built with frameworks like Next.js) — whether you're kicking off something new or upgrading an existing app. It is also a little AI-power

327 lines (326 loc) 8.75 kB
import path from "@reliverse/pathkit"; import fs from "@reliverse/relifso"; import { relinka } from "@reliverse/relinka"; import { confirmPrompt } from "@reliverse/rempts"; import { generateText } from "ai"; import { countTokens } from "gpt-tokenizer/model/gpt-4o-mini"; import { CIRCULAR_TRIGGERS, MAX_TOKENS, MODEL, MODEL_NAME } from "../ai-const.js"; function calculateTokens(content) { return countTokens(content); } function calculatePrice(tokenCount) { const costPerThousand = 0.15; return tokenCount / 1e3 * costPerThousand; } export async function agentRelinter(config, targetPaths, task) { try { if (task && CIRCULAR_TRIGGERS.some((keyword) => task.toLowerCase().includes(keyword))) { await handleCircularDependencies(targetPaths); return; } const lintFiles = await collectAllLintableFiles(targetPaths); if (lintFiles.length === 0) { relinka("info", "No recognized code files found in the specified paths."); return; } relinka( "info", `Found ${lintFiles.length} file(s). Sending them to rse AI (${MODEL_NAME})...` ); let promptDecision; const confirmDecision = config.relinterConfirm; if (confirmDecision === "promptEachFile") { promptDecision = true; } else if (confirmDecision === "promptOnce") { promptDecision = false; } const lintResults = await gatherLintSuggestions( lintFiles, task, promptDecision ); await writeSuggestionsToFile(lintResults); relinka("info", "Lint suggestions written to relinter.json"); } catch (err) { relinka("error", "Error:", err.message); process.exit(1); } } async function collectAllLintableFiles(pathsList) { const fileSet = /* @__PURE__ */ new Set(); for (const rawPath of pathsList) { const absolutePath = path.resolve(rawPath); const files = await collectLintableFiles(absolutePath); for (const f of files) { fileSet.add(f); } } return Array.from(fileSet); } export async function collectLintableFiles(dirOrFile) { const stats = await fs.stat(dirOrFile); if (stats.isFile()) { return isCodeFile(dirOrFile) ? [dirOrFile] : []; } const entries = await fs.readdir(dirOrFile); let results = []; for (const entry of entries) { const fullPath = path.join(dirOrFile, entry); const entryStats = await fs.stat(fullPath); if (entryStats.isDirectory()) { results = results.concat(await collectLintableFiles(fullPath)); } else if (entryStats.isFile() && isCodeFile(fullPath)) { results.push(fullPath); } } return results; } function isCodeFile(filename) { const recognizedExtensions = [ "js", "jsx", "ts", "tsx", "py", "java", "c", "cpp", "cc", "hpp", "cs", "go", "rs", "php", "rb", "m", "mm", "scala", "kt", "kts", "swift", "dart", "sh", "bash", "zsh", "lua", "el", "ex", "elm", "clj", "cljs", "coffee", "perl", "pm", "pl", "groovy", "gradle", "sql", "yml", "yaml", "toml", "ini", "config", "json", "jsonc", "xml", "html", "css", "scss", "sass", "dockerfile", "makefile", "cmake", "asm", "vue", "svelte", "pwn", "inc" ]; const ext = path.extname(filename).toLowerCase().replace(/^\./, ""); const base = path.basename(filename).toLowerCase(); return recognizedExtensions.includes(ext) || recognizedExtensions.includes(base); } export async function gatherLintSuggestions(files, task, promptDecision) { const results = []; for (const filePath of files) { const code = await fs.readFile(filePath, "utf-8"); const fileChunks = chunkFile(code, 150); for (const { content, offset } of fileChunks) { const suggestions = await requestLintSuggestions( filePath, content, offset, task, promptDecision ); results.push(...suggestions); } } return results; } function chunkFile(code, size) { const lines = code.split("\n"); const chunks = []; for (let i = 0; i < lines.length; i += size) { const slice = lines.slice(i, i + size); chunks.push({ content: slice.join("\n"), offset: i }); } return chunks; } async function requestLintSuggestions(filePath, chunk, offset, task, promptDecision) { let systemMessage = ` You are an ESLint-like reviewer for all kinds of programming languages. Return valid JSON array only. Each item must have the following fields: - filePath (string) - startLine (number) - endLine (number) - suggestion (string) - severity (one of: "error", "warning", "info") Keep line numbers relative to the full file, offset is ${offset}. `; if (task) { systemMessage += ` Additional instructions: ${task} `; } const combinedText = systemMessage + chunk; const tokenCount = calculateTokens(combinedText); const tokenCost = calculatePrice(tokenCount); if (promptDecision === false) { const confirmMsg = `Token usage for ${filePath} [offset ${offset}]: ${tokenCount} tokens (~$${tokenCost.toFixed( 4 )} USD)`; const confirmed = await confirmPrompt({ title: "Confirm Token Usage", content: confirmMsg }); if (!confirmed) { return []; } } const response = await generateText({ model: MODEL, messages: [ { role: "system", content: systemMessage }, { role: "user", content: `path: ${filePath} ${chunk}` } ], maxTokens: MAX_TOKENS }); let suggestions = []; let text = response.text; const codeFenceRegex = /```json\s*([\s\S]*?)\s*```/i; const match = codeFenceRegex.exec(text); if (match?.[1]) { text = match[1].trim(); } try { const parsed = JSON.parse(text); if (!Array.isArray(parsed)) { throw new Error(`rse AI (${MODEL_NAME}) did not return an array.`); } parsed.forEach((s) => { s.startLine += offset; s.endLine += offset; if (!s.severity || !["error", "warning", "info"].includes(s.severity)) { s.severity = "warning"; } }); suggestions = parsed; } catch { suggestions = [ { filePath, startLine: offset, endLine: offset, suggestion: `rse AI (${MODEL_NAME}) returned invalid JSON. Output: ${response.text}`, severity: "warning" } ]; } return suggestions; } export async function writeSuggestionsToFile(suggestions) { await fs.writeFile( "relinter.json", JSON.stringify(suggestions, null, 2), "utf-8" ); } async function handleCircularDependencies(targetPaths) { const lintFiles = await collectAllLintableFiles(targetPaths); if (lintFiles.length === 0) { relinka("info", "No recognized code files found in the specified paths."); return; } const adjacency = {}; for (const filePath of lintFiles) { adjacency[filePath] = []; const code = await fs.readFile(filePath, "utf-8"); const importRegex = /import\s+(?:(?:[\w*\s{},]+)\s+from\s+)?["']([^"']+)["']/g; let match; while ((match = importRegex.exec(code)) !== null) { const imported = match[1]; if (imported && (imported.startsWith("./") || imported.startsWith("../") || imported.startsWith("/"))) { const resolvedImport = path.resolve(path.dirname(filePath), imported); if (lintFiles.includes(resolvedImport)) { adjacency[filePath].push(resolvedImport); } } } } const visited = {}; const recStack = {}; const cycles = []; const dfs = (node, pathStack) => { visited[node] = true; recStack[node] = true; pathStack.push(node); for (const neighbor of adjacency[node] ?? []) { if (!visited[neighbor]) { dfs(neighbor, pathStack); } else if (recStack[neighbor]) { const cycleStartIndex = pathStack.indexOf(neighbor); const cycle = pathStack.slice(cycleStartIndex); cycles.push([...cycle, neighbor]); } } pathStack.pop(); recStack[node] = false; }; for (const filePath of lintFiles) { if (!visited[filePath]) { dfs(filePath, []); } } const suggestions = []; if (cycles.length === 0) { relinka("info", "No circular dependencies found."); } else { let i = 0; for (const cycle of cycles) { i++; if (cycle[0]) { suggestions.push({ filePath: cycle[0], startLine: 0, endLine: 0, suggestion: `Detected circular dependency #${i}: ${cycle.join(" -> ")}`, severity: "error" }); } } relinka("error", `Detected ${cycles.length} circular dependency(ies).`); } await writeSuggestionsToFile(suggestions); }