@diegovelasquezweb/a11y-engine 0.11.25 → 0.11.27
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 +290 -2
- package/src/index.mjs +1 -1
package/package.json
CHANGED
|
@@ -201,7 +201,60 @@ function buildExecution(ruleId, intelligenceRule, finding) {
|
|
|
201
201
|
};
|
|
202
202
|
}
|
|
203
203
|
|
|
204
|
-
function
|
|
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 groups = 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 (!groups.has(key)) {
|
|
223
|
+
groups.set(key, { candidates: ranked, findings: [] });
|
|
224
|
+
}
|
|
225
|
+
groups.get(key).findings.push(finding);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return groups;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function buildAiFixInputMulti({ findings, intelligenceRules, candidates, projectHints }) {
|
|
232
|
+
return {
|
|
233
|
+
findings: findings.map((finding) => {
|
|
234
|
+
const ruleId = typeof finding.rule_id === "string" ? finding.rule_id.trim() : "";
|
|
235
|
+
const rule = intelligenceRules[ruleId] || {};
|
|
236
|
+
const execution = buildExecution(ruleId, rule, finding);
|
|
237
|
+
return {
|
|
238
|
+
id: finding.id,
|
|
239
|
+
ruleId,
|
|
240
|
+
title: finding.title,
|
|
241
|
+
severity: finding.severity,
|
|
242
|
+
selector: finding.selector,
|
|
243
|
+
actual: finding.actual,
|
|
244
|
+
expected: finding.expected,
|
|
245
|
+
area: finding.area,
|
|
246
|
+
url: finding.url,
|
|
247
|
+
fixDescription: finding.fix_description || rule.fix?.description || "",
|
|
248
|
+
fixCode: finding.fix_code || rule.fix?.code || "",
|
|
249
|
+
constraints: execution.constraints,
|
|
250
|
+
};
|
|
251
|
+
}),
|
|
252
|
+
projectContext: projectHints || "",
|
|
253
|
+
files: candidates.map((c) => ({ filePath: c.rel, content: c.content.slice(0, 12000) })),
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function buildAiFixInput({ finding, intelligenceRule, execution, candidates, projectHints }) {
|
|
205
258
|
return {
|
|
206
259
|
finding: {
|
|
207
260
|
id: finding.id,
|
|
@@ -223,6 +276,7 @@ function buildAiFixInput({ finding, intelligenceRule, execution, candidates }) {
|
|
|
223
276
|
fixDifficultyNotes: intelligenceRule.fix_difficulty_notes || "",
|
|
224
277
|
},
|
|
225
278
|
execution,
|
|
279
|
+
projectContext: projectHints || "",
|
|
226
280
|
files: candidates.map((c) => ({ filePath: c.rel, content: c.content.slice(0, 12000) })),
|
|
227
281
|
};
|
|
228
282
|
}
|
|
@@ -362,6 +416,7 @@ export async function applyFindingFix(input) {
|
|
|
362
416
|
|
|
363
417
|
const findingId = typeof input.findingId === "string" ? input.findingId.trim() : "";
|
|
364
418
|
const projectDir = typeof input.projectDir === "string" ? input.projectDir.trim() : "";
|
|
419
|
+
const projectHints = typeof input.projectHints === "string" ? input.projectHints.trim() : "";
|
|
365
420
|
|
|
366
421
|
if (!findingId || !projectDir) {
|
|
367
422
|
return buildResult({
|
|
@@ -528,7 +583,7 @@ export async function applyFindingFix(input) {
|
|
|
528
583
|
});
|
|
529
584
|
}
|
|
530
585
|
|
|
531
|
-
const aiInput = buildAiFixInput({ finding, intelligenceRule, execution, candidates });
|
|
586
|
+
const aiInput = buildAiFixInput({ finding, intelligenceRule, execution, candidates, projectHints });
|
|
532
587
|
const candidateSet = new Set(candidates.map((c) => c.rel));
|
|
533
588
|
|
|
534
589
|
let patchOutput = null;
|
|
@@ -597,3 +652,236 @@ export async function applyFindingFix(input) {
|
|
|
597
652
|
usage: claudeUsage,
|
|
598
653
|
});
|
|
599
654
|
}
|
|
655
|
+
|
|
656
|
+
/**
|
|
657
|
+
* Apply fixes for multiple DOM finding IDs grouped by candidate file.
|
|
658
|
+
* PAT-* findings are not handled here — pass them to applyFindingFix individually.
|
|
659
|
+
*
|
|
660
|
+
* @param {{
|
|
661
|
+
* findingIds: string[],
|
|
662
|
+
* findingsPayload?: { findings: Array<Record<string, unknown>> },
|
|
663
|
+
* payload?: { findings: Array<Record<string, unknown>> },
|
|
664
|
+
* projectDir: string,
|
|
665
|
+
* projectHints?: string,
|
|
666
|
+
* ai?: { apiKey?: string, model?: string },
|
|
667
|
+
* }} input
|
|
668
|
+
* @returns {Promise<{ results: Array<{ id: string } & ReturnType<typeof buildResult>> }>}
|
|
669
|
+
*/
|
|
670
|
+
export async function applyFindingsFix(input) {
|
|
671
|
+
if (!isObject(input)) {
|
|
672
|
+
return { results: [] };
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
const findingIds = Array.isArray(input.findingIds)
|
|
676
|
+
? input.findingIds.map((id) => String(id).trim()).filter(Boolean)
|
|
677
|
+
: [];
|
|
678
|
+
const projectDir = typeof input.projectDir === "string" ? input.projectDir.trim() : "";
|
|
679
|
+
const projectHints = typeof input.projectHints === "string" ? input.projectHints.trim() : "";
|
|
680
|
+
const apiKey = input.ai?.apiKey || process.env.ANTHROPIC_API_KEY || "";
|
|
681
|
+
const model = input.ai?.model || DEFAULT_MODEL;
|
|
682
|
+
|
|
683
|
+
function makeResult(id, data) {
|
|
684
|
+
return { id, ...buildResult(data) };
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
if (findingIds.length === 0 || !projectDir) {
|
|
688
|
+
return { results: [] };
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
if (!fs.existsSync(projectDir) || !fs.statSync(projectDir).isDirectory()) {
|
|
692
|
+
return {
|
|
693
|
+
results: findingIds.map((id) =>
|
|
694
|
+
makeResult(id, {
|
|
695
|
+
applied: false,
|
|
696
|
+
reason: FIX_ERROR_CODES.FILE_NOT_RESOLVED,
|
|
697
|
+
message: `Project directory does not exist: ${projectDir}`,
|
|
698
|
+
}),
|
|
699
|
+
),
|
|
700
|
+
};
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
const findings = getFindings(input);
|
|
704
|
+
if (!findings) {
|
|
705
|
+
return {
|
|
706
|
+
results: findingIds.map((id) =>
|
|
707
|
+
makeResult(id, {
|
|
708
|
+
applied: false,
|
|
709
|
+
reason: FIX_ERROR_CODES.INVALID_INPUT,
|
|
710
|
+
message: "Required input is missing: findingsPayload.findings.",
|
|
711
|
+
}),
|
|
712
|
+
),
|
|
713
|
+
};
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
const resultMap = new Map();
|
|
717
|
+
|
|
718
|
+
// Resolve finding objects; mark missing ones immediately
|
|
719
|
+
const resolved = findingIds.map((id) => {
|
|
720
|
+
const finding = findings.find((f) => isObject(f) && f.id === id);
|
|
721
|
+
if (!finding) {
|
|
722
|
+
resultMap.set(
|
|
723
|
+
id,
|
|
724
|
+
makeResult(id, {
|
|
725
|
+
applied: false,
|
|
726
|
+
reason: FIX_ERROR_CODES.FINDING_NOT_FOUND,
|
|
727
|
+
message: `Finding ${id} was not found in findingsPayload.findings.`,
|
|
728
|
+
}),
|
|
729
|
+
);
|
|
730
|
+
}
|
|
731
|
+
return { id, finding: finding || null };
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
const domFindings = resolved.filter((r) => r.finding).map((r) => r.finding);
|
|
735
|
+
if (domFindings.length === 0) {
|
|
736
|
+
return { results: findingIds.map((id) => resultMap.get(id)) };
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
const groups = groupFindingsByFile(domFindings, projectDir);
|
|
740
|
+
|
|
741
|
+
for (const [topFile, { candidates, findings: groupFindings }] of groups) {
|
|
742
|
+
if (candidates.length === 0) {
|
|
743
|
+
for (const finding of groupFindings) {
|
|
744
|
+
resultMap.set(
|
|
745
|
+
finding.id,
|
|
746
|
+
makeResult(finding.id, {
|
|
747
|
+
applied: false,
|
|
748
|
+
reason: FIX_ERROR_CODES.FILE_NOT_RESOLVED,
|
|
749
|
+
message: "No candidate source files were found for this finding.",
|
|
750
|
+
findingTitle: finding.title || "",
|
|
751
|
+
branchSlug: slugify(`${finding.id}-${finding.rule_id || ""}`),
|
|
752
|
+
}),
|
|
753
|
+
);
|
|
754
|
+
}
|
|
755
|
+
continue;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
// Collect intelligence rules and filter out findings missing rule_id
|
|
759
|
+
const intelligenceRules = {};
|
|
760
|
+
const withRules = [];
|
|
761
|
+
for (const finding of groupFindings) {
|
|
762
|
+
const ruleId = typeof finding.rule_id === "string" ? finding.rule_id.trim() : "";
|
|
763
|
+
if (!ruleId) {
|
|
764
|
+
resultMap.set(
|
|
765
|
+
finding.id,
|
|
766
|
+
makeResult(finding.id, {
|
|
767
|
+
applied: false,
|
|
768
|
+
reason: FIX_ERROR_CODES.RULE_MISSING,
|
|
769
|
+
message: `Finding ${finding.id} does not include a rule_id.`,
|
|
770
|
+
findingTitle: finding.title || "",
|
|
771
|
+
}),
|
|
772
|
+
);
|
|
773
|
+
continue;
|
|
774
|
+
}
|
|
775
|
+
intelligenceRules[ruleId] = getIntelligenceForRule(ruleId);
|
|
776
|
+
withRules.push(finding);
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
if (withRules.length === 0) continue;
|
|
780
|
+
|
|
781
|
+
const candidateSet = new Set(candidates.map((c) => c.rel));
|
|
782
|
+
const aiInput = buildAiFixInputMulti({ findings: withRules, intelligenceRules, candidates, projectHints });
|
|
783
|
+
|
|
784
|
+
let patchOutput = null;
|
|
785
|
+
let claudeUsage = { input_tokens: 0, output_tokens: 0 };
|
|
786
|
+
if (apiKey) {
|
|
787
|
+
try {
|
|
788
|
+
const { patch, usage } = await callClaudeForPatch({ apiKey, model, aiInput });
|
|
789
|
+
patchOutput = patch;
|
|
790
|
+
claudeUsage = usage;
|
|
791
|
+
} catch {
|
|
792
|
+
patchOutput = null;
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
if (!patchOutput) {
|
|
797
|
+
for (const finding of withRules) {
|
|
798
|
+
resultMap.set(
|
|
799
|
+
finding.id,
|
|
800
|
+
makeResult(finding.id, {
|
|
801
|
+
applied: false,
|
|
802
|
+
reason: FIX_ERROR_CODES.PATCH_GENERATION_FAILED,
|
|
803
|
+
message: `Could not generate patch output for file group (top file: ${topFile}).`,
|
|
804
|
+
findingTitle: finding.title || "",
|
|
805
|
+
branchSlug: slugify(`${finding.id}-${finding.rule_id || ""}`),
|
|
806
|
+
usage: claudeUsage,
|
|
807
|
+
}),
|
|
808
|
+
);
|
|
809
|
+
}
|
|
810
|
+
continue;
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
const validation = validateAiPatchOutput(patchOutput, projectDir, candidateSet);
|
|
814
|
+
if (!validation.ok) {
|
|
815
|
+
for (const finding of withRules) {
|
|
816
|
+
resultMap.set(
|
|
817
|
+
finding.id,
|
|
818
|
+
makeResult(finding.id, {
|
|
819
|
+
applied: false,
|
|
820
|
+
reason: FIX_ERROR_CODES.PATCH_GENERATION_FAILED,
|
|
821
|
+
message: validation.reason,
|
|
822
|
+
findingTitle: finding.title || "",
|
|
823
|
+
branchSlug: slugify(`${finding.id}-${finding.rule_id || ""}`),
|
|
824
|
+
usage: claudeUsage,
|
|
825
|
+
}),
|
|
826
|
+
);
|
|
827
|
+
}
|
|
828
|
+
continue;
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
const applied = applyChanges(projectDir, patchOutput.changes);
|
|
832
|
+
if (!applied.ok) {
|
|
833
|
+
for (const finding of withRules) {
|
|
834
|
+
resultMap.set(
|
|
835
|
+
finding.id,
|
|
836
|
+
makeResult(finding.id, {
|
|
837
|
+
applied: false,
|
|
838
|
+
reason: FIX_ERROR_CODES.PATCH_APPLY_FAILED,
|
|
839
|
+
message: applied.reason,
|
|
840
|
+
findingTitle: finding.title || "",
|
|
841
|
+
branchSlug: slugify(`${finding.id}-${finding.rule_id || ""}`),
|
|
842
|
+
usage: claudeUsage,
|
|
843
|
+
}),
|
|
844
|
+
);
|
|
845
|
+
}
|
|
846
|
+
continue;
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
// Split token usage evenly across findings in the group
|
|
850
|
+
const n = withRules.length;
|
|
851
|
+
const perInput = Math.round(claudeUsage.input_tokens / n);
|
|
852
|
+
const perOutput = Math.round(claudeUsage.output_tokens / n);
|
|
853
|
+
|
|
854
|
+
for (const finding of withRules) {
|
|
855
|
+
const ruleId = typeof finding.rule_id === "string" ? finding.rule_id.trim() : "";
|
|
856
|
+
const intelligenceRule = intelligenceRules[ruleId] || {};
|
|
857
|
+
const execution = buildExecution(ruleId, intelligenceRule, finding);
|
|
858
|
+
resultMap.set(
|
|
859
|
+
finding.id,
|
|
860
|
+
makeResult(finding.id, {
|
|
861
|
+
applied: true,
|
|
862
|
+
reason: "",
|
|
863
|
+
message: "Patch applied successfully.",
|
|
864
|
+
changedFiles: applied.changedFiles,
|
|
865
|
+
patch: applied.patch,
|
|
866
|
+
verifyRule: patchOutput.verifyRule || execution.verify.ruleId,
|
|
867
|
+
verifyRoute: patchOutput.verifyRoute || execution.verify.route,
|
|
868
|
+
findingTitle: finding.title || "",
|
|
869
|
+
branchSlug: slugify(`${finding.id}-${ruleId}`),
|
|
870
|
+
usage: { input_tokens: perInput, output_tokens: perOutput },
|
|
871
|
+
}),
|
|
872
|
+
);
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
return {
|
|
877
|
+
results: findingIds.map(
|
|
878
|
+
(id) =>
|
|
879
|
+
resultMap.get(id) ||
|
|
880
|
+
makeResult(id, {
|
|
881
|
+
applied: false,
|
|
882
|
+
reason: FIX_ERROR_CODES.FINDING_NOT_FOUND,
|
|
883
|
+
message: `Finding ${id} was not found.`,
|
|
884
|
+
}),
|
|
885
|
+
),
|
|
886
|
+
};
|
|
887
|
+
}
|
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
|
|