@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
JavaScript
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);
}