@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diegovelasquezweb/a11y-engine",
3
- "version": "0.11.26",
3
+ "version": "0.11.28",
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,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
- return { ok: false, reason: `Search block not found in ${rel}` };
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