@diegovelasquezweb/a11y-engine 0.8.1 → 0.8.2

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/ai/claude.mjs +91 -23
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diegovelasquezweb/a11y-engine",
3
- "version": "0.8.1",
3
+ "version": "0.8.2",
4
4
  "description": "WCAG 2.2 accessibility audit engine — scanner, analyzer, and report builders",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/ai/claude.mjs CHANGED
@@ -132,6 +132,54 @@ async function callClaude(apiKey, model, systemPrompt, userMessage) {
132
132
  * @param {string|undefined} githubToken
133
133
  * @returns {Promise<Record<string, string>>}
134
134
  */
135
+ /**
136
+ * Extracts candidate component/class names from a CSS selector or HTML snippet.
137
+ * e.g. ".trustarc-banner-right > span" → ["trustarc", "banner"]
138
+ * e.g. "#search-input" → ["search", "input"]
139
+ */
140
+ function extractSearchTermsFromFinding(finding) {
141
+ const terms = new Set();
142
+ const sources = [
143
+ finding.primarySelector || finding.selector || "",
144
+ finding.title || "",
145
+ ];
146
+
147
+ for (const src of sources) {
148
+ // Extract class names: .foo-bar → ["foo", "bar"]
149
+ const classes = src.match(/\.[\w-]+/g) || [];
150
+ for (const cls of classes) {
151
+ const parts = cls.slice(1).split(/[-_]/);
152
+ for (const p of parts) {
153
+ if (p.length > 3) terms.add(p.toLowerCase());
154
+ }
155
+ }
156
+ // Extract IDs: #foo-bar → ["foo", "bar"]
157
+ const ids = src.match(/#[\w-]+/g) || [];
158
+ for (const id of ids) {
159
+ const parts = id.slice(1).split(/[-_]/);
160
+ for (const p of parts) {
161
+ if (p.length > 3) terms.add(p.toLowerCase());
162
+ }
163
+ }
164
+ // Extract data attributes: [data-component="Foo"] → ["foo"]
165
+ const dataAttrs = src.match(/data-[\w-]+=["']?[\w-]+["']?/g) || [];
166
+ for (const attr of dataAttrs) {
167
+ const val = attr.split(/=["']?/)[1]?.replace(/["']/, "").toLowerCase();
168
+ if (val && val.length > 3) terms.add(val);
169
+ }
170
+ }
171
+
172
+ return [...terms].slice(0, 5);
173
+ }
174
+
175
+ /**
176
+ * Scores a file path by how many search terms it contains.
177
+ */
178
+ function scoreFilePath(filePath, terms) {
179
+ const lower = filePath.toLowerCase();
180
+ return terms.filter((t) => lower.includes(t)).length;
181
+ }
182
+
135
183
  async function fetchSourceFilesForFindings(findings, repoUrl, githubToken) {
136
184
  const sourceFiles = {};
137
185
  if (!repoUrl) return sourceFiles;
@@ -139,30 +187,50 @@ async function fetchSourceFilesForFindings(findings, repoUrl, githubToken) {
139
187
  const { fetchRepoFile, listRepoFiles, parseRepoUrl } = await import("../core/github-api.mjs");
140
188
  if (!parseRepoUrl(repoUrl)) return sourceFiles;
141
189
 
142
- const patterns = new Set(
143
- findings
144
- .filter((f) => f.fileSearchPattern)
145
- .map((f) => f.fileSearchPattern)
146
- );
190
+ // Collect all extensions needed
191
+ const extensions = new Set();
192
+ for (const f of findings) {
193
+ if (!f.fileSearchPattern) continue;
194
+ const extMatch = f.fileSearchPattern.match(/\*\.(\w+)$/);
195
+ if (extMatch) extensions.add(`.${extMatch[1]}`);
196
+ }
197
+ if (extensions.size === 0) return sourceFiles;
147
198
 
148
- for (const pattern of patterns) {
149
- try {
150
- // Extract extension from pattern (e.g. "src/components/*.tsx" -> ".tsx")
151
- const extMatch = pattern.match(/\*\.(\w+)$/);
152
- if (!extMatch) continue;
153
- const ext = `.${extMatch[1]}`;
154
-
155
- const files = await listRepoFiles(repoUrl, [ext], githubToken);
156
- // Pick up to 3 most relevant files per pattern
157
- const relevant = files.slice(0, 3);
158
- for (const filePath of relevant) {
159
- if (!sourceFiles[filePath]) {
160
- const content = await fetchRepoFile(repoUrl, filePath, githubToken);
161
- if (content) sourceFiles[filePath] = content;
162
- }
163
- }
164
- } catch {
165
- // non-fatal
199
+ // Fetch full file list once
200
+ let allFiles = [];
201
+ try {
202
+ allFiles = await listRepoFiles(repoUrl, [...extensions], githubToken);
203
+ } catch {
204
+ return sourceFiles;
205
+ }
206
+
207
+ // For each finding, find the most relevant files by selector/title terms
208
+ const MAX_FILES_PER_FINDING = 2;
209
+ const MAX_TOTAL_FILES = 6;
210
+
211
+ for (const finding of findings) {
212
+ if (Object.keys(sourceFiles).length >= MAX_TOTAL_FILES) break;
213
+
214
+ const terms = extractSearchTermsFromFinding(finding);
215
+
216
+ // Score and sort files by relevance to this finding
217
+ const scored = allFiles
218
+ .map((fp) => ({ fp, score: scoreFilePath(fp, terms) }))
219
+ .filter(({ score }) => score > 0)
220
+ .sort((a, b) => b.score - a.score);
221
+
222
+ // Fall back to first files if no relevant match found
223
+ const candidates = scored.length > 0
224
+ ? scored.slice(0, MAX_FILES_PER_FINDING).map(({ fp }) => fp)
225
+ : allFiles.slice(0, 1);
226
+
227
+ for (const filePath of candidates) {
228
+ if (sourceFiles[filePath]) continue;
229
+ if (Object.keys(sourceFiles).length >= MAX_TOTAL_FILES) break;
230
+ try {
231
+ const content = await fetchRepoFile(repoUrl, filePath, githubToken);
232
+ if (content) sourceFiles[filePath] = content;
233
+ } catch { /* non-fatal */ }
166
234
  }
167
235
  }
168
236