@diegovelasquezweb/a11y-engine 0.11.26 → 0.11.28
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 +303 -1
- package/src/index.mjs +1 -1
package/package.json
CHANGED
|
@@ -201,6 +201,75 @@ function buildExecution(ruleId, intelligenceRule, finding) {
|
|
|
201
201
|
};
|
|
202
202
|
}
|
|
203
203
|
|
|
204
|
+
function groupFindingsByFile(domFindings, projectDir) {
|
|
205
|
+
const allFiles = listFilesRecursive(projectDir).map((abs) => {
|
|
206
|
+
const rel = path.relative(projectDir, abs);
|
|
207
|
+
const content = fs.readFileSync(abs, "utf8");
|
|
208
|
+
return { abs, rel, content };
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
const initialGroups = new Map();
|
|
212
|
+
|
|
213
|
+
for (const finding of domFindings) {
|
|
214
|
+
const tokens = selectorTokens(finding.selector);
|
|
215
|
+
const ranked = allFiles
|
|
216
|
+
.map((f) => ({ ...f, score: scoreFile(f.rel, f.content, tokens) }))
|
|
217
|
+
.filter((f) => f.score > 0)
|
|
218
|
+
.sort((a, b) => b.score - a.score)
|
|
219
|
+
.slice(0, MAX_CANDIDATE_FILES);
|
|
220
|
+
|
|
221
|
+
const key = ranked.length > 0 ? ranked[0].rel : `__no_candidates_${finding.id}`;
|
|
222
|
+
if (!initialGroups.has(key)) {
|
|
223
|
+
initialGroups.set(key, { candidates: ranked, findings: [] });
|
|
224
|
+
}
|
|
225
|
+
initialGroups.get(key).findings.push(finding);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const merged = [];
|
|
229
|
+
for (const group of initialGroups.values()) {
|
|
230
|
+
const fileSet = new Set(group.candidates.map((c) => c.rel));
|
|
231
|
+
const existing = merged.find((m) => m.candidates.some((c) => fileSet.has(c.rel)));
|
|
232
|
+
if (existing) {
|
|
233
|
+
existing.findings.push(...group.findings);
|
|
234
|
+
for (const candidate of group.candidates) {
|
|
235
|
+
if (!existing.candidates.some((c) => c.rel === candidate.rel)) {
|
|
236
|
+
existing.candidates.push(candidate);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
} else {
|
|
240
|
+
merged.push({ candidates: [...group.candidates], findings: [...group.findings] });
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return merged;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function buildAiFixInputMulti({ findings, intelligenceRules, candidates, projectHints }) {
|
|
248
|
+
return {
|
|
249
|
+
findings: findings.map((finding) => {
|
|
250
|
+
const ruleId = typeof finding.rule_id === "string" ? finding.rule_id.trim() : "";
|
|
251
|
+
const rule = intelligenceRules[ruleId] || {};
|
|
252
|
+
const execution = buildExecution(ruleId, rule, finding);
|
|
253
|
+
return {
|
|
254
|
+
id: finding.id,
|
|
255
|
+
ruleId,
|
|
256
|
+
title: finding.title,
|
|
257
|
+
severity: finding.severity,
|
|
258
|
+
selector: finding.selector,
|
|
259
|
+
actual: finding.actual,
|
|
260
|
+
expected: finding.expected,
|
|
261
|
+
area: finding.area,
|
|
262
|
+
url: finding.url,
|
|
263
|
+
fixDescription: finding.fix_description || rule.fix?.description || "",
|
|
264
|
+
fixCode: finding.fix_code || rule.fix?.code || "",
|
|
265
|
+
constraints: execution.constraints,
|
|
266
|
+
};
|
|
267
|
+
}),
|
|
268
|
+
projectContext: projectHints || "",
|
|
269
|
+
files: candidates.map((c) => ({ filePath: c.rel, content: c.content.slice(0, 12000) })),
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
204
273
|
function buildAiFixInput({ finding, intelligenceRule, execution, candidates, projectHints }) {
|
|
205
274
|
return {
|
|
206
275
|
finding: {
|
|
@@ -325,7 +394,7 @@ function applyChanges(projectDir, changes) {
|
|
|
325
394
|
const abs = path.resolve(projectDir, rel);
|
|
326
395
|
const original = fs.readFileSync(abs, "utf8");
|
|
327
396
|
if (!original.includes(change.search)) {
|
|
328
|
-
|
|
397
|
+
continue;
|
|
329
398
|
}
|
|
330
399
|
const updated = original.replace(change.search, change.replace);
|
|
331
400
|
if (updated === original) continue;
|
|
@@ -599,3 +668,236 @@ export async function applyFindingFix(input) {
|
|
|
599
668
|
usage: claudeUsage,
|
|
600
669
|
});
|
|
601
670
|
}
|
|
671
|
+
|
|
672
|
+
/**
|
|
673
|
+
* Apply fixes for multiple DOM finding IDs grouped by candidate file.
|
|
674
|
+
* PAT-* findings are not handled here — pass them to applyFindingFix individually.
|
|
675
|
+
*
|
|
676
|
+
* @param {{
|
|
677
|
+
* findingIds: string[],
|
|
678
|
+
* findingsPayload?: { findings: Array<Record<string, unknown>> },
|
|
679
|
+
* payload?: { findings: Array<Record<string, unknown>> },
|
|
680
|
+
* projectDir: string,
|
|
681
|
+
* projectHints?: string,
|
|
682
|
+
* ai?: { apiKey?: string, model?: string },
|
|
683
|
+
* }} input
|
|
684
|
+
* @returns {Promise<{ results: Array<{ id: string } & ReturnType<typeof buildResult>> }>}
|
|
685
|
+
*/
|
|
686
|
+
export async function applyFindingsFix(input) {
|
|
687
|
+
if (!isObject(input)) {
|
|
688
|
+
return { results: [] };
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
const findingIds = Array.isArray(input.findingIds)
|
|
692
|
+
? input.findingIds.map((id) => String(id).trim()).filter(Boolean)
|
|
693
|
+
: [];
|
|
694
|
+
const projectDir = typeof input.projectDir === "string" ? input.projectDir.trim() : "";
|
|
695
|
+
const projectHints = typeof input.projectHints === "string" ? input.projectHints.trim() : "";
|
|
696
|
+
const apiKey = input.ai?.apiKey || process.env.ANTHROPIC_API_KEY || "";
|
|
697
|
+
const model = input.ai?.model || DEFAULT_MODEL;
|
|
698
|
+
|
|
699
|
+
function makeResult(id, data) {
|
|
700
|
+
return { id, ...buildResult(data) };
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
if (findingIds.length === 0 || !projectDir) {
|
|
704
|
+
return { results: [] };
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
if (!fs.existsSync(projectDir) || !fs.statSync(projectDir).isDirectory()) {
|
|
708
|
+
return {
|
|
709
|
+
results: findingIds.map((id) =>
|
|
710
|
+
makeResult(id, {
|
|
711
|
+
applied: false,
|
|
712
|
+
reason: FIX_ERROR_CODES.FILE_NOT_RESOLVED,
|
|
713
|
+
message: `Project directory does not exist: ${projectDir}`,
|
|
714
|
+
}),
|
|
715
|
+
),
|
|
716
|
+
};
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
const findings = getFindings(input);
|
|
720
|
+
if (!findings) {
|
|
721
|
+
return {
|
|
722
|
+
results: findingIds.map((id) =>
|
|
723
|
+
makeResult(id, {
|
|
724
|
+
applied: false,
|
|
725
|
+
reason: FIX_ERROR_CODES.INVALID_INPUT,
|
|
726
|
+
message: "Required input is missing: findingsPayload.findings.",
|
|
727
|
+
}),
|
|
728
|
+
),
|
|
729
|
+
};
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
const resultMap = new Map();
|
|
733
|
+
|
|
734
|
+
// Resolve finding objects; mark missing ones immediately
|
|
735
|
+
const resolved = findingIds.map((id) => {
|
|
736
|
+
const finding = findings.find((f) => isObject(f) && f.id === id);
|
|
737
|
+
if (!finding) {
|
|
738
|
+
resultMap.set(
|
|
739
|
+
id,
|
|
740
|
+
makeResult(id, {
|
|
741
|
+
applied: false,
|
|
742
|
+
reason: FIX_ERROR_CODES.FINDING_NOT_FOUND,
|
|
743
|
+
message: `Finding ${id} was not found in findingsPayload.findings.`,
|
|
744
|
+
}),
|
|
745
|
+
);
|
|
746
|
+
}
|
|
747
|
+
return { id, finding: finding || null };
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
const domFindings = resolved.filter((r) => r.finding).map((r) => r.finding);
|
|
751
|
+
if (domFindings.length === 0) {
|
|
752
|
+
return { results: findingIds.map((id) => resultMap.get(id)) };
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
const groups = groupFindingsByFile(domFindings, projectDir);
|
|
756
|
+
|
|
757
|
+
for (const { candidates, findings: groupFindings } of groups) {
|
|
758
|
+
if (candidates.length === 0) {
|
|
759
|
+
for (const finding of groupFindings) {
|
|
760
|
+
resultMap.set(
|
|
761
|
+
finding.id,
|
|
762
|
+
makeResult(finding.id, {
|
|
763
|
+
applied: false,
|
|
764
|
+
reason: FIX_ERROR_CODES.FILE_NOT_RESOLVED,
|
|
765
|
+
message: "No candidate source files were found for this finding.",
|
|
766
|
+
findingTitle: finding.title || "",
|
|
767
|
+
branchSlug: slugify(`${finding.id}-${finding.rule_id || ""}`),
|
|
768
|
+
}),
|
|
769
|
+
);
|
|
770
|
+
}
|
|
771
|
+
continue;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
// Collect intelligence rules and filter out findings missing rule_id
|
|
775
|
+
const intelligenceRules = {};
|
|
776
|
+
const withRules = [];
|
|
777
|
+
for (const finding of groupFindings) {
|
|
778
|
+
const ruleId = typeof finding.rule_id === "string" ? finding.rule_id.trim() : "";
|
|
779
|
+
if (!ruleId) {
|
|
780
|
+
resultMap.set(
|
|
781
|
+
finding.id,
|
|
782
|
+
makeResult(finding.id, {
|
|
783
|
+
applied: false,
|
|
784
|
+
reason: FIX_ERROR_CODES.RULE_MISSING,
|
|
785
|
+
message: `Finding ${finding.id} does not include a rule_id.`,
|
|
786
|
+
findingTitle: finding.title || "",
|
|
787
|
+
}),
|
|
788
|
+
);
|
|
789
|
+
continue;
|
|
790
|
+
}
|
|
791
|
+
intelligenceRules[ruleId] = getIntelligenceForRule(ruleId);
|
|
792
|
+
withRules.push(finding);
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
if (withRules.length === 0) continue;
|
|
796
|
+
|
|
797
|
+
const candidateSet = new Set(candidates.map((c) => c.rel));
|
|
798
|
+
const aiInput = buildAiFixInputMulti({ findings: withRules, intelligenceRules, candidates, projectHints });
|
|
799
|
+
|
|
800
|
+
let patchOutput = null;
|
|
801
|
+
let claudeUsage = { input_tokens: 0, output_tokens: 0 };
|
|
802
|
+
if (apiKey) {
|
|
803
|
+
try {
|
|
804
|
+
const { patch, usage } = await callClaudeForPatch({ apiKey, model, aiInput });
|
|
805
|
+
patchOutput = patch;
|
|
806
|
+
claudeUsage = usage;
|
|
807
|
+
} catch {
|
|
808
|
+
patchOutput = null;
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
if (!patchOutput) {
|
|
813
|
+
for (const finding of withRules) {
|
|
814
|
+
resultMap.set(
|
|
815
|
+
finding.id,
|
|
816
|
+
makeResult(finding.id, {
|
|
817
|
+
applied: false,
|
|
818
|
+
reason: FIX_ERROR_CODES.PATCH_GENERATION_FAILED,
|
|
819
|
+
message: `Could not generate patch output for file group (top file: ${candidates[0]?.rel || "unknown"}).`,
|
|
820
|
+
findingTitle: finding.title || "",
|
|
821
|
+
branchSlug: slugify(`${finding.id}-${finding.rule_id || ""}`),
|
|
822
|
+
usage: claudeUsage,
|
|
823
|
+
}),
|
|
824
|
+
);
|
|
825
|
+
}
|
|
826
|
+
continue;
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
const validation = validateAiPatchOutput(patchOutput, projectDir, candidateSet);
|
|
830
|
+
if (!validation.ok) {
|
|
831
|
+
for (const finding of withRules) {
|
|
832
|
+
resultMap.set(
|
|
833
|
+
finding.id,
|
|
834
|
+
makeResult(finding.id, {
|
|
835
|
+
applied: false,
|
|
836
|
+
reason: FIX_ERROR_CODES.PATCH_GENERATION_FAILED,
|
|
837
|
+
message: validation.reason,
|
|
838
|
+
findingTitle: finding.title || "",
|
|
839
|
+
branchSlug: slugify(`${finding.id}-${finding.rule_id || ""}`),
|
|
840
|
+
usage: claudeUsage,
|
|
841
|
+
}),
|
|
842
|
+
);
|
|
843
|
+
}
|
|
844
|
+
continue;
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
const applied = applyChanges(projectDir, patchOutput.changes);
|
|
848
|
+
if (!applied.ok) {
|
|
849
|
+
for (const finding of withRules) {
|
|
850
|
+
resultMap.set(
|
|
851
|
+
finding.id,
|
|
852
|
+
makeResult(finding.id, {
|
|
853
|
+
applied: false,
|
|
854
|
+
reason: FIX_ERROR_CODES.PATCH_APPLY_FAILED,
|
|
855
|
+
message: applied.reason,
|
|
856
|
+
findingTitle: finding.title || "",
|
|
857
|
+
branchSlug: slugify(`${finding.id}-${finding.rule_id || ""}`),
|
|
858
|
+
usage: claudeUsage,
|
|
859
|
+
}),
|
|
860
|
+
);
|
|
861
|
+
}
|
|
862
|
+
continue;
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
// Split token usage evenly across findings in the group
|
|
866
|
+
const n = withRules.length;
|
|
867
|
+
const perInput = Math.round(claudeUsage.input_tokens / n);
|
|
868
|
+
const perOutput = Math.round(claudeUsage.output_tokens / n);
|
|
869
|
+
|
|
870
|
+
for (const finding of withRules) {
|
|
871
|
+
const ruleId = typeof finding.rule_id === "string" ? finding.rule_id.trim() : "";
|
|
872
|
+
const intelligenceRule = intelligenceRules[ruleId] || {};
|
|
873
|
+
const execution = buildExecution(ruleId, intelligenceRule, finding);
|
|
874
|
+
resultMap.set(
|
|
875
|
+
finding.id,
|
|
876
|
+
makeResult(finding.id, {
|
|
877
|
+
applied: true,
|
|
878
|
+
reason: "",
|
|
879
|
+
message: "Patch applied successfully.",
|
|
880
|
+
changedFiles: applied.changedFiles,
|
|
881
|
+
patch: applied.patch,
|
|
882
|
+
verifyRule: patchOutput.verifyRule || execution.verify.ruleId,
|
|
883
|
+
verifyRoute: execution.verify.route,
|
|
884
|
+
findingTitle: finding.title || "",
|
|
885
|
+
branchSlug: slugify(`${finding.id}-${ruleId}`),
|
|
886
|
+
usage: { input_tokens: perInput, output_tokens: perOutput },
|
|
887
|
+
}),
|
|
888
|
+
);
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
return {
|
|
893
|
+
results: findingIds.map(
|
|
894
|
+
(id) =>
|
|
895
|
+
resultMap.get(id) ||
|
|
896
|
+
makeResult(id, {
|
|
897
|
+
applied: false,
|
|
898
|
+
reason: FIX_ERROR_CODES.FINDING_NOT_FOUND,
|
|
899
|
+
message: `Finding ${id} was not found.`,
|
|
900
|
+
}),
|
|
901
|
+
),
|
|
902
|
+
};
|
|
903
|
+
}
|
package/src/index.mjs
CHANGED
|
@@ -6,7 +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
|
+
export { applyFindingFix, applyFindingsFix, FIX_ERROR_CODES } from "./fixes/apply-finding-fix.mjs";
|
|
10
10
|
|
|
11
11
|
// Lazy-loaded asset cache
|
|
12
12
|
|