@diegovelasquezweb/a11y-engine 0.11.26 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diegovelasquezweb/a11y-engine",
3
- "version": "0.11.26",
3
+ "version": "0.11.27",
4
4
  "description": "WCAG 2.2 accessibility audit engine — scanner, analyzer, and report builders",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -201,6 +201,59 @@ 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 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
+
204
257
  function buildAiFixInput({ finding, intelligenceRule, execution, candidates, projectHints }) {
205
258
  return {
206
259
  finding: {
@@ -599,3 +652,236 @@ export async function applyFindingFix(input) {
599
652
  usage: claudeUsage,
600
653
  });
601
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