@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.
Files changed (80) hide show
  1. package/README.md +285 -0
  2. package/bin/a11y.mjs +36 -0
  3. package/bin/diff-scope.mjs +29 -0
  4. package/data/baseline-rules.json +892 -0
  5. package/package.json +68 -0
  6. package/src/agent-lane.ts +138 -0
  7. package/src/agents-block.ts +157 -0
  8. package/src/baseline/gen-baseline.ts +166 -0
  9. package/src/cli.ts +1026 -0
  10. package/src/collect-dom.ts +119 -0
  11. package/src/collect-liquid.ts +103 -0
  12. package/src/collect-swift.ts +227 -0
  13. package/src/collect-unity.ts +99 -0
  14. package/src/collect.ts +54 -0
  15. package/src/commands.ts +314 -0
  16. package/src/config-scan.ts +177 -0
  17. package/src/contract.ts +355 -0
  18. package/src/core.ts +546 -0
  19. package/src/detect-stack.ts +207 -0
  20. package/src/diff-scope-cli.ts +12 -0
  21. package/src/diff-scope.ts +150 -0
  22. package/src/emit-contract.ts +181 -0
  23. package/src/enforce.ts +1125 -0
  24. package/src/eslint-plugin-jsx-a11y.d.ts +11 -0
  25. package/src/evidence.ts +308 -0
  26. package/src/github-identity.ts +201 -0
  27. package/src/hook.ts +242 -0
  28. package/src/impact-gate.ts +107 -0
  29. package/src/imports-resolve.ts +248 -0
  30. package/src/index.ts +183 -0
  31. package/src/liquid-ast.ts +203 -0
  32. package/src/liquid-rules.ts +691 -0
  33. package/src/mcp.ts +363 -0
  34. package/src/module-scope.ts +137 -0
  35. package/src/phone-home.ts +470 -0
  36. package/src/pr-comment.ts +250 -0
  37. package/src/pr-summary-cli.ts +182 -0
  38. package/src/pr-summary.ts +291 -0
  39. package/src/registry.ts +605 -0
  40. package/src/reporter/contract.ts +154 -0
  41. package/src/reporter/finding.ts +87 -0
  42. package/src/reporter/github-adapter.ts +183 -0
  43. package/src/reporter/null-adapter.ts +51 -0
  44. package/src/reporter/registry.ts +22 -0
  45. package/src/reporter-cli.ts +48 -0
  46. package/src/resolve-components.ts +579 -0
  47. package/src/runner/budget.ts +90 -0
  48. package/src/runner/codegraph-lookup.test.ts +93 -0
  49. package/src/runner/codegraph-lookup.ts +197 -0
  50. package/src/runner/index.ts +86 -0
  51. package/src/runner/lookup.ts +69 -0
  52. package/src/runner/provider.ts +72 -0
  53. package/src/runner/providers/anthropic.ts +168 -0
  54. package/src/runner/providers/openai.ts +191 -0
  55. package/src/runner/reasoner.ts +73 -0
  56. package/src/runner/reasoning/index.ts +53 -0
  57. package/src/runner/reasoning/prompt.ts +200 -0
  58. package/src/runner/reasoning/react.ts +149 -0
  59. package/src/runner/reasoning/shopify.ts +214 -0
  60. package/src/runner/reasoning/skills-reasoner.ts +117 -0
  61. package/src/runner/reasoning/types.ts +99 -0
  62. package/src/runner/runner.ts +203 -0
  63. package/src/sarif.ts +148 -0
  64. package/src/source-identity.ts +0 -0
  65. package/src/source-trace.ts +814 -0
  66. package/src/suggest.ts +328 -0
  67. package/src/suppression-ranges.ts +354 -0
  68. package/src/suppressor-map.ts +189 -0
  69. package/src/suppressors.ts +284 -0
  70. package/src/tsconfig-aliases.ts +155 -0
  71. package/src/unity-ast.ts +331 -0
  72. package/src/unity-findings.ts +91 -0
  73. package/src/unity-guid-registry.ts +82 -0
  74. package/src/unity-label-resolve.ts +249 -0
  75. package/src/unity-rule-color-only.ts +127 -0
  76. package/src/unity-rule-missing-label.ts +156 -0
  77. package/src/unity-rules-baseline.ts +273 -0
  78. package/src/wcag-map.ts +35 -0
  79. package/src/wcag-tags.ts +35 -0
  80. package/src/workspace-resolve.ts +405 -0
