nfunc-mcp 0.3.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.
- package/README.md +84 -376
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/mappers/labFieldComparator.d.ts +62 -0
- package/dist/mappers/labFieldComparator.js +134 -0
- package/dist/mappers/labFieldComparator.js.map +1 -0
- package/dist/mappers/psiAggregator.d.ts +130 -0
- package/dist/mappers/psiAggregator.js +293 -0
- package/dist/mappers/psiAggregator.js.map +1 -0
- package/dist/mappers/webVitalsMapper.d.ts +52 -0
- package/dist/mappers/webVitalsMapper.js +131 -0
- package/dist/mappers/webVitalsMapper.js.map +1 -0
- package/dist/tools/performanceAudit.d.ts +2 -0
- package/dist/tools/performanceAudit.js +446 -0
- package/dist/tools/performanceAudit.js.map +1 -0
- package/dist/tools/performanceAuditPlan.d.ts +2 -0
- package/dist/tools/performanceAuditPlan.js +438 -0
- package/dist/tools/performanceAuditPlan.js.map +1 -0
- package/dist/utils/csvReader.d.ts +20 -0
- package/dist/utils/csvReader.js +172 -0
- package/dist/utils/csvReader.js.map +1 -0
- package/dist/utils/httpClient.d.ts +84 -0
- package/dist/utils/httpClient.js +171 -0
- package/dist/utils/httpClient.js.map +1 -0
- package/dist/utils/psiAuth.d.ts +26 -0
- package/dist/utils/psiAuth.js +36 -0
- package/dist/utils/psiAuth.js.map +1 -0
- package/dist/utils/psiParser.d.ts +124 -0
- package/dist/utils/psiParser.js +200 -0
- package/dist/utils/psiParser.js.map +1 -0
- package/dist/utils/publicUrl.d.ts +17 -0
- package/dist/utils/publicUrl.js +115 -0
- package/dist/utils/publicUrl.js.map +1 -0
- package/dist/utils/sitemapReader.d.ts +27 -0
- package/dist/utils/sitemapReader.js +272 -0
- package/dist/utils/sitemapReader.js.map +1 -0
- package/dist/utils/urlClassifier.d.ts +45 -0
- package/dist/utils/urlClassifier.js +267 -0
- package/dist/utils/urlClassifier.js.map +1 -0
- package/docs/manual.md +558 -0
- package/docs/psi-report-spec.md +174 -0
- package/package.json +13 -3
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lab versus field disagreement — the reason this tool exists.
|
|
3
|
+
*
|
|
4
|
+
* A Lighthouse run is a simulation on one machine under one throttling
|
|
5
|
+
* profile. CrUX is what actually happened to real people. When the two agree,
|
|
6
|
+
* confidence is high. When they disagree, the direction of the disagreement is
|
|
7
|
+
* itself the finding, and it is not available from any other tool in this MCP.
|
|
8
|
+
*
|
|
9
|
+
* The Five Below homepage is the canonical case: lab CLS 0 against field CLS
|
|
10
|
+
* 0.55, and lab LCP 11.6 s against field LCP 2.8 s — both metrics inverted, in
|
|
11
|
+
* opposite directions, on one page. A report built on the lab numbers alone
|
|
12
|
+
* led with an LCP emergency real users were not experiencing and missed a
|
|
13
|
+
* layout-shift failure that 70% of them were.
|
|
14
|
+
*/
|
|
15
|
+
import { classifyVital, formatVitalValue, isCoreVital, vitalLabel } from "./webVitalsMapper.js";
|
|
16
|
+
/**
|
|
17
|
+
* Lab audits that measure the same thing as a field metric.
|
|
18
|
+
*
|
|
19
|
+
* INP is deliberately absent, and its absence is load-bearing. Lighthouse
|
|
20
|
+
* cannot produce an INP value at all — INP requires a real interaction, and a
|
|
21
|
+
* lab run never interacts with the page. Total Blocking Time is a *proxy* for
|
|
22
|
+
* responsiveness, not the same measurement, so comparing them would manufacture
|
|
23
|
+
* agreement or disagreement out of two different quantities. INP field findings
|
|
24
|
+
* therefore pass through the comparator untouched, which is why the systemic
|
|
25
|
+
* collapse rule in the aggregator has to exist.
|
|
26
|
+
*/
|
|
27
|
+
const LAB_EQUIVALENT = {
|
|
28
|
+
lcp: "lcpMs",
|
|
29
|
+
cls: "cls",
|
|
30
|
+
fcp: "fcpMs",
|
|
31
|
+
ttfb: "ttfbMs",
|
|
32
|
+
};
|
|
33
|
+
/** Lighthouse audit ids, for matching a lab finding back to the vital it measures. */
|
|
34
|
+
export const AUDIT_TO_VITAL = {
|
|
35
|
+
"largest-contentful-paint": "lcp",
|
|
36
|
+
"cumulative-layout-shift": "cls",
|
|
37
|
+
"first-contentful-paint": "fcp",
|
|
38
|
+
"server-response-time": "ttfb",
|
|
39
|
+
};
|
|
40
|
+
function noteFor(verdict, vital, labDisplay, fieldDisplay) {
|
|
41
|
+
const name = vitalLabel(vital);
|
|
42
|
+
switch (verdict) {
|
|
43
|
+
case "confirmed":
|
|
44
|
+
return `${name} fails in the lab (${labDisplay}) and for real users (${fieldDisplay}). Confirmed by two independent measurements — treat as real and fix.`;
|
|
45
|
+
case "worse_in_field":
|
|
46
|
+
return `The lab run passed ${name} at ${labDisplay}, but real users are at ${fieldDisplay}. The test environment is not reproducing what people actually experience — a real-world network, device class, geography or a third-party script that only loads in production. This is the highest-value finding type here, because no local tool can surface it.`;
|
|
47
|
+
case "worse_in_lab":
|
|
48
|
+
return `${name} fails in the lab (${labDisplay}) but real users are at ${fieldDisplay}, which passes. The lab profile is harsher than this page's actual audience. Treat the lab number as a stress signal rather than a user-experienced defect, and prioritise accordingly.`;
|
|
49
|
+
case "both_pass":
|
|
50
|
+
return `${name} is within target in both the lab (${labDisplay}) and the field (${fieldDisplay}).`;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Compare every field metric that has a lab counterpart.
|
|
55
|
+
*
|
|
56
|
+
* `both_pass` rows are returned rather than filtered, because the comparison
|
|
57
|
+
* table in the report is a statement about coverage — a reader needs to see
|
|
58
|
+
* that LCP was checked and agreed, not infer it from an absence.
|
|
59
|
+
*/
|
|
60
|
+
export function compareLabField(lab, field) {
|
|
61
|
+
if (!field)
|
|
62
|
+
return [];
|
|
63
|
+
const comparisons = [];
|
|
64
|
+
for (const [vital, labKey] of Object.entries(LAB_EQUIVALENT)) {
|
|
65
|
+
const fieldMetric = field.metrics[vital];
|
|
66
|
+
if (!fieldMetric)
|
|
67
|
+
continue;
|
|
68
|
+
const labValue = lab[labKey];
|
|
69
|
+
const fieldFails = classifyVital(vital, fieldMetric.p75) !== "good";
|
|
70
|
+
// No lab reading is not a pass; it is an unknown, and the field number
|
|
71
|
+
// stands on its own.
|
|
72
|
+
const labFails = labValue === null ? fieldFails : classifyVital(vital, labValue) !== "good";
|
|
73
|
+
const verdict = labFails && fieldFails ? "confirmed"
|
|
74
|
+
: !labFails && fieldFails ? "worse_in_field"
|
|
75
|
+
: labFails && !fieldFails ? "worse_in_lab"
|
|
76
|
+
: "both_pass";
|
|
77
|
+
const labDisplay = labValue === null ? null : formatVitalValue(vital, labValue);
|
|
78
|
+
const fieldDisplay = formatVitalValue(vital, fieldMetric.p75);
|
|
79
|
+
comparisons.push({
|
|
80
|
+
metric: vital,
|
|
81
|
+
label: vitalLabel(vital),
|
|
82
|
+
lab: labValue,
|
|
83
|
+
lab_display: labDisplay,
|
|
84
|
+
field_p75: fieldMetric.p75,
|
|
85
|
+
field_display: fieldDisplay,
|
|
86
|
+
field_source: fieldMetric.source,
|
|
87
|
+
verdict,
|
|
88
|
+
note: noteFor(verdict, vital, labDisplay, fieldDisplay),
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
return comparisons;
|
|
92
|
+
}
|
|
93
|
+
const UP = { P1: "P1", P2: "P1", P3: "P2" };
|
|
94
|
+
const DOWN = { P1: "P2", P2: "P3", P3: "P3" };
|
|
95
|
+
/**
|
|
96
|
+
* Promote, but never past a diagnostic's ceiling.
|
|
97
|
+
*
|
|
98
|
+
* `webVitalsMapper` caps FCP and TTFB at P2 because they explain a Core Web
|
|
99
|
+
* Vital rather than being one. Field confirmation makes a finding more
|
|
100
|
+
* certain, not more important, so an unguarded promotion quietly defeated that
|
|
101
|
+
* cap and put FCP at P1 above the LCP it was describing.
|
|
102
|
+
*/
|
|
103
|
+
export function promote(priority, vital) {
|
|
104
|
+
const promoted = UP[priority];
|
|
105
|
+
if (vital && !isCoreVital(vital) && promoted === "P1")
|
|
106
|
+
return "P2";
|
|
107
|
+
return promoted;
|
|
108
|
+
}
|
|
109
|
+
export function demote(priority) {
|
|
110
|
+
return DOWN[priority];
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* How a verdict should move a finding's priority.
|
|
114
|
+
*
|
|
115
|
+
* Field observation outranks simulation, so the two adjustments are not
|
|
116
|
+
* symmetric in what they mean: a promotion says "real users confirm this", a
|
|
117
|
+
* demotion says "only the simulation saw this". A demoted finding is tagged
|
|
118
|
+
* rather than dropped — the lab number is still true, it just is not evidence
|
|
119
|
+
* of user harm, and silently deleting it would hide a genuine regression
|
|
120
|
+
* signal from anyone comparing runs over time.
|
|
121
|
+
*/
|
|
122
|
+
export function adjustmentFor(verdict) {
|
|
123
|
+
switch (verdict) {
|
|
124
|
+
case "confirmed":
|
|
125
|
+
return { direction: "promote", tag: "field_confirmed" };
|
|
126
|
+
case "worse_in_field":
|
|
127
|
+
return { direction: "promote", tag: "field_only" };
|
|
128
|
+
case "worse_in_lab":
|
|
129
|
+
return { direction: "demote", tag: "lab_only" };
|
|
130
|
+
default:
|
|
131
|
+
return { direction: "none" };
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
//# sourceMappingURL=labFieldComparator.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"labFieldComparator.js","sourceRoot":"","sources":["../../src/mappers/labFieldComparator.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,130 @@
|
|
|
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 type { Finding } from "../types.js";
|
|
17
|
+
import type { LabMetrics, ParsedCrux, WebVital } from "../utils/psiParser.js";
|
|
18
|
+
import type { MetricComparison } from "./labFieldComparator.js";
|
|
19
|
+
export interface RunResult {
|
|
20
|
+
template: string;
|
|
21
|
+
label: string;
|
|
22
|
+
url: string;
|
|
23
|
+
strategy: string;
|
|
24
|
+
runs: number;
|
|
25
|
+
scores: Record<string, number>;
|
|
26
|
+
lab: LabMetrics;
|
|
27
|
+
field: ParsedCrux | null;
|
|
28
|
+
comparisons: MetricComparison[];
|
|
29
|
+
findings: Finding[];
|
|
30
|
+
report_file: string;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Rule 2 — component suppression.
|
|
34
|
+
*
|
|
35
|
+
* FCP is a component of LCP: if the largest element paints late, the first one
|
|
36
|
+
* usually did too. Across the Five Below batch the two co-occurred on 17 of 25
|
|
37
|
+
* runs and FCP never once reached P1, so an FCP finding alongside an LCP
|
|
38
|
+
* finding is two tickets describing one defect. The FCP measurement is kept as
|
|
39
|
+
* evidence on the LCP finding rather than discarded.
|
|
40
|
+
*
|
|
41
|
+
* TTFB is deliberately *not* treated this way. It fired on one run out of 25,
|
|
42
|
+
* which is the signature of a metric carrying independent information.
|
|
43
|
+
*/
|
|
44
|
+
export declare function suppressComponentFindings(findings: Finding[]): Finding[];
|
|
45
|
+
export interface SystemicResult {
|
|
46
|
+
findings: Finding[];
|
|
47
|
+
/** Vitals whose per-page findings this replaces. */
|
|
48
|
+
collapsedVitals: WebVital[];
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Rule 1 — systemic collapse.
|
|
52
|
+
*
|
|
53
|
+
* A vital rated below "good" on 80%+ of runs is describing the site, not any
|
|
54
|
+
* one page. INP failed on 25 of 25 Five Below runs with a 2.1x spread and a
|
|
55
|
+
* 316 ms median: twenty-five findings that each say "fix this page" when the
|
|
56
|
+
* true statement is "this site's interaction handling is uniformly mediocre".
|
|
57
|
+
*
|
|
58
|
+
* The threshold is deliberately well above a simple majority — a vital failing
|
|
59
|
+
* on half the pages is discriminating between them, and that is information
|
|
60
|
+
* worth keeping per page.
|
|
61
|
+
*
|
|
62
|
+
* Priority is the worst observed, not an average: a vital that is poor
|
|
63
|
+
* everywhere is not less urgent for being ubiquitous.
|
|
64
|
+
*/
|
|
65
|
+
export declare function collapseSystemicFindings(runs: RunResult[]): SystemicResult;
|
|
66
|
+
export interface PsiAggregate {
|
|
67
|
+
run_count: number;
|
|
68
|
+
pages: number;
|
|
69
|
+
captured_at: string;
|
|
70
|
+
by_strategy: Record<string, {
|
|
71
|
+
performance: {
|
|
72
|
+
mean: number;
|
|
73
|
+
min: number;
|
|
74
|
+
max: number;
|
|
75
|
+
};
|
|
76
|
+
}>;
|
|
77
|
+
by_template: Array<{
|
|
78
|
+
template: string;
|
|
79
|
+
label: string;
|
|
80
|
+
sampled: number;
|
|
81
|
+
performance_mean: Record<string, number>;
|
|
82
|
+
worst_url: string;
|
|
83
|
+
}>;
|
|
84
|
+
/**
|
|
85
|
+
* Lab metrics failing across the audited runs. Renamed from
|
|
86
|
+
* `universal_failures`, which was a lie on any site where a metric failed on
|
|
87
|
+
* some pages but not others — it reported a 25% failure rate under a name the
|
|
88
|
+
* report spec told the agent to treat as a headline finding. `universal` now
|
|
89
|
+
* says explicitly whether the "fails on essentially every page" claim holds.
|
|
90
|
+
*/
|
|
91
|
+
lab_metric_failures: Array<{
|
|
92
|
+
metric: string;
|
|
93
|
+
label: string;
|
|
94
|
+
failing: number;
|
|
95
|
+
of: number;
|
|
96
|
+
pct: number;
|
|
97
|
+
universal: boolean;
|
|
98
|
+
range: string;
|
|
99
|
+
threshold: string;
|
|
100
|
+
}>;
|
|
101
|
+
cwv_verdicts: {
|
|
102
|
+
pass: number;
|
|
103
|
+
needs_improvement: number;
|
|
104
|
+
fail: number;
|
|
105
|
+
};
|
|
106
|
+
lab_vs_field_summary: {
|
|
107
|
+
worse_in_field: Array<{
|
|
108
|
+
metric: string;
|
|
109
|
+
runs: number;
|
|
110
|
+
}>;
|
|
111
|
+
worse_in_lab: Array<{
|
|
112
|
+
metric: string;
|
|
113
|
+
runs: number;
|
|
114
|
+
}>;
|
|
115
|
+
confirmed: Array<{
|
|
116
|
+
metric: string;
|
|
117
|
+
runs: number;
|
|
118
|
+
}>;
|
|
119
|
+
no_field_data: number;
|
|
120
|
+
};
|
|
121
|
+
outliers: Array<{
|
|
122
|
+
url: string;
|
|
123
|
+
strategy: string;
|
|
124
|
+
metric: string;
|
|
125
|
+
value: string;
|
|
126
|
+
vs_median_multiple: number;
|
|
127
|
+
confidence: string;
|
|
128
|
+
}>;
|
|
129
|
+
}
|
|
130
|
+
export declare function aggregate(runs: RunResult[]): PsiAggregate;
|
|
@@ -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[];
|