nfunc-mcp 0.2.0 → 0.4.0

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 (65) hide show
  1. package/README.md +85 -335
  2. package/dist/index.js +27 -4
  3. package/dist/index.js.map +1 -1
  4. package/dist/mappers/a11yDedupe.d.ts +25 -0
  5. package/dist/mappers/a11yDedupe.js +95 -0
  6. package/dist/mappers/a11yDedupe.js.map +1 -0
  7. package/dist/mappers/compositeScore.d.ts +23 -0
  8. package/dist/mappers/compositeScore.js +107 -0
  9. package/dist/mappers/compositeScore.js.map +1 -0
  10. package/dist/mappers/correlator.js +94 -33
  11. package/dist/mappers/correlator.js.map +1 -1
  12. package/dist/mappers/defectFormatter.js +129 -15
  13. package/dist/mappers/defectFormatter.js.map +1 -1
  14. package/dist/mappers/labFieldComparator.d.ts +62 -0
  15. package/dist/mappers/labFieldComparator.js +134 -0
  16. package/dist/mappers/labFieldComparator.js.map +1 -0
  17. package/dist/mappers/priorityMapper.d.ts +38 -0
  18. package/dist/mappers/priorityMapper.js +126 -0
  19. package/dist/mappers/priorityMapper.js.map +1 -1
  20. package/dist/mappers/psiAggregator.d.ts +130 -0
  21. package/dist/mappers/psiAggregator.js +293 -0
  22. package/dist/mappers/psiAggregator.js.map +1 -0
  23. package/dist/mappers/webVitalsMapper.d.ts +52 -0
  24. package/dist/mappers/webVitalsMapper.js +131 -0
  25. package/dist/mappers/webVitalsMapper.js.map +1 -0
  26. package/dist/tools/accessibility.js +72 -31
  27. package/dist/tools/accessibility.js.map +1 -1
  28. package/dist/tools/lighthouse.d.ts +14 -0
  29. package/dist/tools/lighthouse.js +134 -34
  30. package/dist/tools/lighthouse.js.map +1 -1
  31. package/dist/tools/performanceAudit.d.ts +2 -0
  32. package/dist/tools/performanceAudit.js +446 -0
  33. package/dist/tools/performanceAudit.js.map +1 -0
  34. package/dist/tools/performanceAuditPlan.d.ts +2 -0
  35. package/dist/tools/performanceAuditPlan.js +438 -0
  36. package/dist/tools/performanceAuditPlan.js.map +1 -0
  37. package/dist/tools/qaGate.js +109 -34
  38. package/dist/tools/qaGate.js.map +1 -1
  39. package/dist/utils/csvReader.d.ts +20 -0
  40. package/dist/utils/csvReader.js +172 -0
  41. package/dist/utils/csvReader.js.map +1 -0
  42. package/dist/utils/httpClient.d.ts +84 -0
  43. package/dist/utils/httpClient.js +171 -0
  44. package/dist/utils/httpClient.js.map +1 -0
  45. package/dist/utils/outputParsers.d.ts +25 -0
  46. package/dist/utils/outputParsers.js +42 -0
  47. package/dist/utils/outputParsers.js.map +1 -1
  48. package/dist/utils/psiAuth.d.ts +26 -0
  49. package/dist/utils/psiAuth.js +36 -0
  50. package/dist/utils/psiAuth.js.map +1 -0
  51. package/dist/utils/psiParser.d.ts +124 -0
  52. package/dist/utils/psiParser.js +200 -0
  53. package/dist/utils/psiParser.js.map +1 -0
  54. package/dist/utils/publicUrl.d.ts +17 -0
  55. package/dist/utils/publicUrl.js +115 -0
  56. package/dist/utils/publicUrl.js.map +1 -0
  57. package/dist/utils/sitemapReader.d.ts +27 -0
  58. package/dist/utils/sitemapReader.js +272 -0
  59. package/dist/utils/sitemapReader.js.map +1 -0
  60. package/dist/utils/urlClassifier.d.ts +45 -0
  61. package/dist/utils/urlClassifier.js +267 -0
  62. package/dist/utils/urlClassifier.js.map +1 -0
  63. package/docs/manual.md +558 -0
  64. package/docs/psi-report-spec.md +174 -0
  65. package/package.json +14 -4
