@binclusive/a11y 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +285 -0
- package/bin/a11y.mjs +36 -0
- package/bin/diff-scope.mjs +29 -0
- package/data/baseline-rules.json +892 -0
- package/package.json +68 -0
- package/src/agent-lane.ts +138 -0
- package/src/agents-block.ts +157 -0
- package/src/baseline/gen-baseline.ts +166 -0
- package/src/cli.ts +1026 -0
- package/src/collect-dom.ts +119 -0
- package/src/collect-liquid.ts +103 -0
- package/src/collect-swift.ts +227 -0
- package/src/collect-unity.ts +99 -0
- package/src/collect.ts +54 -0
- package/src/commands.ts +314 -0
- package/src/config-scan.ts +177 -0
- package/src/contract.ts +355 -0
- package/src/core.ts +546 -0
- package/src/detect-stack.ts +207 -0
- package/src/diff-scope-cli.ts +12 -0
- package/src/diff-scope.ts +150 -0
- package/src/emit-contract.ts +181 -0
- package/src/enforce.ts +1125 -0
- package/src/eslint-plugin-jsx-a11y.d.ts +11 -0
- package/src/evidence.ts +308 -0
- package/src/github-identity.ts +201 -0
- package/src/hook.ts +242 -0
- package/src/impact-gate.ts +107 -0
- package/src/imports-resolve.ts +248 -0
- package/src/index.ts +183 -0
- package/src/liquid-ast.ts +203 -0
- package/src/liquid-rules.ts +691 -0
- package/src/mcp.ts +363 -0
- package/src/module-scope.ts +137 -0
- package/src/phone-home.ts +470 -0
- package/src/pr-comment.ts +250 -0
- package/src/pr-summary-cli.ts +182 -0
- package/src/pr-summary.ts +291 -0
- package/src/registry.ts +605 -0
- package/src/reporter/contract.ts +154 -0
- package/src/reporter/finding.ts +87 -0
- package/src/reporter/github-adapter.ts +183 -0
- package/src/reporter/null-adapter.ts +51 -0
- package/src/reporter/registry.ts +22 -0
- package/src/reporter-cli.ts +48 -0
- package/src/resolve-components.ts +579 -0
- package/src/runner/budget.ts +90 -0
- package/src/runner/codegraph-lookup.test.ts +93 -0
- package/src/runner/codegraph-lookup.ts +197 -0
- package/src/runner/index.ts +86 -0
- package/src/runner/lookup.ts +69 -0
- package/src/runner/provider.ts +72 -0
- package/src/runner/providers/anthropic.ts +168 -0
- package/src/runner/providers/openai.ts +191 -0
- package/src/runner/reasoner.ts +73 -0
- package/src/runner/reasoning/index.ts +53 -0
- package/src/runner/reasoning/prompt.ts +200 -0
- package/src/runner/reasoning/react.ts +149 -0
- package/src/runner/reasoning/shopify.ts +214 -0
- package/src/runner/reasoning/skills-reasoner.ts +117 -0
- package/src/runner/reasoning/types.ts +99 -0
- package/src/runner/runner.ts +203 -0
- package/src/sarif.ts +148 -0
- package/src/source-identity.ts +0 -0
- package/src/source-trace.ts +814 -0
- package/src/suggest.ts +328 -0
- package/src/suppression-ranges.ts +354 -0
- package/src/suppressor-map.ts +189 -0
- package/src/suppressors.ts +284 -0
- package/src/tsconfig-aliases.ts +155 -0
- package/src/unity-ast.ts +331 -0
- package/src/unity-findings.ts +91 -0
- package/src/unity-guid-registry.ts +82 -0
- package/src/unity-label-resolve.ts +249 -0
- package/src/unity-rule-color-only.ts +127 -0
- package/src/unity-rule-missing-label.ts +156 -0
- package/src/unity-rules-baseline.ts +273 -0
- package/src/wcag-map.ts +35 -0
- package/src/wcag-tags.ts +35 -0
- package/src/workspace-resolve.ts +405 -0
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Shopify (Liquid theme) reasoning core — ported from the
|
|
3
|
+
* `Binclusive-Accessibility-Skills` repo's `shopify-theme-audit` skill:
|
|
4
|
+
* the `references/shopify-theme-a11y.md` audit reference (theme-structure
|
|
5
|
+
* signals, high-risk patterns, per-surface checks, severity + fix-type guidance)
|
|
6
|
+
* and its `SKILL.md` audit order (issue #2319). The prose is carried over
|
|
7
|
+
* faithfully; only its FORMAT changed (loose markdown → typed data) so the
|
|
8
|
+
* runner's AI lane can consult it with no external agent harness.
|
|
9
|
+
*
|
|
10
|
+
* The skills repo ships no dedicated `patterns/shopify-*.md` catalog, so the seed
|
|
11
|
+
* patterns below are drawn from the reference's own "High-Risk Shopify Patterns"
|
|
12
|
+
* and per-surface checks; each pattern's WCAG SC reuses the mapping the skills
|
|
13
|
+
* repo already assigned to the identical web patterns in
|
|
14
|
+
* `patterns/react-nextjs-patterns.md` (same failure, same SC).
|
|
15
|
+
*
|
|
16
|
+
* Shopify is the second framework wired; React / TSX was the first (epic #2083).
|
|
17
|
+
* The remaining frameworks the skills repo covers (React Native, iOS, Android,
|
|
18
|
+
* ASP.NET, Angular, Flutter) are parked.
|
|
19
|
+
*/
|
|
20
|
+
import type { FrameworkGuidance } from "./types";
|
|
21
|
+
|
|
22
|
+
export const SHOPIFY_GUIDANCE: FrameworkGuidance = {
|
|
23
|
+
framework: "Shopify (Liquid theme)",
|
|
24
|
+
appliesTo:
|
|
25
|
+
"Shopify Online Store themes — Liquid layouts/templates/sections/snippets, JSON templates, theme assets/config/locales (`.liquid` source, `liquid` findings).",
|
|
26
|
+
checklist: [
|
|
27
|
+
{
|
|
28
|
+
title: "Theme Structure Signals",
|
|
29
|
+
items: [
|
|
30
|
+
"`layout/*.liquid`: global HTML shell, skip links, main content, global messages, SEO/title, locale direction, app embeds.",
|
|
31
|
+
"`templates/*.json` and `templates/*.liquid`: page composition, section ordering, setting values, customer/cart/product/collection/search/blog/password/404 pages.",
|
|
32
|
+
"`sections/*.liquid`: Online Store 2.0 sections, schema settings, block rendering, merchant-configurable headings, links, media, color schemes, and behavior flags.",
|
|
33
|
+
"`snippets/*.liquid`: shared controls and markup fragments.",
|
|
34
|
+
"`assets/*.{js,css,liquid}`: custom elements, focus management, keyboard support, dynamic rendering, live regions, focus styles, visually hidden utilities, reduced motion, responsive behavior.",
|
|
35
|
+
"`config/settings_schema.json` and `config/settings_data.json`: theme settings that affect colors, headings, social links, checkout/cart behavior, image behavior, labels, and optional features.",
|
|
36
|
+
"`locales/*.json`: visible strings, aria labels, hidden text, status messages, new-window warnings, error messages, and translation completeness.",
|
|
37
|
+
"`blocks/`, `customers/`, or app extension files when present.",
|
|
38
|
+
],
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
title: "High-Risk Shopify Patterns",
|
|
42
|
+
items: [
|
|
43
|
+
"CTA links rendered as `<a>` without `href`, or with `role=\"link\" aria-disabled=\"true\"` as a published-state fallback.",
|
|
44
|
+
"Icon-only cart, search, account, menu, close, quantity, filter, sort, carousel, media, or social controls without localized accessible names.",
|
|
45
|
+
"Merchant-configurable image, slideshow, banner, collection, and product media that may lose meaning when alt text or nearby text is empty.",
|
|
46
|
+
"Product cards with duplicate links, inaccessible quick-add buttons, sale/badge text communicated only visually, or hidden product metadata that changes the accessible name.",
|
|
47
|
+
"Variant pickers, quantity selectors, recipient forms, cart notes, discount fields, and contact/newsletter forms without labels, error association, required state, or autocomplete.",
|
|
48
|
+
"Facets/filters/sort controls that update result counts without a status/live region or lose focus after AJAX updates.",
|
|
49
|
+
"Drawers, modals, predictive search, cart notifications, localization popovers, media modals, pickup availability dialogs, and quick-add flows without focus move, trap, Escape close, and focus restore.",
|
|
50
|
+
"Sliders/carousels without pause control, keyboard-operable controls, reduced-motion handling, or accessible slide names.",
|
|
51
|
+
"Native `<details>/<summary>` patterns over-customized with roles, click delegation, or state that diverges from the native open state.",
|
|
52
|
+
"Tooltip/help content available only on pointer hover or hidden with `aria-hidden` while carrying meaningful information.",
|
|
53
|
+
"CSS utilities such as `.focus-none`, `[hidden]`, `display: none`, `visibility: hidden`, opacity-only hiding, or offscreen techniques that remove focus visibility or expose hidden content incorrectly.",
|
|
54
|
+
"Dynamic HTML replacement that does not preserve focus, update status text, or reinitialize accessible behavior after cart/filter/search changes.",
|
|
55
|
+
"App embeds, remote scripts, reviews, subscriptions, chat, loyalty, cookie banners, and personalization widgets that render inaccessible controls outside the theme source.",
|
|
56
|
+
],
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
title: "Layout and Global Shell",
|
|
60
|
+
items: [
|
|
61
|
+
"Verify a skip link exists and targets a real main container.",
|
|
62
|
+
"Verify one clear `<main>` landmark is present and repeated header/footer/navigation regions are not nested incorrectly.",
|
|
63
|
+
"Verify `<html lang>` and `dir` or locale direction are set when the theme supports multiple languages or RTL locales.",
|
|
64
|
+
"Verify global accessibility helper strings exist in locale files and are used for hidden messages such as refresh/new-window notices.",
|
|
65
|
+
"Check app embed placeholders and third-party script outputs as blind spots when source is unavailable.",
|
|
66
|
+
],
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
title: "Templates, Sections, and Blocks",
|
|
70
|
+
items: [
|
|
71
|
+
"Read JSON templates with the Liquid section files they reference.",
|
|
72
|
+
"Treat `settings` and `blocks` as part of the audit evidence: empty link, heading, image, label, or accessibility text settings can change the result.",
|
|
73
|
+
"Verify section schema defaults do not encourage inaccessible published states.",
|
|
74
|
+
"Check heading level choices for hero/banner/slideshow sections. Mark content architecture concerns as `RUNTIME-CHECK` when final merchant composition determines the issue.",
|
|
75
|
+
"Verify optional buttons are not rendered as disabled fake links when a URL setting is blank.",
|
|
76
|
+
],
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
title: "Links, Buttons, and Controls",
|
|
80
|
+
items: [
|
|
81
|
+
"Prefer real `<button type=\"button\">` for actions and real `<a href>` for navigation.",
|
|
82
|
+
"Flag non-semantic clickable elements and anchors used as buttons.",
|
|
83
|
+
"Verify custom controls expose name, role, state, focusability, and Enter/Space behavior.",
|
|
84
|
+
"Check quantity, variant, filter, sort, and carousel controls for visible labels or localized accessible names.",
|
|
85
|
+
],
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
title: "Forms",
|
|
89
|
+
items: [
|
|
90
|
+
"Verify labels are programmatically associated with inputs, textareas, selects, checkboxes, and radios.",
|
|
91
|
+
"Verify error messages connect with `aria-describedby` and invalid state uses `aria-invalid` where applicable.",
|
|
92
|
+
"Verify required fields, autocomplete tokens, fieldsets/legends, and success/error live regions for customer, newsletter, contact, gift card, cart note, and recipient forms.",
|
|
93
|
+
],
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
title: "Dynamic UI",
|
|
97
|
+
items: [
|
|
98
|
+
"Verify predictive search, cart drawer, quick add, filters, localization selectors, media modals, popovers, and drawers manage focus.",
|
|
99
|
+
"Verify dynamic updates use `role=\"status\"`, `aria-live`, or an equivalent announcement strategy.",
|
|
100
|
+
"Verify `aria-expanded`, `aria-controls`, selected/current states, and disabled states are synchronized with rendered state.",
|
|
101
|
+
"Mark minified or remote behavior as `RUNTIME-CHECK` when static source cannot prove it.",
|
|
102
|
+
],
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
title: "Media and Visual Content",
|
|
106
|
+
items: [
|
|
107
|
+
"Verify informative images have meaningful alt text or nearby text that carries the same meaning.",
|
|
108
|
+
"Verify decorative images and icons are hidden from assistive technology.",
|
|
109
|
+
"Treat hero/banner/slideshow alt text as content-governance risk when merchant content determines whether the image is informative.",
|
|
110
|
+
"Verify video, model viewers, external embeds, and media galleries have accessible controls, captions/transcripts when relevant, and keyboard support.",
|
|
111
|
+
],
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
title: "CSS, Motion, and Visual States",
|
|
115
|
+
items: [
|
|
116
|
+
"Verify focus is visible on interactive controls across theme color schemes.",
|
|
117
|
+
"Flag utilities that remove outline/box-shadow on focus when used on interactive elements.",
|
|
118
|
+
"Verify reduced-motion support for animations, drawers, sliders, carousels, parallax, and transitions.",
|
|
119
|
+
"Verify forced-colors/high-contrast support where CSS custom properties or box shadows carry essential state.",
|
|
120
|
+
"Mark contrast, zoom/reflow, and touch target measurements as `RUNTIME-CHECK` unless directly measured in a running storefront.",
|
|
121
|
+
],
|
|
122
|
+
},
|
|
123
|
+
],
|
|
124
|
+
patterns: [
|
|
125
|
+
{
|
|
126
|
+
id: "PATTERN-SHOPIFY-001",
|
|
127
|
+
title: "CTA link rendered as a disabled fake link",
|
|
128
|
+
componentType: "CTA link / button (section or block)",
|
|
129
|
+
wcag: ["2.1.1", "4.1.2"],
|
|
130
|
+
severityDefault: "Critical",
|
|
131
|
+
fixTypeDefault: "FUNCTIONAL-RISK",
|
|
132
|
+
badShape:
|
|
133
|
+
"A CTA is rendered as `<a>` without `href`, or with `role=\"link\" aria-disabled=\"true\"` as a published-state fallback, typically when a merchant URL setting is blank.",
|
|
134
|
+
detectionHints:
|
|
135
|
+
"`<a>` without `href` in a section/snippet, `aria-disabled=\"true\"` on an anchor, buttons rendered as disabled fake links when a URL setting is empty.",
|
|
136
|
+
correctFix:
|
|
137
|
+
"Prefer a real `<button type=\"button\">` for actions and a real `<a href>` for navigation; do not render optional buttons as disabled fake links when a URL setting is blank.",
|
|
138
|
+
verification:
|
|
139
|
+
"Tab reaches the control, it activates with Enter/Space (or navigates via a real href), and a screen reader announces its name and role.",
|
|
140
|
+
exceptions:
|
|
141
|
+
"Do not flag a genuinely omitted control; when a URL setting is blank the accessible resolution is to not render the control, not to render a disabled fake link.",
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
id: "PATTERN-SHOPIFY-002",
|
|
145
|
+
title: "Icon-only store control without localized accessible name",
|
|
146
|
+
componentType: "Icon-only cart / search / account / menu / close / filter / sort / carousel control",
|
|
147
|
+
wcag: ["4.1.2"],
|
|
148
|
+
severityDefault: "Serious",
|
|
149
|
+
fixTypeDefault: "SAFE",
|
|
150
|
+
badShape:
|
|
151
|
+
"An icon-only cart, search, account, menu, close, quantity, filter, sort, carousel, media, or social control has no localized accessible name.",
|
|
152
|
+
detectionHints:
|
|
153
|
+
"icon/SVG-only controls in header/snippet markup, empty text content, missing `aria-label`/locale-backed hidden text on cart/search/account/menu/close SVGs.",
|
|
154
|
+
correctFix:
|
|
155
|
+
"Provide a localized accessible name that describes the action, sourced from locale strings; hide decorative SVG/icon content from assistive technology.",
|
|
156
|
+
verification: "Screen reader announces the intended action plus role.",
|
|
157
|
+
exceptions:
|
|
158
|
+
"Do not recommend placeholder names such as `button`, `image`, `icon`, or `link`, or untranslated English labels in a localized theme.",
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
id: "PATTERN-SHOPIFY-003",
|
|
162
|
+
title: "Store form control without associated label or error state",
|
|
163
|
+
componentType: "Variant picker / quantity selector / newsletter / contact / cart-note form control",
|
|
164
|
+
wcag: ["1.3.1", "3.3.2"],
|
|
165
|
+
severityDefault: "Serious",
|
|
166
|
+
fixTypeDefault: "SAFE",
|
|
167
|
+
badShape:
|
|
168
|
+
"Variant pickers, quantity selectors, recipient forms, cart notes, discount fields, and contact/newsletter forms lack labels, error association, required state, or autocomplete.",
|
|
169
|
+
detectionHints:
|
|
170
|
+
"inputs/textareas/selects/checkboxes/radios without associated labels, missing `aria-describedby` for errors, missing `aria-invalid`, missing required/autocomplete tokens in customer/newsletter/contact/gift-card/cart-note forms.",
|
|
171
|
+
correctFix:
|
|
172
|
+
"Programmatically associate labels with each control; connect error messages with `aria-describedby` and set invalid state with `aria-invalid`; provide required fields, autocomplete tokens, fieldsets/legends, and success/error live regions.",
|
|
173
|
+
verification: "Accessibility tree exposes the intended name, description, required, and invalid state for each control.",
|
|
174
|
+
exceptions:
|
|
175
|
+
"A control can be validly named by `aria-label` or `aria-labelledby` when no visible label is appropriate.",
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
id: "PATTERN-SHOPIFY-004",
|
|
179
|
+
title: "Drawer or modal without focus management",
|
|
180
|
+
componentType: "Cart drawer / predictive search / quick-add / media modal / localization popover",
|
|
181
|
+
wcag: ["2.4.3", "4.1.2"],
|
|
182
|
+
severityDefault: "Serious",
|
|
183
|
+
fixTypeDefault: "FUNCTIONAL-RISK",
|
|
184
|
+
badShape:
|
|
185
|
+
"Drawers, modals, predictive search, cart notifications, localization popovers, media modals, pickup availability dialogs, and quick-add flows open without focus move, trap, Escape close, and focus restore.",
|
|
186
|
+
detectionHints:
|
|
187
|
+
"custom drawer/modal elements in assets/snippets, missing focus move on open, no Escape handler, no focus restore on close, missing `role=\"dialog\"`/`aria-modal`/`aria-labelledby`.",
|
|
188
|
+
correctFix:
|
|
189
|
+
"Move focus into the dialog on open, trap focus while open, close on Escape, and restore focus to the trigger on close; expose `role=\"dialog\"`, `aria-modal`, and an accessible name.",
|
|
190
|
+
verification:
|
|
191
|
+
"Keyboard focus enters the dialog on open, cannot leave it while open, Escape closes it, and focus returns to the trigger. Mark as `RUNTIME-CHECK` when behavior lives in minified/third-party scripts.",
|
|
192
|
+
exceptions:
|
|
193
|
+
"Do not flag native `<details>/<summary>` disclosure that is not over-customized; mark focus trap/restore as `RUNTIME-CHECK` when it cannot be proven from static source.",
|
|
194
|
+
},
|
|
195
|
+
{
|
|
196
|
+
id: "PATTERN-SHOPIFY-005",
|
|
197
|
+
title: "AJAX facet/cart update without status region or focus preservation",
|
|
198
|
+
componentType: "Facets / filters / sort / cart / predictive-search dynamic update",
|
|
199
|
+
wcag: ["4.1.3"],
|
|
200
|
+
severityDefault: "Serious",
|
|
201
|
+
fixTypeDefault: "FUNCTIONAL-RISK",
|
|
202
|
+
badShape:
|
|
203
|
+
"Facets/filters/sort controls update result counts, or dynamic HTML replacement re-renders cart/filter/search regions, without a status/live region, preserved focus, or reinitialized accessible behavior.",
|
|
204
|
+
detectionHints:
|
|
205
|
+
"AJAX section rendering / `fetch` replacing markup in assets, no `role=\"status\"`/`aria-live` region for updated counts, focus lost after re-render, behavior not reinitialized after cart/filter/search changes.",
|
|
206
|
+
correctFix:
|
|
207
|
+
"Announce dynamic updates with `role=\"status\"`, `aria-live`, or an equivalent strategy; preserve or move focus deliberately after the update; reinitialize accessible behavior on the replaced markup.",
|
|
208
|
+
verification:
|
|
209
|
+
"A screen reader announces the updated result/cart state and focus lands on a sensible element after the update. Mark as `RUNTIME-CHECK` when the behavior is minified or remote.",
|
|
210
|
+
exceptions:
|
|
211
|
+
"Mark minified or remote (app embed / third-party) behavior as `RUNTIME-CHECK` when static source cannot prove the announcement or focus handling.",
|
|
212
|
+
},
|
|
213
|
+
],
|
|
214
|
+
};
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The concrete {@link AgentReasoner} — the audit-reasoning core wired into the
|
|
3
|
+
* runner's seam (issue #2096). It fills the seam #2095 left; it does NOT redesign
|
|
4
|
+
* the runner. The `AgentReasoner` interface needed no extension: the reasoning is
|
|
5
|
+
* injected as data (the framework guidance) and the output is already the
|
|
6
|
+
* seam's `AgentFinding[]`.
|
|
7
|
+
*
|
|
8
|
+
* One pass, per the harness contract:
|
|
9
|
+
* 1. Select the framework guidance for the finding; `null` ⇒ nothing to add.
|
|
10
|
+
* 2. Optionally ask ONE structural lookup (the #2097 seam) for context.
|
|
11
|
+
* 3. Consult the model with the guidance as `system` framing.
|
|
12
|
+
* 4. Parse the reply (zod boundary, issue #2098) into a patch-free enrichment of
|
|
13
|
+
* the SOURCE finding plus zero+ DISCOVERIES, each folded onto a new
|
|
14
|
+
* `corpus-agent` finding.
|
|
15
|
+
*
|
|
16
|
+
* Suggestions-not-patches is structural, not enforced here: this reasoner holds
|
|
17
|
+
* no filesystem handle (its `ReasonContext` grants a provider + lookup + finding,
|
|
18
|
+
* nothing else), and a {@link FixSuggestion} has no patch channel. Nothing it can
|
|
19
|
+
* do writes to the mounted source.
|
|
20
|
+
*/
|
|
21
|
+
import type { Finding } from "../../core";
|
|
22
|
+
import { evidenceFix, type EnrichedFinding, enrich } from "../../evidence";
|
|
23
|
+
import type { LookupTool } from "../lookup";
|
|
24
|
+
import { type AgentFinding, type AgentReasoner, EMPTY_RESULT, type ReasonContext, type ReasonResult } from "../reasoner";
|
|
25
|
+
import { frameworkGuidanceFor } from "./index";
|
|
26
|
+
import { buildSystemPrompt, buildUserPrompt, parseReasonResponse, suggestionMessage } from "./prompt";
|
|
27
|
+
import type { Discovery } from "./types";
|
|
28
|
+
|
|
29
|
+
/** Tuning for the reasoner. All bounded — the harness meters spend underneath. */
|
|
30
|
+
export interface SkillsReasonerOptions {
|
|
31
|
+
/** Upper bound on model output tokens per pass. Default 1024 — a suggestion is short. */
|
|
32
|
+
readonly maxOutputTokens?: number;
|
|
33
|
+
/** Ask one structural lookup for context before reasoning. Default true. */
|
|
34
|
+
readonly useLookup?: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const DEFAULT_OPTIONS: Required<SkillsReasonerOptions> = {
|
|
38
|
+
maxOutputTokens: 1024,
|
|
39
|
+
useLookup: true,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** One structural lookup for context. Tolerant: a cap or a throw yields no context, never a failure. */
|
|
43
|
+
async function structuralContext(lookup: LookupTool, finding: EnrichedFinding): Promise<string | null> {
|
|
44
|
+
try {
|
|
45
|
+
const result = await lookup.lookup({ kind: "renders", target: finding.selector ?? finding.ruleId });
|
|
46
|
+
if (result.status !== "ok") return null;
|
|
47
|
+
return JSON.stringify(result.data).slice(0, 500);
|
|
48
|
+
} catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The prose a discovered finding carries — observation + rationale + fix in one message. */
|
|
54
|
+
function discoveryMessage(d: Discovery): string {
|
|
55
|
+
return `${d.observation} Rationale: ${d.rationale} Suggested fix (${d.fixType}): ${d.suggestedFix}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Fold one DISCOVERY onto a new `corpus-agent` finding. It anchors on the source
|
|
60
|
+
* finding's read-side locators (file/line, or the discovery's named element), and
|
|
61
|
+
* carries the two discovery fields the floor can't: `confidence` and the rationale
|
|
62
|
+
* (folded into the message). Advisory by construction — `enforcement: "warn"`,
|
|
63
|
+
* never inherited `block`, so it can never gate the exit code.
|
|
64
|
+
*/
|
|
65
|
+
function toAgentFinding(source: EnrichedFinding, d: Discovery): AgentFinding {
|
|
66
|
+
const base: Finding = {
|
|
67
|
+
file: source.file,
|
|
68
|
+
line: source.line,
|
|
69
|
+
ruleId: source.ruleId,
|
|
70
|
+
message: discoveryMessage(d),
|
|
71
|
+
wcag: d.wcag.length > 0 ? d.wcag : source.wcag,
|
|
72
|
+
enforcement: "warn",
|
|
73
|
+
provenance: "corpus-agent",
|
|
74
|
+
layer: "recall",
|
|
75
|
+
confidence: d.confidence,
|
|
76
|
+
...(d.patternId !== undefined ? { patternId: d.patternId } : {}),
|
|
77
|
+
...(d.element !== undefined
|
|
78
|
+
? { selector: d.element }
|
|
79
|
+
: source.selector !== undefined
|
|
80
|
+
? { selector: source.selector }
|
|
81
|
+
: {}),
|
|
82
|
+
...(source.impact !== undefined ? { impact: source.impact } : {}),
|
|
83
|
+
};
|
|
84
|
+
return { ...enrich(base), provenance: "corpus-agent" };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Build the reasoning core as an {@link AgentReasoner}. The runner's AI lane
|
|
89
|
+
* consults the framework checklists + pattern catalogs through this — with no
|
|
90
|
+
* Claude-Code (or any external agent) harness dependency.
|
|
91
|
+
*/
|
|
92
|
+
export function createSkillsReasoner(options: SkillsReasonerOptions = {}): AgentReasoner {
|
|
93
|
+
const config = { ...DEFAULT_OPTIONS, ...options };
|
|
94
|
+
|
|
95
|
+
const reason = async (ctx: ReasonContext): Promise<ReasonResult> => {
|
|
96
|
+
const guidance = frameworkGuidanceFor(ctx.finding);
|
|
97
|
+
if (guidance === null) return EMPTY_RESULT;
|
|
98
|
+
|
|
99
|
+
const structural = config.useLookup ? await structuralContext(ctx.lookup, ctx.finding) : null;
|
|
100
|
+
const system = buildSystemPrompt(guidance);
|
|
101
|
+
const user = buildUserPrompt(ctx.finding, evidenceFix(ctx.finding.corpus), structural);
|
|
102
|
+
|
|
103
|
+
const response = await ctx.provider.complete({
|
|
104
|
+
system,
|
|
105
|
+
messages: [{ role: "user", content: user }],
|
|
106
|
+
maxOutputTokens: config.maxOutputTokens,
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const parsed = parseReasonResponse(response.text);
|
|
110
|
+
// Enrich the SOURCE finding in place (prose note); discover NEW findings around it.
|
|
111
|
+
const enrichment = parsed.enrichment !== null ? suggestionMessage(parsed.enrichment) : null;
|
|
112
|
+
const discoveries = parsed.discoveries.map((d) => toAgentFinding(ctx.finding, d));
|
|
113
|
+
return { enrichment, discoveries };
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
return { reason };
|
|
117
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The reasoning-knowledge types — the shape of the audit-reasoning core the AI
|
|
3
|
+
* lane consults. Ported from the `Binclusive-Accessibility-Skills` repo's
|
|
4
|
+
* per-framework checklists + pattern catalogs (issue #2096) and reshaped from
|
|
5
|
+
* loose markdown into typed, in-engine data, so the runner carries its own
|
|
6
|
+
* reasoning with NO external agent-harness dependency.
|
|
7
|
+
*
|
|
8
|
+
* Two omissions encode the epic's law that the AI lane SUGGESTS, never applies:
|
|
9
|
+
* - {@link FixSuggestion} has no `patch` / `diff` / `edits` field. A suggestion
|
|
10
|
+
* is PROSE, never an applicable change — "fix = suggestions only" is
|
|
11
|
+
* unrepresentable to violate because this type carries no channel for a patch.
|
|
12
|
+
* - The reasoner is handed a provider + lookup + finding (`ReasonContext`),
|
|
13
|
+
* never a filesystem handle — so "nothing is written to the mounted source"
|
|
14
|
+
* holds by construction, not by policy.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { AgentConfidence } from "../../core";
|
|
18
|
+
|
|
19
|
+
/** How safe applying a fix is — the trust label from the pattern catalog. */
|
|
20
|
+
export type FixType = "SAFE" | "VISUAL-IMPACT" | "FUNCTIONAL-RISK" | "RUNTIME-CHECK";
|
|
21
|
+
|
|
22
|
+
export const FIX_TYPES: readonly FixType[] = ["SAFE", "VISUAL-IMPACT", "FUNCTIONAL-RISK", "RUNTIME-CHECK"];
|
|
23
|
+
|
|
24
|
+
/** The catalog's severity vocabulary (distinct from axe's runtime impact). */
|
|
25
|
+
export type FixSeverity = "Critical" | "Serious" | "Moderate" | "Minor";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* One catalogued failure pattern — a ported pattern-catalog entry. The fields
|
|
29
|
+
* mirror the skills repo's Pattern Entry Template so the prose survives the port
|
|
30
|
+
* intact; the reasoner projects them into the system prompt.
|
|
31
|
+
*/
|
|
32
|
+
export interface PatternCatalogEntry {
|
|
33
|
+
/** Stable catalog id, e.g. `PATTERN-REACT-001`. Provenance back to the corpus slice. */
|
|
34
|
+
readonly id: string;
|
|
35
|
+
readonly title: string;
|
|
36
|
+
readonly componentType: string;
|
|
37
|
+
/** WCAG SCs / APG patterns this addresses, e.g. `["2.1.1", "4.1.2"]`. */
|
|
38
|
+
readonly wcag: readonly string[];
|
|
39
|
+
readonly severityDefault: FixSeverity;
|
|
40
|
+
/** The common-case trust label; the conditional nuance lives in {@link fixTypeNote}. */
|
|
41
|
+
readonly fixTypeDefault: FixType;
|
|
42
|
+
/** The catalog's "SAFE when …, FUNCTIONAL-RISK when …" conditional, preserved verbatim. */
|
|
43
|
+
readonly fixTypeNote?: string;
|
|
44
|
+
readonly badShape: string;
|
|
45
|
+
readonly detectionHints: string;
|
|
46
|
+
readonly correctFix: string;
|
|
47
|
+
readonly verification: string;
|
|
48
|
+
readonly exceptions: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A named group of checklist prose (e.g. "High-Risk React Patterns"). */
|
|
52
|
+
export interface ChecklistArea {
|
|
53
|
+
readonly title: string;
|
|
54
|
+
readonly items: readonly string[];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The per-framework reasoning core: checklist prose + pattern catalog. */
|
|
58
|
+
export interface FrameworkGuidance {
|
|
59
|
+
/** Human framework name, e.g. `"React / Next.js"`. */
|
|
60
|
+
readonly framework: string;
|
|
61
|
+
/** When this guidance applies — the ported "use this reference only for …" scope. */
|
|
62
|
+
readonly appliesTo: string;
|
|
63
|
+
readonly checklist: readonly ChecklistArea[];
|
|
64
|
+
readonly patterns: readonly PatternCatalogEntry[];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* A single fix the reasoner proposes for a finding — SUGGESTION ONLY. It carries
|
|
69
|
+
* NO patch/diff: the fix is described in prose, never applied. This is the AI
|
|
70
|
+
* lane's whole output vocabulary, and it is patch-free on purpose.
|
|
71
|
+
*/
|
|
72
|
+
export interface FixSuggestion {
|
|
73
|
+
/** What the reasoner observed — grounds the suggestion in the finding. */
|
|
74
|
+
readonly observation: string;
|
|
75
|
+
/** The recommended fix in prose — the catalog's "correct fix" applied here. */
|
|
76
|
+
readonly suggestedFix: string;
|
|
77
|
+
/** WCAG SCs the suggestion addresses. */
|
|
78
|
+
readonly wcag: readonly string[];
|
|
79
|
+
/** How safe applying it is — the trust label carried onto the finding. */
|
|
80
|
+
readonly fixType: FixType;
|
|
81
|
+
/** The catalog pattern this matched, if any — provenance back to the corpus slice. */
|
|
82
|
+
readonly patternId?: string;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* A NEW accessibility issue the deterministic engine missed — the agent lane's
|
|
87
|
+
* DISCOVER output (issue #2098). A {@link FixSuggestion} enriched with the two
|
|
88
|
+
* fields a standalone judgement needs: a `rationale` for why it is a real problem
|
|
89
|
+
* and the agent's `confidence` in that call. Still patch-free — a discovery is a
|
|
90
|
+
* described problem + described fix, never an edit.
|
|
91
|
+
*/
|
|
92
|
+
export interface Discovery extends FixSuggestion {
|
|
93
|
+
/** Why this is a genuine issue — the judgement the deterministic floor can't make. */
|
|
94
|
+
readonly rationale: string;
|
|
95
|
+
/** How sure the agent is (see {@link AgentConfidence}). */
|
|
96
|
+
readonly confidence: AgentConfidence;
|
|
97
|
+
/** The element the discovery is about, when the agent can name one (a CSS-ish locator). */
|
|
98
|
+
readonly element?: string;
|
|
99
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The low-cap pull loop — the runner harness.
|
|
3
|
+
*
|
|
4
|
+
* ONE pass per deterministic finding. Each pass gets a fresh per-finding lookup
|
|
5
|
+
* budget and a provider metered against the shared per-PR token ceiling. The loop
|
|
6
|
+
* is NON-BLOCKING by construction: {@link runAgentLane} returns a {@link RunOutcome}
|
|
7
|
+
* for every input and NEVER rejects. Hitting the token ceiling is the `capped`
|
|
8
|
+
* arm — a normal partial result (fewer findings), not an error. The caller can
|
|
9
|
+
* always `exit 0`.
|
|
10
|
+
*
|
|
11
|
+
* The three seams the harness leaves clean:
|
|
12
|
+
* - #2096 reasoning skills → the `AgentReasoner` (the prompt/system content).
|
|
13
|
+
* - #2097 code-graph lookups → the `LookupTool` the harness caps per finding.
|
|
14
|
+
* - #2098 enrich/discover → the reasoner returns a `ReasonResult` (in-place
|
|
15
|
+
* enrichment + discoveries); the harness folds notes onto the source findings
|
|
16
|
+
* and dedups the discoveries against the deterministic floor.
|
|
17
|
+
*
|
|
18
|
+
* The emit boundary is the canonical contract: agent findings project through the
|
|
19
|
+
* SAME `emit-contract` projection the deterministic engine uses (#2093), so no
|
|
20
|
+
* file/line ever reaches the wire and there is one wire schema, not two. The
|
|
21
|
+
* lenient variant drops a single malformed finding rather than failing the run.
|
|
22
|
+
*/
|
|
23
|
+
import { dedupeRecall } from "../core";
|
|
24
|
+
import type { EnrichedFinding } from "../evidence";
|
|
25
|
+
import { toFindingPayloadLenient } from "../emit-contract";
|
|
26
|
+
import type { FindingPayload } from "@binclusive/a11y-contract";
|
|
27
|
+
import { type BudgetSnapshot, meterProvider, TokenCeilingExceeded, TokenLedger } from "./budget";
|
|
28
|
+
import { LookupCounter, type LookupTool, meterLookup } from "./lookup";
|
|
29
|
+
import type { Provider } from "./provider";
|
|
30
|
+
import type { AgentFinding, AgentReasoner } from "./reasoner";
|
|
31
|
+
|
|
32
|
+
/** The low-cap knobs. Start low; dialing up is a separate concern (epic #2083). */
|
|
33
|
+
export interface RunnerConfig {
|
|
34
|
+
/** Hard per-PR ceiling on `input + output` model tokens across all passes. */
|
|
35
|
+
readonly tokenCeiling: number;
|
|
36
|
+
/** Soft cap on structural lookups PER deterministic finding (~3-5). */
|
|
37
|
+
readonly lookupsPerFinding: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const DEFAULT_RUNNER_CONFIG: RunnerConfig = {
|
|
41
|
+
tokenCeiling: 100_000,
|
|
42
|
+
lookupsPerFinding: 5,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Why one pass ended. A pass is atomic: it produces, is empty, errors, or never
|
|
47
|
+
* ran. `produced` carries BOTH agent behaviors — how many findings it DISCOVERED
|
|
48
|
+
* and whether it ENRICHED the source finding in place — so a note-only pass still
|
|
49
|
+
* reads as productive, not empty.
|
|
50
|
+
*/
|
|
51
|
+
export type PassOutcome =
|
|
52
|
+
| { readonly kind: "produced"; readonly discovered: number; readonly enriched: boolean }
|
|
53
|
+
| { readonly kind: "empty" }
|
|
54
|
+
| { readonly kind: "errored"; readonly reason: string }
|
|
55
|
+
| { readonly kind: "skipped"; readonly reason: "token-ceiling" };
|
|
56
|
+
|
|
57
|
+
/** A local-only diagnostic record of one pass. Never crosses the wire. */
|
|
58
|
+
export interface PassReport {
|
|
59
|
+
/** The deterministic finding's rule id — local diagnostics only, no file/line. */
|
|
60
|
+
readonly ruleId: string;
|
|
61
|
+
readonly lookupsUsed: number;
|
|
62
|
+
readonly outcome: PassOutcome;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface RunInput {
|
|
66
|
+
/** The deterministic findings (from the engine, already `enrich`ed). */
|
|
67
|
+
readonly findings: readonly EnrichedFinding[];
|
|
68
|
+
readonly reasoner: AgentReasoner;
|
|
69
|
+
readonly provider: Provider;
|
|
70
|
+
readonly lookup: LookupTool;
|
|
71
|
+
/** The declared diff scope, carried onto every emitted finding. */
|
|
72
|
+
readonly scope: string;
|
|
73
|
+
readonly config?: RunnerConfig;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The total outcome. Both arms carry a contract-validated {@link FindingPayload}
|
|
78
|
+
* (possibly partial) AND the rich local findings (for local SARIF / PR comments).
|
|
79
|
+
* There is no `failed` arm — the loop is non-blocking.
|
|
80
|
+
*
|
|
81
|
+
* `findings` are the DISCOVERED agent findings (deduped). `enrichedFindings` are
|
|
82
|
+
* the input deterministic findings, in order, some now carrying an `agentNote`
|
|
83
|
+
* from an in-place enrichment — they stay `provenance: deterministic` and feed the
|
|
84
|
+
* deterministic (strict) emit + local render, never the agent payload.
|
|
85
|
+
*/
|
|
86
|
+
export type RunOutcome =
|
|
87
|
+
| {
|
|
88
|
+
readonly status: "complete";
|
|
89
|
+
readonly payload: FindingPayload;
|
|
90
|
+
readonly findings: readonly AgentFinding[];
|
|
91
|
+
readonly enrichedFindings: readonly EnrichedFinding[];
|
|
92
|
+
readonly passes: readonly PassReport[];
|
|
93
|
+
readonly usage: BudgetSnapshot;
|
|
94
|
+
/** Findings dropped at the emit boundary for failing the contract re-parse. */
|
|
95
|
+
readonly dropped: number;
|
|
96
|
+
}
|
|
97
|
+
| {
|
|
98
|
+
readonly status: "capped";
|
|
99
|
+
readonly cappedBy: "token-ceiling";
|
|
100
|
+
readonly payload: FindingPayload;
|
|
101
|
+
readonly findings: readonly AgentFinding[];
|
|
102
|
+
readonly enrichedFindings: readonly EnrichedFinding[];
|
|
103
|
+
readonly passes: readonly PassReport[];
|
|
104
|
+
readonly usage: BudgetSnapshot;
|
|
105
|
+
/** Deterministic findings whose pass completed before the ceiling. */
|
|
106
|
+
readonly processed: number;
|
|
107
|
+
/** Deterministic findings skipped once the ceiling was reached. */
|
|
108
|
+
readonly skipped: number;
|
|
109
|
+
readonly dropped: number;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Drive the AI lane over the deterministic findings, capped and non-blocking.
|
|
114
|
+
* Returns a {@link RunOutcome} for every input; never rejects.
|
|
115
|
+
*/
|
|
116
|
+
export async function runAgentLane(input: RunInput): Promise<RunOutcome> {
|
|
117
|
+
const config = input.config ?? DEFAULT_RUNNER_CONFIG;
|
|
118
|
+
const ledger = new TokenLedger(config.tokenCeiling);
|
|
119
|
+
const provider = meterProvider(input.provider, ledger);
|
|
120
|
+
|
|
121
|
+
const discoveries: AgentFinding[] = [];
|
|
122
|
+
// Parallel to `input.findings`, in order: each finding as-is, or with an
|
|
123
|
+
// `agentNote` folded on when its pass enriched it in place.
|
|
124
|
+
const enrichedFindings: EnrichedFinding[] = [];
|
|
125
|
+
const passes: PassReport[] = [];
|
|
126
|
+
let processed = 0;
|
|
127
|
+
let capped = false;
|
|
128
|
+
|
|
129
|
+
for (let i = 0; i < input.findings.length; i += 1) {
|
|
130
|
+
const finding = input.findings[i];
|
|
131
|
+
|
|
132
|
+
// Pass boundary: a prior pass may have emptied the wallet. Skip the rest —
|
|
133
|
+
// an unprocessed finding keeps its shape (no note), never fails.
|
|
134
|
+
if (capped || ledger.exhausted()) {
|
|
135
|
+
capped = true;
|
|
136
|
+
enrichedFindings.push(finding);
|
|
137
|
+
passes.push({ ruleId: finding.ruleId, lookupsUsed: 0, outcome: { kind: "skipped", reason: "token-ceiling" } });
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const counter = new LookupCounter(config.lookupsPerFinding);
|
|
142
|
+
const lookup = meterLookup(input.lookup, counter);
|
|
143
|
+
|
|
144
|
+
try {
|
|
145
|
+
const result = await input.reasoner.reason({ finding, provider, lookup, scope: input.scope });
|
|
146
|
+
// ENRICH in place: fold the note onto THIS deterministic finding (it stays
|
|
147
|
+
// deterministic). DISCOVER: collect the new agent findings for later dedup.
|
|
148
|
+
enrichedFindings.push(result.enrichment !== null ? { ...finding, agentNote: result.enrichment } : finding);
|
|
149
|
+
discoveries.push(...result.discoveries);
|
|
150
|
+
processed += 1;
|
|
151
|
+
const produced = result.enrichment !== null || result.discoveries.length > 0;
|
|
152
|
+
passes.push({
|
|
153
|
+
ruleId: finding.ruleId,
|
|
154
|
+
lookupsUsed: counter.used,
|
|
155
|
+
outcome: produced
|
|
156
|
+
? { kind: "produced", discovered: result.discoveries.length, enriched: result.enrichment !== null }
|
|
157
|
+
: { kind: "empty" },
|
|
158
|
+
});
|
|
159
|
+
} catch (error) {
|
|
160
|
+
// Every non-cap failure leaves the finding un-enriched but present.
|
|
161
|
+
enrichedFindings.push(finding);
|
|
162
|
+
if (error instanceof TokenCeilingExceeded) {
|
|
163
|
+
// The ceiling blew mid-pass. Discard this pass's partial work and stop
|
|
164
|
+
// pulling new findings — the remaining ones are skipped, not failed.
|
|
165
|
+
capped = true;
|
|
166
|
+
passes.push({ ruleId: finding.ruleId, lookupsUsed: counter.used, outcome: { kind: "skipped", reason: "token-ceiling" } });
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
// Any other failure is this pass's problem alone: record it, keep going.
|
|
170
|
+
passes.push({ ruleId: finding.ruleId, lookupsUsed: counter.used, outcome: { kind: "errored", reason: reasonOf(error) } });
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// DEDUP (issue #2098): drop any discovery the deterministic floor already caught
|
|
175
|
+
// (cross-dedup by file:line:sc) or that duplicates another discovery (self-dedup
|
|
176
|
+
// by file:line:patternId). Reuse the engine's `dedupeRecall` verbatim — one dedup
|
|
177
|
+
// discipline, not a second one. Survivors keep identity, so re-narrow to agent.
|
|
178
|
+
const survivors = new Set(dedupeRecall(discoveries, input.findings));
|
|
179
|
+
const agentFindings = discoveries.filter((d) => survivors.has(d));
|
|
180
|
+
|
|
181
|
+
const { payload, dropped } = toFindingPayloadLenient(agentFindings, input.scope);
|
|
182
|
+
const usage = ledger.snapshot();
|
|
183
|
+
|
|
184
|
+
if (capped) {
|
|
185
|
+
return {
|
|
186
|
+
status: "capped",
|
|
187
|
+
cappedBy: "token-ceiling",
|
|
188
|
+
payload,
|
|
189
|
+
findings: agentFindings,
|
|
190
|
+
enrichedFindings,
|
|
191
|
+
passes,
|
|
192
|
+
usage,
|
|
193
|
+
processed,
|
|
194
|
+
skipped: input.findings.length - processed,
|
|
195
|
+
dropped,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
return { status: "complete", payload, findings: agentFindings, enrichedFindings, passes, usage, dropped };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function reasonOf(error: unknown): string {
|
|
202
|
+
return error instanceof Error ? error.message : String(error);
|
|
203
|
+
}
|