Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AgYRU3zDTqNGV5Jk8aiKLg
576 lines
No EOL
26 KiB
JavaScript
576 lines
No EOL
26 KiB
JavaScript
import * as fs from "node:fs";
|
|
import * as path from "node:path";
|
|
import { getWolfDir, ensureWolfDir, readJSON, writeJSON, readBugLogFile, extractDescription, estimateTokens, appendMarkdown, timeShort, readStdin, normalizePath, isSensitiveFile, getProjectDir, emitHookJSON, recordInjection, hookMain, getSessionFilePath } from "./shared.js";
|
|
import { loadStoreReconciled, saveStore, renderToFile, sha256 } from "./anatomy-store.js";
|
|
import { withAnatomyLock, HOOK_LOCK_BUDGET_MS } from "./anatomy-lock.js";
|
|
import { extractSymbols, symbolsSupported, SYMBOL_MIN_TOKENS } from "./symbol-extractor.js";
|
|
// File types where a value/string change is normal content editing, not a bug
|
|
// fix — auto bug detection never runs on these (see autoDetectBugFix). Without
|
|
// this, a version bump in a README or a key change in a JSON/YAML config is
|
|
// logged as a "wrong-value" bug, since the detector matches quoted spans
|
|
// (including markdown backticks) regardless of file type.
|
|
const NON_CODE_EXTS = new Set([
|
|
".md", ".mdx", ".markdown", ".txt", ".rst", ".adoc",
|
|
".json", ".jsonc", ".yaml", ".yml", ".toml", ".ini", ".env",
|
|
".lock", ".csv", ".tsv",
|
|
]);
|
|
async function main() {
|
|
ensureWolfDir();
|
|
const wolfDir = getWolfDir();
|
|
const projectRoot = getProjectDir();
|
|
const raw = await readStdin();
|
|
let input;
|
|
try {
|
|
input = JSON.parse(raw);
|
|
}
|
|
catch {
|
|
return;
|
|
}
|
|
const sessionFile = getSessionFilePath(input);
|
|
const toolName = input.tool_name ?? "Write";
|
|
const filePath = input.tool_input?.file_path ?? input.tool_input?.path ?? "";
|
|
if (!filePath) {
|
|
return;
|
|
}
|
|
const absolutePath = path.isAbsolute(filePath) ? filePath : path.join(projectRoot, filePath);
|
|
// .wolf/ state files skip anatomy/memory bookkeeping (self-referential),
|
|
// but 2.4 adds the budget-enforcement loop the platform's native memory
|
|
// uses: measure after every write, warn factually when a state file
|
|
// outgrows its budget. Oversized state gets injected/read every session,
|
|
// so bloat here is a recurring tax nothing else pushes back on.
|
|
const relPath = normalizePath(path.relative(projectRoot, absolutePath));
|
|
if (relPath.startsWith(".wolf/")) {
|
|
try {
|
|
const budget = stateBudgetFor(wolfDir, relPath);
|
|
if (budget !== null) {
|
|
const content = fs.readFileSync(absolutePath, "utf-8");
|
|
const tokens = estimateTokens(content, "prose");
|
|
if (tokens > budget) {
|
|
const session = readJSON(sessionFile, { files_written: [], edit_counts: {} });
|
|
const warned = (session.budget_warned ?? {});
|
|
if (!warned[relPath]) {
|
|
warned[relPath] = true;
|
|
session.budget_warned = warned;
|
|
const warn = `OpenWolf: ${relPath} is now ~${tokens} tokens; its budget is ${budget}. It is read at session starts, so size is a recurring cost. Consolidate or move detail into topic files, keeping the most recent and most important entries.`;
|
|
recordInjection(session, "budget_warn", warn);
|
|
writeJSON(sessionFile, session);
|
|
emitHookJSON("PostToolUse", { additionalContext: warn });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
catch { }
|
|
return;
|
|
}
|
|
// Never track files outside the project root (e.g. the Claude Code scratchpad under
|
|
// /private/tmp). path.relative() yields ../.. section keys that pollute anatomy.md and are
|
|
// wiped again by every full `openwolf scan`, so the index churns instead of converging.
|
|
if (relPath.startsWith("..") || path.isAbsolute(relPath)) {
|
|
return;
|
|
}
|
|
// Never track secret-bearing files in anatomy/memory (issue #54): .env is
|
|
// not the only file whose *description* would leak sensitive content.
|
|
const baseName = path.basename(absolutePath);
|
|
if (isSensitiveFile(baseName)) {
|
|
return;
|
|
}
|
|
const oldStr = input.tool_input?.old_string ?? "";
|
|
const newStr = input.tool_input?.new_string ?? "";
|
|
// 1. Update the anatomy store, then re-render anatomy.md from it.
|
|
// All of this happens under the anatomy lock; if the lock cannot be
|
|
// acquired within budget we skip — a later writer converges the state.
|
|
try {
|
|
const relPathLocal = normalizePath(path.relative(projectRoot, absolutePath));
|
|
let fileContent = "";
|
|
try {
|
|
fileContent = fs.readFileSync(absolutePath, "utf-8");
|
|
}
|
|
catch {
|
|
fileContent = input.tool_input?.content ?? "";
|
|
}
|
|
const desc = extractDescription(absolutePath).slice(0, 100);
|
|
const ext = path.extname(absolutePath).toLowerCase();
|
|
const codeExts = new Set([".ts", ".js", ".tsx", ".jsx", ".py", ".json", ".yaml", ".yml", ".css"]);
|
|
const proseExts = new Set([".md", ".txt", ".rst"]);
|
|
const type = codeExts.has(ext) ? "code" : proseExts.has(ext) ? "prose" : "mixed";
|
|
const tokens = estimateTokens(fileContent, type);
|
|
let size;
|
|
let mtimeMs;
|
|
try {
|
|
const st = fs.statSync(absolutePath);
|
|
size = st.size;
|
|
mtimeMs = st.mtimeMs;
|
|
}
|
|
catch { }
|
|
// Symbols are recomputed on every write (never carried over — the
|
|
// content just changed, so old line ranges would misdirect slice reads).
|
|
const symbols = tokens >= SYMBOL_MIN_TOKENS && symbolsSupported(ext)
|
|
? extractSymbols(fileContent, ext)
|
|
: undefined;
|
|
withAnatomyLock(wolfDir, HOOK_LOCK_BUDGET_MS, () => {
|
|
const store = loadStoreReconciled(wolfDir, projectRoot);
|
|
store.files[relPathLocal] = {
|
|
description: desc,
|
|
tokens,
|
|
hash: sha256(fileContent).slice(0, 16),
|
|
size,
|
|
mtimeMs,
|
|
updatedAt: new Date().toISOString(),
|
|
source: "hook",
|
|
symbols: symbols && symbols.length > 0 ? symbols : undefined,
|
|
};
|
|
store.meta.lastScanned = new Date().toISOString();
|
|
renderToFile(wolfDir, store);
|
|
saveStore(wolfDir, store);
|
|
});
|
|
}
|
|
catch { }
|
|
// 2. Append richer entry to memory.md
|
|
try {
|
|
const action = toolName === "Write" ? "Created" : toolName === "MultiEdit" ? "Multi-edited" : "Edited";
|
|
const relFile = normalizePath(path.relative(projectRoot, absolutePath));
|
|
const fileContent = input.tool_input?.content ?? "";
|
|
const ext = path.extname(absolutePath).toLowerCase();
|
|
const codeExts = new Set([".ts", ".js", ".tsx", ".jsx", ".py", ".json", ".yaml", ".yml", ".css"]);
|
|
const type = codeExts.has(ext) ? "code" : "mixed";
|
|
const writeTokens = estimateTokens(fileContent || newStr, type);
|
|
let changeDesc = "";
|
|
if (oldStr && newStr) {
|
|
changeDesc = summarizeEdit(oldStr, newStr, baseName);
|
|
}
|
|
const memoryPath = path.join(wolfDir, "memory.md");
|
|
const outcome = changeDesc || "—";
|
|
appendMarkdown(memoryPath, `| ${timeShort()} | ${action} ${relFile} | ${outcome} | ~${writeTokens} |\n`);
|
|
}
|
|
catch { }
|
|
// 3. Record in session tracker + track edit counts
|
|
try {
|
|
const session = readJSON(sessionFile, { files_written: [], edit_counts: {} });
|
|
if (!session.edit_counts)
|
|
session.edit_counts = {};
|
|
const normalizedFile = normalizePath(filePath);
|
|
const action = toolName === "Write" ? "create" : "edit";
|
|
const fileContent = input.tool_input?.content ?? "";
|
|
const tokens = estimateTokens(fileContent || newStr, "code");
|
|
session.files_written.push({
|
|
file: normalizedFile,
|
|
action,
|
|
tokens,
|
|
at: new Date().toISOString(),
|
|
});
|
|
const editKey = normalizePath(path.relative(projectRoot, absolutePath));
|
|
session.edit_counts[editKey] = (session.edit_counts[editKey] || 0) + 1;
|
|
if (session.files_read && session.files_read[normalizedFile]) {
|
|
delete session.files_read[normalizedFile];
|
|
}
|
|
// Once per file per session: firing on the 3rd edit AND every edit after
|
|
// it would hit ~39% of all write operations (measured) — pure noise.
|
|
if (!session.edit_warned)
|
|
session.edit_warned = {};
|
|
const editWarn = session.edit_counts[editKey] >= 3 && !session.edit_warned[editKey]
|
|
? `OpenWolf: ${baseName} has been edited ${session.edit_counts[editKey]} times this session. If you're fixing a bug, log it to .wolf/buglog.json.`
|
|
: "";
|
|
if (editWarn)
|
|
session.edit_warned[editKey] = true;
|
|
if (editWarn)
|
|
recordInjection(session, "edit_warn", editWarn);
|
|
writeJSON(sessionFile, session);
|
|
if (editWarn) {
|
|
emitHookJSON("PostToolUse", { additionalContext: editWarn });
|
|
}
|
|
}
|
|
catch { }
|
|
// 4. Auto-detect bug-fix patterns and log them
|
|
try {
|
|
if (oldStr && newStr) {
|
|
autoDetectBugFix(wolfDir, absolutePath, projectRoot, oldStr, newStr);
|
|
}
|
|
}
|
|
catch { }
|
|
}
|
|
/** Token budget for a .wolf state file, or null when unbudgeted (2.4). */
|
|
function stateBudgetFor(wolfDir, relPath) {
|
|
const defaults = {
|
|
".wolf/cerebrum.md": 2000,
|
|
".wolf/STATUS.md": 1000,
|
|
};
|
|
const cfg = readJSON(path.join(wolfDir, "config.json"), {});
|
|
const merged = { ...defaults, ...(cfg.openwolf?.context?.state_budgets ?? {}) };
|
|
return merged[relPath] ?? null;
|
|
}
|
|
// ─── Edit Summarizer ─────────────────────────────────────────────
|
|
function summarizeEdit(oldStr, newStr, filename) {
|
|
const oldLines = oldStr.split("\n");
|
|
const newLines = newStr.split("\n");
|
|
const oldCount = oldLines.length;
|
|
const newCount = newLines.length;
|
|
const ext = path.extname(filename).toLowerCase();
|
|
// --- Structural fixes ---
|
|
if (/\btry\b/.test(newStr) && hasCatchConstruct(newStr, ext) && !hasCatchConstruct(oldStr, ext)) {
|
|
return "added error handling";
|
|
}
|
|
if (newStr.includes("?.") && !oldStr.includes("?."))
|
|
return "added optional chaining";
|
|
if (newStr.includes("?? ") && !oldStr.includes("?? "))
|
|
return "added nullish coalescing";
|
|
// --- Deleted code ---
|
|
if (!newStr.trim() || newStr.trim().length < oldStr.trim().length * 0.2) {
|
|
return `removed ${oldCount} lines`;
|
|
}
|
|
// --- Import changes ---
|
|
const oldImports = oldLines.filter(l => /^\s*(import|require|use |from )/.test(l)).length;
|
|
const newImports = newLines.filter(l => /^\s*(import|require|use |from )/.test(l)).length;
|
|
if (newImports > oldImports && Math.abs(newCount - oldCount) <= newImports - oldImports + 1) {
|
|
return `added ${newImports - oldImports} import(s)`;
|
|
}
|
|
// --- Value/string replacement (common bug fix: wrong value) ---
|
|
if (oldCount === 1 && newCount === 1) {
|
|
const o = oldStr.trim();
|
|
const n = newStr.trim();
|
|
// String literal change
|
|
const oStr = o.match(/['"`]([^'"`]+)['"`]/);
|
|
const nStr = n.match(/['"`]([^'"`]+)['"`]/);
|
|
if (oStr && nStr && oStr[1] !== nStr[1]) {
|
|
return `"${oStr[1].slice(0, 25)}" → "${nStr[1].slice(0, 25)}"`;
|
|
}
|
|
// Number change
|
|
const oNum = o.match(/\b(\d+\.?\d*)\b/);
|
|
const nNum = n.match(/\b(\d+\.?\d*)\b/);
|
|
if (oNum && nNum && oNum[1] !== nNum[1] && o.replace(oNum[1], "") === n.replace(nNum[1], "")) {
|
|
return `${oNum[1]} → ${nNum[1]}`;
|
|
}
|
|
return "inline fix";
|
|
}
|
|
// --- Method/function call changes ---
|
|
const oldCalls = extractCalls(oldStr);
|
|
const newCalls = extractCalls(newStr);
|
|
const addedCalls = newCalls.filter(c => !oldCalls.includes(c));
|
|
const removedCalls = oldCalls.filter(c => !newCalls.includes(c));
|
|
if (removedCalls.length === 1 && addedCalls.length === 1) {
|
|
return `${removedCalls[0]}() → ${addedCalls[0]}()`;
|
|
}
|
|
// --- CSS/style changes ---
|
|
if (ext === ".css" || ext === ".scss" || ext === ".vue" || ext === ".tsx" || ext === ".jsx") {
|
|
const oldProps = (oldStr.match(/[\w-]+\s*:/g) || []).map(p => p.replace(/\s*:/, ""));
|
|
const newProps = (newStr.match(/[\w-]+\s*:/g) || []).map(p => p.replace(/\s*:/, ""));
|
|
const changed = newProps.filter(p => !oldProps.includes(p));
|
|
if (changed.length > 0 && changed.length <= 3) {
|
|
return `CSS: ${changed.join(", ")}`;
|
|
}
|
|
}
|
|
// --- Condition changes ---
|
|
const oldConds = (oldStr.match(/if\s*\(([^)]+)\)/g) || []);
|
|
const newConds = (newStr.match(/if\s*\(([^)]+)\)/g) || []);
|
|
if (newConds.length > oldConds.length) {
|
|
return `added ${newConds.length - oldConds.length} condition(s)`;
|
|
}
|
|
// --- Function modified ---
|
|
const fnMatch = newStr.match(/(?:function|def|fn|func|async\s+function)\s+(\w+)/);
|
|
if (fnMatch) {
|
|
return `modified ${fnMatch[1]}()`;
|
|
}
|
|
// --- Class/method context ---
|
|
const methodMatch = newStr.match(/(?:public|private|protected)?\s*(?:async\s+)?(\w+)\s*\([^)]*\)\s*[:{]/);
|
|
if (methodMatch) {
|
|
return `modified ${methodMatch[1]}()`;
|
|
}
|
|
// --- Size-based fallback ---
|
|
if (newCount > oldCount + 5)
|
|
return `expanded (+${newCount - oldCount} lines)`;
|
|
if (oldCount > newCount + 5)
|
|
return `reduced (-${oldCount - newCount} lines)`;
|
|
return `${oldCount}→${newCount} lines`;
|
|
}
|
|
function extractCalls(code) {
|
|
return [...new Set((code.match(/(\w+)\s*\(/g) || [])
|
|
.map(m => m.match(/(\w+)/)?.[1] || "")
|
|
.filter(n => n.length > 2 && !["if", "for", "while", "switch", "catch", "function", "return", "new", "typeof", "instanceof", "const", "let", "var"].includes(n)))];
|
|
}
|
|
// ─── Auto Bug Detection ──────────────────────────────────────────
|
|
function bugAutoDetectEnabled(wolfDir) {
|
|
try {
|
|
const cfg = readJSON(path.join(wolfDir, "config.json"), {});
|
|
// Default on; only an explicit `false` disables auto bug detection.
|
|
return cfg.openwolf?.buglog?.auto_detect !== false;
|
|
}
|
|
catch {
|
|
return true;
|
|
}
|
|
}
|
|
function autoDetectBugFix(wolfDir, absolutePath, projectRoot, oldStr, newStr) {
|
|
const basename = path.basename(absolutePath);
|
|
const ext = path.extname(basename).toLowerCase();
|
|
// Bug-fix detection is a code concept — never fire on prose/docs/data files.
|
|
if (NON_CODE_EXTS.has(ext))
|
|
return;
|
|
// Respect an explicit opt-out in .wolf/config.json (default: enabled).
|
|
if (!bugAutoDetectEnabled(wolfDir))
|
|
return;
|
|
const bugLogPath = path.join(wolfDir, "buglog.json");
|
|
const bugLog = readBugLogFile(wolfDir);
|
|
const relFile = normalizePath(path.relative(projectRoot, absolutePath));
|
|
// Detect what kind of fix this is
|
|
const detection = detectFixPattern(oldStr, newStr, ext, basename);
|
|
if (!detection)
|
|
return;
|
|
// Check for recent duplicate (same file + same category within 5 min)
|
|
const recentDupe = bugLog.bugs.find(b => {
|
|
if (path.basename(b.file) !== basename)
|
|
return false;
|
|
if (!b.tags.includes("auto-detected"))
|
|
return false;
|
|
if (!b.tags.includes(detection.category))
|
|
return false;
|
|
const bugTime = new Date(b.last_seen).getTime();
|
|
return (Date.now() - bugTime) < 5 * 60 * 1000;
|
|
});
|
|
if (recentDupe) {
|
|
recentDupe.occurrences++;
|
|
recentDupe.last_seen = new Date().toISOString();
|
|
// Append additional context
|
|
if (detection.context && !recentDupe.fix.includes(detection.context)) {
|
|
recentDupe.fix += ` | Also: ${detection.context}`;
|
|
}
|
|
writeJSON(bugLogPath, bugLog);
|
|
return;
|
|
}
|
|
const nextId = `bug-${String(bugLog.bugs.length + 1).padStart(3, "0")}`;
|
|
bugLog.bugs.push({
|
|
id: nextId,
|
|
timestamp: new Date().toISOString(),
|
|
error_message: detection.summary,
|
|
file: relFile,
|
|
root_cause: detection.rootCause,
|
|
fix: detection.fix,
|
|
tags: ["auto-detected", detection.category, ext.replace(".", "") || "unknown"],
|
|
related_bugs: [],
|
|
occurrences: 1,
|
|
last_seen: new Date().toISOString(),
|
|
});
|
|
writeJSON(bugLogPath, bugLog);
|
|
}
|
|
function detectFixPattern(oldStr, newStr, ext, basename) {
|
|
const oldLines = oldStr.split("\n");
|
|
const newLines = newStr.split("\n");
|
|
const isTest = isTestFile(basename);
|
|
// --- Error handling added ---
|
|
if (!isTest && hasCatchConstruct(newStr, ext) && !hasCatchConstruct(oldStr, ext)) {
|
|
const fn = newStr.match(/(?:function|def|async)\s+(\w+)/)?.[1];
|
|
return {
|
|
category: "error-handling",
|
|
summary: `Missing error handling in ${fn ? `${fn}()` : basename}`,
|
|
rootCause: "Code path had no error handling — exceptions would propagate uncaught",
|
|
fix: `Added try/catch block`,
|
|
context: extractChangedLines(oldStr, newStr),
|
|
};
|
|
}
|
|
// --- Null/undefined safety ---
|
|
if ((newStr.includes("?.") && !oldStr.includes("?.")) ||
|
|
(newStr.includes("?? ") && !oldStr.includes("?? ")) ||
|
|
(/!==?\s*(null|undefined)/.test(newStr) && !/!==?\s*(null|undefined)/.test(oldStr))) {
|
|
return {
|
|
category: "null-safety",
|
|
summary: `Null/undefined access in ${basename}`,
|
|
rootCause: "Property access on potentially null/undefined value",
|
|
fix: `Added null safety (optional chaining or null check)`,
|
|
context: extractChangedLines(oldStr, newStr),
|
|
};
|
|
}
|
|
// --- Guard clause / early return added ---
|
|
if (!isTest &&
|
|
/if\s*\([^)]*\)\s*(return|throw|continue|break)/.test(newStr) &&
|
|
!/if\s*\([^)]*\)\s*(return|throw|continue|break)/.test(oldStr)) {
|
|
const condition = newStr.match(/if\s*\(([^)]+)\)/)?.[1]?.trim().slice(0, 60) || "condition";
|
|
return {
|
|
category: "guard-clause",
|
|
summary: `Missing guard clause`,
|
|
rootCause: `No early return/throw for edge case: ${condition}`,
|
|
fix: `Added guard clause: if (${condition.slice(0, 40)})`,
|
|
};
|
|
}
|
|
// --- Wrong value / string fix (very common bug) ---
|
|
if (oldLines.length <= 3 && newLines.length <= 3) {
|
|
const oldJoined = oldStr.trim();
|
|
const newJoined = newStr.trim();
|
|
// String literal changed
|
|
const oStrs = oldJoined.match(/['"`]([^'"`]{2,})['"`]/g) || [];
|
|
const nStrs = newJoined.match(/['"`]([^'"`]{2,})['"`]/g) || [];
|
|
if (oStrs.length > 0 && nStrs.length > 0) {
|
|
for (let i = 0; i < Math.min(oStrs.length, nStrs.length); i++) {
|
|
if (oStrs[i] !== nStrs[i]) {
|
|
return {
|
|
category: "wrong-value",
|
|
summary: `Incorrect value in code`,
|
|
rootCause: `Had ${oStrs[i].slice(0, 50)}`,
|
|
fix: `Changed to ${nStrs[i].slice(0, 50)}`,
|
|
};
|
|
}
|
|
}
|
|
}
|
|
// Variable name / method call changed
|
|
const oldTokens = tokenizeCode(oldJoined);
|
|
const newTokens = tokenizeCode(newJoined);
|
|
const changed = [];
|
|
for (let i = 0; i < Math.min(oldTokens.length, newTokens.length); i++) {
|
|
if (oldTokens[i] !== newTokens[i]) {
|
|
changed.push([oldTokens[i], newTokens[i]]);
|
|
}
|
|
}
|
|
if (changed.length === 1 && changed[0][0].length > 2) {
|
|
return {
|
|
category: "wrong-reference",
|
|
summary: `Wrong reference: ${changed[0][0]} should be ${changed[0][1]}`,
|
|
rootCause: `Used "${changed[0][0]}" instead of "${changed[0][1]}"`,
|
|
fix: `Changed ${changed[0][0]} → ${changed[0][1]}`,
|
|
};
|
|
}
|
|
}
|
|
// --- Logic fix (condition changed) ---
|
|
const oldCond = oldStr.match(/if\s*\(([^)]+)\)/)?.[1];
|
|
const newCond = newStr.match(/if\s*\(([^)]+)\)/)?.[1];
|
|
if (oldCond && newCond && oldCond !== newCond && oldLines.length <= 5) {
|
|
return {
|
|
category: "logic-fix",
|
|
summary: `Wrong condition in logic`,
|
|
rootCause: `Condition was: if (${oldCond.slice(0, 50)})`,
|
|
fix: `Changed to: if (${newCond.slice(0, 50)})`,
|
|
};
|
|
}
|
|
// --- Operator fix (=== vs ==, > vs >=, etc.) ---
|
|
const opChange = findOperatorChange(oldStr, newStr);
|
|
if (opChange) {
|
|
return {
|
|
category: "operator-fix",
|
|
summary: `Wrong operator: ${opChange.old} should be ${opChange.new}`,
|
|
rootCause: `Used "${opChange.old}" instead of "${opChange.new}"`,
|
|
fix: `Changed operator ${opChange.old} → ${opChange.new}`,
|
|
};
|
|
}
|
|
// --- Missing import/require ---
|
|
const oldImports = new Set((oldStr.match(/(?:import|require)\s*\(?['"]([^'"]+)['"]\)?/g) || []).map(m => m));
|
|
const newImports = (newStr.match(/(?:import|require)\s*\(?['"]([^'"]+)['"]\)?/g) || []);
|
|
const addedImports = newImports.filter(i => !oldImports.has(i));
|
|
if (addedImports.length > 0 && newLines.length - oldLines.length <= addedImports.length + 2) {
|
|
const modules = addedImports.map(i => i.match(/['"]([^'"]+)['"]/)?.[1] || "").filter(Boolean);
|
|
return {
|
|
category: "missing-import",
|
|
summary: `Missing import: ${modules.join(", ")}`,
|
|
rootCause: `Module(s) not imported: ${modules.join(", ")}`,
|
|
fix: `Added import(s) for ${modules.join(", ")}`,
|
|
};
|
|
}
|
|
// --- Return value fix ---
|
|
const oldReturn = oldStr.match(/return\s+(.+)/)?.[1]?.trim();
|
|
const newReturn = newStr.match(/return\s+(.+)/)?.[1]?.trim();
|
|
if (oldReturn && newReturn && oldReturn !== newReturn && oldLines.length <= 5) {
|
|
return {
|
|
category: "return-value",
|
|
summary: `Wrong return value`,
|
|
rootCause: `Was returning: ${oldReturn.slice(0, 50)}`,
|
|
fix: `Now returns: ${newReturn.slice(0, 50)}`,
|
|
};
|
|
}
|
|
// --- Async/await fix ---
|
|
if (newStr.includes("await ") && !oldStr.includes("await ")) {
|
|
return {
|
|
category: "async-fix",
|
|
summary: `Missing await`,
|
|
rootCause: `Async call without await — returned Promise instead of value`,
|
|
fix: `Added await to async call`,
|
|
context: extractChangedLines(oldStr, newStr),
|
|
};
|
|
}
|
|
if (newStr.includes("async ") && !oldStr.includes("async ")) {
|
|
return {
|
|
category: "async-fix",
|
|
summary: `Function not marked async`,
|
|
rootCause: `Function uses await but wasn't declared async`,
|
|
fix: `Added async modifier`,
|
|
};
|
|
}
|
|
// --- Type annotation/cast fix ---
|
|
if (ext === ".ts" || ext === ".tsx") {
|
|
if ((newStr.includes(" as ") && !oldStr.includes(" as ")) ||
|
|
(newStr.includes(": ") && !oldStr.includes(": ") && oldLines.length <= 3)) {
|
|
return {
|
|
category: "type-fix",
|
|
summary: `Type error`,
|
|
rootCause: `Missing or incorrect type annotation`,
|
|
fix: `Added type assertion/annotation`,
|
|
context: extractChangedLines(oldStr, newStr),
|
|
};
|
|
}
|
|
}
|
|
// --- CSS/style fix ---
|
|
if (ext === ".css" || ext === ".scss" || ext === ".vue" || ext === ".tsx" || ext === ".jsx") {
|
|
const oldProps = extractCSSProps(oldStr);
|
|
const newProps = extractCSSProps(newStr);
|
|
const changedProps = [...newProps.entries()].filter(([k, v]) => oldProps.get(k) !== v && oldProps.has(k));
|
|
if (changedProps.length > 0 && changedProps.length <= 3) {
|
|
const desc = changedProps.map(([k, v]) => `${k}: ${oldProps.get(k)} → ${v}`).join("; ");
|
|
return {
|
|
category: "style-fix",
|
|
summary: `CSS fix: ${changedProps.map(([k]) => k).join(", ")}`,
|
|
rootCause: desc,
|
|
fix: `Changed ${desc}`,
|
|
};
|
|
}
|
|
}
|
|
// --- Significant diff (catch-all for substantial edits) ---
|
|
const diffRatio = Math.abs(newStr.length - oldStr.length) / Math.max(oldStr.length, 1);
|
|
if (diffRatio > 0.3 && oldLines.length >= 3 && newLines.length >= 3) {
|
|
// Only log if there's meaningful structural change, not just additions
|
|
const removedLines = oldLines.filter(l => l.trim() && !newLines.some(nl => nl.trim() === l.trim()));
|
|
if (removedLines.length >= 2) {
|
|
return {
|
|
category: "refactor",
|
|
summary: `Significant refactor of ${basename}`,
|
|
rootCause: `${removedLines.length} lines replaced/restructured`,
|
|
fix: `Rewrote ${oldLines.length}→${newLines.length} lines (${removedLines.length} removed)`,
|
|
context: removedLines.slice(0, 2).map(l => l.trim().slice(0, 50)).join("; "),
|
|
};
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
function hasCatchConstruct(code, ext) {
|
|
if (ext === ".py")
|
|
return /\bexcept\b[^\n]*:/.test(code);
|
|
return /\bcatch\s*[({]/.test(code);
|
|
}
|
|
function isTestFile(basename) {
|
|
return /(\.test\.|\.spec\.|_test\.|_spec\.)/i.test(basename) ||
|
|
/(Test|Tests|IT|Spec)\.\w+$/.test(basename);
|
|
}
|
|
function extractChangedLines(oldStr, newStr) {
|
|
const oldLines = new Set(oldStr.split("\n").map(l => l.trim()).filter(Boolean));
|
|
const newLines = newStr.split("\n").map(l => l.trim()).filter(Boolean);
|
|
const added = newLines.filter(l => !oldLines.has(l));
|
|
return added.slice(0, 2).map(l => l.slice(0, 60)).join("; ");
|
|
}
|
|
function tokenizeCode(code) {
|
|
return code.replace(/[^\w$]/g, " ").split(/\s+/).filter(t => t.length > 0);
|
|
}
|
|
function findOperatorChange(oldStr, newStr) {
|
|
const operators = ["===", "!==", "==", "!=", ">=", "<=", ">>", "<<", "&&", "||", "??"];
|
|
for (const op of operators) {
|
|
if (oldStr.includes(op) && !newStr.includes(op)) {
|
|
for (const op2 of operators) {
|
|
if (op2 !== op && newStr.includes(op2) && !oldStr.includes(op2)) {
|
|
return { old: op, new: op2 };
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
function extractCSSProps(code) {
|
|
const props = new Map();
|
|
const matches = code.matchAll(/([\w-]+)\s*:\s*([^;}\n]+)/g);
|
|
for (const m of matches) {
|
|
props.set(m[1].trim(), m[2].trim());
|
|
}
|
|
return props;
|
|
}
|
|
hookMain("post-write", main);
|
|
//# sourceMappingURL=post-write.js.map
|