@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diegovelasquezweb/a11y-engine",
3
- "version": "0.11.25",
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,7 +201,60 @@ function buildExecution(ruleId, intelligenceRule, finding) {
201
201
  };
202
202
  }
203
203
 
204
- function buildAiFixInput({ finding, intelligenceRule, execution, candidates }) {
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