"use strict"; const assert = require("assert"); const fs = require("fs"); const os = require("os"); const path = require("path"); const createStructuralTools = require("../lib/structural-tools"); const AiChat = require("../lib/ai-chat"); async function rejected(promise, pattern) { let error = null; try { await promise; } catch (caught) { error = caught; } assert.ok(error, "expected operation to reject"); assert.match(String(error.message || error), pattern); } function write(workspace, name, content) { const file = path.join(workspace, name); fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, content); return file; } async function one(tools, args) { const result = await tools.ast_search.execute(args); assert.strictEqual(result.structuredContent.count, 1, String(args.path || ".") + ": " + JSON.stringify(result.structuredContent, null, 2)); return result.structuredContent.matches[0]; } async function main() { const root = fs.mkdtempSync(path.join(os.tmpdir(), "vrcloud-ast-")); const workspace = path.join(root, "workspace"); fs.mkdirSync(workspace, { recursive: true }); write(workspace, "src/view.jsx", "import React from \"react\";\n" + "export function View() { return ; }\n"); write(workspace, "src/panel.tsx", "import type { ReactNode } from \"react\";\n" + "export interface PanelProps { child: ReactNode }\n" + "declare function decorate(child: ReactNode): ReactNode;\n" + "export function Panel(props: PanelProps) { return
{decorate(props.child)}
; }\n"); write(workspace, "src/service.py", "from pathlib import Path\n\n" + "def load_service():\n" + " return Path(\"data\")\n"); write(workspace, "src/main.go", "package main\n\nimport \"fmt\"\n\nfunc RunServer() {\n\tfmt.Println(\"up\")\n}\n"); write(workspace, "src/lib.rs", "use std::fmt;\n\npub fn run_worker() {\n fmt::format(format_args!(\"up\"));\n}\n"); const tools = createStructuralTools({ workspace }); const outside = path.join(root, "outside"); write(outside, "escaped.js", "function escaped() { return 1; }\n"); const escapeLink = path.join(workspace, "escape-link"); fs.symlinkSync(outside, escapeLink, process.platform === "win32" ? "junction" : "dir"); await rejected(tools.ast_search.execute({ kind: "declaration", name: "escaped", path: "escape-link/escaped.js", }), /symlink|junction/i); await rejected(tools.ast_edit.execute({ path: "escape-link/escaped.js", match_id: "ast-" + "0".repeat(24), file_hash: "0".repeat(64), operation: "replace", text: "function escaped() { return 2; }", }), /symlink|junction/i); assert.match(fs.readFileSync(path.join(outside, "escaped.js"), "utf8"), /return 1/); const declarations = [ ["src/view.jsx", "View", "javascript"], ["src/panel.tsx", "Panel", "tsx"], ["src/service.py", "load_service", "python"], ["src/main.go", "RunServer", "go"], ["src/lib.rs", "run_worker", "rust"], ]; for (const [file, name, language] of declarations) { const match = await one(tools, { kind: "declaration", name, path: file }); assert.strictEqual(match.language, language); assert.match(match.id, /^ast-[a-f0-9]{24}$/); assert.match(match.file_hash, /^[a-f0-9]{64}$/); assert.ok(match.end_byte > match.start_byte); } const callCases = [ ["src/view.jsx", "renderWidget"], ["src/panel.tsx", "decorate"], ["src/service.py", "Path"], ["src/main.go", "Println"], ["src/lib.rs", "format"], ]; for (const [file, name] of callCases) { const result = await tools.ast_search.execute({ kind: "call", name, path: file }); assert.strictEqual(result.structuredContent.count, 1, file); } for (const [file, name] of [ ["src/view.jsx", "react"], ["src/panel.tsx", "react"], ["src/service.py", "pathlib"], ["src/main.go", "fmt"], ["src/lib.rs", "std::fmt"], ]) { const match = await one(tools, { kind: "import", name, path: file }); assert.strictEqual(match.kind, "import"); } const bounded = await tools.ast_search.execute({ kind: "declaration", path: "src", limit: 1 }); assert.strictEqual(bounded.structuredContent.count, 1); assert.strictEqual(bounded.structuredContent.truncated, true); const stableA = await one(tools, { kind: "declaration", name: "View", path: "src/view.jsx" }); const stableB = await one(tools, { kind: "declaration", name: "View", path: "src/view.jsx" }); assert.strictEqual(stableA.id, stableB.id); assert.strictEqual(stableA.file_hash, stableB.file_hash); const staleMatch = await one(tools, { kind: "declaration", name: "load_service", path: "src/service.py" }); fs.appendFileSync(path.join(workspace, "src/service.py"), "\n# changed\n"); await rejected(tools.ast_edit.execute({ path: staleMatch.path, match_id: staleMatch.id, file_hash: staleMatch.file_hash, operation: "replace", text: "def load_service():\n return None", }), /Stale structural match/); write(workspace, "src/ambiguous.js", "ping();\nping();\n"); const duplicateSearch = await tools.ast_search.execute({ kind: "call", name: "ping", path: "src/ambiguous.js" }); assert.strictEqual(duplicateSearch.structuredContent.count, 2); const firstCall = duplicateSearch.structuredContent.matches[0]; assert.strictEqual(firstCall.id, duplicateSearch.structuredContent.matches[1].id); await rejected(tools.ast_edit.execute({ path: firstCall.path, match_id: firstCall.id, file_hash: firstCall.file_hash, operation: "insert-before", text: "/* selected */ ", }), /Ambiguous structural match/); const inserted = await tools.ast_edit.execute({ path: firstCall.path, match_id: firstCall.id, file_hash: firstCall.file_hash, start_byte: firstCall.start_byte, operation: "insert-before", text: "/* selected */ ", }); assert.ok(fs.readFileSync(path.join(workspace, "src/ambiguous.js"), "utf8").startsWith("/* selected */ ping();")); assert.strictEqual(inserted.structuredContent.linesRemoved, 0); const afterInsert = await tools.ast_search.execute({ kind: "call", name: "ping", path: "src/ambiguous.js" }); const secondCall = afterInsert.structuredContent.matches[1]; await tools.ast_edit.execute({ path: secondCall.path, match_id: secondCall.id, file_hash: secondCall.file_hash, start_byte: secondCall.start_byte, operation: "insert-after", text: " /* trailing */", }); assert.match(fs.readFileSync(path.join(workspace, "src/ambiguous.js"), "utf8"), /ping\(\) \/\* trailing \*\/;/); write(workspace, "src/reject.js", "function valid() {\n return 1;\n}\n"); const valid = await one(tools, { kind: "declaration", name: "valid", path: "src/reject.js" }); await rejected(tools.ast_edit.execute({ path: valid.path, match_id: valid.id, file_hash: valid.file_hash, operation: "replace", text: "function valid(", }), /syntax errors would increase/); assert.match(fs.readFileSync(path.join(workspace, "src/reject.js"), "utf8"), /return 1/); const unicodePath = path.join(workspace, "src/unicode.js"); const unicodeBefore = Buffer.concat([ Buffer.from([0xef, 0xbb, 0xbf]), Buffer.from("const banner = \"🙂\";\r\nfunction café() {\r\n return say(\"hé\");\r\n}\r\n", "utf8"), ]); fs.writeFileSync(unicodePath, unicodeBefore); const unicodeCall = await one(tools, { kind: "call", name: "say", path: "src/unicode.js" }); const unicodeEdit = await tools.ast_edit.execute({ path: unicodeCall.path, match_id: unicodeCall.id, file_hash: unicodeCall.file_hash, operation: "replace", text: "say(\"雪\")", }); const unicodeAfter = fs.readFileSync(unicodePath); assert.deepStrictEqual(Array.from(unicodeAfter.subarray(0, 3)), [0xef, 0xbb, 0xbf]); const unicodeText = unicodeAfter.subarray(3).toString("utf8"); assert.ok(unicodeText.includes("🙂")); assert.ok(unicodeText.includes("café")); assert.ok(unicodeText.includes("say(\"雪\")")); assert.ok(!/(^|[^\r])\n/.test(unicodeText), "CRLF file gained a bare LF"); assert.match(unicodeEdit.structuredContent.after_hash, /^[a-f0-9]{64}$/); const ask = new AiChat({ workspace, customTools: tools }); ask.mode = "ask"; assert.deepStrictEqual(Object.keys(ask.customToolSet()), ["ast_search"]); ask.mode = "agent"; assert.deepStrictEqual(Object.keys(ask.customToolSet()).sort(), ["ast_edit", "ast_search"]); const changed = []; const tracked = new AiChat({ workspace, onFsChange: (paths) => changed.push(paths) }); const state = { live: { touched: [] }, todos: null }; const frames = []; tracked.emitFromEvent({ type: "tool_call", name: "mcp", call_id: "ast-1", args: { toolName: "ast_edit", args: { path: "src/unicode.js" } }, status: "completed", result: { status: "success", value: { structuredContent: { path: "src/unicode.js", linesAdded: 1, linesRemoved: 1, diffString: "--- src/unicode.js\n+++ src/unicode.js", }, }, }, }, (kind, value) => frames.push({ kind, value }), state); assert.deepStrictEqual(state.live.touched, ["src/unicode.js"]); assert.deepStrictEqual(changed, [["src/unicode.js"]]); assert.strictEqual(frames[0].value.meta.structural, true); assert.strictEqual(frames[0].value.meta.added, 1); assert.match(frames[0].value.detail, /unicode\.js/); const leftovers = fs.readdirSync(path.dirname(unicodePath)).filter((name) => /\.ast-.*\.tmp$/.test(name)); assert.deepStrictEqual(leftovers, []); fs.rmSync(root, { recursive: true, force: true }); console.log("smart-agent-ast: ok"); } main().catch((error) => { console.error(error.stack || error); process.exitCode = 1; });