Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions node_modules
124 changes: 72 additions & 52 deletions src/tools/accessibility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import {
compareIssues,
loadMainIssues,
parseAccessibilityReportFromCSV,
type ReviewCounts,
type IssueSummary as WebIssueSummary,
} from "./accessiblity-utils/report-parser.js";
import { wrapUntrusted } from "../lib/untrusted-content.js";
import { queryAccessibilityRAG } from "./accessiblity-utils/accessibility-rag.js";
Expand Down Expand Up @@ -545,26 +545,35 @@ function summaryNote(summary: AppIssueSummary, total: number): string[] {
];
}

const REVIEW_TAGS_NOTE =
"needs_review marks an issue the engine could not decide, so a person confirms it: the value is its review status, and until it is confirmed it is not a confirmed failure. best_practice marks a best practice rather than a WCAG failure, and wcag names the WCAG criteria an issue checks. Issues with neither needs_review nor best_practice are confirmed WCAG failures.";
/** How many rules and pages a web report's summary names; the rest are counted. */
const SUMMARY_RULES = 20;
const SUMMARY_PAGES = 10;

/**
* How many of a web report's issues still need a person to confirm them, and how many are
* best practices: counted over the whole report, not just this page.
* A web report's issues at a glance, all of them rather than one page's: by severity, how
* many a person still has to confirm, how many are best practices, by rule and by page.
*/
function reviewCountsNote(
counts: ReviewCounts | undefined,
function webSummaryNote(
summary: WebIssueSummary | undefined,
total: number,
records: { needs_review?: string; best_practice?: true }[],
): string[] {
const review = counts?.needs_review ?? 0;
const practice = counts?.best_practice ?? 0;
if (!summary || total === 0) return [];
const one = (n: number, singular: string, plural: string) =>
n === 1 ? singular : plural;
const parts = [
const rank = (severity: string) => {
const i = SEVERITY_ORDER.indexOf(severity);
return i === -1 ? SEVERITY_ORDER.length : i;
};
const severities = Object.entries(summary.by_severity)
.sort(([a], [b]) => rank(a) - rank(b))
.map(([severity, n]) => `${n} ${severity}`)
.join(", ");
const review = summary.needs_review;
const practice = summary.best_practice;
const notes = [
...(review > 0
? [
`${review} ${one(review, "needs review (a person has to confirm it)", "need review (a person has to confirm them)")}`,
`${review} ${one(review, "needs", "need")} review (a person has to confirm ${one(review, "it", "them")})`,
]
: []),
...(practice > 0
Expand All @@ -573,17 +582,40 @@ function reviewCountsNote(
]
: []),
];
const tagged =
parts.length > 0 ||
records.some((record) => record.needs_review || record.best_practice);
const listed = <T>(
rows: T[],
limit: number,
label: (row: T) => string,
rest: string,
) => {
const more = rows.length - Math.min(rows.length, limit);
return `${rows.slice(0, limit).map(label).join(", ")}${more > 0 ? `, and ${more} more ${rest}` : ""}.`;
};
const pages = summary.by_page ?? [];
return [
...(parts.length > 0
? [`Of the ${total} issues, ${parts.join(" and ")}.`]
: []),
...(tagged ? [REVIEW_TAGS_NOTE] : []),
`Summary of the ${total} issues: ${severities}${notes.map((note) => `; ${note}`).join("")}.`,
wrapUntrusted(
"accessibility issue counts",
[
`By rule: ${listed(summary.by_rule, SUMMARY_RULES, (r) => `${r.issue_type} ${r.count}`, "rules")}`,
...(pages.length > 0
? [
`By page: ${listed(pages, SUMMARY_PAGES, (p) => `${p.url} ${p.count}`, "pages")}`,
]
: []),
].join("\n"),
),
];
}

const WEB_ISSUES_NOTE =
"Each issue names its rule (issue_type): the rule's name, description, WCAG criteria and usual fix are under rules, and an issue has its own how_to_fix only when it differs. An issue with needs_review is one the engine could not decide, so a person confirms it: the value is its review status, and until it is confirmed it is not a confirmed failure. An issue with no needs_review, whose rule is not a best practice (best_practice under rules), is a confirmed WCAG failure.";

/** The CSV link, with what it is for: an answer gives a page of issues at a time. */
function fullReportLine(reportLink: string): string {
return `The full list of issues is a CSV, valid for about 12 hours: download it when every issue is needed at once (this answer gives them a page at a time), from the following link: ${reportLink}`;
}

/** How many new and resolved issues a comparison lists per page. */
const COMPARE_LIST_MAX = 50;

Expand Down Expand Up @@ -1002,6 +1034,7 @@ async function fetchAccessibilityIssues(

const {
records,
rules,
summary,
page_length,
total_issues,
Expand All @@ -1021,10 +1054,12 @@ async function fetchAccessibilityIssues(
...processingNote(processing),
...(page ? pageNote(page) : []),
`Retrieved ${page_length} accessibility issues (Total: ${total_issues})`,
...reviewCountsNote(summary, total_issues, records),
// The whole report's counts, said on its first page.
...(cursor ? [] : webSummaryNote(summary, total_issues)),
...excludedIssuesNote(duplicate_issues, unlinked_duplicates, hidden_issues),
`You can also download the full report (CSV) from the following link, valid for about 12 hours: ${reportLink}`,
`Issues: ${wrapUntrusted("accessibility scan results", JSON.stringify(records, null, 2))}`,
fullReportLine(reportLink),
WEB_ISSUES_NOTE,
`Issues: ${wrapUntrusted("accessibility scan results", JSON.stringify({ issues: records, rules }))}`,
closingMessage(next_page, remaining, source, stillProcessing, pageArg),
]);
}
Expand Down Expand Up @@ -1432,28 +1467,29 @@ function createScanFailureResponse(

function createScanSuccessResponse(
name: string,
totalIssues: number,
pageLength: number,
records: any[],
report: Awaited<ReturnType<typeof parseAccessibilityReportFromCSV>>,
scanId: string,
scanRunId: string,
reportUrl: string,
cursor: number | null,
duplicates: string[] = [],
dashboardLink: string | undefined,
settings: ScanSettings = {},
applied?: ScanSettings,
dashboardLink?: string,
reviewNotes: string[] = [],
): CallToolResult {
const cursor = report.next_page;
const messages = [
`Accessibility scan "${name}" completed. ${scanDashboardNote(dashboardLink, scanId)}`,
`Scan ID: ${scanId} and Scan Run ID: ${scanRunId}`,
scanSettingsNote(settings, applied),
`You can also download the full report from the following link: ${reportUrl}`,
`We found ${totalIssues} issues. Below are the details of the ${pageLength} most critical issues.`,
...reviewNotes,
...duplicates,
`Scan results: ${wrapUntrusted("accessibility scan results", JSON.stringify(records, null, 2))}`,
fullReportLine(reportUrl),
`We found ${report.total_issues} issues. Below are the details of the ${report.page_length} most critical issues.`,
...webSummaryNote(report.summary, report.total_issues),
...excludedIssuesNote(
report.duplicate_issues,
report.unlinked_duplicates,
report.hidden_issues,
),
WEB_ISSUES_NOTE,
`Scan results: ${wrapUntrusted("accessibility scan results", JSON.stringify({ issues: report.records, rules: report.rules }))}`,
];

if (cursor !== null) {
Expand Down Expand Up @@ -1528,31 +1564,15 @@ async function runAccessibilityScan(
throw error;
}

const {
records,
summary,
page_length,
total_issues,
next_page,
duplicate_issues,
unlinked_duplicates,
hidden_issues,
} = await parseAccessibilityReportFromCSV(reportLink);

return createScanSuccessResponse(
name,
total_issues,
page_length,
records,
await parseAccessibilityReportFromCSV(reportLink),
scanId,
scanRunId,
reportLink,
next_page,
excludedIssuesNote(duplicate_issues, unlinked_duplicates, hidden_issues),
dashboardLink,
settings,
applied,
dashboardLink,
reviewCountsNote(summary, total_issues, records),
);
}

Expand Down
14 changes: 1 addition & 13 deletions src/tools/accessiblity-utils/app-report-fetcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { apiClient } from "../../lib/apiClient.js";
import { SessionType } from "../../lib/constants.js";
import { sessionsListUrl } from "../automate-utils/list-session-ids.js";
import { getAutomationBaseUrl } from "../rca-agent-utils/constants.js";
import { pageOf, sortBySeverity } from "./paginate.js";
import { mostCommon, pageOf, sortBySeverity } from "./paginate.js";

/**
* App Accessibility: the issues of a mobile app build run through App Automate with
Expand Down Expand Up @@ -390,18 +390,6 @@ export function flattenAppSnapshots(
return sortBySeverity(rows);
}

/** The value most rows give, the first of them on a tie. */
function mostCommon(values: string[]): string {
const counts = new Map<string, number>();
for (const v of values) counts.set(v, (counts.get(v) ?? 0) + 1);
let best = "";
let bestCount = 0;
for (const [v, n] of counts) {
if (n > bestCount) [best, bestCount] = [v, n];
}
return best;
}

/**
* An issue's name, description, WCAG criteria and fix are its rule's, and its screenshot is
* its screen's: two thirds of an issue's length, repeated on every one. Said once, under
Expand Down
50 changes: 42 additions & 8 deletions src/tools/accessiblity-utils/paginate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,20 +11,37 @@ const SEVERITY_ORDER: Record<string, number> = {
minor: 3,
};

/** A severity's place, most severe first; an unknown severity sorts last. */
export function severityRank(severity: string): number {
return SEVERITY_ORDER[severity] ?? 99;
}

/** Most severe first; an unknown severity sorts last. */
export function sortBySeverity<T extends { severity: string }>(rows: T[]): T[] {
return rows.sort(
(a, b) =>
(SEVERITY_ORDER[a.severity] ?? 99) - (SEVERITY_ORDER[b.severity] ?? 99),
(a, b) => severityRank(a.severity) - severityRank(b.severity),
);
}

/** The value most rows give, the first of them on a tie. */
export function mostCommon(values: string[]): string {
const counts = new Map<string, number>();
for (const v of values) counts.set(v, (counts.get(v) ?? 0) + 1);
let best = "";
let bestCount = 0;
for (const [v, n] of counts) {
if (n > bestCount) [best, bestCount] = [v, n];
}
return best;
}

/**
* How many characters of issue JSON one call hands out by default: about 2,500 tokens, so
* a page stays small next to a client's own limit on tool output while a large report
* still takes few calls.
* How many characters of issue JSON one call hands out by default: about 8,000 tokens, under
* Claude Code's 10,000-token warning and its 50,000-character limit on a tool's result
* (above which the result is saved to a file instead of shown), so a large report takes
* few calls and a mid-size one fits in one.
*/
export const PAGE_CHARACTERS = 10_000;
export const PAGE_CHARACTERS = 30_000;

export interface Page<T> {
records: T[];
Expand All @@ -37,7 +54,19 @@ export interface Page<T> {

export function pageOf<T>(
all: T[],
{ maxCharacterLength = PAGE_CHARACTERS, nextPage = 0 } = {},
{
maxCharacterLength = PAGE_CHARACTERS,
nextPage = 0,
extraLength,
}: {
maxCharacterLength?: number;
nextPage?: number;
/**
* What else a row brings onto its page, counted against the page's length but not
* the cursor: the text of a rule the page has not named yet.
*/
extraLength?: (record: T) => number;
} = {},
): Page<T> {
// Walk to the starting offset
let charCursor = 0;
Expand All @@ -51,12 +80,17 @@ export function pageOf<T>(
// Collect up to maxCharacterLength; a row larger than that still makes a page of its
// own, or the cursor would never move past it
const page: T[] = [];
let extra = 0;
for (let i = idx; i < all.length; i++) {
const recStr = JSON.stringify(all[i]);
const fits = charCursor - nextPage + recStr.length <= maxCharacterLength;
const more = extraLength?.(all[i]) ?? 0;
const fits =
charCursor - nextPage + extra + more + recStr.length <=
maxCharacterLength;
if (!fits && page.length > 0) break;
page.push(all[i]);
charCursor += recStr.length;
extra += more;
}

const remaining = all.length - idx - page.length;
Expand Down
Loading