@@ -0,0 +1,293 @@
1
+ /**
2
+ * Cross-run arithmetic.
3
+ *
4
+ * This module owns every number that describes the audit as a whole, because
5
+ * that is precisely where reading JSON by hand goes wrong. The manual Five
6
+ * Below report got "TBT fails on 22/22 pages" right and the direction of the
7
+ * CrUX CLS disagreement backwards, in the same document — the first is a count
8
+ * and the second is a comparison, and a human doing 26 files will eventually
9
+ * miss one. Everything here is computed so the report can quote rather than
10
+ * derive.
11
+ *
12
+ * It also owns the two redundancy rules, because both need the page set in
13
+ * view. A single-URL audit should still report every failing metric; the
14
+ * repetition only exists across a set.
15
+ */
16
+ import { classifyVital, formatVitalValue, vitalLabel } from "./webVitalsMapper.js";
17
+ /** A vital failing on at least this share of runs is a candidate for collapse. */
18
+ const SYSTEMIC_THRESHOLD = 0.8;
19
+ /**
20
+ * ...but only if it also varies little between pages.
21
+ *
22
+ * Ubiquity alone is not enough, and the first implementation of this rule got
23
+ * it wrong: it collapsed CLS, which fails on 25 of 25 Five Below runs but
24
+ * ranges 0.18 to 0.85 — a 4.7x spread that is the single most page-specific
25
+ * signal in the dataset. Collapsing it would have deleted the finding the
26
+ * whole tool exists to surface.
27
+ *
28
+ * A shared-code characteristic looks the same everywhere: INP fails on every
29
+ * page within a 2.1x band. A per-page defect that happens to be widespread
30
+ * does not. So collapse requires both — fails nearly everywhere *and* barely
31
+ * moves between pages.
32
+ */
33
+ const SYSTEMIC_MAX_SPREAD = 2.5;
34
+ /** Lab-only metrics with no field counterpart, needed for the universal-failure counts. */
35
+ const LAB_THRESHOLDS = {
36
+ tbtMs: { label: "Total Blocking Time", good: 200, unit: "ms" },
37
+ };
38
+ const PRIORITY_RANK = { P1: 0, P2: 1, P3: 2 };
39
+ function median(values) {
40
+ if (values.length === 0)
41
+ return 0;
42
+ const sorted = [...values].sort((a, b) => a - b);
43
+ const mid = Math.floor(sorted.length / 2);
44
+ return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid];
45
+ }
46
+ function mean(values) {
47
+ return values.length === 0 ? 0 : Math.round(values.reduce((a, b) => a + b, 0) / values.length);
48
+ }
49
+ /**
50
+ * Rule 2 — component suppression.
51
+ *
52
+ * FCP is a component of LCP: if the largest element paints late, the first one
53
+ * usually did too. Across the Five Below batch the two co-occurred on 17 of 25
54
+ * runs and FCP never once reached P1, so an FCP finding alongside an LCP
55
+ * finding is two tickets describing one defect. The FCP measurement is kept as
56
+ * evidence on the LCP finding rather than discarded.
57
+ *
58
+ * TTFB is deliberately *not* treated this way. It fired on one run out of 25,
59
+ * which is the signature of a metric carrying independent information.
60
+ */
61
+ export function suppressComponentFindings(findings) {
62
+ const lcp = findings.find((f) => f.evidence.audit_id === "crux.lcp");
63
+ const fcp = findings.find((f) => f.evidence.audit_id === "crux.fcp");
64
+ if (!lcp || !fcp)
65
+ return findings;
66
+ lcp.evidence = {
67
+ ...lcp.evidence,
68
+ supporting_fcp: fcp.evidence.value,
69
+ note: "First Contentful Paint is also below target; it is a component of this metric, not a separate defect.",
70
+ };
71
+ return findings.filter((f) => f !== fcp);
72
+ }
73
+ /**
74
+ * Rule 1 — systemic collapse.
75
+ *
76
+ * A vital rated below "good" on 80%+ of runs is describing the site, not any
77
+ * one page. INP failed on 25 of 25 Five Below runs with a 2.1x spread and a
78
+ * 316 ms median: twenty-five findings that each say "fix this page" when the
79
+ * true statement is "this site's interaction handling is uniformly mediocre".
80
+ *
81
+ * The threshold is deliberately well above a simple majority — a vital failing
82
+ * on half the pages is discriminating between them, and that is information
83
+ * worth keeping per page.
84
+ *
85
+ * Priority is the worst observed, not an average: a vital that is poor
86
+ * everywhere is not less urgent for being ubiquitous.
87
+ */
88
+ export function collapseSystemicFindings(runs) {
89
+ const findings = [];
90
+ const collapsedVitals = [];
91
+ if (runs.length < 3)
92
+ return { findings, collapsedVitals };
93
+ const vitals = ["lcp", "inp", "cls", "fcp", "ttfb"];
94
+ for (const vital of vitals) {
95
+ // Only URL-level measurements can support a site-wide conclusion.
96
+ //
97
+ // Origin-level CrUX is *the same number* repeated for every page that falls
98
+ // back to it, so both tests below are guaranteed to pass on it: the failure
99
+ // rate is 100% and the spread is exactly 1.0x. That manufactured a
100
+ // "fails everywhere and varies little between pages" finding from what was
101
+ // literally one measurement copied across three runs. The spread gate is
102
+ // only meaningful over independent per-page data.
103
+ const measured = runs.filter((r) => r.field?.metrics[vital]?.source === "url");
104
+ if (measured.length < 3)
105
+ continue;
106
+ // Count runs that still carry a finding for this vital, not runs whose raw
107
+ // metric is below target. The two rules have to compose: FCP is folded into
108
+ // LCP by component suppression, and counting raw metrics resurrected it as
109
+ // a site-wide finding that per-page reporting had deliberately dropped.
110
+ const failing = measured.filter((r) => r.findings.some((f) => f.evidence.audit_id === `crux.${vital}`));
111
+ if (failing.length / measured.length < SYSTEMIC_THRESHOLD)
112
+ continue;
113
+ const values = failing.map((r) => r.field?.metrics[vital]?.p75 ?? 0);
114
+ // Spread gate. Guard against a zero floor, which would make any spread
115
+ // infinite and suppress the collapse for the wrong reason.
116
+ const low = Math.min(...values);
117
+ const high = Math.max(...values);
118
+ if (low > 0 && high / low > SYSTEMIC_MAX_SPREAD)
119
+ continue;
120
+ const worst = failing
121
+ .flatMap((r) => r.findings.filter((f) => f.evidence.audit_id === `crux.${vital}`))
122
+ .reduce((acc, f) => (PRIORITY_RANK[f.priority] < PRIORITY_RANK[acc] ? f.priority : acc), "P3");
123
+ const med = median(values);
124
+ const min = low;
125
+ const max = high;
126
+ collapsedVitals.push(vital);
127
+ findings.push({
128
+ priority: worst,
129
+ title: `${vitalLabel(vital)} is below target site-wide (${failing.length}/${measured.length} runs)`,
130
+ description: `Real users are below Google's ${vitalLabel(vital)} target on ` +
131
+ `${failing.length} of ${measured.length} audited page/device runs, with a median of ` +
132
+ `${formatVitalValue(vital, med)} and a range of ${formatVitalValue(vital, min)} to ` +
133
+ `${formatVitalValue(vital, max)}. Because it fails almost everywhere and varies little ` +
134
+ `between pages, this is a characteristic of the site's shared code rather than a defect ` +
135
+ `on any one template — fixing individual pages will not move it. Investigate the common ` +
136
+ `layer: the shared bundle, the third-party tags loaded on every page, or the base template.`,
137
+ evidence: {
138
+ audit_id: `crux.${vital}.systemic`,
139
+ value: formatVitalValue(vital, med),
140
+ failing_runs: failing.length,
141
+ total_runs: measured.length,
142
+ range: `${formatVitalValue(vital, min)}–${formatVitalValue(vital, max)}`,
143
+ },
144
+ });
145
+ }
146
+ return { findings, collapsedVitals };
147
+ }
148
+ /** Core Web Vitals verdict per run, field-first, matching the manual report's tiers. */
149
+ function cwvVerdict(run) {
150
+ const core = ["lcp", "inp", "cls"];
151
+ let failing = 0;
152
+ for (const vital of core) {
153
+ const fieldMetric = run.field?.metrics[vital];
154
+ if (fieldMetric) {
155
+ if (classifyVital(vital, fieldMetric.p75) !== "good")
156
+ failing++;
157
+ continue;
158
+ }
159
+ // INP has no lab fallback, so a run with no field INP is scored on what exists.
160
+ const labValue = vital === "lcp" ? run.lab.lcpMs : vital === "cls" ? run.lab.cls : null;
161
+ if (labValue !== null && classifyVital(vital, labValue) !== "good")
162
+ failing++;
163
+ }
164
+ if (failing === 0)
165
+ return "pass";
166
+ return failing === 1 ? "needs_improvement" : "fail";
167
+ }
168
+ export function aggregate(runs) {
169
+ const strategies = [...new Set(runs.map((r) => r.strategy))];
170
+ const by_strategy = {};
171
+ for (const strategy of strategies) {
172
+ const scores = runs
173
+ .filter((r) => r.strategy === strategy)
174
+ .map((r) => r.scores.performance)
175
+ .filter((n) => typeof n === "number");
176
+ if (scores.length === 0)
177
+ continue;
178
+ by_strategy[strategy] = {
179
+ performance: { mean: mean(scores), min: Math.min(...scores), max: Math.max(...scores) },
180
+ };
181
+ }
182
+ const templateIds = [...new Set(runs.map((r) => r.template))];
183
+ const by_template = templateIds.map((template) => {
184
+ const group = runs.filter((r) => r.template === template);
185
+ const performance_mean = {};
186
+ for (const strategy of strategies) {
187
+ const scores = group
188
+ .filter((r) => r.strategy === strategy)
189
+ .map((r) => r.scores.performance)
190
+ .filter((n) => typeof n === "number");
191
+ if (scores.length > 0)
192
+ performance_mean[strategy] = mean(scores);
193
+ }
194
+ const worst = [...group].sort((a, b) => (a.scores.performance ?? 100) - (b.scores.performance ?? 100))[0];
195
+ return {
196
+ template,
197
+ label: group[0].label,
198
+ sampled: new Set(group.map((r) => r.url)).size,
199
+ performance_mean,
200
+ worst_url: worst?.url ?? "",
201
+ };
202
+ });
203
+ // Lab metrics failing across the runs. At 100% this is the "22/22 fail TBT"
204
+ // line — the strongest sentence in the manual report, and the one most likely
205
+ // to be miscounted by hand. Below 80% it is not a headline; `universal` says
206
+ // which case a reader is looking at.
207
+ const lab_metric_failures = [];
208
+ for (const [key, spec] of Object.entries(LAB_THRESHOLDS)) {
209
+ const values = runs
210
+ .map((r) => r.lab[key])
211
+ .filter((v) => typeof v === "number");
212
+ if (values.length === 0)
213
+ continue;
214
+ const failing = values.filter((v) => v > spec.good);
215
+ if (failing.length === 0)
216
+ continue;
217
+ const pct = Math.round((failing.length / values.length) * 100);
218
+ lab_metric_failures.push({
219
+ metric: key,
220
+ label: spec.label,
221
+ failing: failing.length,
222
+ of: values.length,
223
+ pct,
224
+ universal: pct >= 80,
225
+ range: `${Math.round(Math.min(...failing))} ms–${Math.round(Math.max(...failing))} ms`,
226
+ threshold: `${spec.good} ms`,
227
+ });
228
+ }
229
+ const cwv_verdicts = { pass: 0, needs_improvement: 0, fail: 0 };
230
+ for (const run of runs)
231
+ cwv_verdicts[cwvVerdict(run)]++;
232
+ const tally = (verdict) => {
233
+ const counts = new Map();
234
+ for (const run of runs) {
235
+ for (const c of run.comparisons) {
236
+ if (c.verdict !== verdict)
237
+ continue;
238
+ counts.set(c.metric, (counts.get(c.metric) ?? 0) + 1);
239
+ }
240
+ }
241
+ return [...counts.entries()]
242
+ .map(([metric, n]) => ({ metric, runs: n }))
243
+ .sort((a, b) => b.runs - a.runs);
244
+ };
245
+ // Outliers: a value far above the median for its own strategy. With
246
+ // runs_per_url = 1 there is nothing to check it against, so it is reported
247
+ // as unconfirmed rather than asserted — the manual report had to make this
248
+ // caveat in prose for its 9.97 s TBT reading.
249
+ const outliers = [];
250
+ for (const strategy of strategies) {
251
+ const group = runs.filter((r) => r.strategy === strategy);
252
+ for (const key of Object.keys(LAB_THRESHOLDS)) {
253
+ const values = group
254
+ .map((r) => r.lab[key])
255
+ .filter((v) => typeof v === "number");
256
+ if (values.length < 4)
257
+ continue;
258
+ const med = median(values);
259
+ if (med <= 0)
260
+ continue;
261
+ for (const run of group) {
262
+ const value = run.lab[key];
263
+ if (typeof value !== "number" || value / med < 3)
264
+ continue;
265
+ outliers.push({
266
+ url: run.url,
267
+ strategy,
268
+ metric: String(key),
269
+ value: `${Math.round(value)} ms`,
270
+ vs_median_multiple: Number((value / med).toFixed(1)),
271
+ confidence: run.runs > 1 ? `median_of_${run.runs}` : "unconfirmed_single_run",
272
+ });
273
+ }
274
+ }
275
+ }
276
+ return {
277
+ run_count: runs.length,
278
+ pages: new Set(runs.map((r) => r.url)).size,
279
+ captured_at: new Date().toISOString(),
280
+ by_strategy,
281
+ by_template,
282
+ lab_metric_failures,
283
+ cwv_verdicts,
284
+ lab_vs_field_summary: {
285
+ worse_in_field: tally("worse_in_field"),
286
+ worse_in_lab: tally("worse_in_lab"),
287
+ confirmed: tally("confirmed"),
288
+ no_field_data: runs.filter((r) => !r.field).length,
289
+ },
290
+ outliers,
291
+ };
292
+ }
293
+ //# sourceMappingURL=psiAggregator.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"psiAggregator.js","sourceRoot":"","sources":["../../src/mappers/psiAggregator.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Core Web Vitals field data → priorities and defect prose.
3
+ *
4
+ * The lab side of a PSI response goes through the existing
5
+ * `formatLighthouseFinding` path unchanged. This module handles the CrUX half,
6
+ * where the inputs are 75th-percentile measurements from real users rather
7
+ * than audit scores, so neither the impact-weight mapping nor the WCAG
8
+ * technique table applies.
9
+ *
10
+ * Field findings are written to read differently from lab findings on purpose.
11
+ * A lab finding says the page did something under simulation; a field finding
12
+ * says a measurable share of real people already experienced it. That
13
+ * distinction is the whole reason to call PSI, and it should survive into the
14
+ * defect ticket.
15
+ */
16
+ import type { Finding, Priority } from "../types.js";
17
+ import type { CruxMetric, WebVital } from "../utils/psiParser.js";
18
+ export type VitalRating = "good" | "needs-improvement" | "poor";
19
+ export declare function vitalLabel(vital: WebVital): string;
20
+ /**
21
+ * LCP, INP and CLS gate a release and affect ranking; FCP and TTFB explain
22
+ * them. Exported because the priority cap has to hold everywhere a priority is
23
+ * decided, not just where one is first assigned.
24
+ */
25
+ export declare function isCoreVital(vital: WebVital): boolean;
26
+ /**
27
+ * Rate a p75 value against Google's thresholds.
28
+ *
29
+ * PSI also returns its own FAST/AVERAGE/SLOW `category` per metric, and the two
30
+ * agree in practice. We classify from the published thresholds anyway, so that
31
+ * the boundary a finding was raised at is a documented number in this file
32
+ * rather than a verdict from an opaque field — and so origin-level and
33
+ * URL-level metrics are graded identically.
34
+ */
35
+ export declare function classifyVital(vital: WebVital, p75: number): VitalRating;
36
+ /**
37
+ * Poor → P1, needs improvement → P2, good → no finding (never report a passing
38
+ * check). Non-core diagnostics cap at P2.
39
+ */
40
+ export declare function fieldVitalToPriority(vital: WebVital, p75: number): Priority | null;
41
+ /** Human-readable measurement. CLS is unitless; everything else is milliseconds. */
42
+ export declare function formatVitalValue(vital: WebVital, value: number): string;
43
+ /**
44
+ * One field metric → a Finding, or null when real users are having a fine time.
45
+ *
46
+ * `source` reaches the evidence deliberately. An origin-level metric describes
47
+ * the whole site, not this page, and a reader deciding whether to act on the
48
+ * finding needs to know which they are looking at.
49
+ */
50
+ export declare function formatFieldFinding(vital: WebVital, metric: CruxMetric): Finding | null;
51
+ /** Every failing field metric in a parsed CrUX block, unsorted. */
52
+ export declare function formatFieldFindings(metrics: Partial<Record<WebVital, CruxMetric>>): Finding[];
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Core Web Vitals field data → priorities and defect prose.
3
+ *
4
+ * The lab side of a PSI response goes through the existing
5
+ * `formatLighthouseFinding` path unchanged. This module handles the CrUX half,
6
+ * where the inputs are 75th-percentile measurements from real users rather
7
+ * than audit scores, so neither the impact-weight mapping nor the WCAG
8
+ * technique table applies.
9
+ *
10
+ * Field findings are written to read differently from lab findings on purpose.
11
+ * A lab finding says the page did something under simulation; a field finding
12
+ * says a measurable share of real people already experienced it. That
13
+ * distinction is the whole reason to call PSI, and it should survive into the
14
+ * defect ticket.
15
+ */
16
+ /** Google's official p75 boundaries. */
17
+ const VITALS = {
18
+ lcp: { label: "Largest Contentful Paint", good: 2500, needsImprovement: 4000, isCoreVital: true },
19
+ inp: { label: "Interaction to Next Paint", good: 200, needsImprovement: 500, isCoreVital: true },
20
+ cls: { label: "Cumulative Layout Shift", good: 0.1, needsImprovement: 0.25, isCoreVital: true },
21
+ fcp: { label: "First Contentful Paint", good: 1800, needsImprovement: 3000, isCoreVital: false },
22
+ ttfb: { label: "Time to First Byte", good: 800, needsImprovement: 1800, isCoreVital: false },
23
+ };
24
+ export function vitalLabel(vital) {
25
+ return VITALS[vital].label;
26
+ }
27
+ /**
28
+ * LCP, INP and CLS gate a release and affect ranking; FCP and TTFB explain
29
+ * them. Exported because the priority cap has to hold everywhere a priority is
30
+ * decided, not just where one is first assigned.
31
+ */
32
+ export function isCoreVital(vital) {
33
+ return VITALS[vital].isCoreVital;
34
+ }
35
+ /**
36
+ * Rate a p75 value against Google's thresholds.
37
+ *
38
+ * PSI also returns its own FAST/AVERAGE/SLOW `category` per metric, and the two
39
+ * agree in practice. We classify from the published thresholds anyway, so that
40
+ * the boundary a finding was raised at is a documented number in this file
41
+ * rather than a verdict from an opaque field — and so origin-level and
42
+ * URL-level metrics are graded identically.
43
+ */
44
+ export function classifyVital(vital, p75) {
45
+ const spec = VITALS[vital];
46
+ if (p75 <= spec.good)
47
+ return "good";
48
+ if (p75 <= spec.needsImprovement)
49
+ return "needs-improvement";
50
+ return "poor";
51
+ }
52
+ /**
53
+ * Poor → P1, needs improvement → P2, good → no finding (never report a passing
54
+ * check). Non-core diagnostics cap at P2.
55
+ */
56
+ export function fieldVitalToPriority(vital, p75) {
57
+ const rating = classifyVital(vital, p75);
58
+ if (rating === "good")
59
+ return null;
60
+ if (rating === "needs-improvement")
61
+ return "P2";
62
+ return VITALS[vital].isCoreVital ? "P1" : "P2";
63
+ }
64
+ /** Human-readable measurement. CLS is unitless; everything else is milliseconds. */
65
+ export function formatVitalValue(vital, value) {
66
+ if (vital === "cls")
67
+ return value.toFixed(3).replace(/0+$/, "").replace(/\.$/, "");
68
+ return value >= 1000 ? `${(value / 1000).toFixed(1)} s` : `${Math.round(value)} ms`;
69
+ }
70
+ /** "7 in 10 users" reads more concretely in a ticket than "0.7019". */
71
+ function shareOfUsers(proportion) {
72
+ const pct = Math.round(proportion * 100);
73
+ return `${pct}% of real users`;
74
+ }
75
+ /**
76
+ * Defect prose for each vital, in the register `defectFormatter.ts` established:
77
+ * what the user experiences, not what the metric is called.
78
+ *
79
+ * Every template states the share of real users in the poor bucket. A p75 alone
80
+ * invites the reply "that's just the tail" — naming the proportion answers it
81
+ * before it is asked, and the number is already in the response.
82
+ */
83
+ const FIELD_DESCRIPTIONS = {
84
+ lcp: (_m, value, poor) => `Real users wait ${value} for the main page content to appear (75th percentile, trailing 28 days). ${poor} experience a load slow enough to be rated poor, well past the point where visitors begin abandoning the page.`,
85
+ inp: (_m, value, poor) => `The page takes ${value} to respond visibly after a real user taps or clicks (75th percentile, trailing 28 days). ${poor} experience responsiveness rated poor — taps appear to do nothing, so users tap again and trigger duplicate actions.`,
86
+ cls: (_m, value, poor) => `Real users see the layout shift by ${value} while the page loads (75th percentile, trailing 28 days). ${poor} experience shifting rated poor, which causes mis-taps on the wrong control and loss of reading position.`,
87
+ fcp: (_m, value, poor) => `Real users stare at a blank screen for ${value} before anything paints (75th percentile, trailing 28 days). ${poor} experience a first paint rated poor. This is a diagnostic for the slow Largest Contentful Paint rather than a defect to fix on its own.`,
88
+ ttfb: (_m, value, poor) => `The server takes ${value} to return the first byte for real users (75th percentile, trailing 28 days). ${poor} experience a response rated poor; every downstream resource waits on this, so it caps how fast the rest of the page can possibly be.`,
89
+ };
90
+ /**
91
+ * One field metric → a Finding, or null when real users are having a fine time.
92
+ *
93
+ * `source` reaches the evidence deliberately. An origin-level metric describes
94
+ * the whole site, not this page, and a reader deciding whether to act on the
95
+ * finding needs to know which they are looking at.
96
+ */
97
+ export function formatFieldFinding(vital, metric) {
98
+ const priority = fieldVitalToPriority(vital, metric.p75);
99
+ if (!priority)
100
+ return null;
101
+ const value = formatVitalValue(vital, metric.p75);
102
+ const poor = shareOfUsers(metric.distribution.poor);
103
+ const spec = VITALS[vital];
104
+ const scope = metric.source === "origin" ? " (site-wide data)" : "";
105
+ return {
106
+ priority,
107
+ title: `${spec.label} is ${classifyVital(vital, metric.p75) === "poor" ? "poor" : "below target"} for real users${scope}`,
108
+ description: FIELD_DESCRIPTIONS[vital](metric, value, poor) +
109
+ (metric.source === "origin"
110
+ ? " This URL has too little traffic for its own field data, so these figures describe the whole origin and may not reflect this page."
111
+ : ""),
112
+ evidence: {
113
+ audit_id: `crux.${vital}`,
114
+ value,
115
+ threshold: formatVitalValue(vital, spec.good),
116
+ field_source: metric.source,
117
+ users_affected_pct: Math.round(metric.distribution.poor * 100),
118
+ },
119
+ };
120
+ }
121
+ /** Every failing field metric in a parsed CrUX block, unsorted. */
122
+ export function formatFieldFindings(metrics) {
123
+ const findings = [];
124
+ for (const [vital, metric] of Object.entries(metrics)) {
125
+ const finding = formatFieldFinding(vital, metric);
126
+ if (finding)
127
+ findings.push(finding);
128
+ }
129
+ return findings;
130
+ }
131
+ //# sourceMappingURL=webVitalsMapper.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"webVitalsMapper.js","sourceRoot":"","sources":["../../src/mappers/webVitalsMapper.ts"],"names":[],"mappings":"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"}
@@ -3,56 +3,97 @@ import { runShell } from "../utils/shellRunner.js";
3
3
  import { parsePa11yJSON } from "../utils/outputParsers.js";
