dtp-caal 1.0.0 β 1.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 +334 -0
- package/dist/analyzer.js +81 -43
- package/dist/cache.js +99 -0
- package/dist/index.js +27 -7
- package/dist/remediator.js +3 -1
- package/dist/reporter.js +23 -12
- package/package.json +2 -2
- package/dist/src/analyzer.js +0 -119
- package/dist/src/index.js +0 -59
- package/dist/src/reporter.js +0 -48
- package/dist/src/scanner.js +0 -54
package/README.md
ADDED
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
# dtp-caal (Context-Aware Accessibility Linter)
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/dtp-caal)
|
|
4
|
+
[](https://opensource.org/licenses/ISC)
|
|
5
|
+
[](https://playwright.dev/)
|
|
6
|
+
[](https://www.w3.org/WAI/standards-guidelines/wcag/)
|
|
7
|
+
|
|
8
|
+
> **Next-generation, context-aware web accessibility auditing and automated remediation powered by Headless Browser automation and LLMs.**
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
## π Overview
|
|
13
|
+
|
|
14
|
+
Traditional accessibility tools (like Lighthouse, axe-core, and WAVE) rely on static DOM syntax checks and regular expressions. While effective for basic rules, they suffer from fundamental limitations:
|
|
15
|
+
|
|
16
|
+
1. **They are "Rule-Based", Not "Semantic":** An image with `<img src="hero.png" alt="photo.jpg">` passes static tests because an `alt` attribute is technically presentβeven though "photo.jpg" is meaningless to a screen-reader user.
|
|
17
|
+
2. **Inability to Understand Context:** Generic elements like multiple `<button>Read More</button>` links pass syntax checks, but screen readers cannot determine which article or topic each button relates to.
|
|
18
|
+
3. **Flagging vs. Fixing:** Existing tools dump warnings with links to dense WCAG documentation, forcing developers to manually decipher and write complex ARIA markup.
|
|
19
|
+
|
|
20
|
+
**`dtp-caal`** shifts accessibility testing from static syntax checking to **context-aware semantic analysis**:
|
|
21
|
+
- It launches a headless browser via **Playwright** to let single-page applications (React, Vue, Next.js, Angular, etc.) fully render.
|
|
22
|
+
- It extracts interactive elements along with their **surrounding parent DOM context**.
|
|
23
|
+
- It uses high-performance LLMs (via the **Groq API**) to assess accessibility intent and generate exact, framework-compliant ARIA code.
|
|
24
|
+
- With the `--auto-fix` option, it automatically finds the corresponding component in your source directory and patches the fix directly!
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## β¨ Features
|
|
29
|
+
|
|
30
|
+
- π **Headless Browser Scanning:** Accurately audits dynamic, client-side rendered Single Page Applications (SPAs) using Playwright.
|
|
31
|
+
- π§ **Context-Aware Semantic Analysis:** Analyzes target elements in conjunction with their surrounding DOM tree to understand intent.
|
|
32
|
+
- π οΈ **Automated Source Remediation (`--auto-fix`):** Locates the responsible source file (`.tsx`, `.jsx`, `.html`, `.vue`, etc.) and patches the fix directly.
|
|
33
|
+
- π **Multi-Format Reporting:** Generates clean, human-readable **Markdown (`.md`)** reports or structured **JSON (`.json`)** summaries.
|
|
34
|
+
- π **CI/CD & Pull Request Integration:** Exits with code `1` upon detecting accessibility violations to guard against PR regressions in CI/CD pipelines.
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
## π Prerequisites
|
|
39
|
+
|
|
40
|
+
1. **Node.js**: Version 18 or higher (Node 20+ recommended).
|
|
41
|
+
2. **Groq API Key**: `dtp-caal` uses Groq for fast inference. You can get a free key from the [Groq Console](https://console.groq.com/).
|
|
42
|
+
3. **Playwright Chromium**: Playwright headless browser binaries must be installed.
|
|
43
|
+
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
## π Installation & Quick Start
|
|
47
|
+
|
|
48
|
+
### 1. Instant Run with `npx` (Recommended)
|
|
49
|
+
|
|
50
|
+
You can run `dtp-caal` on any project immediately without installing it globally:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
# Set your API Key
|
|
54
|
+
# Linux / macOS:
|
|
55
|
+
export GROQ_API_KEY="your_groq_api_key_here"
|
|
56
|
+
|
|
57
|
+
# Windows (PowerShell):
|
|
58
|
+
$env:GROQ_API_KEY="your_groq_api_key_here"
|
|
59
|
+
|
|
60
|
+
# Install Chromium browser binaries (first-time only)
|
|
61
|
+
npx playwright install chromium
|
|
62
|
+
|
|
63
|
+
# Run the audit against your local dev server
|
|
64
|
+
npx dtp-caal --url http://localhost:3000
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
---
|
|
68
|
+
|
|
69
|
+
### 2. Global Installation
|
|
70
|
+
|
|
71
|
+
Install globally across your machine:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
npm install -g dtp-caal
|
|
75
|
+
npx playwright install chromium
|
|
76
|
+
|
|
77
|
+
# Run anywhere
|
|
78
|
+
dtp-caal --url http://localhost:3000
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
### 3. Local Project Dependency
|
|
84
|
+
|
|
85
|
+
Install within your web project:
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
npm install --save-dev dtp-caal
|
|
89
|
+
npx playwright install chromium
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Add an audit script to your `package.json`:
|
|
93
|
+
|
|
94
|
+
```json
|
|
95
|
+
{
|
|
96
|
+
"scripts": {
|
|
97
|
+
"a11y:audit": "dtp-caal --url http://localhost:3000",
|
|
98
|
+
"a11y:fix": "dtp-caal --url http://localhost:3000 --auto-fix --src-dir ./src"
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Then run:
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
npm run a11y:audit
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
---
|
|
110
|
+
|
|
111
|
+
## π» CLI Usage & Commands
|
|
112
|
+
|
|
113
|
+
### Basic Audit
|
|
114
|
+
Audit a local or remote URL and output a Markdown report:
|
|
115
|
+
```bash
|
|
116
|
+
dtp-caal --url http://localhost:3000
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### Automated Remediation (Auto-Fix)
|
|
120
|
+
Audit the rendered application and apply source code patches directly to your project files:
|
|
121
|
+
```bash
|
|
122
|
+
dtp-caal --url http://localhost:3000 --auto-fix --src-dir ./src
|
|
123
|
+
```
|
|
124
|
+
> **Note:** Changes made by `--auto-fix` are left uncommitted in your working tree so you can review diffs (`git diff`) before committing.
|
|
125
|
+
|
|
126
|
+
### Custom Output Report Path and Format
|
|
127
|
+
Generate a JSON report for programmatic consumption or custom CI dashboards:
|
|
128
|
+
```bash
|
|
129
|
+
dtp-caal --url http://localhost:3000 --format json --output ./reports/a11y-results.json
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
---
|
|
133
|
+
|
|
134
|
+
## βοΈ CLI Options Reference
|
|
135
|
+
|
|
136
|
+
| Flag | Shorthand | Default | Description |
|
|
137
|
+
| :--- | :--- | :--- | :--- |
|
|
138
|
+
| `--url <url>` | `-u` | `http://localhost:3000` | The URL of the web page to scan. |
|
|
139
|
+
| `--output <path>` | `-o` | `./caal-report.md` | Destination file path for the audit report. |
|
|
140
|
+
| `--format <format>`| `-f` | `md` | Output format: `md` (Markdown) or `json` (JSON). |
|
|
141
|
+
| `--auto-fix` | | `false` | Automatically attempt to locate and patch source files. |
|
|
142
|
+
| `--src-dir <path>` | | `./` | Directory containing source files when `--auto-fix` is enabled. |
|
|
143
|
+
| `--cache <path>` | | `./.caal-cache.json` | Verdict cache file. Unchanged elements reuse their previous verdict. |
|
|
144
|
+
| `--no-cache` | | | Ignore the cache and analyze every element fresh (nothing is read or written). |
|
|
145
|
+
| `--help` | `-h` | | Display help and argument descriptions. |
|
|
146
|
+
| `--version` | `-V` | | Display CLI version. |
|
|
147
|
+
|
|
148
|
+
---
|
|
149
|
+
|
|
150
|
+
## π Consistent Results (Verdict Cache)
|
|
151
|
+
|
|
152
|
+
LLMs are not fully deterministic: even with temperature `0` and a fixed seed, asking the model about the same element twice can occasionally produce a different verdict. To keep audits stable, `dtp-caal` caches every verdict:
|
|
153
|
+
|
|
154
|
+
- Each element is fingerprinted from its HTML and its parent's HTML (plus the model and prompt version). Markup that changes on every page load without changing meaning (React `useId()` values, `nonce` attributes, HTML comments, whitespace) is ignored.
|
|
155
|
+
- If an element is unchanged since a previous run, its cached verdict is reused and no API call is made. Only new or changed elements are sent to the model.
|
|
156
|
+
- Identical elements within one run (e.g. the same nav link on every card) are analyzed once.
|
|
157
|
+
- Entries not used for 30 days are dropped automatically.
|
|
158
|
+
|
|
159
|
+
To share verdicts across machines, either commit `.caal-cache.json` to your repository or persist it in CI (see the GitHub Actions example below). Delete the file, or use `--no-cache`, to force a completely fresh audit.
|
|
160
|
+
|
|
161
|
+
### Exit Codes
|
|
162
|
+
|
|
163
|
+
| Code | Meaning |
|
|
164
|
+
| :--- | :--- |
|
|
165
|
+
| `0` | All analyzed elements passed. |
|
|
166
|
+
| `1` | Accessibility issues were found (or the audit crashed). |
|
|
167
|
+
| `2` | No issues found, but some elements could not be analyzed because of API errors (rate limits, invalid key). These are listed under **Not Analyzed** in the report and are never cached, so re-running retries only them. |
|
|
168
|
+
|
|
169
|
+
---
|
|
170
|
+
|
|
171
|
+
## π How It Works
|
|
172
|
+
|
|
173
|
+
```
|
|
174
|
+
ββββββββββββββββββββββββββ
|
|
175
|
+
β Running Web App β (e.g., http://localhost:3000)
|
|
176
|
+
βββββββββββββ¬βββββββββββββ
|
|
177
|
+
β 1. Navigate & Render (Playwright)
|
|
178
|
+
βΌ
|
|
179
|
+
ββββββββββββββββββββββββββ
|
|
180
|
+
β Target Elements & β (Buttons, images, inputs, links +
|
|
181
|
+
β Parent DOM Context β surrounding contextual HTML)
|
|
182
|
+
βββββββββββββ¬βββββββββββββ
|
|
183
|
+
β 2. Semantic Evaluation
|
|
184
|
+
βΌ
|
|
185
|
+
ββββββββββββββββββββββββββ
|
|
186
|
+
β Groq LLM Engine β (WCAG validation, explanation,
|
|
187
|
+
β (openai/gpt-oss-120b) β and precise code replacement)
|
|
188
|
+
βββββββββββββ¬βββββββββββββ
|
|
189
|
+
β 3. Output
|
|
190
|
+
βββββββ΄βββββββββββββββββββββββββ
|
|
191
|
+
βΌ βΌ
|
|
192
|
+
ββββββββββββββββββββββ βββββββββββββββββββββββββββββ
|
|
193
|
+
β Report Generated β β Auto-Fix Applied β
|
|
194
|
+
β (.md / .json) β β (Direct source file patch)β
|
|
195
|
+
ββββββββββββββββββββββ βββββββββββββββββββββββββββββ
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
1. **Extraction:** Playwright launches headless Chromium, navigates to the specified URL, waits for network idle, and queries target elements (`button`, `img`, `input`, `a`, `[role="button"]`, etc.). For each element, it extracts both the element's markup and sanitized parent container HTML.
|
|
199
|
+
2. **Contextual Analysis:** Each element is first looked up in the verdict cache; only new or changed elements are analyzed using Groq's LLM endpoint (temperature `0`, fixed seed, strict JSON schema). The prompt instructs the model to act as an accessibility engineer, identifying WCAG failures and synthesizing valid replacement markup.
|
|
200
|
+
3. **Reporting:** Results are structured into either a Markdown document or JSON file.
|
|
201
|
+
4. **Remediation:** If `--auto-fix` is passed, the tool searches the specified `--src-dir` for files containing matching tokens, prompts the LLM to integrate the accessibility fix while preserving framework syntax (JSX, Vue, standard HTML), and updates the source files.
|
|
202
|
+
5. **Exit Code:** If any element fails WCAG checks, the CLI terminates with exit code `1`, making it ideal for CI/CD gates (see [Exit Codes](#exit-codes)).
|
|
203
|
+
|
|
204
|
+
---
|
|
205
|
+
|
|
206
|
+
## π€ CI/CD Integration (GitHub Actions)
|
|
207
|
+
|
|
208
|
+
You can easily integrate `dtp-caal` into your pull request pipeline to block regressions and post automated fixes:
|
|
209
|
+
|
|
210
|
+
```yaml
|
|
211
|
+
name: Accessibility Linter (CAAL)
|
|
212
|
+
|
|
213
|
+
on:
|
|
214
|
+
pull_request:
|
|
215
|
+
branches: [ main, master ]
|
|
216
|
+
|
|
217
|
+
jobs:
|
|
218
|
+
a11y-audit:
|
|
219
|
+
runs-on: ubuntu-latest
|
|
220
|
+
steps:
|
|
221
|
+
- name: Checkout Code
|
|
222
|
+
uses: actions/checkout@v3
|
|
223
|
+
|
|
224
|
+
- name: Setup Node.js
|
|
225
|
+
uses: actions/setup-node@v3
|
|
226
|
+
with:
|
|
227
|
+
node-version: '20'
|
|
228
|
+
|
|
229
|
+
- name: Install App Dependencies & Build
|
|
230
|
+
run: |
|
|
231
|
+
npm ci
|
|
232
|
+
npm run build --if-present
|
|
233
|
+
|
|
234
|
+
- name: Start App Server
|
|
235
|
+
run: |
|
|
236
|
+
npm run start &
|
|
237
|
+
npx wait-on http://localhost:3000
|
|
238
|
+
|
|
239
|
+
- name: Install Playwright Browsers
|
|
240
|
+
run: npx playwright install --with-deps chromium
|
|
241
|
+
|
|
242
|
+
- name: Restore Verdict Cache
|
|
243
|
+
uses: actions/cache/restore@v4
|
|
244
|
+
with:
|
|
245
|
+
path: .caal-cache.json
|
|
246
|
+
key: caal-verdicts-${{ github.run_id }}
|
|
247
|
+
restore-keys: caal-verdicts-
|
|
248
|
+
|
|
249
|
+
- name: Run Accessibility Audit
|
|
250
|
+
id: a11y_audit
|
|
251
|
+
continue-on-error: true
|
|
252
|
+
env:
|
|
253
|
+
GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
|
|
254
|
+
run: |
|
|
255
|
+
npx dtp-caal --url http://localhost:3000 --output ./caal-report.md --auto-fix --src-dir ./src
|
|
256
|
+
|
|
257
|
+
# Save even when the audit fails, so the next run reuses these verdicts
|
|
258
|
+
- name: Save Verdict Cache
|
|
259
|
+
if: always()
|
|
260
|
+
uses: actions/cache/save@v4
|
|
261
|
+
with:
|
|
262
|
+
path: .caal-cache.json
|
|
263
|
+
key: caal-verdicts-${{ github.run_id }}
|
|
264
|
+
|
|
265
|
+
- name: Generate Fix Patch
|
|
266
|
+
id: git_diff
|
|
267
|
+
run: |
|
|
268
|
+
git diff > caal-fix.patch
|
|
269
|
+
if [ -s caal-fix.patch ]; then
|
|
270
|
+
echo "has_fixes=true" >> $GITHUB_OUTPUT
|
|
271
|
+
fi
|
|
272
|
+
|
|
273
|
+
- name: Comment on PR
|
|
274
|
+
if: steps.a11y_audit.outcome == 'failure'
|
|
275
|
+
uses: actions/github-script@v7
|
|
276
|
+
with:
|
|
277
|
+
github-token: ${{ secrets.GITHUB_TOKEN }}
|
|
278
|
+
script: |
|
|
279
|
+
const fs = require('fs');
|
|
280
|
+
const report = fs.readFileSync('caal-report.md', 'utf8');
|
|
281
|
+
let body = `## π¨ Accessibility Audit Failed\n\n${report}`;
|
|
282
|
+
|
|
283
|
+
if (fs.existsSync('caal-fix.patch')) {
|
|
284
|
+
const patch = fs.readFileSync('caal-fix.patch', 'utf8');
|
|
285
|
+
if (patch.trim()) {
|
|
286
|
+
body += `\n\n### π οΈ Suggested Code Fix\n\`\`\`diff\n${patch}\n\`\`\``;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
await github.rest.issues.createComment({
|
|
291
|
+
issue_number: context.issue.number,
|
|
292
|
+
owner: context.repo.owner,
|
|
293
|
+
repo: context.repo.repo,
|
|
294
|
+
body: body
|
|
295
|
+
});
|
|
296
|
+
core.setFailed('Accessibility violations detected.');
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
---
|
|
300
|
+
|
|
301
|
+
## π οΈ Local Development & Building
|
|
302
|
+
|
|
303
|
+
If you are contributing to or modifying the CLI module:
|
|
304
|
+
|
|
305
|
+
```bash
|
|
306
|
+
# Clone the repository
|
|
307
|
+
git clone https://github.com/ankith5980/Mini_Project.git
|
|
308
|
+
cd Mini_Project/DTP_CAAL/cli
|
|
309
|
+
|
|
310
|
+
# Install dependencies
|
|
311
|
+
npm install
|
|
312
|
+
|
|
313
|
+
# Build TypeScript to dist/
|
|
314
|
+
npm run build
|
|
315
|
+
|
|
316
|
+
# Run in development mode
|
|
317
|
+
npm start -- --url http://localhost:3000
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
---
|
|
321
|
+
|
|
322
|
+
## β Troubleshooting
|
|
323
|
+
|
|
324
|
+
| Issue | Cause | Solution |
|
|
325
|
+
| :--- | :--- | :--- |
|
|
326
|
+
| `Error: GROQ_API_KEY environment variable is not set.` | Missing API key in environment. | Set `GROQ_API_KEY` via `export`, `set`, PowerShell `$env:`, or a `.env` file. |
|
|
327
|
+
| `browserType.launch: Executable doesn't exist` | Playwright browser binaries not installed. | Run `npx playwright install chromium` or `npx playwright install --with-deps chromium`. |
|
|
328
|
+
| `Page load timeout / net::ERR_CONNECTION_REFUSED` | The target server is not running on the specified URL. | Ensure your development server is active before running the audit (e.g. `npm run dev`). |
|
|
329
|
+
|
|
330
|
+
---
|
|
331
|
+
|
|
332
|
+
## π License
|
|
333
|
+
|
|
334
|
+
This project is licensed under the [ISC License](LICENSE).
|
package/dist/analyzer.js
CHANGED
|
@@ -3,17 +3,41 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
-
exports.
|
|
6
|
+
exports.isUnanalysed = exports.isIssue = exports.DETERMINISTIC_PARAMS = exports.MODEL = void 0;
|
|
7
7
|
exports.analyzeElements = analyzeElements;
|
|
8
8
|
const groq_sdk_1 = __importDefault(require("groq-sdk"));
|
|
9
9
|
const dotenv_1 = __importDefault(require("dotenv"));
|
|
10
|
+
const cache_1 = require("./cache");
|
|
10
11
|
dotenv_1.default.config();
|
|
11
12
|
const apiKey = process.env.GROQ_API_KEY || '';
|
|
12
13
|
const groq = new groq_sdk_1.default({ apiKey });
|
|
14
|
+
exports.MODEL = 'openai/gpt-oss-120b';
|
|
15
|
+
// Fixed sampling settings so the same input gets the same answer as often as the API allows
|
|
16
|
+
exports.DETERMINISTIC_PARAMS = { temperature: 0, seed: 42, reasoning_effort: 'medium' };
|
|
17
|
+
// Bump whenever the prompt or schema changes, so verdicts cached under the old prompt are not reused
|
|
18
|
+
const PROMPT_VERSION = '2';
|
|
19
|
+
const nullableString = { type: ['string', 'null'] };
|
|
20
|
+
const VERDICT_SCHEMA = {
|
|
21
|
+
type: 'object',
|
|
22
|
+
properties: {
|
|
23
|
+
isAccessible: { type: 'boolean' },
|
|
24
|
+
issueTitle: nullableString,
|
|
25
|
+
explanation: nullableString,
|
|
26
|
+
suggestedFixCode: nullableString,
|
|
27
|
+
fixReasoning: nullableString
|
|
28
|
+
},
|
|
29
|
+
required: ['isAccessible', 'issueTitle', 'explanation', 'suggestedFixCode', 'fixReasoning'],
|
|
30
|
+
additionalProperties: false
|
|
31
|
+
};
|
|
32
|
+
// An element that could not be analysed (API error) is not an accessibility issue; it is reported separately
|
|
33
|
+
const isIssue = (r) => !r.error && !r.isAccessible;
|
|
34
|
+
exports.isIssue = isIssue;
|
|
35
|
+
const isUnanalysed = (r) => Boolean(r.error);
|
|
36
|
+
exports.isUnanalysed = isUnanalysed;
|
|
13
37
|
const delay = (ms) => new Promise(res => setTimeout(res, ms));
|
|
14
|
-
async function
|
|
38
|
+
async function requestVerdict(element) {
|
|
15
39
|
const prompt = `
|
|
16
|
-
You are an expert accessibility engineer. Your task is to analyze an HTML/JSX element within its parent context to determine if it meets WCAG accessibility standards.
|
|
40
|
+
You are an expert accessibility engineer. Your task is to analyze an HTML/JSX element within its parent context to determine if it meets WCAG accessibility standards.
|
|
17
41
|
You must output ONLY valid JSON without any markdown code blocks or conversational text.
|
|
18
42
|
|
|
19
43
|
Context:
|
|
@@ -47,7 +71,7 @@ Return a JSON object with this exact structure:
|
|
|
47
71
|
`;
|
|
48
72
|
let retries = 3;
|
|
49
73
|
let delayMs = 2000;
|
|
50
|
-
while (
|
|
74
|
+
while (true) {
|
|
51
75
|
try {
|
|
52
76
|
const completion = await groq.chat.completions.create({
|
|
53
77
|
messages: [
|
|
@@ -60,60 +84,74 @@ Return a JSON object with this exact structure:
|
|
|
60
84
|
content: prompt
|
|
61
85
|
}
|
|
62
86
|
],
|
|
63
|
-
model:
|
|
64
|
-
|
|
87
|
+
model: exports.MODEL,
|
|
88
|
+
...exports.DETERMINISTIC_PARAMS,
|
|
89
|
+
response_format: {
|
|
90
|
+
type: 'json_schema',
|
|
91
|
+
json_schema: { name: 'accessibility_verdict', schema: VERDICT_SCHEMA, strict: true }
|
|
92
|
+
}
|
|
65
93
|
});
|
|
66
94
|
const text = completion.choices[0]?.message?.content;
|
|
67
|
-
if (text) {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
95
|
+
if (!text) {
|
|
96
|
+
throw new Error("No text in response");
|
|
97
|
+
}
|
|
98
|
+
const parsed = JSON.parse(text);
|
|
99
|
+
if (!(0, cache_1.isVerdict)(parsed)) {
|
|
100
|
+
throw new Error("Response did not match the verdict schema");
|
|
73
101
|
}
|
|
74
|
-
|
|
102
|
+
return {
|
|
103
|
+
isAccessible: parsed.isAccessible,
|
|
104
|
+
issueTitle: parsed.issueTitle,
|
|
105
|
+
explanation: parsed.explanation,
|
|
106
|
+
suggestedFixCode: parsed.suggestedFixCode,
|
|
107
|
+
fixReasoning: parsed.fixReasoning
|
|
108
|
+
};
|
|
75
109
|
}
|
|
76
110
|
catch (error) {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
return {
|
|
82
|
-
...element,
|
|
83
|
-
isAccessible: false,
|
|
84
|
-
error: "Failed to analyze due to API rate limits."
|
|
85
|
-
};
|
|
86
|
-
}
|
|
87
|
-
await delay(delayMs);
|
|
88
|
-
delayMs *= 2;
|
|
89
|
-
}
|
|
90
|
-
else {
|
|
91
|
-
return {
|
|
92
|
-
...element,
|
|
93
|
-
isAccessible: false,
|
|
94
|
-
error: error.message || "Unknown API error"
|
|
95
|
-
};
|
|
111
|
+
const retryable = error.status === 503 || error.status === 429 || error.message?.includes('503') || error.message?.includes('429');
|
|
112
|
+
retries--;
|
|
113
|
+
if (!retryable || retries === 0) {
|
|
114
|
+
throw retryable ? new Error("Failed to analyze due to API rate limits.") : error;
|
|
96
115
|
}
|
|
116
|
+
console.warn(`[WARN] Rate limited. Retries left: ${retries}. Retrying in ${delayMs}ms...`);
|
|
117
|
+
await delay(delayMs);
|
|
118
|
+
delayMs *= 2;
|
|
97
119
|
}
|
|
98
120
|
}
|
|
99
|
-
return {
|
|
100
|
-
...element,
|
|
101
|
-
isAccessible: false,
|
|
102
|
-
error: "Exhausted retries"
|
|
103
|
-
};
|
|
104
121
|
}
|
|
105
|
-
async function analyzeElements(elements) {
|
|
122
|
+
async function analyzeElements(elements, cache) {
|
|
106
123
|
const results = [];
|
|
124
|
+
let apiCalls = 0;
|
|
107
125
|
console.log(`Analyzing ${elements.length} elements using Groq API...`);
|
|
108
126
|
for (let i = 0; i < elements.length; i++) {
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
127
|
+
const element = elements[i];
|
|
128
|
+
const key = (0, cache_1.cacheKey)(exports.MODEL, PROMPT_VERSION, element.elementHtml, element.parentHtml);
|
|
129
|
+
// Cache hits cover both earlier runs and identical elements earlier in this run
|
|
130
|
+
const cachedVerdict = cache.get(key);
|
|
131
|
+
if (cachedVerdict) {
|
|
132
|
+
console.log(`Analyzing element ${i + 1}/${elements.length}: <${element.tagName}> (cached)`);
|
|
133
|
+
results.push({ ...element, ...cachedVerdict, cached: true });
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
113
136
|
// Wait briefly between requests to avoid rate limits
|
|
114
|
-
if (
|
|
137
|
+
if (apiCalls > 0) {
|
|
115
138
|
await delay(1000);
|
|
116
139
|
}
|
|
140
|
+
apiCalls++;
|
|
141
|
+
console.log(`Analyzing element ${i + 1}/${elements.length}: <${element.tagName}>...`);
|
|
142
|
+
try {
|
|
143
|
+
const verdict = await requestVerdict(element);
|
|
144
|
+
cache.set(key, verdict);
|
|
145
|
+
results.push({ ...element, ...verdict, cached: false });
|
|
146
|
+
}
|
|
147
|
+
catch (error) {
|
|
148
|
+
// Errors are never cached, so the next run retries this element
|
|
149
|
+
results.push({ ...element, isAccessible: false, error: error.message || "Unknown API error" });
|
|
150
|
+
}
|
|
151
|
+
// Persist progress periodically so an interrupted run keeps what it already paid for
|
|
152
|
+
if (apiCalls % 20 === 0) {
|
|
153
|
+
cache.save();
|
|
154
|
+
}
|
|
117
155
|
}
|
|
118
156
|
return results;
|
|
119
157
|
}
|
package/dist/cache.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.VerdictCache = void 0;
|
|
7
|
+
exports.normalizeHtml = normalizeHtml;
|
|
8
|
+
exports.cacheKey = cacheKey;
|
|
9
|
+
exports.isVerdict = isVerdict;
|
|
10
|
+
const crypto_1 = __importDefault(require("crypto"));
|
|
11
|
+
const fs_1 = __importDefault(require("fs"));
|
|
12
|
+
const path_1 = __importDefault(require("path"));
|
|
13
|
+
// Entries not used for this long are dropped on save (their element most likely changed or was removed)
|
|
14
|
+
const MAX_UNUSED_DAYS = 30;
|
|
15
|
+
const today = () => new Date().toISOString().slice(0, 10);
|
|
16
|
+
// Removes markup that differs between page loads without changing what the element is,
|
|
17
|
+
// so the same component produces the same cache key on every run.
|
|
18
|
+
function normalizeHtml(html) {
|
|
19
|
+
return html
|
|
20
|
+
.replace(/<!--[\s\S]*?-->/g, '')
|
|
21
|
+
.replace(/\s(?:data-caal-[\w-]+|nonce)="[^"]*"/g, '')
|
|
22
|
+
.replace(/:r[0-9a-z]+:|Β«r[0-9a-z]+Β»|_r_[0-9a-z]+_/g, ':id:') // React useId() values
|
|
23
|
+
.replace(/\s+/g, ' ')
|
|
24
|
+
.replace(/>\s+</g, '><')
|
|
25
|
+
.trim();
|
|
26
|
+
}
|
|
27
|
+
function cacheKey(model, promptVersion, elementHtml, parentHtml) {
|
|
28
|
+
return crypto_1.default
|
|
29
|
+
.createHash('sha256')
|
|
30
|
+
.update(JSON.stringify([promptVersion, model, normalizeHtml(elementHtml), normalizeHtml(parentHtml)]))
|
|
31
|
+
.digest('hex');
|
|
32
|
+
}
|
|
33
|
+
function isVerdict(value) {
|
|
34
|
+
const optionalString = (v) => v === null || typeof v === 'string';
|
|
35
|
+
return (typeof value === 'object' && value !== null &&
|
|
36
|
+
typeof value.isAccessible === 'boolean' &&
|
|
37
|
+
optionalString(value.issueTitle) &&
|
|
38
|
+
optionalString(value.explanation) &&
|
|
39
|
+
optionalString(value.suggestedFixCode) &&
|
|
40
|
+
optionalString(value.fixReasoning));
|
|
41
|
+
}
|
|
42
|
+
class VerdictCache {
|
|
43
|
+
// filePath === null disables persistence (the cache then only dedupes within a single run)
|
|
44
|
+
constructor(filePath) {
|
|
45
|
+
this.filePath = filePath;
|
|
46
|
+
this.entries = {};
|
|
47
|
+
this.dirty = false;
|
|
48
|
+
if (!filePath || !fs_1.default.existsSync(filePath))
|
|
49
|
+
return;
|
|
50
|
+
try {
|
|
51
|
+
const data = JSON.parse(fs_1.default.readFileSync(filePath, 'utf-8'));
|
|
52
|
+
if (data.version === 1 && data.entries) {
|
|
53
|
+
this.entries = data.entries;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
console.warn(`[WARN] Ignoring unreadable cache file: ${filePath}`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
get size() {
|
|
61
|
+
return Object.keys(this.entries).length;
|
|
62
|
+
}
|
|
63
|
+
get(key) {
|
|
64
|
+
const entry = this.entries[key];
|
|
65
|
+
if (!entry || !isVerdict(entry.verdict))
|
|
66
|
+
return undefined;
|
|
67
|
+
if (entry.lastUsed !== today()) {
|
|
68
|
+
entry.lastUsed = today();
|
|
69
|
+
this.dirty = true;
|
|
70
|
+
}
|
|
71
|
+
return entry.verdict;
|
|
72
|
+
}
|
|
73
|
+
set(key, verdict) {
|
|
74
|
+
this.entries[key] = { verdict, lastUsed: today() };
|
|
75
|
+
this.dirty = true;
|
|
76
|
+
}
|
|
77
|
+
save() {
|
|
78
|
+
if (!this.filePath || !this.dirty)
|
|
79
|
+
return;
|
|
80
|
+
const cutoff = new Date(Date.now() - MAX_UNUSED_DAYS * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
|
|
81
|
+
const sorted = {};
|
|
82
|
+
for (const key of Object.keys(this.entries).sort()) {
|
|
83
|
+
if (this.entries[key].lastUsed >= cutoff) {
|
|
84
|
+
sorted[key] = this.entries[key];
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
this.entries = sorted;
|
|
88
|
+
const dir = path_1.default.dirname(this.filePath);
|
|
89
|
+
if (!fs_1.default.existsSync(dir)) {
|
|
90
|
+
fs_1.default.mkdirSync(dir, { recursive: true });
|
|
91
|
+
}
|
|
92
|
+
// Write to a temp file and rename so an interrupted run never leaves a half-written cache
|
|
93
|
+
const tmpPath = `${this.filePath}.tmp`;
|
|
94
|
+
fs_1.default.writeFileSync(tmpPath, JSON.stringify({ version: 1, entries: sorted }, null, 2) + '\n');
|
|
95
|
+
fs_1.default.renameSync(tmpPath, this.filePath);
|
|
96
|
+
this.dirty = false;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
exports.VerdictCache = VerdictCache;
|
package/dist/index.js
CHANGED
|
@@ -8,19 +8,24 @@ const commander_1 = require("commander");
|
|
|
8
8
|
const chalk_1 = __importDefault(require("chalk"));
|
|
9
9
|
const scanner_1 = require("./scanner");
|
|
10
10
|
const analyzer_1 = require("./analyzer");
|
|
11
|
+
const cache_1 = require("./cache");
|
|
11
12
|
const reporter_1 = require("./reporter");
|
|
12
13
|
const remediator_1 = require("./remediator");
|
|
13
14
|
const program = new commander_1.Command();
|
|
14
15
|
program
|
|
15
|
-
.name('
|
|
16
|
+
.name('dtp-caal')
|
|
16
17
|
.description('Context-Aware Accessibility Linter CLI')
|
|
17
|
-
.version('1.
|
|
18
|
+
.version('1.1.0')
|
|
18
19
|
.requiredOption('-u, --url <url>', 'URL to scan (e.g., http://localhost:3000)', 'http://localhost:3000')
|
|
19
20
|
.option('-o, --output <path>', 'Output file path', './caal-report.md')
|
|
20
21
|
.option('-f, --format <format>', 'Output format (json or md)', 'md')
|
|
21
22
|
.option('--auto-fix', 'Automatically attempt to fix source files (beta)')
|
|
22
23
|
.option('--src-dir <path>', 'Directory containing source files for auto-fix', './')
|
|
24
|
+
.option('--cache <path>', 'Verdict cache file; unchanged elements reuse their previous verdict', './.caal-cache.json')
|
|
25
|
+
.option('--no-cache', 'Ignore the cache file and analyze every element fresh')
|
|
23
26
|
.action(async (options) => {
|
|
27
|
+
// options.cache is the file path, or false when --no-cache is passed
|
|
28
|
+
const cache = new cache_1.VerdictCache(options.cache || null);
|
|
24
29
|
try {
|
|
25
30
|
console.log(chalk_1.default.blue(`Starting accessibility audit for: ${options.url}`));
|
|
26
31
|
// Step 1: Scan page and extract elements
|
|
@@ -29,12 +34,16 @@ program
|
|
|
29
34
|
console.log(chalk_1.default.yellow('No relevant elements found to analyze.'));
|
|
30
35
|
return;
|
|
31
36
|
}
|
|
32
|
-
// Step 2: Analyze with LLM
|
|
37
|
+
// Step 2: Analyze with LLM (cached verdicts are reused)
|
|
33
38
|
if (!process.env.GROQ_API_KEY) {
|
|
34
39
|
console.error(chalk_1.default.red('Error: GROQ_API_KEY environment variable is not set.'));
|
|
35
40
|
process.exit(1);
|
|
36
41
|
}
|
|
37
|
-
|
|
42
|
+
if (options.cache) {
|
|
43
|
+
console.log(chalk_1.default.gray(`Using verdict cache ${options.cache} (${cache.size} entries)`));
|
|
44
|
+
}
|
|
45
|
+
const results = await (0, analyzer_1.analyzeElements)(scannedElements, cache);
|
|
46
|
+
cache.save();
|
|
38
47
|
// Step 3: Report
|
|
39
48
|
const format = options.format.toLowerCase();
|
|
40
49
|
if (format === 'json' || options.output.endsWith('.json')) {
|
|
@@ -48,17 +57,28 @@ program
|
|
|
48
57
|
await (0, remediator_1.autoFix)(results, options.srcDir);
|
|
49
58
|
}
|
|
50
59
|
// Step 5: Exit with error code if issues found (useful for CI/CD)
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
60
|
+
const cachedCount = results.filter(r => r.cached).length;
|
|
61
|
+
console.log(chalk_1.default.gray(`\n${cachedCount}/${results.length} verdicts came from the cache.`));
|
|
62
|
+
const issueCount = results.filter(analyzer_1.isIssue).length;
|
|
63
|
+
const unanalysedCount = results.filter(analyzer_1.isUnanalysed).length;
|
|
64
|
+
if (unanalysedCount > 0) {
|
|
65
|
+
console.log(chalk_1.default.yellow(`${unanalysedCount} elements could not be analyzed (API errors). Re-run to retry only those.`));
|
|
66
|
+
}
|
|
67
|
+
if (issueCount > 0) {
|
|
68
|
+
console.log(chalk_1.default.red(`\nFound ${issueCount} accessibility issues!`));
|
|
54
69
|
process.exit(1);
|
|
55
70
|
}
|
|
71
|
+
else if (unanalysedCount > 0) {
|
|
72
|
+
// Incomplete audit: not a pass, but distinguishable from real issues
|
|
73
|
+
process.exit(2);
|
|
74
|
+
}
|
|
56
75
|
else {
|
|
57
76
|
console.log(chalk_1.default.green('\nAll checks passed! π'));
|
|
58
77
|
process.exit(0);
|
|
59
78
|
}
|
|
60
79
|
}
|
|
61
80
|
catch (error) {
|
|
81
|
+
cache.save();
|
|
62
82
|
console.error(chalk_1.default.red('Audit failed:'), error);
|
|
63
83
|
process.exit(1);
|
|
64
84
|
}
|
package/dist/remediator.js
CHANGED
|
@@ -8,6 +8,7 @@ const fs_1 = __importDefault(require("fs"));
|
|
|
8
8
|
const glob_1 = require("glob");
|
|
9
9
|
const groq_sdk_1 = __importDefault(require("groq-sdk"));
|
|
10
10
|
const chalk_1 = __importDefault(require("chalk"));
|
|
11
|
+
const analyzer_1 = require("./analyzer");
|
|
11
12
|
const dotenv_1 = __importDefault(require("dotenv"));
|
|
12
13
|
dotenv_1.default.config();
|
|
13
14
|
const apiKey = process.env.GROQ_API_KEY || '';
|
|
@@ -115,7 +116,8 @@ DO NOT wrap the response in markdown code blocks. OUTPUT ONLY THE RAW CODE.
|
|
|
115
116
|
content: prompt
|
|
116
117
|
}
|
|
117
118
|
],
|
|
118
|
-
model:
|
|
119
|
+
model: analyzer_1.MODEL,
|
|
120
|
+
...analyzer_1.DETERMINISTIC_PARAMS,
|
|
119
121
|
});
|
|
120
122
|
let newContent = completion.choices[0]?.message?.content || '';
|
|
121
123
|
// Just in case it wraps in markdown despite instructions
|
package/dist/reporter.js
CHANGED
|
@@ -7,6 +7,7 @@ exports.generateJsonReport = generateJsonReport;
|
|
|
7
7
|
exports.generateMarkdownReport = generateMarkdownReport;
|
|
8
8
|
const fs_1 = __importDefault(require("fs"));
|
|
9
9
|
const path_1 = __importDefault(require("path"));
|
|
10
|
+
const analyzer_1 = require("./analyzer");
|
|
10
11
|
function generateJsonReport(results, outputPath) {
|
|
11
12
|
const outputDir = path_1.default.dirname(outputPath);
|
|
12
13
|
if (!fs_1.default.existsSync(outputDir)) {
|
|
@@ -20,29 +21,39 @@ function generateMarkdownReport(results, outputPath) {
|
|
|
20
21
|
if (!fs_1.default.existsSync(outputDir)) {
|
|
21
22
|
fs_1.default.mkdirSync(outputDir, { recursive: true });
|
|
22
23
|
}
|
|
23
|
-
const failedElements = results.filter(
|
|
24
|
+
const failedElements = results.filter(analyzer_1.isIssue);
|
|
25
|
+
const unanalysedElements = results.filter(analyzer_1.isUnanalysed);
|
|
26
|
+
const cachedCount = results.filter(r => r.cached).length;
|
|
24
27
|
let md = `# Accessibility Audit Report\n\n`;
|
|
25
28
|
md += `**Total Elements Scanned:** ${results.length}\n`;
|
|
26
|
-
md += `**Accessibility Issues Found:** ${failedElements.length}\n
|
|
29
|
+
md += `**Accessibility Issues Found:** ${failedElements.length}\n`;
|
|
30
|
+
if (unanalysedElements.length > 0) {
|
|
31
|
+
md += `**Elements Not Analyzed (API errors):** ${unanalysedElements.length}\n`;
|
|
32
|
+
}
|
|
33
|
+
md += `**Verdicts Reused From Cache:** ${cachedCount}\n\n`;
|
|
27
34
|
if (failedElements.length === 0) {
|
|
28
|
-
md +=
|
|
35
|
+
md += unanalysedElements.length === 0
|
|
36
|
+
? `π Congratulations! No accessibility issues were found.\n`
|
|
37
|
+
: `No accessibility issues were found in the analyzed elements, but the audit is incomplete.\n`;
|
|
29
38
|
}
|
|
30
39
|
else {
|
|
31
40
|
md += `## Issues Detected\n\n`;
|
|
32
41
|
failedElements.forEach((el, i) => {
|
|
33
42
|
md += `### ${i + 1}. <${el.tagName}>: ${el.issueTitle || 'Issue'}\n\n`;
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
md += `**Explanation:** ${el.explanation}\n\n`;
|
|
39
|
-
md += `**Original Code:**\n\`\`\`html\n${el.elementHtml}\n\`\`\`\n\n`;
|
|
40
|
-
md += `**Suggested Fix:**\n\`\`\`html\n${el.suggestedFixCode}\n\`\`\`\n\n`;
|
|
41
|
-
md += `**Why this works:** ${el.fixReasoning}\n\n`;
|
|
42
|
-
}
|
|
43
|
+
md += `**Explanation:** ${el.explanation}\n\n`;
|
|
44
|
+
md += `**Original Code:**\n\`\`\`html\n${el.elementHtml}\n\`\`\`\n\n`;
|
|
45
|
+
md += `**Suggested Fix:**\n\`\`\`html\n${el.suggestedFixCode}\n\`\`\`\n\n`;
|
|
46
|
+
md += `**Why this works:** ${el.fixReasoning}\n\n`;
|
|
43
47
|
md += `---\n\n`;
|
|
44
48
|
});
|
|
45
49
|
}
|
|
50
|
+
if (unanalysedElements.length > 0) {
|
|
51
|
+
md += `\n## Not Analyzed\n\n`;
|
|
52
|
+
md += `These elements could not be analyzed because of API errors. They are not cached, so re-running the audit retries only these.\n\n`;
|
|
53
|
+
unanalysedElements.forEach(el => {
|
|
54
|
+
md += `- \`<${el.tagName}>\`: ${el.error}\n`;
|
|
55
|
+
});
|
|
56
|
+
}
|
|
46
57
|
fs_1.default.writeFileSync(outputPath, md);
|
|
47
58
|
console.log(`Markdown report generated at ${outputPath}`);
|
|
48
59
|
}
|
package/package.json
CHANGED
package/dist/src/analyzer.js
DELETED
|
@@ -1,119 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
-
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
-
};
|
|
5
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
-
exports.analyzeElement = analyzeElement;
|
|
7
|
-
exports.analyzeElements = analyzeElements;
|
|
8
|
-
const groq_sdk_1 = __importDefault(require("groq-sdk"));
|
|
9
|
-
const dotenv_1 = __importDefault(require("dotenv"));
|
|
10
|
-
dotenv_1.default.config();
|
|
11
|
-
const apiKey = process.env.GROQ_API_KEY || '';
|
|
12
|
-
const groq = new groq_sdk_1.default({ apiKey });
|
|
13
|
-
const delay = (ms) => new Promise(res => setTimeout(res, ms));
|
|
14
|
-
async function analyzeElement(element) {
|
|
15
|
-
const prompt = `
|
|
16
|
-
You are an expert accessibility engineer. Your task is to analyze an HTML/JSX element within its parent context to determine if it meets WCAG accessibility standards.
|
|
17
|
-
You must output ONLY valid JSON without any markdown code blocks or conversational text.
|
|
18
|
-
|
|
19
|
-
Context:
|
|
20
|
-
Parent Code:
|
|
21
|
-
\`\`\`
|
|
22
|
-
${element.parentHtml}
|
|
23
|
-
\`\`\`
|
|
24
|
-
|
|
25
|
-
Target Element Code:
|
|
26
|
-
\`\`\`
|
|
27
|
-
${element.elementHtml}
|
|
28
|
-
\`\`\`
|
|
29
|
-
|
|
30
|
-
Analyze the target element. Does it have sufficient context for screen readers? Does it use semantic HTML properly? Is it missing ARIA attributes where necessary?
|
|
31
|
-
|
|
32
|
-
Be extremely accurate in your code fix. Ensure the replacement code contains all necessary structural semantics, aria-labels, alt text, and valid roles based on the parent context.
|
|
33
|
-
CRITICAL INSTRUCTIONS FOR FIX:
|
|
34
|
-
1. Your suggested fix MUST COMPLETELY resolve the accessibility issue. If the fixed element were analyzed again, it MUST pass all WCAG checks.
|
|
35
|
-
2. Provide the COMPLETE Target Element in your fix, including its opening tag, all original children, and its closing tag. Do NOT provide partial snippets.
|
|
36
|
-
3. The replacement code MUST match the exact framework syntax of the input. If the input uses React JSX syntax (like \`className\`, camelCase attributes, or \`style={{}}\`), the output MUST be valid JSX. If standard HTML, output standard HTML.
|
|
37
|
-
4. Maintain all existing non-accessibility attributes (e.g., \`id\`, \`class\`, \`onClick\`, \`href\`, etc.) exactly as they appear in the original Target Element.
|
|
38
|
-
|
|
39
|
-
Return a JSON object with this exact structure:
|
|
40
|
-
{
|
|
41
|
-
"isAccessible": boolean,
|
|
42
|
-
"issueTitle": string (or null if isAccessible is true. A concise title of the WCAG violation),
|
|
43
|
-
"explanation": string (or null. Explain the issue concisely to a developer),
|
|
44
|
-
"suggestedFixCode": string (or null. Provide the highly accurate, complete replacement code for the Target Element that fixes the issue, matching the input's syntax),
|
|
45
|
-
"fixReasoning": string (or null. Briefly explain exactly what the suggested fix code does and how it solves the accessibility issue)
|
|
46
|
-
}
|
|
47
|
-
`;
|
|
48
|
-
let retries = 3;
|
|
49
|
-
let delayMs = 2000;
|
|
50
|
-
while (retries > 0) {
|
|
51
|
-
try {
|
|
52
|
-
const completion = await groq.chat.completions.create({
|
|
53
|
-
messages: [
|
|
54
|
-
{
|
|
55
|
-
role: 'system',
|
|
56
|
-
content: 'You are an AI that only outputs valid JSON. Do not output anything else.'
|
|
57
|
-
},
|
|
58
|
-
{
|
|
59
|
-
role: 'user',
|
|
60
|
-
content: prompt
|
|
61
|
-
}
|
|
62
|
-
],
|
|
63
|
-
model: 'llama-3.3-70b-versatile',
|
|
64
|
-
response_format: { type: 'json_object' }
|
|
65
|
-
});
|
|
66
|
-
const text = completion.choices[0]?.message?.content;
|
|
67
|
-
if (text) {
|
|
68
|
-
const parsed = JSON.parse(text);
|
|
69
|
-
return {
|
|
70
|
-
...element,
|
|
71
|
-
...parsed
|
|
72
|
-
};
|
|
73
|
-
}
|
|
74
|
-
throw new Error("No text in response");
|
|
75
|
-
}
|
|
76
|
-
catch (error) {
|
|
77
|
-
if (error.status === 503 || error.status === 429 || error.message?.includes('503') || error.message?.includes('429')) {
|
|
78
|
-
console.warn(`[WARN] Rate limited. Retries left: ${retries - 1}. Retrying in ${delayMs}ms...`);
|
|
79
|
-
retries--;
|
|
80
|
-
if (retries === 0) {
|
|
81
|
-
return {
|
|
82
|
-
...element,
|
|
83
|
-
isAccessible: false,
|
|
84
|
-
error: "Failed to analyze due to API rate limits."
|
|
85
|
-
};
|
|
86
|
-
}
|
|
87
|
-
await delay(delayMs);
|
|
88
|
-
delayMs *= 2;
|
|
89
|
-
}
|
|
90
|
-
else {
|
|
91
|
-
return {
|
|
92
|
-
...element,
|
|
93
|
-
isAccessible: false,
|
|
94
|
-
error: error.message || "Unknown API error"
|
|
95
|
-
};
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
return {
|
|
100
|
-
...element,
|
|
101
|
-
isAccessible: false,
|
|
102
|
-
error: "Exhausted retries"
|
|
103
|
-
};
|
|
104
|
-
}
|
|
105
|
-
async function analyzeElements(elements) {
|
|
106
|
-
const results = [];
|
|
107
|
-
console.log(`Analyzing ${elements.length} elements using Groq API...`);
|
|
108
|
-
for (let i = 0; i < elements.length; i++) {
|
|
109
|
-
// Log progress
|
|
110
|
-
console.log(`Analyzing element ${i + 1}/${elements.length}: <${elements[i].tagName}>...`);
|
|
111
|
-
const result = await analyzeElement(elements[i]);
|
|
112
|
-
results.push(result);
|
|
113
|
-
// Wait briefly between requests to avoid rate limits
|
|
114
|
-
if (i < elements.length - 1) {
|
|
115
|
-
await delay(1000);
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
return results;
|
|
119
|
-
}
|
package/dist/src/index.js
DELETED
|
@@ -1,59 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
"use strict";
|
|
3
|
-
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
4
|
-
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
5
|
-
};
|
|
6
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
|
-
const commander_1 = require("commander");
|
|
8
|
-
const chalk_1 = __importDefault(require("chalk"));
|
|
9
|
-
const scanner_1 = require("./scanner");
|
|
10
|
-
const analyzer_1 = require("./analyzer");
|
|
11
|
-
const reporter_1 = require("./reporter");
|
|
12
|
-
const program = new commander_1.Command();
|
|
13
|
-
program
|
|
14
|
-
.name('a11y-audit')
|
|
15
|
-
.description('Context-Aware Accessibility Linter CLI')
|
|
16
|
-
.version('1.0.0')
|
|
17
|
-
.requiredOption('-u, --url <url>', 'URL to scan (e.g., http://localhost:3000)', 'http://localhost:3000')
|
|
18
|
-
.option('-o, --output <path>', 'Output file path', './caal-report.md')
|
|
19
|
-
.option('-f, --format <format>', 'Output format (json or md)', 'md')
|
|
20
|
-
.action(async (options) => {
|
|
21
|
-
try {
|
|
22
|
-
console.log(chalk_1.default.blue(`Starting accessibility audit for: ${options.url}`));
|
|
23
|
-
// Step 1: Scan page and extract elements
|
|
24
|
-
const scannedElements = await (0, scanner_1.scanPage)(options.url);
|
|
25
|
-
if (scannedElements.length === 0) {
|
|
26
|
-
console.log(chalk_1.default.yellow('No relevant elements found to analyze.'));
|
|
27
|
-
return;
|
|
28
|
-
}
|
|
29
|
-
// Step 2: Analyze with LLM
|
|
30
|
-
if (!process.env.GROQ_API_KEY) {
|
|
31
|
-
console.error(chalk_1.default.red('Error: GROQ_API_KEY environment variable is not set.'));
|
|
32
|
-
process.exit(1);
|
|
33
|
-
}
|
|
34
|
-
const results = await (0, analyzer_1.analyzeElements)(scannedElements);
|
|
35
|
-
// Step 3: Report
|
|
36
|
-
const format = options.format.toLowerCase();
|
|
37
|
-
if (format === 'json' || options.output.endsWith('.json')) {
|
|
38
|
-
(0, reporter_1.generateJsonReport)(results, options.output);
|
|
39
|
-
}
|
|
40
|
-
else {
|
|
41
|
-
(0, reporter_1.generateMarkdownReport)(results, options.output);
|
|
42
|
-
}
|
|
43
|
-
// Step 4: Exit with error code if issues found (useful for CI/CD)
|
|
44
|
-
const failedElements = results.filter(r => !r.isAccessible);
|
|
45
|
-
if (failedElements.length > 0) {
|
|
46
|
-
console.log(chalk_1.default.red(`\nFound ${failedElements.length} accessibility issues!`));
|
|
47
|
-
process.exit(1);
|
|
48
|
-
}
|
|
49
|
-
else {
|
|
50
|
-
console.log(chalk_1.default.green('\nAll checks passed! π'));
|
|
51
|
-
process.exit(0);
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
catch (error) {
|
|
55
|
-
console.error(chalk_1.default.red('Audit failed:'), error);
|
|
56
|
-
process.exit(1);
|
|
57
|
-
}
|
|
58
|
-
});
|
|
59
|
-
program.parse(process.argv);
|
package/dist/src/reporter.js
DELETED
|
@@ -1,48 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
-
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
-
};
|
|
5
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
-
exports.generateJsonReport = generateJsonReport;
|
|
7
|
-
exports.generateMarkdownReport = generateMarkdownReport;
|
|
8
|
-
const fs_1 = __importDefault(require("fs"));
|
|
9
|
-
const path_1 = __importDefault(require("path"));
|
|
10
|
-
function generateJsonReport(results, outputPath) {
|
|
11
|
-
const outputDir = path_1.default.dirname(outputPath);
|
|
12
|
-
if (!fs_1.default.existsSync(outputDir)) {
|
|
13
|
-
fs_1.default.mkdirSync(outputDir, { recursive: true });
|
|
14
|
-
}
|
|
15
|
-
fs_1.default.writeFileSync(outputPath, JSON.stringify(results, null, 2));
|
|
16
|
-
console.log(`JSON report generated at ${outputPath}`);
|
|
17
|
-
}
|
|
18
|
-
function generateMarkdownReport(results, outputPath) {
|
|
19
|
-
const outputDir = path_1.default.dirname(outputPath);
|
|
20
|
-
if (!fs_1.default.existsSync(outputDir)) {
|
|
21
|
-
fs_1.default.mkdirSync(outputDir, { recursive: true });
|
|
22
|
-
}
|
|
23
|
-
const failedElements = results.filter(r => !r.isAccessible);
|
|
24
|
-
let md = `# Accessibility Audit Report\n\n`;
|
|
25
|
-
md += `**Total Elements Scanned:** ${results.length}\n`;
|
|
26
|
-
md += `**Accessibility Issues Found:** ${failedElements.length}\n\n`;
|
|
27
|
-
if (failedElements.length === 0) {
|
|
28
|
-
md += `π Congratulations! No accessibility issues were found.\n`;
|
|
29
|
-
}
|
|
30
|
-
else {
|
|
31
|
-
md += `## Issues Detected\n\n`;
|
|
32
|
-
failedElements.forEach((el, i) => {
|
|
33
|
-
md += `### ${i + 1}. <${el.tagName}>: ${el.issueTitle || 'Issue'}\n\n`;
|
|
34
|
-
if (el.error) {
|
|
35
|
-
md += `**Error during analysis:** ${el.error}\n\n`;
|
|
36
|
-
}
|
|
37
|
-
else {
|
|
38
|
-
md += `**Explanation:** ${el.explanation}\n\n`;
|
|
39
|
-
md += `**Original Code:**\n\`\`\`html\n${el.elementHtml}\n\`\`\`\n\n`;
|
|
40
|
-
md += `**Suggested Fix:**\n\`\`\`html\n${el.suggestedFixCode}\n\`\`\`\n\n`;
|
|
41
|
-
md += `**Why this works:** ${el.fixReasoning}\n\n`;
|
|
42
|
-
}
|
|
43
|
-
md += `---\n\n`;
|
|
44
|
-
});
|
|
45
|
-
}
|
|
46
|
-
fs_1.default.writeFileSync(outputPath, md);
|
|
47
|
-
console.log(`Markdown report generated at ${outputPath}`);
|
|
48
|
-
}
|
package/dist/src/scanner.js
DELETED
|
@@ -1,54 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.scanPage = scanPage;
|
|
4
|
-
const playwright_1 = require("playwright");
|
|
5
|
-
async function scanPage(url) {
|
|
6
|
-
console.log(`\nNavigating to ${url}...`);
|
|
7
|
-
// Launch headless chromium
|
|
8
|
-
const browser = await playwright_1.chromium.launch({ headless: true });
|
|
9
|
-
const page = await browser.newPage();
|
|
10
|
-
try {
|
|
11
|
-
await page.goto(url, { waitUntil: 'networkidle', timeout: 30000 });
|
|
12
|
-
console.log('Page loaded. Extracting elements...');
|
|
13
|
-
// Wait a tiny bit extra for framework rendering if needed
|
|
14
|
-
await page.waitForTimeout(2000);
|
|
15
|
-
// Evaluate extraction logic in the page context
|
|
16
|
-
const extractedElements = await page.evaluate(() => {
|
|
17
|
-
function extractContext(element) {
|
|
18
|
-
const parent = element.parentElement;
|
|
19
|
-
let parentHtml = '';
|
|
20
|
-
if (parent) {
|
|
21
|
-
const clone = parent.cloneNode(true);
|
|
22
|
-
clone.querySelectorAll('script, style').forEach(el => el.remove());
|
|
23
|
-
parentHtml = clone.outerHTML;
|
|
24
|
-
if (parentHtml.length > 15000) {
|
|
25
|
-
parentHtml = parentHtml.substring(0, 15000) + '\\n... [TRUNCATED]';
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
return {
|
|
29
|
-
elementHtml: element.outerHTML,
|
|
30
|
-
parentHtml
|
|
31
|
-
};
|
|
32
|
-
}
|
|
33
|
-
const elements = Array.from(document.querySelectorAll('button, img, input, a, [role="button"], [role="link"], [role="img"]'));
|
|
34
|
-
return elements.map((el, index) => {
|
|
35
|
-
const context = extractContext(el);
|
|
36
|
-
return {
|
|
37
|
-
id: index.toString(),
|
|
38
|
-
tagName: el.tagName.toLowerCase(),
|
|
39
|
-
elementHtml: context.elementHtml,
|
|
40
|
-
parentHtml: context.parentHtml
|
|
41
|
-
};
|
|
42
|
-
});
|
|
43
|
-
});
|
|
44
|
-
console.log(`Found ${extractedElements.length} elements to analyze.`);
|
|
45
|
-
return extractedElements;
|
|
46
|
-
}
|
|
47
|
-
catch (error) {
|
|
48
|
-
console.error(`Error scanning page: ${error}`);
|
|
49
|
-
throw error;
|
|
50
|
-
}
|
|
51
|
-
finally {
|
|
52
|
-
await browser.close();
|
|
53
|
-
}
|
|
54
|
-
}
|