package/src/index.ts ADDED
@@ -0,0 +1,183 @@
1
+ /**
2
+ * `@binclusive/a11y` — the engine library surface.
3
+ *
4
+ * The curated public API (see `docs/ENGINE-API.md`): the functions a CLI
5
+ * (`b8e`, a lean shim) and agents call, organized into four clusters —
6
+ * Scan, Contract lifecycle, Agent integration, Corpus.
7
+ *
8
+ * Engine INTERNALS are deliberately NOT re-exported here: component resolution,
9
+ * source tracing, the registry, module-scope, workspace/tsconfig resolution,
10
+ * the enforce pass, and the `distill/` corpus build-tool. They are implementation,
11
+ * not contract — import them by path if you're working inside the engine.
12
+ */
13
+
14
+ // ── 1 · Scan & enrich — the read path ────────────────────────────────────────
15
+ export { collectTsx } from "./collect";
16
+ export {
17
+ type DiffScopeInput,
18
+ parseChangedFiles,
19
+ scopeChangedTsx,
20
+ scopeChangedTsxFromEnv,
21
+ } from "./diff-scope";
22
+ export {
23
+ checkFiles,
24
+ dedupeRecall,
25
+ type Finding,
26
+ type FindingLayer,
27
+ type FindingProvenance,
28
+ type ScanResult,
29
+ scan,
30
+ } from "./core";
31
+ export { type DomScanOptions, type DomScanResult, scanUrl } from "./collect-dom";
32
+ export { type SwiftScanResult, scanSwift } from "./collect-swift";
33
+ export {
34
+ type DisplayContract,
35
+ type EnrichedFinding,
36
+ enrich,
37
+ enrichAll,
38
+ resolveDisplay,
39
+ } from "./evidence";
40
+ // Types that `ScanResult` and the CLI presentation reference (the functions that
41
+ // produce them — `resolveComponents` — stay internal):
42
+ export {
43
+ type ComponentResolution,
44
+ type Coverage,
45
+ type OpaqueKind,
46
+ type Provenance,
47
+ type ResolvedComponents,
48
+ type ResolvedProvenance,
49
+ } from "./resolve-components";
50
+
51
+ // ── 2 · Contract lifecycle — init / config ──────────────────────────────────
52
+ export {
53
+ BLOCK_TARGETS,
54
+ CONTRACT_FILE,
55
+ type DriftEntry,
56
+ type GenResult,
57
+ gen,
58
+ type InitOptions,
59
+ type InitResult,
60
+ init,
61
+ type LearnInput,
62
+ type LearnResult,
63
+ learn,
64
+ loadContract,
65
+ } from "./commands";
66
+ export {
67
+ contractForFiles,
68
+ type EnforcementLevel,
69
+ enforcementFor,
70
+ fileIgnoreMatcher,
71
+ findContractFrom,
72
+ ignoredRuleIds,
73
+ } from "./config-scan";
74
+ export {
75
+ CONTRACT_VERSION,
76
+ type Contract,
77
+ ContractParseError,
78
+ type Declarations,
79
+ defaultEnforcement,
80
+ type Enforcement,
81
+ emptyDeclarations,
82
+ type Language,
83
+ type LearnedRule,
84
+ parseContract,
85
+ type Router,
86
+ serializeContract,
87
+ type Stack,
88
+ } from "./contract";
89
+ export { detectStack } from "./detect-stack";
90
+ // Emit path — the metadata-only wire projection (local finding -> contract) and
91
+ // the LOCAL SARIF renderer that reads the rich model directly. Both narrow
92
+ // through the ONE `evidenceImpact` accessor + `impactToLevel` (impact -> SARIF).
93
+ export {
94
+ type LenientPayload,
95
+ toContractFinding,
96
+ toContractProvenance,
97
+ toFindingPayload,
98
+ toFindingPayloadLenient,
99
+ } from "./emit-contract";
100
+ export {
101
+ diskLineSource,
102
+ isPageFinding,
103
+ type LineSource,
104
+ lineContentHash,
105
+ type LocationOptions,
106
+ normalizeLine,
107
+ resolveLocations,
108
+ } from "./source-identity";
109
+ export { formatSarif, impactToLevel } from "./sarif";
110
+ export {
111
+ type ComponentSuggestion,
112
+ type SuggestConfidence,
113
+ type SuggestOptions,
114
+ type SuggestResult,
115
+ suggestComponentMap,
116
+ } from "./suggest";
117
+
118
+ // Provider-agnostic AI-lane runner (#2095): BYO provider + the capped,
119
+ // non-blocking pull loop. Reasoning content (skills/lookups/finding logic) is
120
+ // injected through the AgentReasoner + LookupTool seams (#2096/#2097/#2098).
121
+ export {
122
+ type AgentFinding,
123
+ type AgentReasoner,
124
+ type BudgetSnapshot,
125
+ type CodeGraphLookupConfig,
126
+ type CodeGraphLookupData,
127
+ type CodeGraphQueryKind,
128
+ createCodeGraphLookup,
129
+ DEFAULT_RUNNER_CONFIG,
130
+ LookupCounter,
131
+ type LookupQuery,
132
+ type LookupResult,
133
+ isMeterableUsage,
134
+ type LookupTool,
135
+ meterLookup,
136
+ meterProvider,
137
+ type PassOutcome,
138
+ type PassReport,
139
+ type Provider,
140
+ type ProviderMessage,
141
+ type ProviderRequest,
142
+ type ProviderResponse,
143
+ type ReasonContext,
144
+ type RunInput,
145
+ runAgentLane,
146
+ type RunnerConfig,
147
+ type RunOutcome,
148
+ TokenCeilingExceeded,
149
+ TokenLedger,
150
+ type TokenUsage,
151
+ usageTotal,
152
+ } from "./runner";
153
+
154
+ // ── 3 · Agent integration — hook + MCP ──────────────────────────────────────
155
+ export { type HookOutput, runHook } from "./hook";
156
+ export {
157
+ buildServer,
158
+ type CheckA11yResult,
159
+ type CheckFinding,
160
+ type CheckUrlResult,
161
+ checkA11y,
162
+ checkUrl,
163
+ type GetA11yRulesResult,
164
+ getA11yRules,
165
+ type LearnA11yRuleResult,
166
+ learnA11yRule,
167
+ startStdioServer,
168
+ } from "./mcp";
169
+
170
+ // ── 4 · Evidence — the coverage catalog (axe baseline), read-only ────────────
171
+ // The corpus left the engine (ADR 0041 §G): pure detection, baseline everywhere.
172
+ export {
173
+ type AxeImpact,
174
+ type BaselineRuleInfo,
175
+ baselineRules,
176
+ type Evidence,
177
+ evidenceBestPractice,
178
+ evidenceFix,
179
+ evidenceHelpUrl,
180
+ evidenceImpact,
181
+ } from "./evidence";
182
+ export { BLOCK_BEGIN, BLOCK_END, extractBlock, renderBlock, spliceBlock } from "./agents-block";
183
+ export { RULE_ID_TO_WCAG, wcagForRuleId } from "./wcag-map";
@@ -0,0 +1,203 @@
1
+ /**
2
+ * The Liquid AST layer — L1 of the Shopify/Liquid static producer (issue #47).
3
+ *
4
+ * This is the parser foundation the structural-absence rules (L2) and the
5
+ * `collect-liquid` producer + `check-shopify` command (L3) build on. It wraps
6
+ * `@shopify/liquid-html-parser` (Shopify's own theme-check parser) and exposes the
7
+ * one distinction the whole precision invariant rides on: an HTML attribute can be
8
+ * ABSENT, or PRESENT with a value that is static text, a render-time Liquid
9
+ * expression (dynamic), or empty. A dynamic value means the attribute IS present —
10
+ * conflating "value is dynamic" with "attribute missing" is the false positive that
11
+ * gets an a11y tool uninstalled, so it is isolated and tested here, not in the rules.
12
+ *
13
+ * Liquid holes (`{{ }}` / `{% %}`) stay opaque: the parser keeps them as Liquid
14
+ * nodes, never text/markup that could trip a rule. Parsing runs in tolerant mode so
15
+ * odd real-world theme markup falls back to a string node instead of throwing.
16
+ *
17
+ * Pattern docs (read before changing this): `.patterns/liquid-html-parser/`
18
+ * (`parsing.md`, `attributes.md`, `traversal.md`).
19
+ */
20
+
21
+ import {
22
+ getName,
23
+ NodeTypes,
24
+ toLiquidHtmlAST,
25
+ walk,
26
+ type AttrDoubleQuoted,
27
+ type AttrEmpty,
28
+ type AttrSingleQuoted,
29
+ type AttrUnquoted,
30
+ type DocumentNode,
31
+ type HtmlElement,
32
+ type HtmlRawNode,
33
+ type HtmlSelfClosingElement,
34
+ type HtmlVoidElement,
35
+ type LiquidHtmlNode,
36
+ } from "@shopify/liquid-html-parser";
37
+
38
+ /** The HTML element node kinds that carry an `attributes` list. */
39
+ export type HtmlElementNode =
40
+ | HtmlElement
41
+ | HtmlVoidElement
42
+ | HtmlSelfClosingElement
43
+ | HtmlRawNode;
44
+
45
+ /** The concrete attribute node kinds (`{% if %}`-wrapped Liquid nodes are descended,
46
+ * never returned as attributes). */
47
+ export type AttrNode = AttrSingleQuoted | AttrDoubleQuoted | AttrUnquoted | AttrEmpty;
48
+
49
+ /**
50
+ * Parse result. Tolerant-mode parsing never throws on `{{ }}`/`{% %}`, but an
51
+ * unclosed HTML/Liquid block still throws by default — we catch it and return
52
+ * `{ ok: false }` so a producer scanning a whole theme never crashes on one
53
+ * malformed file. The caller (L3) decides whether to skip or surface it.
54
+ */
55
+ export type ParseResult =
56
+ | { readonly ok: true; readonly ast: DocumentNode }
57
+ | { readonly ok: false; readonly error: Error };
58
+
59
+ /**
60
+ * How an HTML attribute's value resolves when read statically from `.liquid`.
61
+ * The discriminant is what the structural-absence rules branch on: only `absent`
62
+ * (and, per rule, `empty`) is ever a finding — `dynamic` is present-and-unknowable
63
+ * and must never be flagged.
64
+ */
65
+ export type AttrValue =
66
+ | { readonly kind: "absent" } // no such attribute node on the element
67
+ | { readonly kind: "empty" } // present, no value (AttrEmpty, or value=[])
68
+ | { readonly kind: "static"; readonly text: string } // value is purely literal text
69
+ | { readonly kind: "dynamic" }; // value contains a Liquid expression
70
+
71
+ /** Byte-offset span into the source — for L2/L3 to anchor a finding's location. */
72
+ export interface SourceSpan {
73
+ readonly start: number; // 0-indexed, inclusive
74
+ readonly end: number; // 0-indexed, exclusive
75
+ }
76
+
77
+ const HTML_ELEMENT_TYPES: ReadonlySet<string> = new Set([
78
+ NodeTypes.HtmlElement,
79
+ NodeTypes.HtmlVoidElement,
80
+ NodeTypes.HtmlSelfClosingElement,
81
+ NodeTypes.HtmlRawNode,
82
+ ]);
83
+
84
+ const ATTR_TYPES: ReadonlySet<string> = new Set([
85
+ NodeTypes.AttrSingleQuoted,
86
+ NodeTypes.AttrDoubleQuoted,
87
+ NodeTypes.AttrUnquoted,
88
+ NodeTypes.AttrEmpty,
89
+ ]);
90
+
91
+ /**
92
+ * Parse a `.liquid` source string into an AST. Tolerant mode: unrecognized Liquid
93
+ * markup falls back to a string node rather than throwing, so a real-world theme
94
+ * with odd tags still parses. An unclosed document throws (the parser default);
95
+ * we catch and return it as `{ ok: false }`.
96
+ */
97
+ export function parseLiquid(source: string): ParseResult {
98
+ try {
99
+ const ast = toLiquidHtmlAST(source, {
100
+ mode: "tolerant",
101
+ allowUnclosedDocumentNode: false,
102
+ });
103
+ return { ok: true, ast };
104
+ } catch (error) {
105
+ return { ok: false, error: error instanceof Error ? error : new Error(String(error)) };
106
+ }
107
+ }
108
+
109
+ /** Is this node an HTML element (i.e. does it carry an `attributes` list)? */
110
+ export function isHtmlElement(node: LiquidHtmlNode): node is HtmlElementNode {
111
+ return HTML_ELEMENT_TYPES.has(node.type);
112
+ }
113
+
114
+ /**
115
+ * Visit every HTML element node in the tree. Uses the library's `walk`, which
116
+ * descends into attributes and name arrays too — plain `children` recursion would
117
+ * silently skip them.
118
+ */
119
+ export function eachHtmlElement(
120
+ ast: DocumentNode,
121
+ fn: (node: HtmlElementNode) => void,
122
+ ): void {
123
+ walk(ast, (node: LiquidHtmlNode) => {
124
+ if (isHtmlElement(node)) fn(node);
125
+ });
126
+ }
127
+
128
+ /** The element's tag name, normalized to a string (`getName` handles both the
129
+ * plain-string void-element form and the compound array form). `null` if unnamed. */
130
+ export function elementName(node: HtmlElementNode): string | null {
131
+ return getName(node);
132
+ }
133
+
134
+ /**
135
+ * All attribute nodes on an element, descending into a `{% if %}`-wrapped attribute
136
+ * list so an attribute hidden inside a Liquid tag still counts as present. The
137
+ * returned nodes are the real `Attr*` nodes, never the Liquid wrapper.
138
+ */
139
+ export function htmlAttributes(node: HtmlElementNode): AttrNode[] {
140
+ const out: AttrNode[] = [];
141
+ const collect = (attrs: readonly LiquidHtmlNode[]): void => {
142
+ for (const a of attrs) {
143
+ if (ATTR_TYPES.has(a.type)) {
144
+ out.push(a as AttrNode);
145
+ } else if ("children" in a && Array.isArray((a as { children?: unknown }).children)) {
146
+ collect((a as unknown as { children: LiquidHtmlNode[] }).children);
147
+ }
148
+ }
149
+ };
150
+ collect(node.attributes as readonly LiquidHtmlNode[]);
151
+ return out;
152
+ }
153
+
154
+ /** Find the attribute node named `name` (case-insensitive) on an element, if any. */
155
+ export function findAttr(node: HtmlElementNode, name: string): AttrNode | undefined {
156
+ const target = name.toLowerCase();
157
+ return htmlAttributes(node).find((a) => {
158
+ const n = getName(a);
159
+ return n != null && n.toLowerCase() === target;
160
+ });
161
+ }
162
+
163
+ /**
164
+ * Classify an attribute node into absent / empty / static / dynamic. `undefined`
165
+ * (the result of a failed lookup) is `absent`. Presence is decided by node
166
+ * existence, never by whether the value is empty or dynamic — see
167
+ * `.patterns/liquid-html-parser/attributes.md`.
168
+ */
169
+ export function classifyAttr(attr: AttrNode | undefined): AttrValue {
170
+ if (!attr) return { kind: "absent" };
171
+ if (attr.type === NodeTypes.AttrEmpty) return { kind: "empty" };
172
+ const value = (attr as { value?: Array<{ type: string; value?: string }> }).value;
173
+ if (!value || value.length === 0) return { kind: "empty" };
174
+ const allText = value.every((v) => v.type === NodeTypes.TextNode);
175
+ if (allText) return { kind: "static", text: value.map((v) => v.value ?? "").join("") };
176
+ return { kind: "dynamic" };
177
+ }
178
+
179
+ /** Convenience: classify attribute `name` on `node` in one call. */
180
+ export function attr(node: HtmlElementNode, name: string): AttrValue {
181
+ return classifyAttr(findAttr(node, name));
182
+ }
183
+
184
+ /** The source span a node covers, for anchoring a finding's location. */
185
+ export function spanOf(node: LiquidHtmlNode): SourceSpan {
186
+ const position = (node as { position: SourceSpan }).position;
187
+ return { start: position.start, end: position.end };
188
+ }
189
+
190
+ /**
191
+ * The exact source text a node covers, reconstructed from its position span. The
192
+ * one way to read inside an `HtmlRawNode` (`<svg>`, `<script>`, `<style>`): its
193
+ * body is opaque raw markup, never parsed into child nodes, so a name source like
194
+ * an `<svg>`'s `<title>` is only visible here. See
195
+ * `.patterns/liquid-html-parser/node-taxonomy.md` (HtmlRawNode) and `traversal.md`
196
+ * (source position). Returns `""` if the node carries no `source`/`position`.
197
+ */
198
+ export function rawSourceOf(node: LiquidHtmlNode): string {
199
+ const source = (node as { source?: string }).source;
200
+ const position = (node as { position?: SourceSpan }).position;
201
+ if (typeof source !== "string" || !position) return "";
202
+ return source.slice(position.start, position.end);
203
+ }