4
4
  import { shellErrorResponse, parseErrorResponse, } from "../utils/toolResponse.js";
5
5
  import { formatA11yFinding } from "../mappers/defectFormatter.js";
6
+ import { dedupeA11yFindings } from "../mappers/a11yDedupe.js";
6
7
  import { sortFindingsByPriority } from "../mappers/priorityMapper.js";
7
8
  const inputShape = {
8
9
  url: z.string().url(),
9
10
  standard: z.enum(["WCAG2A", "WCAG2AA", "WCAG2AAA"]).optional(),
10
11
  ignore: z.array(z.string()).optional(),
12
+ runner: z
13
+ .enum(["htmlcs", "axe", "both"])
14
+ .optional()
15
+ .describe("Which accessibility engine to run. 'htmlcs' (default) checks WCAG " +
16
+ "techniques and is strong on document structure, labels, and forms. " +
17
+ "'axe' is Deque's engine and is materially stronger on ARIA — roles, " +
18
+ "required parent/child relationships, prohibited and unsupported " +
19
+ "attributes — and on computed colour contrast, so prefer it when the " +
20
+ "work under test involves ARIA or a component library. 'both' runs " +
21
+ "the two and merges the results, which is the most thorough option " +
22
+ "and roughly doubles runtime."),
11
23
  };
