diff --git a/.serena/memories/critical-info.md b/.serena/memories/critical-info.md index 23abc1910e..20dbabca03 100644 --- a/.serena/memories/critical-info.md +++ b/.serena/memories/critical-info.md @@ -18,6 +18,7 @@ Memories: - connection between the JavaScript API and the ClojureScript code: `frontend/js-api-to-cljs-binding`. - executing ClojureScript code in the frontend: `frontend/cljs-repl`. - programmatically navigating to a file in the workspace: `frontend/navigation`. + - handling Clojure compiler errors, runtime patching and debug helpers: `frontend/handling-errors-and-debugging`. ## Detecting Crashes diff --git a/.serena/memories/frontend/handling-errors-and-debugging.md b/.serena/memories/frontend/handling-errors-and-debugging.md new file mode 100644 index 0000000000..29c7929112 --- /dev/null +++ b/.serena/memories/frontend/handling-errors-and-debugging.md @@ -0,0 +1,49 @@ +# Handling Errors and Debugging + +## Finding source errors + +You have access to two tools for finding errors in Clojure source code (which you may introduce yourself through edits): + +1. cljs_compiler_output +2. clj_check_parentheses + +The latter is needed because syntax errors in parentheses give an uninformative compiler error, and the second +tool can often find the exact location of such errors. + +## Runtime patching with `set!` + +Some frontend vars are deliberately mutable escape hatches for runtime instrumentation or circular-dependency patching. +From `cljs_repl`, use `set!` for temporary debugging of CLJS vars such as +`app.main.store/on-event`, `app.main.errors/reload-file`, `app.main.errors/is-plugin-error?`, +`app.main.errors/last-report`, or `app.main.errors/last-exception`. +These patches affect only the live browser runtime and disappear on reload or recompilation. + +```clojure +;; Log non-noisy Potok events temporarily. +(set! app.main.store/on-event + (fn [event] + (when (potok.v2.core/event? event) + (.log js/console (potok.v2.core/repr-event event))))) +``` + +Restore mutable hooks after debugging, or reload the frontend. Use JVM `alter-var-root` only for JVM Clojure; +it is not the normal way to patch live CLJS browser vars. + +## Browser-console debug namespace + +In development, the JS console exposes `debug` helpers from `frontend/src/debug.cljs`: + +```javascript +debug.set_logging("namespace", "debug"); +debug.dump_state(); +debug.dump_buffer(); +debug.get_state(":workspace-local :selected"); +debug.dump_objects(); +debug.dump_object("Rect-1"); +debug.dump_selected(); +debug.dump_tree(true, true); +``` + +Visual workspace debug overlays can be toggled with `debug.toggle_debug("bounding-boxes")`, `"group"`, `"events"`, or `"rotation-handler"`; `debug.debug_all()` and `debug.debug_none()` toggle all visual aids. + +For temporary source traces, prefer existing logging (`app.common.logging` / `app.util.logging`) or short-lived `prn`, `app.common.pprint/pprint`, `js/console.log`, or `js-debugger` calls. Remove temporary source instrumentation before committing. diff --git a/mcp/packages/server/src/PenpotMcpServer.ts b/mcp/packages/server/src/PenpotMcpServer.ts index 53b40f6070..473596aebd 100644 --- a/mcp/packages/server/src/PenpotMcpServer.ts +++ b/mcp/packages/server/src/PenpotMcpServer.ts @@ -13,6 +13,8 @@ import { ExportShapeTool } from "./tools/ExportShapeTool"; import { ImportImageTool } from "./tools/ImportImageTool"; import { CljsReplTool } from "./tools/CljsReplTool"; import { ImportPenpotFileTool } from "./tools/ImportPenpotFileTool"; +import { CljsCompilerOutputTool } from "./tools/CljsCompilerOutputTool"; +import { CljCheckParentheses } from "./tools/CljCheckParentheses"; import { NreplClient } from "./NreplClient"; import { ReplServer } from "./ReplServer"; import { ApiDocs } from "./ApiDocs"; @@ -194,6 +196,8 @@ export class PenpotMcpServer { const nreplClient = new NreplClient(); toolInstances.push(new CljsReplTool(this, nreplClient)); toolInstances.push(new ImportPenpotFileTool(this, nreplClient)); + toolInstances.push(new CljsCompilerOutputTool(this, nreplClient)); + toolInstances.push(new CljCheckParentheses(this)); } return toolInstances.map((instance) => { diff --git a/mcp/packages/server/src/tools/CljCheckParentheses.ts b/mcp/packages/server/src/tools/CljCheckParentheses.ts new file mode 100644 index 0000000000..81e2a9628c --- /dev/null +++ b/mcp/packages/server/src/tools/CljCheckParentheses.ts @@ -0,0 +1,242 @@ +import { z } from "zod"; +import { Tool } from "../Tool"; +import "reflect-metadata"; +import type { ToolResponse } from "../ToolResponse"; +import { TextResponse } from "../ToolResponse"; +import type { PenpotMcpServer } from "../PenpotMcpServer"; +import * as fs from "fs"; + +/** + * Arguments for the FindUnclosedParensTool. + */ +export class CljCheckParenthesesArgs { + static schema = { + file: z.string().min(1).describe("Absolute path to a Clojure/ClojureScript source file"), + }; + + file!: string; +} + +interface OpenDelim { + id: number; + line: number; // 0-based + col: number; // 0-based + char: string; + baselineKey: string; // the baseline key this delimiter owns +} + +interface ParenIssue { + line: number; // 1-based + col: number; // 1-based + char: string; + detectedAtLine?: number; // 1-based line where the stack-state mismatch was observed +} + +/** + * Finds unclosed delimiters in Clojure/ClojureScript source files using a + * stack-state invariant derived from cljfmt formatting conventions. + * + * Invariant: in cljfmt-formatted code, every opening delimiter of type T at + * column C must see the same stack depth each time that (T, C) combination + * occurs. A depth mismatch means delimiters opened between the baseline + * occurrence and the current one were never closed. + * + * The parser correctly handles string literals (including multi-line and escape + * sequences), comment lines, character literals, and regex literals. + */ +export class CljCheckParentheses extends Tool { + constructor(mcpServer: PenpotMcpServer) { + super(mcpServer, CljCheckParenthesesArgs.schema); + } + + public getToolName(): string { + return "clj_check_parentheses"; + } + + public getToolDescription(): string { + return "Analyzes a Clojure/ClojureScript source file for unclosed delimiters and reports the area of interest."; + } + + protected async executeCore(args: CljCheckParenthesesArgs): Promise { + const filePath = args.file; + + if (!fs.existsSync(filePath)) { + return new TextResponse(`File not found: ${filePath}`); + } + + const content = fs.readFileSync(filePath, "utf-8"); + const issues = analyzeParens(content); + + if (issues.length === 0) { + return new TextResponse("All delimiters are properly balanced."); + } + + const sourceLines = content.split("\n"); + const parts: string[] = [`Found ${issues.length} unclosed delimiter(s):\n`]; + + for (const issue of issues) { + const srcLine = (sourceLines[issue.line - 1] ?? "").trimEnd(); + const pointer = " ".repeat(String(issue.line).length) + " " + " ".repeat(issue.col - 1) + "^"; + + if (issue.detectedAtLine != null) { + const detectedSrcLine = (sourceLines[issue.detectedAtLine - 1] ?? "").trimEnd(); + parts.push( + ` Unclosed '${issue.char}' at line ${issue.line}, col ${issue.col}:\n` + + ` ${issue.line} | ${srcLine}\n` + + ` ${pointer}\n` + + ` Stack-state mismatch detected at line ${issue.detectedAtLine}:\n` + + ` ${issue.detectedAtLine} | ${detectedSrcLine}\n` + ); + } else { + parts.push( + ` Unclosed '${issue.char}' at line ${issue.line}, col ${issue.col} (still open at end of file):\n` + + ` ${issue.line} | ${srcLine}\n` + + ` ${pointer}\n` + ); + } + } + + return new TextResponse(parts.join("\n")); + } +} + +/** + * Analyses delimiter balance in a Clojure/ClojureScript source string. + * + * Algorithm + * --------- + * Maintain a stack of open delimiters and a map from (delimiter-type, column) + * to the stack depth recorded on the first occurrence of that combination. + * + * Each time an opening delimiter of type T appears at column C: + * 1. Look up the key (T, C) in the map. + * 2. If absent, record the current stack depth as the baseline. + * 3. If present, compare the current depth with the baseline. + * - If deeper: the extra stack entries (from baseline depth to current + * depth) are delimiters that should have been closed. Report them. + * - If shallower: more delimiters were closed than opened between the + * baseline and here (over-closed). Update the baseline downward so + * subsequent occurrences don't cascade. + * 4. Push the delimiter onto the stack. + * + * After the full file is processed, any delimiter still on the stack is + * unclosed. If it was already reported via a mismatch, the report includes + * the detection line; otherwise it is reported as open-at-EOF. + */ +function analyzeParens(content: string): ParenIssue[] { + // Precompute line-start offsets for O(1) column lookup. + const lineStarts: number[] = [0]; + for (let i = 0; i < content.length; i++) { + if (content[i] === "\n") lineStarts.push(i + 1); + } + + let nextId = 0; + const stack: OpenDelim[] = []; + + // (type, column) → baseline stack depth. + // Each baseline is owned by the delimiter that established it (stored + // as baselineKey on the stack entry). When that delimiter is popped, + // its baseline is discarded — it was scoped to that delimiter's lifetime. + const baseline: Map = new Map(); + + let inString = false; + let inComment = false; + let escape = false; + let currentLine = 0; + + for (let i = 0; i < content.length; i++) { + const ch = content[i]; + + // ── Newline ────────────────────────────────────────────────────── + if (ch === "\n") { + inComment = false; + currentLine++; + if (!inString) escape = false; + continue; + } + + // ── Escape: skip next character ────────────────────────────────── + if (escape) { + escape = false; + continue; + } + + // ── Inside comment: skip until newline ─────────────────────────── + if (inComment) continue; + + // ── Inside string literal ──────────────────────────────────────── + if (inString) { + if (ch === "\\") escape = true; + else if (ch === '"') inString = false; + continue; + } + + // ── Outside string / comment ───────────────────────────────────── + if (ch === "\\") { + escape = true; + continue; + } + if (ch === '"') { + inString = true; + continue; + } + if (ch === ";") { + inComment = true; + continue; + } + + // ── Opening delimiter ──────────────────────────────────────────── + if (ch === "(" || ch === "[" || ch === "{") { + const col = i - lineStarts[currentLine]; + const key = `${ch}:${col}`; + const currentDepth = stack.length; + + const recorded = baseline.get(key); + if (recorded !== undefined && currentDepth > recorded) { + // Stack is deeper than expected. The entries from index + // `recorded` to `currentDepth - 1` are unclosed delimiters + // that should have been closed before reaching this + // position. Return immediately — further parsing would be + // against a corrupted stack and only produce cascading noise. + return stack.slice(recorded, currentDepth).map((delim) => ({ + line: delim.line + 1, + col: delim.col + 1, + char: delim.char, + detectedAtLine: currentLine + 1, + })); + } + + // Establish or re-establish the baseline for this key, + // owned by this delimiter. Discarded when it is popped. + baseline.set(key, currentDepth); + + stack.push({ + id: nextId++, + line: currentLine, + col, + char: ch, + baselineKey: key, + }); + } + + // ── Closing delimiter ──────────────────────────────────────────── + else if (ch === ")" || ch === "]" || ch === "}") { + if (stack.length > 0) { + const closed = stack.pop()!; + + // The baseline this delimiter owned is no longer valid — + // the context it was recorded in has closed. + baseline.delete(closed.baselineKey); + } + } + } + + // ── EOF: no mismatch was found, but the stack is not empty ────────── + // This happens when the unclosed delimiter has no second occurrence of + // the same (type, column) to compare against (e.g. last form in file). + return stack.map((delim) => ({ + line: delim.line + 1, + col: delim.col + 1, + char: delim.char, + })); +} diff --git a/mcp/packages/server/src/tools/CljsCompilerOutputTool.ts b/mcp/packages/server/src/tools/CljsCompilerOutputTool.ts new file mode 100644 index 0000000000..11989c6303 --- /dev/null +++ b/mcp/packages/server/src/tools/CljsCompilerOutputTool.ts @@ -0,0 +1,52 @@ +import { Tool, EmptyToolArgs } from "../Tool"; +import "reflect-metadata"; +import type { ToolResponse } from "../ToolResponse"; +import { TextResponse } from "../ToolResponse"; +import { PenpotMcpServer } from "../PenpotMcpServer"; +import { NreplClient } from "../NreplClient"; + +/** + * Reports the compiler status of the shadow-cljs `:main` build. + * + * If the most recent build failed, returns the relevant fields of the failure data + * (tag, message, resource name, line, column, etc.); otherwise returns `:ok`. + */ +export class CljsCompilerOutputTool extends Tool { + private static readonly STATUS_CODE = + "(require (quote [shadow.cljs.devtools.api :as shadow])) " + + "(let [fd (-> (shadow/get-worker :main) :state-ref deref :failure-data)] " + + "(if fd (pr-str fd) :ok))"; + + private readonly nreplClient: NreplClient; + + constructor(mcpServer: PenpotMcpServer, nreplClient: NreplClient) { + super(mcpServer, EmptyToolArgs.schema); + this.nreplClient = nreplClient; + } + + public getToolName(): string { + return "cljs_compiler_output"; + } + + public getToolDescription(): string { + return ( + "Reports the status of the most recent shadow-cljs `:main` build. " + + "Use this to diagnose compilation errors when needed. For syntax errors, " + + "consider using the clj_check_parentheses tool on the relevant source files." + ); + } + + protected async executeCore(_args: EmptyToolArgs): Promise { + const result = await this.nreplClient.eval(CljsCompilerOutputTool.STATUS_CODE); + + // multiple top-level forms produce multiple values; the build status is the last one + const status = result.values[result.values.length - 1] ?? "nil"; + + const parts: string[] = [status]; + if (result.err) { + parts.push(`stderr:\n${result.err}`); + } + + return new TextResponse(parts.join("\n\n")); + } +}