diff --git a/README.md b/README.md index c45d37b..ad679fa 100644 --- a/README.md +++ b/README.md @@ -245,7 +245,7 @@ This server implements the [Model Context Protocol (MCP)](https://modelcontextpr - **Runs as a local process** — your AI client spawns the server and communicates locally. No network requests, no API keys. - **Uses the same parser as the [Apex Log Analyzer VS Code extension](https://github.com/certinia/debug-log-analyzer)** — battle-tested parsing of the Apex debug log format. - **Returns structured data** — all durations in milliseconds, governor limits as used/max rows, operations with SOQL/DML counts — so your AI assistant can reason about the results. -- **Keeps responses lean** — TOON encoding, no duplicated figures, and zero/empty fields omitted, so more of the context window is left for reasoning. +- **Keeps responses lean** — TOON tables and no duplicated figures, so more of the context window is left for reasoning. The fields stay, including the ones at zero: see [Tools Reference](#tools-reference). - **Parses a log once, not once per tool** — a summary followed by a deeper analysis of the same file reuses the parse, so a large log is read and parsed one time. ## Documentation diff --git a/scripts/eval.mjs b/scripts/eval.mjs index 77589c2..f7f948a 100644 --- a/scripts/eval.mjs +++ b/scripts/eval.mjs @@ -99,6 +99,7 @@ const ANSWERABILITY = { { question: "What did the transaction spend its time on?", keys: ["operations"] }, { question: "Was it a method, a query, a search or DML?", + keys: ["operations"], columns: ["kind", "callCount"], }, { @@ -107,10 +108,12 @@ const ANSWERABILITY = { }, { question: "Did any of them touch the database, and how much did they move?", + keys: ["operations"], columns: ["dmlCount", "soqlCount", "soslCount", "rowCount"], }, { question: "Where in the code are they, and whose namespace are they in?", + keys: ["operations"], columns: ["namespace", "lineNumber"], }, ], @@ -312,7 +315,7 @@ function createClient() { function inspect(toon) { const scalars = new Map(); const keys = []; - const columns = new Set(); + const columns = new Map(); const tables = new Map(); const strings = []; let table = new Map(); @@ -326,7 +329,10 @@ function inspect(toon) { table = new Map(); tables.set(key, table); if (header) { - header.split(",").forEach((column) => columns.add(column.trim())); + columns.set( + key, + new Set(header.split(",").map((column) => column.trim())), + ); } else if (value !== "" && !line.endsWith(":")) { const numeric = Number(value); if (Number.isFinite(numeric) && /^-?[\d.]+$/.test(value)) { @@ -360,8 +366,19 @@ function checkAnswerability({ tool, fixture }, toon, failures) { for (const key of check.keys ?? []) { if (!keys.includes(key)) missing.push(key); } - for (const column of check.columns ?? []) { - if (!columns.has(column)) missing.push(column); + // A column belongs to one table. Pooling every header into one set let a + // check pass on a column another table happened to carry. + if (check.columns) { + const [table, ...rest] = check.keys ?? []; + if (!table || rest.length) { + throw new Error( + `${tool}: a "columns" check names the one table they are in, in "keys" — "${check.question}"`, + ); + } + const header = columns.get(table) ?? new Set(); + for (const column of check.columns) { + if (!header.has(column)) missing.push(`${table}.${column}`); + } } for (const limit of check.limits ?? []) { if (!limitRows.has(limit)) missing.push(`governorLimits.${limit}`); diff --git a/src/salesforce/debugLevels.ts b/src/salesforce/debugLevels.ts index 82c3118..adb1183 100644 --- a/src/salesforce/debugLevels.ts +++ b/src/salesforce/debugLevels.ts @@ -35,6 +35,29 @@ export type TraceCategory = (typeof TRACE_CATEGORIES)[number]; export type TraceConfig = Partial>; +/** + * The same categories as a debug log header spells them. + * + * A log opens with `APEX_CODE,FINE;DB,FINEST;…`, which is what the parser reads + * into `debugLevels`, so this is the spelling every response uses. `DATA_ACCESS` + * appears there but is not a `DebugLevel` field, so nothing can set it. + */ +export const LOG_CATEGORIES = [ + "APEX_CODE", + "APEX_PROFILING", + "CALLOUT", + "DATA_ACCESS", + "DB", + "NBA", + "SYSTEM", + "VALIDATION", + "VISUALFORCE", + "WAVE", + "WORKFLOW", +] as const; + +export type LogCategory = (typeof LOG_CATEGORIES)[number]; + /** The only place the per-category defaults live. */ export const DEFAULT_TRACE_CONFIG: Required = { apexCode: "FINE", diff --git a/src/tools/operations.ts b/src/tools/operations.ts index 99c4693..d0d990b 100644 --- a/src/tools/operations.ts +++ b/src/tools/operations.ts @@ -3,6 +3,7 @@ */ import type { ApexLog, LogLine, LogSubCategory } from "../ApexLogParser.js"; +import type { LogCategory } from "../salesforce/debugLevels.js"; import { walkLog } from "./apexLogSource.js"; /** @@ -29,8 +30,13 @@ export const OPERATION_KINDS = [ export type OperationKind = (typeof OPERATION_KINDS)[number]; -/** The trace category that decides whether a kind reaches the log. */ -const LOG_CATEGORY_BY_KIND: Record = { +/** + * The category that decides whether a kind reaches the log. + * + * Typed as `LogCategory`, so the spelling here cannot drift from the one the + * `debugLevels` rows carry — a caller reads `timeByKind` against them. + */ +const LOG_CATEGORY_BY_KIND: Record = { codeUnit: "APEX_CODE", managedPackage: "APEX_CODE", method: "APEX_CODE", @@ -42,7 +48,7 @@ const LOG_CATEGORY_BY_KIND: Record = { workflow: "WORKFLOW", }; -export function logCategoryOf(kind: OperationKind): string { +export function logCategoryOf(kind: OperationKind): LogCategory { return LOG_CATEGORY_BY_KIND[kind]; } diff --git a/tests/operations.test.ts b/tests/operations.test.ts index 5998777..581c2ce 100644 --- a/tests/operations.test.ts +++ b/tests/operations.test.ts @@ -3,6 +3,7 @@ */ import type { ApexLog } from "../src/ApexLogParser"; +import { LOG_CATEGORIES } from "../src/salesforce/debugLevels"; import { groupOperations, listOperations, @@ -92,7 +93,9 @@ describe("listOperations", () => { it("covers every kind it declares", () => { expect(new Set(OPERATION_KINDS).size).toBe(OPERATION_KINDS.length); - OPERATION_KINDS.forEach((kind) => expect(logCategoryOf(kind)).toBeTruthy()); + OPERATION_KINDS.forEach((kind) => + expect(LOG_CATEGORIES).toContain(logCategoryOf(kind)), + ); }); it("drops the transaction frame, which owns no time of its own", () => { diff --git a/tests/salesforce/debugLevels.test.ts b/tests/salesforce/debugLevels.test.ts index 6dd3cbf..767eaaa 100644 --- a/tests/salesforce/debugLevels.test.ts +++ b/tests/salesforce/debugLevels.test.ts @@ -2,8 +2,15 @@ * Copyright (c) 2025 Certinia Inc. All rights reserved. */ +import fs from "node:fs"; +import path from "node:path"; + import { Connection } from "@salesforce/core"; -import { getOrCreateDebugLevelId } from "../../src/salesforce/debugLevels"; +import { + getOrCreateDebugLevelId, + LOG_CATEGORIES, + TRACE_CATEGORIES, +} from "../../src/salesforce/debugLevels"; describe("Debug Levels", () => { const testId = "000000000000000000"; @@ -260,4 +267,40 @@ describe("Debug Levels", () => { }); }); }); + + describe("log categories", () => { + const fixtures = path.join(__dirname, "..", "eval", "fixtures"); + + it.each(fs.readdirSync(fixtures).filter((name) => name.endsWith(".log")))( + "spells every category in %s the way the log header does", + (name) => { + const header = fs + .readFileSync(path.join(fixtures, name), "utf8") + .split("\n")[0]!; + const categories = header + .split(" ")[1]! + .split(";") + .map((pair) => pair.split(",")[0]!); + + expect(categories.length).toBeGreaterThan(0); + categories.forEach((category) => + expect(LOG_CATEGORIES).toContain(category), + ); + }, + ); + + it("gives every settable category a header spelling", () => { + TRACE_CATEGORIES.forEach((category) => { + const spelling = + category === "database" ? "DB" : toScreamingSnake(category); + + expect(LOG_CATEGORIES).toContain(spelling); + }); + }); + }); }); + +/** `apexCode` as a log header spells it: `APEX_CODE`. */ +function toScreamingSnake(category: string): string { + return category.replace(/([A-Z])/g, "_$1").toUpperCase(); +}