From 989894673fcb4afb22d4243aeabb47d1e6b7fd52 Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Wed, 12 Aug 2026 14:13:36 +0100 Subject: [PATCH 1/3] fix(tools): type the log categories, so no third spelling can appear LOG_CATEGORY_BY_KIND wrote the debug log header categories a third time, beside TRACE_CATEGORIES and the header itself. Nothing joined them, so a typo would have shipped a timeByKind row a caller cannot read against the debugLevels rows beside it. Declare LOG_CATEGORIES once, in the module CLAUDE.md names as the one home for the categories, and type logCategoryOf against it. Tests lock the list to the header spellings the fixture logs carry, and to the settable categories. --- src/salesforce/debugLevels.ts | 23 ++++++++++++++ src/tools/operations.ts | 12 ++++++-- tests/operations.test.ts | 5 +++- tests/salesforce/debugLevels.test.ts | 45 +++++++++++++++++++++++++++- 4 files changed, 80 insertions(+), 5 deletions(-) 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(); +} From 874bffc2950ed172ca236c824e52b7727291ed82 Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Wed, 12 Aug 2026 14:28:55 +0100 Subject: [PATCH 2/3] docs: stop the feature list promising fields are dropped "zero/empty fields omitted" described 1.x. 2.0 returns every governor limit, category and column, including the ones at zero, so a caller can tell "none" from "never measured". The bullet said the opposite of the policy the Tools Reference states ten lines above it. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 7bf26df75df7cb769f7090ca848a53de95ae23db Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Wed, 12 Aug 2026 14:54:52 +0100 Subject: [PATCH 3/3] test(eval): scope a columns assertion to the table it names inspect() pooled every table header into one set, so a "columns" check passed on a column any other table in the response carried. That let a wrong assertion through earlier in this series. Key the headers by table, and make a "columns" check name its one table in "keys". The three slow-operations checks that named no table now name "operations". --- scripts/eval.mjs | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) 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}`);