diff_results
Compare before and after accessibility analysis results to identify improvements, regressions, and penalty changes per target. Verify that fixes addressed issues effectively.
Instructions
Compare two Tactual analysis results (before/after). Shows what improved, regressed, which penalties were resolved or added, and severity band changes per target. Returns a JSON array of {targetId, baselineScore, candidateScore, status, penalties}.
Read-only, no side effects. Use after fixing accessibility issues to verify improvements. Both inputs must be JSON strings from analyze_url (format='json'). Not useful for SARIF output — use analyze_url directly for before/after SARIF comparisons.
Input Schema
| Name | Required | Description | Default |
|---|---|---|---|
| baseline | Yes | Baseline analysis result as JSON string | |
| candidate | Yes | Candidate analysis result as JSON string |
Implementation Reference
- src/pipeline/diff-results.ts:46-124 (handler)Core handler that diffs two Tactual analysis results. Iterates over all target IDs, computes score deltas, identifies penalties resolved/added, and returns a structured DiffResult with summary counts and up to 20 changes.
export function runDiffResults(baseline: unknown, candidate: unknown): DiffResult { let baseFindings; let candFindings; try { baseFindings = extractFindings(baseline); candFindings = extractFindings(candidate); } catch (err) { throw new DiffResultsError( "bad-input", `Error parsing results: ${err instanceof Error ? err.message : String(err)}`, ); } const baseMap = new Map(baseFindings.map((f) => [f.targetId, f])); const candMap = new Map(candFindings.map((f) => [f.targetId, f])); const allIds = new Set([...baseMap.keys(), ...candMap.keys()]); const changes: DiffChange[] = []; let improved = 0; let regressed = 0; let added = 0; let removed = 0; for (const id of allIds) { const b = baseMap.get(id); const c = candMap.get(id); const bScore = b?.overall ?? null; const cScore = c?.overall ?? null; const delta = (cScore ?? 0) - (bScore ?? 0); const bPenalties = new Set(b?.penalties ?? []); const cPenalties = new Set(c?.penalties ?? []); const penaltiesResolved = [...bPenalties].filter((p) => !cPenalties.has(p)); const penaltiesAdded = [...cPenalties].filter((p) => !bPenalties.has(p)); let status: DiffChange["status"]; if (!b) { status = "new"; added++; } else if (!c) { status = "removed"; removed++; } else if (delta > 0) { status = "improved"; improved++; } else if (delta < 0) { status = "regressed"; regressed++; } else { continue; } changes.push({ targetId: id, baselineScore: bScore, candidateScore: cScore, delta, baselineSeverity: b?.severity ?? null, candidateSeverity: c?.severity ?? null, severityChanged: (b?.severity ?? null) !== (c?.severity ?? null), penaltiesResolved, penaltiesAdded, status, }); } const statusOrder = { regressed: 0, new: 1, removed: 2, improved: 3, unchanged: 4 }; changes.sort((a, b) => statusOrder[a.status] - statusOrder[b.status] || a.delta - b.delta); const allResolved = changes.flatMap((c) => c.penaltiesResolved); const allAdded = changes.flatMap((c) => c.penaltiesAdded); return { summary: { improved, regressed, added, removed }, penaltiesResolved: [...new Set(allResolved)].slice(0, 5), penaltiesAdded: [...new Set(allAdded)].slice(0, 5), changes: changes.slice(0, 20), }; } - src/pipeline/diff-results.ts:11-34 (schema)Type definitions for DiffChange and DiffResult interfaces used by the tool.
export interface DiffChange { targetId: string; baselineScore: number | null; candidateScore: number | null; delta: number; baselineSeverity: string | null; candidateSeverity: string | null; severityChanged: boolean; penaltiesResolved: string[]; penaltiesAdded: string[]; status: "improved" | "regressed" | "new" | "removed" | "unchanged"; } export interface DiffResult { summary: { improved: number; regressed: number; added: number; removed: number; }; penaltiesResolved: string[]; penaltiesAdded: string[]; changes: DiffChange[]; } - src/mcp/tools/diff-results.ts:5-36 (registration)MCP tool registration of 'diff_results' with Zod inputSchema (baseline, candidate as JSON strings) and async handler that calls runDiffResults.
export function registerDiffResults(server: McpServer): void { server.registerTool( "diff_results", { description: "Compare two Tactual analysis results (before/after). Shows what improved, regressed, " + "which penalties were resolved or added, and severity band changes per target. " + "Returns a JSON array of {targetId, baselineScore, candidateScore, status, penalties}.\n\n" + "Read-only, no side effects. Use after fixing accessibility issues to verify improvements. " + "Both inputs must be JSON strings from analyze_url (format='json'). " + "Not useful for SARIF output — use analyze_url directly for before/after SARIF comparisons.", inputSchema: { baseline: z.string().describe("Baseline analysis result as JSON string"), candidate: z.string().describe("Candidate analysis result as JSON string"), }, }, async ({ baseline, candidate }) => { try { const result = runDiffResults(JSON.parse(baseline), JSON.parse(candidate)); return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }], }; } catch (err) { const text = err instanceof DiffResultsError ? err.message : `Error parsing results: ${err instanceof Error ? err.message : String(err)}`; return { content: [{ type: "text" as const, text }], isError: true }; } }, ); } - src/mcp/index.ts:14-40 (registration)Import and registration call (line 37) of registerDiffResults into the MCP server.
import { registerDiffResults } from "./tools/diff-results.js"; import { registerSuggestRemediations } from "./tools/suggest-remediations.js"; import { registerTracePath } from "./tools/trace-path.js"; import { registerSaveAuth } from "./tools/save-auth.js"; import { registerAnalyzePages } from "./tools/analyze-pages.js"; export { extractFindings, getOverallScore } from "./helpers.js"; export { closeSharedBrowser } from "./browser.js"; /** * Construct the Tactual MCP server with all tools registered. * Each tool lives in its own file under tools/; shared infrastructure * (browser pool, probe-budget helpers) lives alongside this index. */ export function createMcpServer(): McpServer { const server = new McpServer({ name: "tactual", version: VERSION, }); registerAnalyzeUrl(server); registerValidateUrl(server); registerListProfiles(server); registerDiffResults(server); registerSuggestRemediations(server); registerTracePath(server); registerSaveAuth(server); - src/core/result-extraction.ts:22-96 (helper)extractFindings helper used by runDiffResults to normalize both raw AnalysisResult, SummarizedResult, and SARIF inputs into a common NormalizedFinding format.
export function extractFindings(data: unknown): NormalizedFinding[] { const obj = data as Record<string, unknown>; if (Array.isArray(obj.findings)) { return (obj.findings as Array<Record<string, unknown>>).map((f) => ({ targetId: String(f.targetId ?? ""), overall: getOverallScore(f), severity: String(f.severity ?? "unknown"), penalties: (f.penalties as string[] | undefined) ?? [], suggestedFixes: (f.suggestedFixes as string[] | undefined) ?? [], })); } if (Array.isArray(obj.worstFindings)) { return (obj.worstFindings as Array<Record<string, unknown>>).map((f) => ({ targetId: String(f.targetId ?? ""), overall: getOverallScore(f), severity: String(f.severity ?? "unknown"), penalties: (f.penalties as string[] | undefined) ?? [], suggestedFixes: (f.suggestedFixes as string[] | undefined) ?? [], })); } if (Array.isArray(obj.runs)) { const runs = obj.runs as Array<Record<string, unknown>>; const results = (runs[0]?.results ?? []) as Array<Record<string, unknown>>; return results .filter((r) => { const props = r.properties as Record<string, unknown> | undefined; return !props?.truncated; }) .map((r) => { const props = (r.properties ?? {}) as Record<string, unknown>; const scores = (props.scores ?? {}) as Record<string, unknown>; const locs = (r.locations ?? []) as Array<Record<string, unknown>>; const logLocs = (locs[0]?.logicalLocations ?? []) as Array<Record<string, unknown>>; const targetId = String(logLocs[0]?.name ?? ""); const level = String(r.level ?? "note"); const severity = level === "error" ? "high" : level === "warning" ? "moderate" : "acceptable"; const msgText = String((r.message as Record<string, unknown>)?.text ?? ""); const penalties: string[] = []; const suggestedFixes: string[] = []; const issuesMatch = msgText.match(/Issues:\s*(.+?)(?:\.\s*Fixes:|$)/); if (issuesMatch) penalties.push(...issuesMatch[1].split(/;\s*/).filter(Boolean)); const fixesMatch = msgText.match(/Fixes:\s*(.+)/); if (fixesMatch) suggestedFixes.push(...fixesMatch[1].split(/;\s*/).filter(Boolean)); return { targetId, overall: Number(scores.overall ?? 0), severity, penalties, suggestedFixes, }; }); } throw new Error( 'Input must contain "findings" (raw), "worstFindings" (summarized), or "runs" (SARIF) array. ' + "Pass the full analysis result object, not a sub-field.", ); } /** * Extract the overall score from either Finding or DetailedFinding shape. * Finding: { scores: { overall: N } } * DetailedFinding: { overall: N, scores: { discoverability, ... } } */ export function getOverallScore(f: Record<string, unknown>): number { if (typeof f.overall === "number") return f.overall; const scores = f.scores as Record<string, unknown> | undefined; if (scores && typeof scores.overall === "number") return scores.overall; return 0; }