mirror of
https://github.com/penpot/penpot.git
synced 2026-05-12 19:43:48 +00:00
🎉 Add two new MCP tools for Clojure development
* CljsCompilerOutputTool: Checks compiler output and reports errors * CljCheckParentheses: Precisely locates incorrect/unbalanced parentheses GitHub #9214
This commit is contained in:
parent
b952783621
commit
7a2ca6c08f
@ -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
|
||||
|
||||
|
||||
49
.serena/memories/frontend/handling-errors-and-debugging.md
Normal file
49
.serena/memories/frontend/handling-errors-and-debugging.md
Normal file
@ -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.
|
||||
@ -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) => {
|
||||
|
||||
242
mcp/packages/server/src/tools/CljCheckParentheses.ts
Normal file
242
mcp/packages/server/src/tools/CljCheckParentheses.ts
Normal file
@ -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<CljCheckParenthesesArgs> {
|
||||
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<ToolResponse> {
|
||||
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<string, number> = 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,
|
||||
}));
|
||||
}
|
||||
52
mcp/packages/server/src/tools/CljsCompilerOutputTool.ts
Normal file
52
mcp/packages/server/src/tools/CljsCompilerOutputTool.ts
Normal file
@ -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<EmptyToolArgs> {
|
||||
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<ToolResponse> {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user