12
24
  export function registerAccessibilityTool(server) {
13
25
  server.registerTool("run_accessibility_check", {
14
- description: "Runs pa11y against a URL and returns a QA-style report with WCAG " +
15
- "violations mapped to priorities (Level A P1, AA → P2, AAA → P3; " +
16
- "notices are filtered out). Requires the pa11y CLI to be installed " +
17
- "globally on PATH (`npm install -g pa11y`).",
26
+ description: "Runs pa11y against a URL and returns a QA-style report of WCAG " +
27
+ "violations prioritised P1/P2/P3 (notices are filtered out). " +
28
+ "\n\n" +
29
+ "Two engines are available via `runner`. htmlcs (default) checks WCAG " +
30
+ "techniques and is strong on document structure, labels, and forms. " +
31
+ "axe is Deque's engine and is materially stronger on ARIA — invalid " +
32
+ "roles, missing required parent/child relationships, prohibited and " +
33
+ "unsupported attributes — and on computed colour contrast. " +
34
+ "**Prefer runner='axe' whenever the work under test involves ARIA, a " +
35
+ "component library, or a design system**, and runner='both' for the " +
36
+ "most thorough sweep. The two engines overlap only partly: on a real " +
37
+ "commerce page htmlcs found unlabelled inputs and duplicate ids that " +
38
+ "axe did not, while axe found aria-allowed-attr, aria-required-parent " +
39
+ "and image-alt failures htmlcs missed entirely. " +
40
+ "\n\n" +
41
+ "Requires the pa11y CLI on PATH (`npm install -g pa11y`).",
18
42
  inputSchema: inputShape,
19
- }, async ({ url, standard, ignore }) => {
43
+ }, async ({ url, standard, ignore, runner }) => {
20
44
  const resolvedStandard = standard ?? "WCAG2AA";
21
- const args = [
22
- url,
23
- "--reporter",
24
- "json",
25
- "--standard",
26
- resolvedStandard,
27
- ];
28
- if (ignore && ignore.length > 0) {
29
- args.push("--ignore", ignore.join(";"));
30
- }
31
- const result = await runShell("pa11y", args, { timeoutMs: 120_000 });
45
+ const resolvedRunner = runner ?? "htmlcs";
46
+ const engines = resolvedRunner === "both" ? ["htmlcs", "axe"] : [resolvedRunner];
47
+ const buildArgs = (engine) => {
48
+ const args = [url, "--reporter", "json", "--standard", resolvedStandard,
49
+ "--runner", engine];
50
+ if (ignore && ignore.length > 0)
51
+ args.push("--ignore", ignore.join(";"));
52
+ return args;
53
+ };
54
+ // Engines run concurrently so "both" costs little more wall time than one.
55
+ const results = await Promise.all(engines.map((e) => runShell("pa11y", buildArgs(e), { timeoutMs: 120_000 })));
32
56
  // pa11y exits 2 when issues are found — that's a successful run with data.
33
57
  // Exit 1 means pa11y itself failed (browser launch, bad URL, etc.).
34
- const ranSuccessfully = result.exitCode === 0 || result.exitCode === 2;
35
- if (!ranSuccessfully || !result.stdout) {
36
- return shellErrorResponse("pa11y did not produce a usable report", result);
37
- }
38
- let parsed;
39
- try {
40
- parsed = parsePa11yJSON(result.stdout);
41
- }
42
- catch (err) {
43
- return parseErrorResponse("Failed to parse pa11y JSON", err, result);
58
+ const usable = results.filter((r) => (r.exitCode === 0 || r.exitCode === 2) && r.stdout);
59
+ if (usable.length === 0) {
60
+ return shellErrorResponse("pa11y did not produce a usable report", results[0]);
44
61
  }
45
- const findings = [];
46
- for (const violation of parsed.violations) {
47
- const finding = formatA11yFinding(violation);
48
- if (finding)
49
- findings.push(finding);
62
+ const rawFindings = [];
63
+ for (const result of usable) {
64
+ let parsed;
65
+ try {
66
+ parsed = parsePa11yJSON(result.stdout);
67
+ }
68
+ catch (err) {
69
+ return parseErrorResponse("Failed to parse pa11y JSON", err, result);
70
+ }
71
+ for (const violation of parsed.violations) {
72
+ const finding = formatA11yFinding(violation);
73
+ if (finding)
74
+ rawFindings.push(finding);
75
+ }
50
76
  }
77
+ // Collapse repeats of the same defect before counting or sorting — see
78
+ // a11yDedupe for why pa11y produces them. Note this dedupes *within* an
79
+ // engine only: the key is (rule_code, selector), and the two engines emit
80
+ // different code shapes for the same defect ("...1_1_1.H37" vs
81
+ // "image-alt"), so an element both engines flag appears twice. That is
82
+ // deliberate for now — two independent engines agreeing is corroboration
83
+ // worth seeing, the same signal the Lighthouse/pa11y correlator promotes.
84
+ const { findings, rawCount } = dedupeA11yFindings(rawFindings);
51
85
  sortFindingsByPriority(findings);
52
86
  const report = {
53
87
  url,
54
88
  standard: resolvedStandard,
89
+ runners: engines,
90
+ ...(usable.length < engines.length
91
+ ? { warnings: [`Only ${usable.length} of ${engines.length} runners produced output.`] }
92
+ : {}),
55
93
  violation_count: findings.length,
94
+ // Pre-dedup total, so a large drop between the two is explainable
95
+ // rather than looking like dropped findings.
96
+ raw_violation_count: rawCount,
56
97
  findings,
57
98
  };
58
99
  return {