From 14a278bdb674900892e8e76e41017b9f64fb4602 Mon Sep 17 00:00:00 2001 From: Josh Crites Date: Wed, 4 Mar 2026 16:40:56 -0500 Subject: [PATCH] feat: add aztec_lookup_error tool for error diagnosis Add a new MCP tool that diagnoses Aztec errors by message, error code, or hex signature. Combines a curated static catalog with dynamic parsers that extract errors from cloned source files (Errors.sol, error_texts.ts, debugging.md, operator-faq.md). - Static catalog covers circuit codes, AVM errors, and common contract assertions - Dynamic parsers run at query time with session-level caching - Cache is invalidated after repo sync to prevent stale results - Doc sources moved to sparsePathOverrides (next branch) to match docs workflow - Matching algorithm supports exact code, hex sig, pattern, substring, and word overlap Co-Authored-By: Claude Opus 4.6 --- src/data/error-catalog.ts | 220 ++++++++++++++ src/index.ts | 40 +++ src/repos/config.ts | 3 + src/tools/error-lookup.ts | 31 ++ src/tools/index.ts | 1 + src/tools/sync.ts | 4 + src/utils/error-lookup.ts | 484 +++++++++++++++++++++++++++++++ src/utils/format.ts | 51 ++++ tests/repos/config.test.ts | 2 + tests/utils/error-lookup.test.ts | 262 +++++++++++++++++ 10 files changed, 1098 insertions(+) create mode 100644 src/data/error-catalog.ts create mode 100644 src/tools/error-lookup.ts create mode 100644 src/utils/error-lookup.ts create mode 100644 tests/utils/error-lookup.test.ts diff --git a/src/data/error-catalog.ts b/src/data/error-catalog.ts new file mode 100644 index 0000000..8cedf4b --- /dev/null +++ b/src/data/error-catalog.ts @@ -0,0 +1,220 @@ +/** + * Static error catalog for Aztec errors that need curated cause/fix text. + * Only entries whose meaning can't be auto-extracted from source files belong here. + */ + +export interface ErrorEntry { + id: string; + name: string; + category: ErrorCategory; + /** Strings to match against (lowercased at match time) */ + patterns: string[]; + cause: string; + fix: string; + source: string; + hexSignature?: string; + errorCode?: number; +} + +export type ErrorCategory = + | "contract" + | "circuit" + | "tx-validation" + | "l1" + | "avm" + | "sequencer" + | "operator" + | "general"; + +/** + * Curated entries for errors whose cause/fix can't be auto-extracted from source. + * Circuit error codes, AVM error types, and common contract assertion messages. + */ +export const STATIC_ERROR_CATALOG: ErrorEntry[] = [ + // --- Circuit error codes (from debugging.md prose / protocol docs) --- + { + id: "circuit-2002", + name: "Invalid contract address", + category: "circuit", + patterns: ["2002"], + cause: "The contract address computed by the circuit doesn't match the expected address. Often caused by a mismatch between the deployer, salt, or initialization hash.", + fix: "Verify the contract deployment parameters (deployer address, salt, constructor args). Redeploy if needed.", + source: "protocol circuit", + errorCode: 2002, + }, + { + id: "circuit-2005", + name: "Note hash mismatch (private)", + category: "circuit", + patterns: ["2005"], + cause: "A note hash read in the private kernel doesn't match any committed note hash in the tree.", + fix: "Ensure the note you're reading hasn't been nullified and was included in a prior block. Check that note contents match exactly.", + source: "protocol circuit", + errorCode: 2005, + }, + { + id: "circuit-2006", + name: "Nullifier already exists", + category: "circuit", + patterns: ["2006"], + cause: "Attempted to create a nullifier that already exists in the nullifier tree.", + fix: "The note may have already been consumed. Check for double-spend logic in your contract.", + source: "protocol circuit", + errorCode: 2006, + }, + { + id: "circuit-2017", + name: "Public data tree inconsistency", + category: "circuit", + patterns: ["2017"], + cause: "The public data read or write doesn't match the expected tree state.", + fix: "Ensure public state reads are consistent. If the state was modified concurrently, retry the transaction.", + source: "protocol circuit", + errorCode: 2017, + }, + { + id: "circuit-3001", + name: "App circuit proof verification failed", + category: "circuit", + patterns: ["3001"], + cause: "The proof generated by the application circuit failed verification in the kernel circuit.", + fix: "Check for constraint failures in your contract. Run with debug logging to see which assertion failed.", + source: "protocol circuit", + errorCode: 3001, + }, + { + id: "circuit-3005", + name: "Kernel circuit validation error", + category: "circuit", + patterns: ["3005"], + cause: "The kernel circuit detected an invalid state transition or constraint violation.", + fix: "Review your transaction's private function calls and ensure all inputs satisfy circuit constraints.", + source: "protocol circuit", + errorCode: 3005, + }, + { + id: "circuit-4007", + name: "Rollup base proof failure", + category: "circuit", + patterns: ["4007"], + cause: "The base rollup circuit couldn't verify the kernel proof or tree insertions.", + fix: "This usually indicates a sequencer-side issue. Check the sequencer logs for more context.", + source: "protocol circuit", + errorCode: 4007, + }, + { + id: "circuit-4008", + name: "Rollup merge proof failure", + category: "circuit", + patterns: ["4008"], + cause: "The merge rollup circuit couldn't verify the child rollup proofs.", + fix: "This is typically a sequencer/prover issue. Check prover logs and ensure the proving system is up to date.", + source: "protocol circuit", + errorCode: 4008, + }, + { + id: "circuit-7008", + name: "Public VM execution failure", + category: "circuit", + patterns: ["7008"], + cause: "The AVM circuit detected an execution error during public function simulation.", + fix: "Check your public function for runtime errors (out-of-bounds access, assertion failures, gas exhaustion).", + source: "protocol circuit", + errorCode: 7008, + }, + { + id: "circuit-7009", + name: "AVM proof verification failure", + category: "circuit", + patterns: ["7009"], + cause: "The AVM proof failed verification, typically due to a mismatch between execution trace and proof.", + fix: "Re-simulate the public function. If persistent, report as a potential prover bug.", + source: "protocol circuit", + errorCode: 7009, + }, + + // --- AVM error types --- + { + id: "avm-out-of-gas", + name: "OutOfGasError", + category: "avm", + patterns: ["outofgaserror", "out of gas", "outofgas"], + cause: "The public function execution ran out of gas (L2 or DA gas).", + fix: "Increase the gas limit in your transaction request, or optimize the function to use less gas. Check both L2 gas and DA gas limits.", + source: "AVM execution", + }, + { + id: "avm-tag-check", + name: "TagCheckError", + category: "avm", + patterns: ["tagcheckerror", "tag check", "tagcheck"], + cause: "An AVM instruction received an operand with an unexpected type tag (e.g., used a field where a u32 was expected).", + fix: "Check your public function for type mismatches. Ensure casts are correct and storage reads return the expected types.", + source: "AVM execution", + }, + { + id: "avm-invalid-opcode", + name: "InvalidOpcodeError", + category: "avm", + patterns: ["invalidopcodeerror", "invalid opcode"], + cause: "The AVM encountered an unrecognized instruction opcode.", + fix: "Recompile your contract with a compatible version of the Aztec compiler. The bytecode may be from an incompatible version.", + source: "AVM execution", + }, + { + id: "avm-revert", + name: "Assertion failure / Revert", + category: "avm", + patterns: ["avmreverterror", "avm revert", "explicit revert"], + cause: "A public function hit an assert!() or explicitly reverted.", + fix: "Check the revert data for the assertion message. Review the public function logic to understand which condition failed.", + source: "AVM execution", + }, + + // --- Common contract assertion messages --- + { + id: "contract-not-owner", + name: "Caller is not the owner", + category: "contract", + patterns: ["caller is not the owner", "not the owner", "unauthorized owner"], + cause: "The function requires the caller to be the contract owner, but a different address called it.", + fix: "Call the function from the owner account, or update the contract's owner if appropriate.", + source: "contract assertion", + }, + { + id: "contract-insufficient-balance", + name: "Insufficient balance", + category: "contract", + patterns: ["insufficient balance", "balance too low", "not enough balance"], + cause: "The account doesn't have enough tokens to complete the transfer or operation.", + fix: "Ensure the sender has sufficient token balance. Check both private and public balances as appropriate.", + source: "contract assertion", + }, + { + id: "contract-already-initialized", + name: "Contract already initialized", + category: "contract", + patterns: ["already initialized", "already been initialized"], + cause: "Attempted to call an initializer on a contract that has already been initialized.", + fix: "Each contract can only be initialized once. Deploy a new instance if you need a fresh contract.", + source: "contract assertion", + }, + { + id: "contract-not-initialized", + name: "Contract not initialized", + category: "contract", + patterns: ["not initialized", "must be initialized"], + cause: "Attempted to call a function on a contract that hasn't been initialized yet.", + fix: "Call the contract's initializer function before using other functions.", + source: "contract assertion", + }, + { + id: "contract-invalid-nonce", + name: "Invalid nonce", + category: "contract", + patterns: ["invalid nonce", "nonce mismatch"], + cause: "The transaction nonce doesn't match the expected value, often due to a concurrent transaction.", + fix: "Retry the transaction. If using authwits, ensure the nonce in the authwit matches the transaction.", + source: "contract assertion", + }, +]; diff --git a/src/index.ts b/src/index.ts index 46b2c6c..a825ff2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -23,6 +23,7 @@ import { listAztecExamples, readAztecExample, readRepoFile, + lookupAztecError, } from "./tools/index.js"; import { formatSyncResult, @@ -31,6 +32,7 @@ import { formatExamplesList, formatExampleContent, formatFileContent, + formatErrorLookupResult, } from "./utils/format.js"; import { MCP_VERSION } from "./version.js"; import { getSyncState, writeAutoResyncAttempt } from "./utils/sync-metadata.js"; @@ -190,6 +192,33 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({ required: ["path"], }, }, + { + name: "aztec_lookup_error", + description: + "Look up an Aztec error by message, error code, or hex signature. " + + "Returns root cause and suggested fix. Searches Solidity errors, " + + "TX validation errors, circuit codes, AVM errors, and documentation.", + inputSchema: { + type: "object", + properties: { + query: { + type: "string", + description: + "Error message, numeric error code (e.g., '2002'), or hex signature (e.g., '0xa5b2ba17')", + }, + category: { + type: "string", + description: + "Filter by error category. Options: contract, circuit, tx-validation, l1, avm, sequencer, operator, general", + }, + maxResults: { + type: "number", + description: "Maximum results to return (default: 10)", + }, + }, + required: ["query"], + }, + }, ], })); @@ -201,6 +230,7 @@ function validateToolRequest(name: string, args: Record | undef break; case "aztec_search_code": case "aztec_search_docs": + case "aztec_lookup_error": if (!args?.query) throw new McpError(ErrorCode.InvalidParams, "query is required"); break; case "aztec_read_example": @@ -365,6 +395,16 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { break; } + case "aztec_lookup_error": { + const result = lookupAztecError({ + query: args!.query as string, + category: args?.category as string | undefined, + maxResults: args?.maxResults as number | undefined, + }); + text = formatErrorLookupResult(result); + break; + } + } return { diff --git a/src/repos/config.ts b/src/repos/config.ts index 692ad05..978bc8e 100644 --- a/src/repos/config.ts +++ b/src/repos/config.ts @@ -45,6 +45,7 @@ const BASE_REPOS: Omit[] = [ "barretenberg/ts/src", "boxes", "playground", + "l1-contracts/src/core/libraries/Errors.sol", ], sparsePathOverrides: [ { @@ -52,6 +53,8 @@ const BASE_REPOS: Omit[] = [ "docs/developer_versioned_docs/version-{version}", "docs/static/aztec-nr-api/devnet", "docs/static/typescript-api/devnet", + "docs/docs-developers/docs/aztec-nr/debugging.md", + "docs/docs-operate/operators/operator-faq.md", ], branch: "next", }, diff --git a/src/tools/error-lookup.ts b/src/tools/error-lookup.ts new file mode 100644 index 0000000..e56ac31 --- /dev/null +++ b/src/tools/error-lookup.ts @@ -0,0 +1,31 @@ +/** + * Error lookup tool — diagnose any Aztec error by message, code, or hex signature. + */ + +import { lookupError } from "../utils/error-lookup.js"; +import type { ErrorLookupResult } from "../utils/error-lookup.js"; + +export function lookupAztecError(options: { + query: string; + category?: string; + maxResults?: number; +}): { + success: boolean; + result: ErrorLookupResult; + message: string; +} { + const { query, category, maxResults = 10 } = options; + + const result = lookupError(query, { category, maxResults }); + + const totalMatches = result.catalogMatches.length + result.codeMatches.length; + + return { + success: true, + result, + message: + totalMatches > 0 + ? `Found ${result.catalogMatches.length} known error(s) and ${result.codeMatches.length} code reference(s) for "${query}"` + : `No matches found for "${query}". Try a different error message, code, or hex signature.`, + }; +} diff --git a/src/tools/index.ts b/src/tools/index.ts index 4894080..5b401fe 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -10,3 +10,4 @@ export { readAztecExample, readRepoFile, } from "./search.js"; +export { lookupAztecError } from "./error-lookup.js"; diff --git a/src/tools/sync.ts b/src/tools/sync.ts index 06da6ce..7ff067a 100644 --- a/src/tools/sync.ts +++ b/src/tools/sync.ts @@ -7,6 +7,7 @@ import { join } from "path"; import { AZTEC_REPOS, getAztecRepos, DEFAULT_AZTEC_VERSION, RepoConfig } from "../repos/config.js"; import { cloneRepo, getReposStatus, getNoirCommitFromAztec, getRepoPath, REPOS_DIR, Logger } from "../utils/git.js"; import { writeSyncMetadata, stampMetadataMcpVersion, readSyncMetadata, SyncMetadata } from "../utils/sync-metadata.js"; +import { clearErrorCache } from "../utils/error-lookup.js"; export interface SyncResult { success: boolean; @@ -203,6 +204,9 @@ export async function syncRepos(options: { } } + // Invalidate cached error entries so the next lookup re-parses from updated files + clearErrorCache(); + const message = !allSuccess ? "Some repositories failed to sync" : metadataWriteFailed diff --git a/src/utils/error-lookup.ts b/src/utils/error-lookup.ts new file mode 100644 index 0000000..a7dfb0f --- /dev/null +++ b/src/utils/error-lookup.ts @@ -0,0 +1,484 @@ +/** + * Error lookup utilities — dynamic parsers and matching algorithm. + * + * Parsers extract ErrorEntry[] from cloned source files at query time. + * Results are cached for the session (parse once, reuse thereafter). + */ + +import { readFileSync, existsSync } from "fs"; +import { join } from "path"; +import { createHash } from "crypto"; +import { REPOS_DIR } from "./git.js"; +import { searchCode } from "./search.js"; +import type { SearchResult } from "./search.js"; +import type { ErrorEntry, ErrorCategory } from "../data/error-catalog.js"; +import { STATIC_ERROR_CATALOG } from "../data/error-catalog.js"; + +// --------------------------------------------------------------------------- +// Public types +// --------------------------------------------------------------------------- + +export interface ErrorMatch { + entry: ErrorEntry; + matchType: "exact-code" | "hex-signature" | "exact-pattern" | "substring" | "word-overlap"; + score: number; +} + +export interface ErrorLookupResult { + catalogMatches: ErrorMatch[]; + codeMatches: SearchResult[]; + query: string; +} + +// --------------------------------------------------------------------------- +// Session-level cache for parsed entries +// --------------------------------------------------------------------------- + +let cachedDynamic: ErrorEntry[] | null = null; + +export function clearErrorCache(): void { + cachedDynamic = null; +} + +// --------------------------------------------------------------------------- +// Dynamic parsers +// --------------------------------------------------------------------------- + +function safeRead(filePath: string): string | null { + try { + if (!existsSync(filePath)) return null; + return readFileSync(filePath, "utf-8"); + } catch { + return null; + } +} + +/** + * Parse Errors.sol for Solidity custom error definitions. + * Format: `error Module__Name(type param); // 0xhexhash` + */ +export function parseSolidityErrors(filePath: string): ErrorEntry[] { + const content = safeRead(filePath); + if (!content) return []; + + const entries: ErrorEntry[] = []; + const errorRegex = /^\s*error\s+(\w+)\(([^)]*)\);\s*(?:\/\/\s*(0x[0-9a-fA-F]+))?/gm; + + let match: RegExpExecArray | null; + while ((match = errorRegex.exec(content)) !== null) { + const [, fullName, , hexSig] = match; + const parts = fullName.split("__"); + const module = parts.length > 1 ? parts[0] : "Unknown"; + const shortName = parts.length > 1 ? parts.slice(1).join("__") : fullName; + + // Build human-readable cause from the name + const readable = shortName.replace(/([A-Z])/g, " $1").trim(); + + const entry: ErrorEntry = { + id: `l1-${fullName}`, + name: fullName, + category: "l1", + patterns: [fullName.toLowerCase(), shortName.toLowerCase()], + cause: `L1 ${module} error: ${readable}.`, + fix: `Check L1 logs for ${module} errors. Inspect the transaction on the L1 block explorer for revert details.`, + source: "Errors.sol", + }; + + if (hexSig) { + entry.hexSignature = hexSig.toLowerCase(); + entry.patterns.push(hexSig.toLowerCase()); + } + + entries.push(entry); + } + + return entries; +} + +/** + * Parse error_texts.ts for TX validation error constants. + * Format: `export const TX_ERROR_FOO = 'Human readable message';` + */ +export function parseTxValidationErrors(filePath: string): ErrorEntry[] { + const content = safeRead(filePath); + if (!content) return []; + + const entries: ErrorEntry[] = []; + const constRegex = /export\s+const\s+(TX_ERROR_\w+)\s*=\s*['"`]([^'"`]+)['"`]/g; + + // Extract section comments for categorization + const lines = content.split("\n"); + let currentSection = "general"; + + const sectionMap = new Map(); + for (let i = 0; i < lines.length; i++) { + const sectionMatch = lines[i].match(/\/\/\s*(.+)/); + if (sectionMatch && !lines[i].includes("export")) { + currentSection = sectionMatch[1].trim().toLowerCase(); + sectionMap.set(i, currentSection); + } + } + + let m: RegExpExecArray | null; + while ((m = constRegex.exec(content)) !== null) { + const [, constName, message] = m; + + // Find the section this constant belongs to + const lineNum = content.substring(0, m.index).split("\n").length - 1; + let section = "general"; + for (const [sLine, sName] of sectionMap) { + if (sLine <= lineNum) section = sName; + } + + entries.push({ + id: `tx-${constName}`, + name: constName, + category: "tx-validation", + patterns: [constName.toLowerCase(), message.toLowerCase()], + cause: message, + fix: inferTxValidationFix(constName, section), + source: "error_texts.ts", + }); + } + + return entries; +} + +function inferTxValidationFix(constName: string, section: string): string { + const name = constName.toLowerCase(); + if (name.includes("fee") || name.includes("gas") || section.includes("gas")) { + return "Increase the fee or gas limit in your transaction request."; + } + if (name.includes("nullifier") || section.includes("nullifier")) { + return "Check for duplicate nullifiers. The note may have already been consumed."; + } + if (name.includes("proof") || section.includes("proof")) { + return "Re-generate the proof. Ensure the proving system version matches the network."; + } + if (name.includes("size") || section.includes("size")) { + return "Reduce the transaction size. Split into multiple smaller transactions if needed."; + } + if (name.includes("block") || name.includes("header") || section.includes("block")) { + return "The transaction may reference a stale block. Retry with a fresh simulation."; + } + return "Review the transaction parameters and retry."; +} + +/** + * Parse debugging.md for error→solution tables. + * Expects markdown table rows: `| error message | solution |` + */ +export function parseDebuggingDoc(filePath: string): ErrorEntry[] { + const content = safeRead(filePath); + if (!content) return []; + + const entries: ErrorEntry[] = []; + const lines = content.split("\n"); + + let inTable = false; + let headerSkipped = false; + let tableCategory: ErrorCategory = "general"; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + + // Detect section headers for categorization + if (line.startsWith("##")) { + const heading = line.replace(/^#+\s*/, "").toLowerCase(); + if (heading.includes("sequencer")) tableCategory = "sequencer"; + else if (heading.includes("contract")) tableCategory = "contract"; + else tableCategory = "general"; + inTable = false; + headerSkipped = false; + } + + // Detect table start (header row) + if (line.startsWith("|") && line.includes("|") && !inTable) { + inTable = true; + headerSkipped = false; + continue; + } + + // Skip separator row + if (inTable && !headerSkipped && line.match(/^\|[\s-|]+\|$/)) { + headerSkipped = true; + continue; + } + + // Parse table data rows + if (inTable && headerSkipped && line.startsWith("|")) { + const cells = line + .split("|") + .map((c) => c.trim()) + .filter((c) => c.length > 0); + + if (cells.length >= 2) { + const errorMsg = cells[0].replace(/`/g, "").trim(); + const solution = cells[1].trim(); + + if (errorMsg && solution) { + entries.push({ + id: `debug-${createHash("md5").update(errorMsg).digest("hex").slice(0, 8)}`, + name: errorMsg, + category: tableCategory, + patterns: [errorMsg.toLowerCase()], + cause: errorMsg, + fix: solution, + source: "debugging.md", + }); + } + } + } + + // End of table + if (inTable && headerSkipped && !line.startsWith("|") && line.length > 0) { + inTable = false; + headerSkipped = false; + } + } + + return entries; +} + +/** + * Parse operator-faq.md for error headings and their solutions. + * Format: `### Issue Title` followed by **Symptom**: / **Cause**: / **Solution**: + */ +export function parseOperatorFaq(filePath: string): ErrorEntry[] { + const content = safeRead(filePath); + if (!content) return []; + + const entries: ErrorEntry[] = []; + const lines = content.split("\n"); + + let currentTitle = ""; + let symptom = ""; + let cause = ""; + let fix = ""; + + function flush() { + if (currentTitle && (cause || symptom)) { + entries.push({ + id: `op-${createHash("md5").update(currentTitle).digest("hex").slice(0, 8)}`, + name: currentTitle, + category: "operator", + patterns: [ + currentTitle.toLowerCase(), + ...(symptom ? [symptom.toLowerCase()] : []), + ], + cause: cause || symptom || currentTitle, + fix: fix || "See the operator FAQ documentation for detailed steps.", + source: "operator-faq.md", + }); + } + currentTitle = ""; + symptom = ""; + cause = ""; + fix = ""; + } + + for (const line of lines) { + const trimmed = line.trim(); + + if (trimmed.startsWith("### ")) { + flush(); + currentTitle = trimmed.replace(/^###\s*/, ""); + continue; + } + + const symptomMatch = trimmed.match(/^\*\*Symptom\*\*:\s*(.*)/); + if (symptomMatch) { + symptom = symptomMatch[1]; + continue; + } + + const causeMatch = trimmed.match(/^\*\*Cause\*\*:\s*(.*)/); + if (causeMatch) { + cause = causeMatch[1]; + continue; + } + + const fixMatch = trimmed.match(/^\*\*Solutions?\*\*:\s*(.*)/); + if (fixMatch) { + fix = fixMatch[1]; + continue; + } + } + + flush(); + return entries; +} + +// --------------------------------------------------------------------------- +// Collect all entries (static + dynamic) +// --------------------------------------------------------------------------- + +function getDynamicEntries(): ErrorEntry[] { + if (cachedDynamic) return cachedDynamic; + + const entries: ErrorEntry[] = []; + + // Solidity errors + const errorsPath = join(REPOS_DIR, "aztec-packages", "l1-contracts", "src", "core", "libraries", "Errors.sol"); + entries.push(...parseSolidityErrors(errorsPath)); + + // TX validation errors — search a few known locations + const txErrorPaths = [ + join(REPOS_DIR, "aztec-packages", "yarn-project", "stdlib", "src", "tx", "validator", "error_texts.ts"), + join(REPOS_DIR, "aztec-packages", "yarn-project", "circuit-types", "src", "tx", "validator", "error_texts.ts"), + ]; + for (const p of txErrorPaths) { + const parsed = parseTxValidationErrors(p); + if (parsed.length > 0) { + entries.push(...parsed); + break; + } + } + + // Debugging doc (lives in aztec-packages-docs, cloned from `next` branch) + const debugPaths = [ + join(REPOS_DIR, "aztec-packages-docs", "docs", "docs-developers", "docs", "aztec-nr", "debugging.md"), + join(REPOS_DIR, "aztec-packages", "docs", "docs-developers", "docs", "aztec-nr", "debugging.md"), + ]; + for (const p of debugPaths) { + const parsed = parseDebuggingDoc(p); + if (parsed.length > 0) { + entries.push(...parsed); + break; + } + } + + // Operator FAQ (lives in aztec-packages-docs, cloned from `next` branch) + const faqPaths = [ + join(REPOS_DIR, "aztec-packages-docs", "docs", "docs-operate", "operators", "operator-faq.md"), + join(REPOS_DIR, "aztec-packages", "docs", "docs-operate", "operators", "operator-faq.md"), + ]; + for (const p of faqPaths) { + const parsed = parseOperatorFaq(p); + if (parsed.length > 0) { + entries.push(...parsed); + break; + } + } + + cachedDynamic = entries; + return entries; +} + +function getAllEntries(): ErrorEntry[] { + return [...STATIC_ERROR_CATALOG, ...getDynamicEntries()]; +} + +// --------------------------------------------------------------------------- +// Matching algorithm +// --------------------------------------------------------------------------- + +function tokenize(s: string): Set { + return new Set( + s + .toLowerCase() + .replace(/[^a-z0-9]+/g, " ") + .split(/\s+/) + .filter((w) => w.length > 1) + ); +} + +function jaccard(a: Set, b: Set): number { + if (a.size === 0 || b.size === 0) return 0; + let intersection = 0; + for (const w of a) { + if (b.has(w)) intersection++; + } + return intersection / (a.size + b.size - intersection); +} + +function matchEntry(entry: ErrorEntry, query: string, queryLower: string, queryTokens: Set): ErrorMatch | null { + // 1. Exact error code match + if (entry.errorCode !== undefined && /^\d+$/.test(query) && entry.errorCode === parseInt(query, 10)) { + return { entry, matchType: "exact-code", score: 100 }; + } + + // 2. Hex signature match + if (entry.hexSignature && queryLower.startsWith("0x") && entry.hexSignature === queryLower) { + return { entry, matchType: "hex-signature", score: 100 }; + } + + // 3. Exact pattern match + for (const pattern of entry.patterns) { + if (pattern === queryLower) { + return { entry, matchType: "exact-pattern", score: 95 }; + } + } + + // 4. Substring match + for (const pattern of entry.patterns) { + if (pattern.includes(queryLower)) { + return { entry, matchType: "substring", score: 80 }; + } + if (queryLower.includes(pattern) && pattern.length > 3) { + return { entry, matchType: "substring", score: 70 }; + } + } + + // 5. Word overlap (Jaccard) + let bestJaccard = 0; + for (const pattern of entry.patterns) { + const patternTokens = tokenize(pattern); + const j = jaccard(queryTokens, patternTokens); + if (j > bestJaccard) bestJaccard = j; + } + // Also check name + const nameJ = jaccard(queryTokens, tokenize(entry.name)); + if (nameJ > bestJaccard) bestJaccard = nameJ; + + if (bestJaccard >= 0.25) { + const score = Math.round(50 + bestJaccard * 15); + return { entry, matchType: "word-overlap", score: Math.min(score, 65) }; + } + + return null; +} + +// --------------------------------------------------------------------------- +// Main lookup function +// --------------------------------------------------------------------------- + +export function lookupError( + query: string, + options: { category?: string; maxResults?: number } = {} +): ErrorLookupResult { + const { category, maxResults = 10 } = options; + const queryLower = query.toLowerCase().trim(); + const queryTokens = tokenize(query); + + const allEntries = getAllEntries(); + const matches: ErrorMatch[] = []; + + for (const entry of allEntries) { + // Category filter + if (category && entry.category !== category) continue; + + const m = matchEntry(entry, query.trim(), queryLower, queryTokens); + if (m) matches.push(m); + } + + // Sort by score descending + matches.sort((a, b) => b.score - a.score); + const catalogMatches = matches.slice(0, maxResults); + + // Fallback: if fewer than 3 catalog matches, search code + let codeMatches: SearchResult[] = []; + if (catalogMatches.length < 3) { + try { + codeMatches = searchCode(query, { + filePattern: "*.ts", + repo: "aztec-packages", + maxResults: Math.min(maxResults, 5), + }); + } catch { + // Repos may not be cloned — that's fine + } + } + + return { catalogMatches, codeMatches, query }; +} diff --git a/src/utils/format.ts b/src/utils/format.ts index d05e9ae..ca2c2fb 100644 --- a/src/utils/format.ts +++ b/src/utils/format.ts @@ -5,6 +5,7 @@ import type { SyncResult } from "../tools/sync.js"; import type { SearchResult, FileInfo } from "./search.js"; import type { SyncMetadata } from "./sync-metadata.js"; +import type { ErrorLookupResult } from "./error-lookup.js"; export function formatSyncResult(result: SyncResult): string { const lines = [ @@ -151,3 +152,53 @@ export function formatFileContent(result: { return result.content; } + +export function formatErrorLookupResult(result: { + success: boolean; + result: ErrorLookupResult; + message: string; +}): string { + const lines = [result.message, ""]; + + const { catalogMatches, codeMatches } = result.result; + + if (catalogMatches.length > 0) { + lines.push("## Known Errors"); + lines.push(""); + + for (const m of catalogMatches) { + const { entry } = m; + lines.push(`**${entry.name}**`); + if (entry.errorCode !== undefined) lines.push(`- Code: ${entry.errorCode}`); + if (entry.hexSignature) lines.push(`- Hex: ${entry.hexSignature}`); + lines.push(`- Category: ${entry.category}`); + lines.push(`- Source: ${entry.source}`); + lines.push(`- Match: ${m.matchType} (score ${m.score})`); + lines.push(`- **Cause**: ${entry.cause}`); + lines.push(`- **Fix**: ${entry.fix}`); + lines.push(""); + } + } + + if (codeMatches.length > 0) { + lines.push("## Related Code References"); + lines.push(""); + + for (const match of codeMatches) { + lines.push(`**${match.file}:${match.line}**`); + lines.push("```"); + lines.push(match.content); + lines.push("```"); + lines.push(""); + } + } + + if (catalogMatches.length === 0 && codeMatches.length === 0) { + lines.push("No matching errors found. Try:"); + lines.push("- A numeric error code (e.g., `2002`)"); + lines.push("- A hex signature (e.g., `0xa5b2ba17`)"); + lines.push("- An error message substring (e.g., `insufficient fee`)"); + } + + return lines.join("\n"); +} diff --git a/tests/repos/config.test.ts b/tests/repos/config.test.ts index 8f24c9d..bf0f12b 100644 --- a/tests/repos/config.test.ts +++ b/tests/repos/config.test.ts @@ -35,6 +35,8 @@ describe("AZTEC_REPOS", () => { `docs/developer_versioned_docs/version-${DEFAULT_AZTEC_VERSION}`, "docs/static/aztec-nr-api/devnet", "docs/static/typescript-api/devnet", + "docs/docs-developers/docs/aztec-nr/debugging.md", + "docs/docs-operate/operators/operator-faq.md", ], branch: "next", }, diff --git a/tests/utils/error-lookup.test.ts b/tests/utils/error-lookup.test.ts new file mode 100644 index 0000000..cb4aa05 --- /dev/null +++ b/tests/utils/error-lookup.test.ts @@ -0,0 +1,262 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// Mock dependencies before importing the module under test +vi.mock("fs", () => ({ + existsSync: vi.fn(), + readFileSync: vi.fn(), + mkdirSync: vi.fn(), +})); + +vi.mock("child_process", () => ({ + execSync: vi.fn(), +})); + +vi.mock("globby", () => ({ + globbySync: vi.fn(() => []), +})); + +vi.mock("../../src/utils/git.js", () => ({ + REPOS_DIR: "/fake/repos", + getRepoPath: vi.fn((name: string) => `/fake/repos/${name}`), + isRepoCloned: vi.fn(() => false), +})); + +import { existsSync, readFileSync } from "fs"; +import { + parseSolidityErrors, + parseTxValidationErrors, + parseDebuggingDoc, + parseOperatorFaq, + lookupError, + clearErrorCache, +} from "../../src/utils/error-lookup.js"; + +const mockExistsSync = vi.mocked(existsSync); +const mockReadFileSync = vi.mocked(readFileSync); + +beforeEach(() => { + vi.clearAllMocks(); + clearErrorCache(); + mockExistsSync.mockReturnValue(false); +}); + +// --------------------------------------------------------------------------- +// Parser tests +// --------------------------------------------------------------------------- + +describe("parseSolidityErrors", () => { + it("extracts error definitions with hex signatures", () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue( + "error Rollup__InvalidProof(bytes32 expected, bytes32 actual); // 0xa5b2ba17\n" + + "error Inbox__MsgTooLarge(uint256 size);\n" + + "error Outbox__AlreadyConsumed(bytes32 msgHash); // 0xdeadbeef\n" + ); + + const entries = parseSolidityErrors("/fake/Errors.sol"); + + expect(entries).toHaveLength(3); + + expect(entries[0].name).toBe("Rollup__InvalidProof"); + expect(entries[0].hexSignature).toBe("0xa5b2ba17"); + expect(entries[0].category).toBe("l1"); + expect(entries[0].patterns).toContain("rollup__invalidproof"); + expect(entries[0].patterns).toContain("0xa5b2ba17"); + + expect(entries[1].name).toBe("Inbox__MsgTooLarge"); + expect(entries[1].hexSignature).toBeUndefined(); + + expect(entries[2].hexSignature).toBe("0xdeadbeef"); + }); + + it("returns empty array for missing file", () => { + mockExistsSync.mockReturnValue(false); + expect(parseSolidityErrors("/missing.sol")).toEqual([]); + }); +}); + +describe("parseTxValidationErrors", () => { + it("extracts TX error constants with messages", () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(` +// Gas and fees +export const TX_ERROR_INSUFFICIENT_FEE_PER_GAS = 'Insufficient fee per gas'; +export const TX_ERROR_GAS_LIMIT_EXCEEDED = 'Gas limit exceeded'; + +// Nullifiers +export const TX_ERROR_DUPLICATE_NULLIFIER = 'Duplicate nullifier detected'; +`); + + const entries = parseTxValidationErrors("/fake/error_texts.ts"); + + expect(entries).toHaveLength(3); + + expect(entries[0].name).toBe("TX_ERROR_INSUFFICIENT_FEE_PER_GAS"); + expect(entries[0].category).toBe("tx-validation"); + expect(entries[0].patterns).toContain("insufficient fee per gas"); + expect(entries[0].fix).toMatch(/fee|gas/i); + + expect(entries[2].name).toBe("TX_ERROR_DUPLICATE_NULLIFIER"); + expect(entries[2].fix).toMatch(/nullifier/i); + }); + + it("returns empty array for missing file", () => { + mockExistsSync.mockReturnValue(false); + expect(parseTxValidationErrors("/missing.ts")).toEqual([]); + }); +}); + +describe("parseDebuggingDoc", () => { + it("extracts error→solution table rows", () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(`--- +title: Debugging +--- + +## Contract Errors + +| Error | Solution | +| --- | --- | +| \`Cannot find module\` | Run \`aztec-nargo compile\` first | +| Storage slot collision | Use unique storage slots for each variable | + +## Other stuff +Some text here. +`); + + const entries = parseDebuggingDoc("/fake/debugging.md"); + + expect(entries).toHaveLength(2); + expect(entries[0].name).toBe("Cannot find module"); + expect(entries[0].fix).toContain("aztec-nargo compile"); + expect(entries[0].category).toBe("contract"); + + expect(entries[1].name).toBe("Storage slot collision"); + }); +}); + +describe("parseOperatorFaq", () => { + it("extracts FAQ entries with symptom/cause/solution", () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(`--- +title: Operator FAQ +--- + +## Overview + +Common operator issues. + +## Node Sync Issues + +### Node fails to sync +**Symptom**: Node is stuck at a specific block height +**Cause**: Peer connectivity issue +**Solution**: Restart the node and check firewall settings + +### Database corruption +**Symptom**: Error reading from database +**Cause**: Unclean shutdown +**Solution**: Run database repair command +`); + + const entries = parseOperatorFaq("/fake/operator-faq.md"); + + expect(entries).toHaveLength(2); + + expect(entries[0].name).toBe("Node fails to sync"); + expect(entries[0].cause).toBe("Peer connectivity issue"); + expect(entries[0].fix).toContain("Restart the node"); + expect(entries[0].category).toBe("operator"); + expect(entries[0].patterns).toContain("node is stuck at a specific block height"); + + expect(entries[1].name).toBe("Database corruption"); + }); +}); + +// --------------------------------------------------------------------------- +// Lookup algorithm tests +// --------------------------------------------------------------------------- + +describe("lookupError", () => { + it("matches circuit errors by numeric code", () => { + const result = lookupError("2002"); + expect(result.catalogMatches.length).toBeGreaterThan(0); + expect(result.catalogMatches[0].entry.id).toBe("circuit-2002"); + expect(result.catalogMatches[0].matchType).toBe("exact-code"); + expect(result.catalogMatches[0].score).toBe(100); + }); + + it("matches AVM errors by name", () => { + const result = lookupError("OutOfGasError"); + expect(result.catalogMatches.length).toBeGreaterThan(0); + expect(result.catalogMatches[0].entry.id).toBe("avm-out-of-gas"); + }); + + it("matches by substring", () => { + const result = lookupError("insufficient balance"); + expect(result.catalogMatches.length).toBeGreaterThan(0); + const match = result.catalogMatches.find((m) => m.entry.id === "contract-insufficient-balance"); + expect(match).toBeDefined(); + }); + + it("filters by category", () => { + const result = lookupError("2002", { category: "avm" }); + // circuit-2002 is category "circuit", so it shouldn't match with "avm" filter + const circuitMatch = result.catalogMatches.find((m) => m.entry.id === "circuit-2002"); + expect(circuitMatch).toBeUndefined(); + }); + + it("respects maxResults", () => { + const result = lookupError("error", { maxResults: 2 }); + expect(result.catalogMatches.length).toBeLessThanOrEqual(2); + }); + + it("matches dynamic Solidity errors by hex signature", () => { + // Set up a mock Errors.sol file + mockExistsSync.mockImplementation((p) => { + return String(p).includes("Errors.sol"); + }); + mockReadFileSync.mockReturnValue( + `error Rollup__InvalidProof(bytes32 expected, bytes32 actual); // 0xa5b2ba17\n` + ); + + clearErrorCache(); + const result = lookupError("0xa5b2ba17"); + expect(result.catalogMatches.length).toBeGreaterThan(0); + const match = result.catalogMatches.find((m) => m.entry.hexSignature === "0xa5b2ba17"); + expect(match).toBeDefined(); + expect(match!.matchType).toBe("hex-signature"); + }); + + it("matches dynamic TX validation errors", () => { + mockExistsSync.mockImplementation((p) => { + return String(p).includes("error_texts.ts"); + }); + mockReadFileSync.mockReturnValue( + `export const TX_ERROR_INSUFFICIENT_FEE_PER_GAS = 'Insufficient fee per gas';\n` + ); + + clearErrorCache(); + const result = lookupError("insufficient fee"); + const match = result.catalogMatches.find((m) => m.entry.name === "TX_ERROR_INSUFFICIENT_FEE_PER_GAS"); + expect(match).toBeDefined(); + }); + + it("returns code matches as fallback when few catalog matches", () => { + // With no dynamic sources available, searching for something obscure + // should still return a result object (code matches may be empty if rg fails) + const result = lookupError("xyzzy_nonexistent_error_12345"); + expect(result).toHaveProperty("catalogMatches"); + expect(result).toHaveProperty("codeMatches"); + expect(result).toHaveProperty("query", "xyzzy_nonexistent_error_12345"); + }); + + it("sorts results by score descending", () => { + const result = lookupError("2002"); + for (let i = 1; i < result.catalogMatches.length; i++) { + expect(result.catalogMatches[i - 1].score).toBeGreaterThanOrEqual( + result.catalogMatches[i].score + ); + } + }); +});