@diegovelasquezweb/a11y-engine 0.11.14 → 0.11.16
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/package.json +1 -1
- package/src/fixes/apply-finding-fix.mjs +436 -0
- package/src/index.d.mts +42 -0
- package/src/index.mjs +1 -0
- package/src/reports/renderers/html.mjs +0 -6
package/package.json
CHANGED
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { ASSETS } from "../core/asset-loader.mjs";
|
|
4
|
+
|
|
5
|
+
const ANTHROPIC_API = "https://api.anthropic.com/v1/messages";
|
|
6
|
+
const DEFAULT_MODEL = "claude-haiku-4-5-20251001";
|
|
7
|
+
const MAX_CANDIDATE_FILES = 12;
|
|
8
|
+
const SUPPORTED_EXTENSIONS = new Set([".html", ".htm", ".jsx", ".tsx", ".vue", ".astro", ".liquid"]);
|
|
9
|
+
|
|
10
|
+
export const FIX_ERROR_CODES = {
|
|
11
|
+
INVALID_INPUT: "invalid-input",
|
|
12
|
+
FINDING_NOT_FOUND: "finding-not-found",
|
|
13
|
+
RULE_MISSING: "rule-missing",
|
|
14
|
+
FILE_NOT_RESOLVED: "file-not-resolved",
|
|
15
|
+
PATCH_GENERATION_FAILED: "patch-generation-failed",
|
|
16
|
+
PATCH_APPLY_FAILED: "patch-apply-failed",
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
function isObject(value) {
|
|
20
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function isWithin(root, target) {
|
|
24
|
+
const rel = path.relative(root, target);
|
|
25
|
+
return rel && !rel.startsWith("..") && !path.isAbsolute(rel);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function normalizeRoute(value) {
|
|
29
|
+
if (typeof value !== "string") return "/";
|
|
30
|
+
const route = value.trim();
|
|
31
|
+
return route || "/";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function slugify(value) {
|
|
35
|
+
return String(value || "fix")
|
|
36
|
+
.toLowerCase()
|
|
37
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
38
|
+
.replace(/^-+|-+$/g, "")
|
|
39
|
+
.slice(0, 60);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function mapStatus(applied, reason) {
|
|
43
|
+
if (applied) return "patched";
|
|
44
|
+
if (reason === FIX_ERROR_CODES.INVALID_INPUT || reason === FIX_ERROR_CODES.FILE_NOT_RESOLVED) return "error";
|
|
45
|
+
return "not_applied";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function buildResult(data = {}) {
|
|
49
|
+
const applied = Boolean(data.applied);
|
|
50
|
+
const reason = data.reason || "";
|
|
51
|
+
const changedFiles = Array.isArray(data.changedFiles) ? data.changedFiles : [];
|
|
52
|
+
const verifyRule = data.verifyRule || "";
|
|
53
|
+
const verifyRoute = data.verifyRoute || "/";
|
|
54
|
+
const findingTitle = data.findingTitle || "";
|
|
55
|
+
const branchSlug = data.branchSlug || "a11y-fix";
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
applied,
|
|
59
|
+
reason,
|
|
60
|
+
message: data.message || "",
|
|
61
|
+
changedFiles,
|
|
62
|
+
patch: data.patch || "",
|
|
63
|
+
verifyRule,
|
|
64
|
+
verifyRoute,
|
|
65
|
+
findingTitle,
|
|
66
|
+
branchSlug,
|
|
67
|
+
|
|
68
|
+
status: mapStatus(applied, reason),
|
|
69
|
+
patchedFile: changedFiles[0] || "",
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function getFindingsPayload(input) {
|
|
74
|
+
if (!isObject(input)) return null;
|
|
75
|
+
if (isObject(input.findingsPayload)) return input.findingsPayload;
|
|
76
|
+
if (isObject(input.payload)) return input.payload;
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function getFindings(input) {
|
|
81
|
+
const payload = getFindingsPayload(input);
|
|
82
|
+
if (!isObject(payload) || !Array.isArray(payload.findings)) return null;
|
|
83
|
+
return payload.findings;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function getIntelligenceForRule(ruleId) {
|
|
87
|
+
const rules = ASSETS.remediation.intelligence?.rules || {};
|
|
88
|
+
return isObject(rules[ruleId]) ? rules[ruleId] : {};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function listFilesRecursive(dir) {
|
|
92
|
+
const out = [];
|
|
93
|
+
const stack = [dir];
|
|
94
|
+
while (stack.length > 0) {
|
|
95
|
+
const current = stack.pop();
|
|
96
|
+
const entries = fs.readdirSync(current, { withFileTypes: true });
|
|
97
|
+
for (const entry of entries) {
|
|
98
|
+
const abs = path.join(current, entry.name);
|
|
99
|
+
if (entry.isDirectory()) {
|
|
100
|
+
if (entry.name === ".git" || entry.name === "node_modules" || entry.name === ".next") continue;
|
|
101
|
+
stack.push(abs);
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
const ext = path.extname(entry.name).toLowerCase();
|
|
105
|
+
if (SUPPORTED_EXTENSIONS.has(ext)) out.push(abs);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function selectorTokens(selector) {
|
|
112
|
+
return String(selector || "")
|
|
113
|
+
.replace(/[:#\.\[\]>+~(),=*"']/g, " ")
|
|
114
|
+
.split(/\s+/)
|
|
115
|
+
.map((t) => t.trim().toLowerCase())
|
|
116
|
+
.filter((t) => t && t.length > 1 && !/^nth-/.test(t))
|
|
117
|
+
.slice(0, 8);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function scoreFile(filePath, content, tokens) {
|
|
121
|
+
if (tokens.length === 0) return 1;
|
|
122
|
+
const lcPath = filePath.toLowerCase();
|
|
123
|
+
const lcContent = content.toLowerCase();
|
|
124
|
+
let score = 0;
|
|
125
|
+
for (const token of tokens) {
|
|
126
|
+
if (lcPath.includes(token)) score += 2;
|
|
127
|
+
if (lcContent.includes(token)) score += 3;
|
|
128
|
+
}
|
|
129
|
+
return score;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function getCandidateFiles(projectDir, finding) {
|
|
133
|
+
const files = listFilesRecursive(projectDir);
|
|
134
|
+
const tokens = selectorTokens(finding.selector);
|
|
135
|
+
const ranked = files
|
|
136
|
+
.map((abs) => {
|
|
137
|
+
const content = fs.readFileSync(abs, "utf8");
|
|
138
|
+
const rel = path.relative(projectDir, abs);
|
|
139
|
+
return { abs, rel, content, score: scoreFile(rel, content, tokens) };
|
|
140
|
+
})
|
|
141
|
+
.filter((item) => item.score > 0)
|
|
142
|
+
.sort((a, b) => b.score - a.score)
|
|
143
|
+
.slice(0, MAX_CANDIDATE_FILES);
|
|
144
|
+
return ranked;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function buildExecution(ruleId, intelligenceRule, finding) {
|
|
148
|
+
const ruleVerify = finding.rule_id || ruleId || "";
|
|
149
|
+
const route = normalizeRoute(finding.area);
|
|
150
|
+
return {
|
|
151
|
+
strategy: "ai-dom-patch",
|
|
152
|
+
operations: ["text-replace"],
|
|
153
|
+
constraints: {
|
|
154
|
+
must: intelligenceRule.guardrails_overrides?.must || intelligenceRule.guardrails?.must || [],
|
|
155
|
+
must_not:
|
|
156
|
+
intelligenceRule.guardrails_overrides?.must_not || intelligenceRule.guardrails?.must_not || [],
|
|
157
|
+
verify: intelligenceRule.guardrails_overrides?.verify || intelligenceRule.guardrails?.verify || [],
|
|
158
|
+
},
|
|
159
|
+
verify: {
|
|
160
|
+
ruleId: ruleVerify,
|
|
161
|
+
route,
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function buildAiFixInput({ finding, intelligenceRule, execution, candidates }) {
|
|
167
|
+
return {
|
|
168
|
+
finding: {
|
|
169
|
+
id: finding.id,
|
|
170
|
+
ruleId: finding.rule_id,
|
|
171
|
+
title: finding.title,
|
|
172
|
+
severity: finding.severity,
|
|
173
|
+
selector: finding.selector,
|
|
174
|
+
actual: finding.actual,
|
|
175
|
+
expected: finding.expected,
|
|
176
|
+
area: finding.area,
|
|
177
|
+
url: finding.url,
|
|
178
|
+
fixDescription: finding.fix_description || intelligenceRule.fix?.description || "",
|
|
179
|
+
fixCode: finding.fix_code || intelligenceRule.fix?.code || "",
|
|
180
|
+
},
|
|
181
|
+
intelligence: {
|
|
182
|
+
category: intelligenceRule.category || "",
|
|
183
|
+
frameworkNotes: intelligenceRule.framework_notes || {},
|
|
184
|
+
cmsNotes: intelligenceRule.cms_notes || {},
|
|
185
|
+
fixDifficultyNotes: intelligenceRule.fix_difficulty_notes || "",
|
|
186
|
+
},
|
|
187
|
+
execution,
|
|
188
|
+
files: candidates.map((c) => ({ filePath: c.rel, content: c.content.slice(0, 12000) })),
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function parseJsonBlock(text) {
|
|
193
|
+
const raw = String(text || "").trim();
|
|
194
|
+
if (!raw) return null;
|
|
195
|
+
const codeFence = raw.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
|
196
|
+
const source = codeFence ? codeFence[1] : raw;
|
|
197
|
+
try {
|
|
198
|
+
return JSON.parse(source);
|
|
199
|
+
} catch {
|
|
200
|
+
const objMatch = source.match(/\{[\s\S]*\}/);
|
|
201
|
+
if (!objMatch) return null;
|
|
202
|
+
try {
|
|
203
|
+
return JSON.parse(objMatch[0]);
|
|
204
|
+
} catch {
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function callClaudeForPatch({ apiKey, model, aiInput }) {
|
|
211
|
+
const system = [
|
|
212
|
+
"You are an accessibility fix engine.",
|
|
213
|
+
"Return JSON only.",
|
|
214
|
+
"Generate deterministic text replacements for provided files.",
|
|
215
|
+
"Do not create files. Do not modify paths outside provided filePath values.",
|
|
216
|
+
"Schema:",
|
|
217
|
+
"{\"changes\":[{\"filePath\":\"...\",\"search\":\"...\",\"replace\":\"...\"}],\"verifyRule\":\"...\",\"verifyRoute\":\"...\",\"notes\":\"...\"}",
|
|
218
|
+
].join("\n");
|
|
219
|
+
|
|
220
|
+
const userMessage = JSON.stringify(aiInput, null, 2);
|
|
221
|
+
|
|
222
|
+
const res = await fetch(ANTHROPIC_API, {
|
|
223
|
+
method: "POST",
|
|
224
|
+
headers: {
|
|
225
|
+
"Content-Type": "application/json",
|
|
226
|
+
"x-api-key": apiKey,
|
|
227
|
+
"anthropic-version": "2023-06-01",
|
|
228
|
+
},
|
|
229
|
+
body: JSON.stringify({
|
|
230
|
+
model,
|
|
231
|
+
max_tokens: 4096,
|
|
232
|
+
system,
|
|
233
|
+
messages: [{ role: "user", content: userMessage }],
|
|
234
|
+
}),
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
if (!res.ok) {
|
|
238
|
+
const message = await res.text();
|
|
239
|
+
throw new Error(`Claude patch generation failed: ${res.status} ${message}`);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const data = await res.json();
|
|
243
|
+
const content = data.content?.[0]?.text || "";
|
|
244
|
+
const parsed = parseJsonBlock(content);
|
|
245
|
+
if (!isObject(parsed)) throw new Error("AI patch output is not valid JSON object");
|
|
246
|
+
return parsed;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function validateAiPatchOutput(output, projectDir, fileSet) {
|
|
250
|
+
if (!isObject(output)) return { ok: false, reason: "AI patch output is empty" };
|
|
251
|
+
if (!Array.isArray(output.changes) || output.changes.length === 0) {
|
|
252
|
+
return { ok: false, reason: "AI patch output has no changes" };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
for (const change of output.changes) {
|
|
256
|
+
if (!isObject(change)) return { ok: false, reason: "Invalid change item" };
|
|
257
|
+
const filePath = typeof change.filePath === "string" ? change.filePath.trim() : "";
|
|
258
|
+
const search = typeof change.search === "string" ? change.search : "";
|
|
259
|
+
const replace = typeof change.replace === "string" ? change.replace : "";
|
|
260
|
+
if (!filePath || !search) return { ok: false, reason: "Change is missing filePath/search" };
|
|
261
|
+
if (!fileSet.has(filePath)) return { ok: false, reason: `Change file not in candidate set: ${filePath}` };
|
|
262
|
+
|
|
263
|
+
const abs = path.resolve(projectDir, filePath);
|
|
264
|
+
if (!isWithin(projectDir, abs) && abs !== path.resolve(projectDir, filePath)) {
|
|
265
|
+
return { ok: false, reason: `Change path escapes projectDir: ${filePath}` };
|
|
266
|
+
}
|
|
267
|
+
if (replace.length > 20000) return { ok: false, reason: `Replacement too large for ${filePath}` };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return { ok: true };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function applyChanges(projectDir, changes) {
|
|
274
|
+
const changedFiles = [];
|
|
275
|
+
const patchParts = [];
|
|
276
|
+
|
|
277
|
+
for (const change of changes) {
|
|
278
|
+
const rel = change.filePath;
|
|
279
|
+
const abs = path.resolve(projectDir, rel);
|
|
280
|
+
const original = fs.readFileSync(abs, "utf8");
|
|
281
|
+
if (!original.includes(change.search)) {
|
|
282
|
+
return { ok: false, reason: `Search block not found in ${rel}` };
|
|
283
|
+
}
|
|
284
|
+
const updated = original.replace(change.search, change.replace);
|
|
285
|
+
if (updated === original) continue;
|
|
286
|
+
fs.writeFileSync(abs, updated, "utf8");
|
|
287
|
+
changedFiles.push(rel);
|
|
288
|
+
patchParts.push(`--- ${rel}\n+++ ${rel}\n@@\n-${change.search}\n+${change.replace}`);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (changedFiles.length === 0) return { ok: false, reason: "No effective changes were applied" };
|
|
292
|
+
|
|
293
|
+
return {
|
|
294
|
+
ok: true,
|
|
295
|
+
changedFiles: [...new Set(changedFiles)],
|
|
296
|
+
patch: patchParts.join("\n"),
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* @param {{
|
|
302
|
+
* findingId: string,
|
|
303
|
+
* findingsPayload?: { findings: Array<Record<string, unknown>> },
|
|
304
|
+
* payload?: { findings: Array<Record<string, unknown>> },
|
|
305
|
+
* projectDir: string,
|
|
306
|
+
* ai?: { apiKey?: string, model?: string },
|
|
307
|
+
* }} input
|
|
308
|
+
*/
|
|
309
|
+
export async function applyFindingFix(input) {
|
|
310
|
+
if (!isObject(input)) {
|
|
311
|
+
return buildResult({
|
|
312
|
+
applied: false,
|
|
313
|
+
reason: FIX_ERROR_CODES.INVALID_INPUT,
|
|
314
|
+
message: "Input must be an object.",
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const findingId = typeof input.findingId === "string" ? input.findingId.trim() : "";
|
|
319
|
+
const projectDir = typeof input.projectDir === "string" ? input.projectDir.trim() : "";
|
|
320
|
+
const findings = getFindings(input);
|
|
321
|
+
|
|
322
|
+
if (!findingId || !projectDir || !findings) {
|
|
323
|
+
return buildResult({
|
|
324
|
+
applied: false,
|
|
325
|
+
reason: FIX_ERROR_CODES.INVALID_INPUT,
|
|
326
|
+
message: "Required input is missing: findingId, findingsPayload.findings, or projectDir.",
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (!fs.existsSync(projectDir) || !fs.statSync(projectDir).isDirectory()) {
|
|
331
|
+
return buildResult({
|
|
332
|
+
applied: false,
|
|
333
|
+
reason: FIX_ERROR_CODES.FILE_NOT_RESOLVED,
|
|
334
|
+
message: `Project directory does not exist: ${projectDir}`,
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const finding = findings.find((entry) => isObject(entry) && entry.id === findingId);
|
|
339
|
+
if (!finding) {
|
|
340
|
+
return buildResult({
|
|
341
|
+
applied: false,
|
|
342
|
+
reason: FIX_ERROR_CODES.FINDING_NOT_FOUND,
|
|
343
|
+
message: `Finding ${findingId} was not found in findingsPayload.findings.`,
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const ruleId = typeof finding.rule_id === "string" ? finding.rule_id.trim() : "";
|
|
348
|
+
const verifyRoute = normalizeRoute(finding.area);
|
|
349
|
+
if (!ruleId) {
|
|
350
|
+
return buildResult({
|
|
351
|
+
applied: false,
|
|
352
|
+
reason: FIX_ERROR_CODES.RULE_MISSING,
|
|
353
|
+
message: `Finding ${findingId} does not include a rule_id.`,
|
|
354
|
+
verifyRoute,
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const intelligenceRule = getIntelligenceForRule(ruleId);
|
|
359
|
+
const execution = buildExecution(ruleId, intelligenceRule, finding);
|
|
360
|
+
const candidates = getCandidateFiles(projectDir, finding);
|
|
361
|
+
if (candidates.length === 0) {
|
|
362
|
+
return buildResult({
|
|
363
|
+
applied: false,
|
|
364
|
+
reason: FIX_ERROR_CODES.FILE_NOT_RESOLVED,
|
|
365
|
+
message: "No candidate source files were found for this finding.",
|
|
366
|
+
verifyRule: execution.verify.ruleId,
|
|
367
|
+
verifyRoute: execution.verify.route,
|
|
368
|
+
findingTitle: finding.title || "",
|
|
369
|
+
branchSlug: slugify(`${findingId}-${ruleId}`),
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const aiInput = buildAiFixInput({ finding, intelligenceRule, execution, candidates });
|
|
374
|
+
const candidateSet = new Set(candidates.map((c) => c.rel));
|
|
375
|
+
const apiKey = input.ai?.apiKey || process.env.ANTHROPIC_API_KEY || "";
|
|
376
|
+
const model = input.ai?.model || DEFAULT_MODEL;
|
|
377
|
+
|
|
378
|
+
let patchOutput = null;
|
|
379
|
+
if (apiKey) {
|
|
380
|
+
try {
|
|
381
|
+
patchOutput = await callClaudeForPatch({ apiKey, model, aiInput });
|
|
382
|
+
} catch {
|
|
383
|
+
patchOutput = null;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
if (!patchOutput) {
|
|
388
|
+
return buildResult({
|
|
389
|
+
applied: false,
|
|
390
|
+
reason: FIX_ERROR_CODES.PATCH_GENERATION_FAILED,
|
|
391
|
+
message: `Could not generate patch output for rule ${ruleId}.`,
|
|
392
|
+
verifyRule: execution.verify.ruleId,
|
|
393
|
+
verifyRoute: execution.verify.route,
|
|
394
|
+
findingTitle: finding.title || "",
|
|
395
|
+
branchSlug: slugify(`${findingId}-${ruleId}`),
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const validation = validateAiPatchOutput(patchOutput, projectDir, candidateSet);
|
|
400
|
+
if (!validation.ok) {
|
|
401
|
+
return buildResult({
|
|
402
|
+
applied: false,
|
|
403
|
+
reason: FIX_ERROR_CODES.PATCH_GENERATION_FAILED,
|
|
404
|
+
message: validation.reason,
|
|
405
|
+
verifyRule: execution.verify.ruleId,
|
|
406
|
+
verifyRoute: execution.verify.route,
|
|
407
|
+
findingTitle: finding.title || "",
|
|
408
|
+
branchSlug: slugify(`${findingId}-${ruleId}`),
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const applied = applyChanges(projectDir, patchOutput.changes);
|
|
413
|
+
if (!applied.ok) {
|
|
414
|
+
return buildResult({
|
|
415
|
+
applied: false,
|
|
416
|
+
reason: FIX_ERROR_CODES.PATCH_APPLY_FAILED,
|
|
417
|
+
message: applied.reason,
|
|
418
|
+
verifyRule: execution.verify.ruleId,
|
|
419
|
+
verifyRoute: execution.verify.route,
|
|
420
|
+
findingTitle: finding.title || "",
|
|
421
|
+
branchSlug: slugify(`${findingId}-${ruleId}`),
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
return buildResult({
|
|
426
|
+
applied: true,
|
|
427
|
+
reason: "",
|
|
428
|
+
message: "Patch applied successfully.",
|
|
429
|
+
changedFiles: applied.changedFiles,
|
|
430
|
+
patch: applied.patch,
|
|
431
|
+
verifyRule: patchOutput.verifyRule || execution.verify.ruleId,
|
|
432
|
+
verifyRoute: patchOutput.verifyRoute || execution.verify.route,
|
|
433
|
+
findingTitle: finding.title || "",
|
|
434
|
+
branchSlug: slugify(`${findingId}-${ruleId}`),
|
|
435
|
+
});
|
|
436
|
+
}
|
package/src/index.d.mts
CHANGED
|
@@ -528,3 +528,45 @@ export interface AiOptions {
|
|
|
528
528
|
systemPrompt?: string;
|
|
529
529
|
audience?: "pm" | "dev";
|
|
530
530
|
}
|
|
531
|
+
|
|
532
|
+
export interface FixDomInput {
|
|
533
|
+
findingId: string;
|
|
534
|
+
payload?: {
|
|
535
|
+
findings: Array<Record<string, unknown>>;
|
|
536
|
+
metadata?: Record<string, unknown>;
|
|
537
|
+
};
|
|
538
|
+
findingsPayload?: {
|
|
539
|
+
findings: Array<Record<string, unknown>>;
|
|
540
|
+
metadata?: Record<string, unknown>;
|
|
541
|
+
};
|
|
542
|
+
projectDir: string;
|
|
543
|
+
ai?: {
|
|
544
|
+
apiKey?: string;
|
|
545
|
+
model?: string;
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
export interface FixDomResult {
|
|
550
|
+
applied: boolean;
|
|
551
|
+
reason: string;
|
|
552
|
+
message: string;
|
|
553
|
+
changedFiles: string[];
|
|
554
|
+
patch: string;
|
|
555
|
+
verifyRule: string;
|
|
556
|
+
verifyRoute: string;
|
|
557
|
+
findingTitle?: string;
|
|
558
|
+
branchSlug?: string;
|
|
559
|
+
status?: "patched" | "not_applied" | "error";
|
|
560
|
+
patchedFile?: string;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
export const FIX_ERROR_CODES: {
|
|
564
|
+
INVALID_INPUT: "invalid-input";
|
|
565
|
+
FINDING_NOT_FOUND: "finding-not-found";
|
|
566
|
+
RULE_MISSING: "rule-missing";
|
|
567
|
+
FILE_NOT_RESOLVED: "file-not-resolved";
|
|
568
|
+
PATCH_GENERATION_FAILED: "patch-generation-failed";
|
|
569
|
+
PATCH_APPLY_FAILED: "patch-apply-failed";
|
|
570
|
+
};
|
|
571
|
+
|
|
572
|
+
export function applyFindingFix(input: FixDomInput): Promise<FixDomResult>;
|
package/src/index.mjs
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import { ASSET_PATHS, loadAssetJson } from "./core/asset-loader.mjs";
|
|
8
8
|
export { DEFAULT_AI_SYSTEM_PROMPT, PM_AI_SYSTEM_PROMPT } from "./ai/claude.mjs";
|
|
9
|
+
export { applyFindingFix, FIX_ERROR_CODES } from "./fixes/apply-finding-fix.mjs";
|
|
9
10
|
|
|
10
11
|
// Lazy-loaded asset cache
|
|
11
12
|
|
|
@@ -313,10 +313,6 @@ export function buildManualCheckCard(check) {
|
|
|
313
313
|
.map((s, i) => `<li class="text-[13px] text-slate-600 leading-relaxed"><span class="font-bold text-slate-400 mr-1.5">${i + 1}.</span>${escapeHtml(s)}</li>`)
|
|
314
314
|
.join("");
|
|
315
315
|
|
|
316
|
-
const criterionPill = check.level !== "AT"
|
|
317
|
-
? `<span class="px-2.5 py-1 rounded-full text-[10px] font-mono font-semibold bg-slate-100 text-slate-700 border border-slate-200">${escapeHtml(check.criterion)}</span>`
|
|
318
|
-
: "";
|
|
319
|
-
|
|
320
316
|
const levelPill = check.level === "AT"
|
|
321
317
|
? `<span class="px-2.5 py-1 rounded-full text-[11px] font-bold bg-violet-50 text-violet-700 border border-violet-200">Assistive Technology</span>`
|
|
322
318
|
: `<span class="px-2.5 py-1 rounded-full text-[11px] font-bold bg-indigo-50 text-indigo-700 border border-indigo-100">WCAG ${escapeHtml(check.level)}</span>`;
|
|
@@ -356,8 +352,6 @@ export function buildManualCheckCard(check) {
|
|
|
356
352
|
<div class="flex items-center gap-4">
|
|
357
353
|
<div class="flex-1 min-w-0">
|
|
358
354
|
<div class="flex flex-wrap items-center gap-2 mb-2.5">
|
|
359
|
-
<span class="manual-badge px-2.5 py-1 rounded-full text-[11px] font-bold bg-amber-100 text-amber-800 border border-amber-200 uppercase tracking-wider">Manual</span>
|
|
360
|
-
${criterionPill}
|
|
361
355
|
${levelPill}
|
|
362
356
|
</div>
|
|
363
357
|
<h3 class="text-base font-extrabold text-slate-900 group-hover:text-amber-900 transition-colors">${escapeHtml(check.title)}</h3>
|