kaloko 0.18.0 → 0.19.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/package.json +1 -1
- package/schemas/config.v1.json +36 -1
- package/schemas/run.v1.json +47 -1
- package/schemas/scenario.v1.json +81 -14
- package/skills/kaloko/reference/acceptance.md +18 -4
- package/skills/kaloko/reference/evaluation.md +44 -1
- package/skills/kaloko/reference/integrations.md +6 -0
- package/skills/kaloko/reference/mcp.md +2 -0
- package/skills/kaloko/reference/stacks.md +104 -14
- package/src/cli.mjs +157 -33
- package/src/lib/build.mjs +18 -7
- package/src/lib/capture.mjs +31 -9
- package/src/lib/config.mjs +15 -1
- package/src/lib/crawler.mjs +242 -0
- package/src/lib/draft.mjs +2 -0
- package/src/lib/evaluate.mjs +124 -38
- package/src/lib/findings.mjs +73 -0
- package/src/lib/health-core.mjs +28 -5
- package/src/lib/health-index.mjs +187 -0
- package/src/lib/health.mjs +4 -0
- package/src/lib/jev.mjs +34 -14
- package/src/lib/linkcheck.mjs +59 -22
- package/src/lib/local-evaluator.mjs +270 -0
- package/src/lib/manifest-core.mjs +38 -12
- package/src/lib/pack-help.mjs +24 -0
- package/src/lib/packs.mjs +41 -3
- package/src/lib/pii.mjs +2 -0
- package/src/lib/pr.mjs +5 -1
- package/src/lib/probe.mjs +62 -1
- package/src/lib/recheck.mjs +5 -1
- package/src/lib/scenario.mjs +11 -3
- package/src/lib/seo-core.mjs +372 -0
- package/src/lib/seo-init.mjs +272 -0
- package/src/lib/service.mjs +31 -2
- package/src/lib/site.mjs +362 -0
- package/src/lib/stack.mjs +230 -23
- package/src/lib/upload.mjs +3 -2
- package/viewer/index.html +31 -17
package/src/lib/evaluate.mjs
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { join, resolve } from 'node:path'
|
|
2
2
|
import { readJson, writeJson, sha256 } from './util.mjs'
|
|
3
3
|
import { readCaptures, runPaths } from './run.mjs'
|
|
4
|
-
import { buildRequest,
|
|
4
|
+
import { buildRequest, verdictFor, PROMPT_VERSION, SIGNAL_NAMES, purposeOf } from './jev.mjs'
|
|
5
|
+
import { textAsker, evaluatorBudget, jevSettings, safeUrl, LOCAL_PROMPT_VERSION } from './local-evaluator.mjs'
|
|
6
|
+
import { appliesToState, STACK_SEMANTIC_SAMPLE } from './manifest-core.mjs'
|
|
5
7
|
import { visionSettings, visionCriteria, templateReason, VISION_PROMPT_VERSION } from './vision-core.mjs'
|
|
6
8
|
import { prepareImages, visionPlan, askVision, hostedVisionSender } from './vision.mjs'
|
|
7
9
|
import { triageSettings, probeFindings, TRIAGE_PROMPT_VERSION } from './triage-core.mjs'
|
|
@@ -10,7 +12,7 @@ import { KEYBOARD_ASSERTS } from './keyboard.mjs'
|
|
|
10
12
|
import { needsDrift, driftForCapture, loadTokens, driftCacheDir } from './drift.mjs'
|
|
11
13
|
import { packHelp } from './pack-help.mjs'
|
|
12
14
|
import { tokenCriteria, storedValueChecks, STYLE_ASSERTS } from './assertions.mjs'
|
|
13
|
-
import {
|
|
15
|
+
import { errorPageOnly, isErrorPage } from './packs.mjs'
|
|
14
16
|
import { isHealthCriterion, findingSeverity, toAcceptanceId } from './health-core.mjs'
|
|
15
17
|
|
|
16
18
|
/** Latest capture per node/locale/viewport. */
|
|
@@ -28,6 +30,8 @@ export const criteriaHash = (node) => sha256((node.criteria || []).map(({ accept
|
|
|
28
30
|
export const appliesOn = (c, environment) => !environment || !Array.isArray(c.environments) || !c.environments.length || c.environments.includes(environment)
|
|
29
31
|
/** Environment and step state (#46): a criterion with `states:` applies only to captures of those states. */
|
|
30
32
|
export const appliesTo = (c, environment, capture) => appliesOn(c, environment) && appliesToState(c, capture?.step_state)
|
|
33
|
+
/** The errors pack's soft-404 and explanation checks (HC601, HC605) apply to error pages only (packs.mjs isErrorPage). */
|
|
34
|
+
export const appliesToPage = (c, node, capture) => !errorPageOnly(c) || isErrorPage(node, capture)
|
|
31
35
|
/** Folder of a capture under steps/: the node, or steps/<node>/items/<item> for a page of a stack step. */
|
|
32
36
|
export const stepIdOf = (e) => (e.item ? `${e.node}/items/${e.item}` : e.node)
|
|
33
37
|
export const evaluationSource = (node, capture) => sha256({ capture, criteria: node.criteria || [], purpose: node.purpose || '' })
|
|
@@ -41,10 +45,11 @@ export function combine ({ node, capture, jev, vision = null, triage = null, cas
|
|
|
41
45
|
return (node.criteria || []).map((c) => {
|
|
42
46
|
if (!appliesOn(c, environment)) return { id: c.id, type: c.check.type, verdict: 'not_evaluated', evidence: `Applies on ${c.environments.join(', ')} only; this walk ran on ${environment}.`, na: true, not_applicable: 'environment' }
|
|
43
47
|
if (!appliesToState(c, capture?.step_state)) return { id: c.id, type: c.check.type, verdict: 'not_evaluated', evidence: `Applies to the state${c.states.length > 1 ? 's' : ''} ${c.states.join(', ')} only; this capture shows ${capture?.step_state || 'default'}.`, na: true, not_applicable: 'state' }
|
|
48
|
+
if (!appliesToPage(c, node, capture)) return { id: c.id, type: c.check.type, verdict: 'not_evaluated', evidence: `Checked on error pages only; this page answered HTTP ${capture?.http_status ?? '(unknown)'} and the step declares no expect_status of 400 or more.`, na: true, not_applicable: 'error_page' }
|
|
44
49
|
if (c.check.type === 'deterministic') {
|
|
45
50
|
const r = capture.checks?.[c.id]
|
|
46
51
|
const verdict = r == null || r.ok == null ? 'not_evaluated' : r.ok ? 'pass' : 'fail'
|
|
47
|
-
const live = c.check.assert === 'no_console_errors' || c.check.assert === 'no_overflow' || c.check.assert === 'http_status' || c.check.assert === 'links_ok' || c.check.assert === 'hreflang_pairs' || /^perf_/.test(c.check.assert || '') || KEYBOARD_ASSERTS.has(c.check.assert)
|
|
52
|
+
const live = c.check.assert === 'no_console_errors' || c.check.assert === 'no_overflow' || c.check.assert === 'http_status' || c.check.assert === 'links_ok' || c.check.assert === 'hreflang_pairs' || c.check.assert === 'llms_txt' || c.check.assert === 'nojs_parity' || /^perf_/.test(c.check.assert || '') || KEYBOARD_ASSERTS.has(c.check.assert)
|
|
48
53
|
return { id: c.id, type: 'deterministic', verdict, evidence: r?.evidence ?? null, ...(r?.na ? { na: true } : {}), ...(capture.rechecked_at && !live ? { rechecked: true } : {}) }
|
|
49
54
|
}
|
|
50
55
|
if (c.check.type === 'semantic' && c.check.about === 'console' && !(capture.console_errors || []).length && capture.checks) {
|
|
@@ -53,6 +58,12 @@ export function combine ({ node, capture, jev, vision = null, triage = null, cas
|
|
|
53
58
|
if (c.check.type === 'semantic') {
|
|
54
59
|
const answer = jev?.response?.answers?.[c.id]
|
|
55
60
|
const text = answer?.noul ?? null
|
|
61
|
+
// the text evaluator that gave this answer: JEV, your own model, or JEV after your model was unsure (#158)
|
|
62
|
+
const own = jev?.response?.provider === 'openai-compatible'
|
|
63
|
+
const esc = jev?.response?.escalation
|
|
64
|
+
const textProvider = answer?.escalated ? esc?.provider : jev?.response?.provider || 'typesafe-jev'
|
|
65
|
+
// with your own model in play every text verdict names who decided it: via, model and prompt
|
|
66
|
+
const origin = !own || !answer ? null : answer.escalated ? { via: esc.via, model: esc.model, prompt_version: esc.prompt_version } : { via: 'own-model', model: jev.response.model, prompt_version: LOCAL_PROMPT_VERSION }
|
|
56
67
|
const seen = vision?.answers?.[c.id] || null
|
|
57
68
|
// Clef's answer on the screenshot (#71): recorded beside the others; with cascade on it decides when it is sure
|
|
58
69
|
const clef = triage?.criteria?.[c.id] ?? null
|
|
@@ -62,10 +73,12 @@ export function combine ({ node, capture, jev, vision = null, triage = null, cas
|
|
|
62
73
|
const reason = seen?.reason || (clefDecides ? `Judged from the screenshot by Clef: ${Math.round(clef * 100)} % yes, clear enough for a verdict.` : templateReason({ score, scope: c.check.scope, about: c.check.about, verdict }))
|
|
63
74
|
return {
|
|
64
75
|
id: c.id, type: 'semantic', verdict, evidence: null, score,
|
|
65
|
-
provider: seen ? `${vision.provider || 'anthropic'}-vision` : clefDecides ? 'cloudflare-clef' : answer ?
|
|
66
|
-
note: score == null ? (jev?.skipped || jev?.error || vision?.skipped || vision?.error || null) : null,
|
|
76
|
+
provider: seen ? `${vision.provider || 'anthropic'}-vision` : clefDecides ? 'cloudflare-clef' : answer ? textProvider : null,
|
|
77
|
+
note: score == null ? (jev?.skipped || jev?.error || (own ? esc?.skipped || esc?.error || 'your own evaluator gave no usable answer' : null) || vision?.skipped || vision?.error || null) : null,
|
|
78
|
+
...(origin && !seen && !clefDecides ? origin : {}),
|
|
67
79
|
...(reason ? { reason, reason_source: seen?.reason ? 'model' : 'template' } : {}),
|
|
68
80
|
...((seen || clefDecides) && text != null ? { text_score: text } : {}),
|
|
81
|
+
...(answer?.escalated && answer.first_noul != null ? { first_score: answer.first_noul } : {}),
|
|
69
82
|
...(clef != null ? { clef_score: clef } : {}),
|
|
70
83
|
...(!seen && (vision?.skipped || vision?.error) && vision.ids?.includes(c.id) ? { vision_note: vision.skipped || vision.error } : {})
|
|
71
84
|
}
|
|
@@ -76,14 +89,77 @@ export function combine ({ node, capture, jev, vision = null, triage = null, cas
|
|
|
76
89
|
|
|
77
90
|
const BATCH = 50
|
|
78
91
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
92
|
+
/**
|
|
93
|
+
* What decides a stack page's place in a prioritised sample: a failing deterministic acceptance criterion of the step
|
|
94
|
+
* (advisory health checks do not count), and when the page was checked last.
|
|
95
|
+
*/
|
|
96
|
+
export const itemFacts = (capture, node = null) => {
|
|
97
|
+
const acceptance = node ? new Set((node.criteria || []).filter((c) => c.check?.type === 'deterministic' && !isHealthCriterion(c)).map((c) => c.id)) : null
|
|
98
|
+
return {
|
|
99
|
+
failing: Object.entries(capture?.checks || {}).some(([id, r]) => r && r.ok === false && !r.na && (!acceptance || acceptance.has(id))),
|
|
100
|
+
last_checked: capture?.item?.last_checked ?? null
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** A stack step's sample is chosen by priority with a budget or items.semantic_order: priority; else in walk order. */
|
|
105
|
+
export const prioritised = (node, budget) => node?.items?.semantic_order === 'priority' || (budget?.limit != null && node?.items?.semantic_order !== 'walk')
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The pages of each stack step that get the semantic questions (items.semantic_sample, default 20). With facts for a
|
|
109
|
+
* step (a prioritised sample, #158) the most useful come first: pages failing a deterministic check, then pages never
|
|
110
|
+
* or least recently checked (last_checked of a rotating sample), then the rest in walk order; without, walk order.
|
|
111
|
+
* Stable: the same captures give the same sample.
|
|
112
|
+
* facts: Map "node|item" → { failing, last_checked } (itemFacts of each capture, merged). Returns Map node → item ids
|
|
113
|
+
* in that order.
|
|
114
|
+
*/
|
|
115
|
+
export function sampleItems (latest, nodes, facts = new Map()) {
|
|
116
|
+
const order = new Map()
|
|
117
|
+
for (const e of latest) {
|
|
118
|
+
if (!e.item || !nodes.has(e.node)) continue
|
|
119
|
+
const list = order.get(e.node) || []; order.set(e.node, list)
|
|
120
|
+
if (!list.includes(e.item)) list.push(e.item)
|
|
121
|
+
}
|
|
122
|
+
const out = new Map()
|
|
123
|
+
for (const [node, items] of order) {
|
|
124
|
+
const n = nodes.get(node).items?.semantic_sample ?? STACK_SEMANTIC_SAMPLE
|
|
125
|
+
const ranked = items.map((id, at) => ({ id, at, ...(facts.get(`${node}|${id}`) || { failing: false, last_checked: null }) }))
|
|
126
|
+
.sort((a, b) => (b.failing - a.failing) || ((a.last_checked != null) - (b.last_checked != null)) || String(a.last_checked ?? '').localeCompare(String(b.last_checked ?? '')) || a.at - b.at)
|
|
127
|
+
out.set(node, ranked.slice(0, n).map((r) => r.id))
|
|
128
|
+
}
|
|
129
|
+
return out
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Facts of the pages of prioritised stacks whose sample is a choice (more pages than semantic_sample). */
|
|
133
|
+
async function stackFacts (latest, nodes, runDir, budget) {
|
|
134
|
+
const count = new Map()
|
|
135
|
+
for (const e of latest) if (e.item) { const s = count.get(e.node) || new Set(); s.add(e.item); count.set(e.node, s) }
|
|
136
|
+
const facts = new Map()
|
|
137
|
+
for (const e of latest) {
|
|
138
|
+
if (!e.item) continue
|
|
139
|
+
const node = nodes.get(e.node)
|
|
140
|
+
if (!node || !prioritised(node, budget) || count.get(e.node).size <= (node.items?.semantic_sample ?? STACK_SEMANTIC_SAMPLE)) continue
|
|
141
|
+
const f = itemFacts(await readJson(join(runDir, e.file), null), node)
|
|
142
|
+
const k = `${e.node}|${e.item}`; const was = facts.get(k)
|
|
143
|
+
facts.set(k, was ? { failing: was.failing || f.failing, last_checked: was.last_checked == null || f.last_checked == null ? null : was.last_checked < f.last_checked ? was.last_checked : f.last_checked } : f)
|
|
144
|
+
}
|
|
145
|
+
return facts
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Evaluates the latest captures of a run. `budget` (kaloko evaluate --budget) overrides evaluate.budget.calls. */
|
|
149
|
+
export async function evaluateRun ({ run, scenario, config, root, fetchImpl, budget: budgetCalls = null }) {
|
|
150
|
+
const settings = jevSettings(config)
|
|
82
151
|
const nodes = new Map(scenario.nodes.map((n) => [n.id, n]))
|
|
83
152
|
const paths = runPaths(run.dir)
|
|
84
153
|
// criteria limited to other environments (environments: [production]) are reported as not applicable here
|
|
85
154
|
const environment = run.meta?.environment?.name || null
|
|
86
155
|
const report = { evaluated: 0, semantic_calls: 0, semantic_cached: 0, skipped: [], model: settings.model, drift: 0, outdated: new Set(), via: {}, hosted: null, hostedStop: null, findings: [] }
|
|
156
|
+
// per-evaluation budget of evaluator calls (#158) and the text evaluator: JEV, or your own model escalating to it
|
|
157
|
+
const budget = evaluatorBudget(config, budgetCalls)
|
|
158
|
+
let budgetSkipped = 0 // captures whose semantic criteria the budget left out
|
|
159
|
+
const hosted = hostedSender(config, report)
|
|
160
|
+
const text = textAsker({ config, root, fetchImpl, hosted, budget, stopped: () => report.hostedStop })
|
|
161
|
+
report.model = text.model
|
|
162
|
+
if (text.primary.kind !== 'jev') report.evaluator = { name: text.primary.name, base_url: safeUrl(text.primary.settings.base_url), ...(text.escalate ? { escalate_to: text.escalate.name, escalated: 0, escalation_skipped: 0 } : {}) }
|
|
87
163
|
// the vision evaluator (#17): flagged and uncertain criteria, a bounded number of calls per evaluation
|
|
88
164
|
const vset = visionSettings(config)
|
|
89
165
|
if (vset) report.vision = { model: vset.model, asked: 0, calls: 0, cached: 0, skipped: [], via: {}, hosted: null }
|
|
@@ -101,17 +177,17 @@ export async function evaluateRun ({ run, scenario, config, root, fetchImpl }) {
|
|
|
101
177
|
try {
|
|
102
178
|
const entries = await readCaptures(run.dir)
|
|
103
179
|
const latest = latestCaptures(entries)
|
|
104
|
-
// stack steps: semantic criteria only on
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
const
|
|
111
|
-
|
|
180
|
+
// stack steps: semantic criteria only on N pages (items.semantic_sample, default 20); cost stays bounded. With a
|
|
181
|
+
// budget or semantic_order: priority, failing and least recently checked pages first
|
|
182
|
+
const chosen = sampleItems(latest, nodes, await stackFacts(latest, nodes, run.dir, budget))
|
|
183
|
+
const sampled = new Map([...chosen].map(([node, ids]) => [node, new Set(ids)]))
|
|
184
|
+
// with a budget the calls go where they matter most: steps first, then each stack's sample in its order
|
|
185
|
+
if (budget.limit != null) {
|
|
186
|
+
const rank = new Map([...chosen].flatMap(([node, ids]) => ids.map((id, i) => [`${node}|${id}`, i])))
|
|
187
|
+
const key = (e) => (!e.item ? -1 : rank.get(`${e.node}|${e.item}`) ?? Infinity)
|
|
188
|
+
latest.sort((a, b) => key(a) - key(b))
|
|
112
189
|
}
|
|
113
190
|
// in batches of 50 captures: memory holds one batch of page states, not a whole stack of thousands
|
|
114
|
-
const hosted = hostedSender(config, report)
|
|
115
191
|
for (let at = 0; at < latest.length; at += BATCH) {
|
|
116
192
|
const batch = latest.slice(at, at + BATCH)
|
|
117
193
|
// 1. what each capture needs; 2. the evaluator calls, several at a time; 3. drift and verdicts in order
|
|
@@ -125,25 +201,28 @@ export async function evaluateRun ({ run, scenario, config, root, fetchImpl }) {
|
|
|
125
201
|
const current = capture.criteria_hash === criteriaHash(node)
|
|
126
202
|
// console triage needs the evaluator only when the capture has console errors
|
|
127
203
|
const outOfSample = entry.item && !sampled.get(entry.node)?.has(entry.item)
|
|
128
|
-
const semantic = outOfSample ? [] : (node.criteria || []).filter((c) => c.check.type === 'semantic' && appliesTo(c, environment, capture) && !(c.check.about === 'console' && !(capture.console_errors || []).length))
|
|
204
|
+
const semantic = outOfSample ? [] : (node.criteria || []).filter((c) => c.check.type === 'semantic' && appliesTo(c, environment, capture) && appliesToPage(c, node, capture) && !(c.check.about === 'console' && !(capture.console_errors || []).length))
|
|
129
205
|
// quick signals ride along on every captured page state (also steps without semantic criteria)
|
|
130
206
|
const signals = settings.signals !== false && !outOfSample && !!capture.state
|
|
131
207
|
// an imported screen without page text (#11) has nothing to ask about: a person decides on the canvas
|
|
132
208
|
const textless = capture.capture?.source === 'import' && !capture.state
|
|
133
|
-
const request = (semantic.length || signals) && current && !textless ? buildRequest({ node, capture, criteria: semantic, model:
|
|
209
|
+
const request = (semantic.length || signals) && current && !textless ? buildRequest({ node, capture, criteria: semantic, model: text.model, signals }) : null
|
|
134
210
|
work.push({ entry, node, capture, current, outOfSample, request, asks: semantic.length > 0, jev: !current ? { skipped: 'Capture has no matching criteria fingerprint; capture this step again.' } : textless && semantic.length ? { skipped: 'imported screenshot without page text; a person decides on the canvas' } : null })
|
|
135
211
|
if (!current) report.outdated.add(entry.node)
|
|
136
212
|
}
|
|
137
213
|
|
|
214
|
+
// the text evaluator answers the batch in its order (budget tickets follow it, not who answers first)
|
|
138
215
|
const queue = work.filter((w) => w.request)
|
|
139
|
-
await
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
216
|
+
const answers = await text.askAll(queue.map((w) => ({ request: w.request, asks: w.asks })))
|
|
217
|
+
for (const [i, w] of queue.entries()) {
|
|
218
|
+
w.jev = answers[i]
|
|
219
|
+
if (w.jev.response) { w.jev.cached ? report.semantic_cached++ : report.semantic_calls++; report.via[w.jev.via] = (report.via[w.jev.via] || 0) + 1; if (w.jev.response.qawalk) report.hosted = w.jev.response.qawalk }
|
|
220
|
+
const esc = w.jev.response?.escalation
|
|
221
|
+
if (esc && report.evaluator) { if (esc.via) { report.evaluator.escalated += esc.ids.length; report.via[esc.via] = (report.via[esc.via] || 0) + 1 } else report.evaluator.escalation_skipped += esc.ids.length }
|
|
222
|
+
if (w.jev.budget && w.asks) budgetSkipped++
|
|
223
|
+
// signals alone are best effort: only captures with semantic criteria report why they were not evaluated
|
|
224
|
+
if ((w.jev.skipped || w.jev.error) && w.asks) report.skipped.push(`${w.entry.node}/${w.entry.locale}/${w.entry.viewport}: ${w.jev.skipped || w.jev.error}`)
|
|
225
|
+
}
|
|
147
226
|
// what kind of problem each screen and its recording shows; with cascade on, Clef may settle uncertain criteria
|
|
148
227
|
if (triage) await triage.ask(work.filter((w) => w.current && !w.outOfSample), run.dir, entries)
|
|
149
228
|
// second opinion with the screenshot for flagged and uncertain criteria (those Clef did not settle)
|
|
@@ -161,15 +240,17 @@ export async function evaluateRun ({ run, scenario, config, root, fetchImpl }) {
|
|
|
161
240
|
node: entry.node, ...(entry.item ? { item: entry.item, semantic_sample: !outOfSample } : {}), locale: entry.locale, viewport: entry.viewport,
|
|
162
241
|
evaluated_at: new Date().toISOString(),
|
|
163
242
|
semantic: jev?.response ? {
|
|
164
|
-
provider: 'typesafe-jev',
|
|
243
|
+
provider: jev.response.provider || 'typesafe-jev',
|
|
165
244
|
via: jev.via || 'own-key',
|
|
166
|
-
model: jev.response.model ||
|
|
167
|
-
prompt_version: PROMPT_VERSION,
|
|
245
|
+
model: jev.response.model || text.model,
|
|
246
|
+
prompt_version: jev.response.provider === 'openai-compatible' ? LOCAL_PROMPT_VERSION : PROMPT_VERSION,
|
|
168
247
|
state_hash: jev.key,
|
|
169
248
|
cached: jev.cached,
|
|
170
249
|
usage: jev.response.usage || null,
|
|
171
|
-
answers: jev.response.answers
|
|
172
|
-
|
|
250
|
+
answers: jev.response.answers,
|
|
251
|
+
// uncertain answers re-asked of the escalation evaluator (#158)
|
|
252
|
+
...(jev.response.escalation ? { escalation: jev.response.escalation } : {})
|
|
253
|
+
} : (jev ? { provider: text.primary.kind === 'jev' ? 'typesafe-jev' : 'openai-compatible', skipped: jev.skipped || null, error: jev.error || null, ...(jev.budget ? { budget: true } : {}) } : null),
|
|
173
254
|
thresholds: settings.thresholds,
|
|
174
255
|
state_chars: request ? JSON.stringify(request.state).length : null,
|
|
175
256
|
...(signalsOf(jev) ? { signals: signalsOf(jev) } : {}),
|
|
@@ -189,6 +270,7 @@ export async function evaluateRun ({ run, scenario, config, root, fetchImpl }) {
|
|
|
189
270
|
// triage is on by default but stays silent where nobody asked for it and it did not run (experiment off, no service)
|
|
190
271
|
if (report.triage && !report.triage.screens && config.evaluators?.triage == null) delete report.triage
|
|
191
272
|
if (report.triage) { const ms = report.triage.ms.filter((x) => x != null).sort((a, b) => a - b); report.triage.median_ms = ms.length ? ms[Math.floor(ms.length / 2)] : null; delete report.triage.ms }
|
|
273
|
+
if (budget.limit != null) report.budget = { calls: budget.limit, used: budget.used, skipped: budgetSkipped }
|
|
192
274
|
const { hostedStop, visionStop, triageStop, ...out } = report
|
|
193
275
|
return { ...out, outdated: [...report.outdated] }
|
|
194
276
|
}
|
|
@@ -207,16 +289,19 @@ function signalsOf (jev) {
|
|
|
207
289
|
return Object.keys(out).length ? out : null
|
|
208
290
|
}
|
|
209
291
|
|
|
210
|
-
export function semanticPrefetch ({ scenario, config, root, environment = null }) {
|
|
211
|
-
const settings =
|
|
212
|
-
const cacheDir = join(resolve(root, config.output), '.cache', 'jev')
|
|
292
|
+
export function semanticPrefetch ({ scenario, config, root, environment = null, budget: budgetCalls = null, fetchImpl }) {
|
|
293
|
+
const settings = jevSettings(config)
|
|
213
294
|
const nodes = new Map(scenario.nodes.map((n) => [n.id, n]))
|
|
214
295
|
const report = { hostedStop: null }
|
|
215
296
|
const hosted = hostedSender(config, report)
|
|
297
|
+
// with a budget the paid questions wait for the evaluation at the end of the walk, which asks them in a fixed
|
|
298
|
+
// order; the walk asks only your own model (free), without escalating
|
|
299
|
+
const budgeted = evaluatorBudget(config, budgetCalls).limit != null
|
|
300
|
+
const text = textAsker({ config, root, fetchImpl, hosted, budget: null, stopped: () => report.hostedStop })
|
|
216
301
|
const queue = []; const running = new Set(); let asked = 0
|
|
217
302
|
let idle = null // resolves when the queue drains, for done()
|
|
218
303
|
const pump = () => {
|
|
219
|
-
while (running.size <
|
|
304
|
+
while (running.size < text.concurrency && queue.length) {
|
|
220
305
|
const job = queue.shift()
|
|
221
306
|
const p = job().catch(() => {}).finally(() => { running.delete(p); pump() })
|
|
222
307
|
running.add(p)
|
|
@@ -226,6 +311,7 @@ export function semanticPrefetch ({ scenario, config, root, environment = null }
|
|
|
226
311
|
return {
|
|
227
312
|
add (entries, runDir) {
|
|
228
313
|
if (config.evaluators?.jev === false || config.evaluators?.jev === null) return
|
|
314
|
+
if (budgeted && text.primary.kind === 'jev') return
|
|
229
315
|
for (const e of [].concat(entries || [])) {
|
|
230
316
|
if (e?.type !== 'capture' || e.item) continue
|
|
231
317
|
const node = nodes.get(e.node)
|
|
@@ -233,11 +319,11 @@ export function semanticPrefetch ({ scenario, config, root, environment = null }
|
|
|
233
319
|
if (!semantic.length && settings.signals === false) continue
|
|
234
320
|
queue.push(async () => {
|
|
235
321
|
const capture = await readJson(join(runDir, e.file))
|
|
236
|
-
const crit = semantic.filter((c) => appliesToState(c, capture.step_state) && !(c.check.about === 'console' && !(capture.console_errors || []).length))
|
|
322
|
+
const crit = semantic.filter((c) => appliesToState(c, capture.step_state) && appliesToPage(c, node, capture) && !(c.check.about === 'console' && !(capture.console_errors || []).length))
|
|
237
323
|
const signals = settings.signals !== false && !!capture.state
|
|
238
324
|
if ((!crit.length && !signals) || capture.criteria_hash !== criteriaHash(node)) return
|
|
239
325
|
asked++
|
|
240
|
-
await
|
|
326
|
+
await text.askAll([{ request: buildRequest({ node, capture, criteria: crit, model: text.model, signals }), asks: crit.length > 0 }], { escalation: !budgeted })
|
|
241
327
|
})
|
|
242
328
|
}
|
|
243
329
|
pump()
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// `kaloko export findings` (#160): the fix list of a run. Every failing acceptance criterion and health check, per
|
|
2
|
+
// page and locale, with the evidence and the way back to the source (edit_url, content_ref of stack pages), as CSV for
|
|
3
|
+
// a content team or JSON for an agent that feeds a content API. Stack steps are read page by page from their items
|
|
4
|
+
// index; the viewports a check fails in are one row.
|
|
5
|
+
import { healthOfNode, levelOf, isHealthCriterion } from './health-core.mjs'
|
|
6
|
+
import { toCsv } from './evidence-core.mjs'
|
|
7
|
+
|
|
8
|
+
export const FINDING_COLUMNS = ['step', 'page', 'locale', 'viewports', 'url', 'kind', 'pack', 'check', 'level', 'text', 'evidence', 'edit_url', 'content_ref', 'recheck']
|
|
9
|
+
|
|
10
|
+
const recheckOf = (step, locale, page) => `kaloko walk --steps ${step}${page ? ` --items ${page}` : ''}${locale ? ` --locales ${locale}` : ''}`
|
|
11
|
+
|
|
12
|
+
/** Rows of failing checks, grouped by step, page, locale and check (viewports joined). */
|
|
13
|
+
function collect (rows, base, results) {
|
|
14
|
+
const at = new Map()
|
|
15
|
+
for (const r of results) {
|
|
16
|
+
const k = `${base.step}|${base.page || ''}|${r.locale}|${r.check}`
|
|
17
|
+
const row = at.get(k)
|
|
18
|
+
if (row) { if (!row.viewports.includes(r.viewport)) row.viewports.push(r.viewport); row.evidence ||= r.evidence || null; continue }
|
|
19
|
+
const next = { ...base, locale: r.locale, viewports: [r.viewport], url: r.url || base.url || null, kind: r.kind, pack: r.pack || null, check: r.check, level: r.level, text: r.text, evidence: r.evidence || null, recheck: recheckOf(base.step, r.locale, base.page) }
|
|
20
|
+
at.set(k, next); rows.push(next)
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The fix list of a manifest: [{ step, page, locale, viewports, url, kind (acceptance|health), pack, check, level
|
|
26
|
+
* (error|warning for health, fail for acceptance), text, evidence, edit_url, content_ref, recheck }]. `stackIndex(node)`
|
|
27
|
+
* returns a stack step's items index (qawalk.stack.v1). `locale` and `step` filter.
|
|
28
|
+
*/
|
|
29
|
+
export async function runFindings (manifest, { stackIndex, locale = null, step = null } = {}) {
|
|
30
|
+
const rows = []
|
|
31
|
+
for (const n of manifest?.nodes || []) {
|
|
32
|
+
if (n.kind === 'decision' || (step && n.id !== step)) continue
|
|
33
|
+
if (n.kind === 'stack' && n.stack?.items) {
|
|
34
|
+
const idx = await stackIndex(n)
|
|
35
|
+
if (!idx) continue
|
|
36
|
+
const crit = new Map((idx.criteria || []).filter((c) => !c.not_applicable).map((c) => [c.id, c]))
|
|
37
|
+
for (const it of idx.items || []) {
|
|
38
|
+
const results = []
|
|
39
|
+
for (const [key, res] of Object.entries(it.results || {})) {
|
|
40
|
+
const [l, vp] = key.split('|')
|
|
41
|
+
if (locale && l !== locale) continue
|
|
42
|
+
for (const [id, [verdict, evidence]] of Object.entries(res || {})) {
|
|
43
|
+
const c = crit.get(id)
|
|
44
|
+
if (!c || verdict !== 'fail') continue
|
|
45
|
+
results.push({ locale: l, viewport: vp, url: it.captures?.[l]?.[vp]?.url || it.url, check: id, kind: c.health ? 'health' : 'acceptance', pack: c.pack, level: c.health ? (c.level || levelOf(id)) : 'fail', text: c.text, evidence })
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
collect(rows, { step: n.id, page: it.id, url: it.url, edit_url: it.edit_url || null, content_ref: it.content_ref || null }, results)
|
|
49
|
+
}
|
|
50
|
+
continue
|
|
51
|
+
}
|
|
52
|
+
const checks = [...(n.criteria || []).filter((c) => !isHealthCriterion(c)).map((c) => ({ ...c, kind: 'acceptance' })), ...healthOfNode(n).list.map((c) => ({ ...c, kind: 'health' }))]
|
|
53
|
+
const results = []
|
|
54
|
+
for (const c of checks) {
|
|
55
|
+
if (c.not_applicable) continue
|
|
56
|
+
for (const [l, vps] of Object.entries(c.by_locale || {})) {
|
|
57
|
+
if (locale && l !== locale) continue
|
|
58
|
+
for (const [vp, r] of Object.entries(vps || {})) {
|
|
59
|
+
if (r?.verdict !== 'fail') continue
|
|
60
|
+
results.push({ locale: l, viewport: vp, url: n.captures?.[l]?.[vp]?.url || null, check: c.id, kind: c.kind, pack: c.pack, level: c.kind === 'health' ? levelOf(c.id) : 'fail', text: c.text, evidence: r.evidence ? String(r.evidence).slice(0, 500) : (r.reason ? String(r.reason).slice(0, 500) : null) })
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
collect(rows, { step: n.id, page: null, url: null, edit_url: null, content_ref: null }, results)
|
|
65
|
+
}
|
|
66
|
+
return rows
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The fix list as CSV (one row per finding, viewports space-separated) or JSON ({ run, scenario, findings }). */
|
|
70
|
+
export function findingsText (rows, format, { run = null, scenario = null } = {}) {
|
|
71
|
+
if (format === 'json') return JSON.stringify({ schema: 'qawalk.findings.v1', run, scenario, count: rows.length, findings: rows }, null, 2)
|
|
72
|
+
return toCsv(rows.map((r) => ({ ...r, viewports: r.viewports.join(' ') })), FINDING_COLUMNS)
|
|
73
|
+
}
|
package/src/lib/health-core.mjs
CHANGED
|
@@ -16,9 +16,14 @@
|
|
|
16
16
|
// never rewrite what is stored; an own criterion AC302 without `from` still replaces the pack check HC302, as an
|
|
17
17
|
// adopted check always did.
|
|
18
18
|
//
|
|
19
|
+
// Site checks (#152) run once per site, not per step: the manifest keeps them as `site`, and their findings name the
|
|
20
|
+
// step site:<host>. A cross-page check of a stack (#151, `cross_page`) is a finding of its stack step.
|
|
21
|
+
//
|
|
19
22
|
// Pure: no Node APIs. Shared by the CLI, the service (worker/src/app-health.js) and the tests; the canvas keeps a copy
|
|
20
23
|
// of the few rules it needs (viewer/index.html, hl*).
|
|
21
24
|
|
|
25
|
+
import { packHelp } from './pack-help.mjs'
|
|
26
|
+
|
|
22
27
|
/** Packs that count for acceptance unless a scenario says otherwise: the design reference check (#42). */
|
|
23
28
|
export const ACCEPTANCE_PACKS = ['design']
|
|
24
29
|
|
|
@@ -56,7 +61,8 @@ export const CHECK_LEVEL = {
|
|
|
56
61
|
HC301: 'error', HC302: 'error', HC303: 'error', HC305: 'error', HC310: 'error', HC312: 'error',
|
|
57
62
|
HC501: 'error',
|
|
58
63
|
HC601: 'error', HC604: 'error', HC606: 'error',
|
|
59
|
-
HC701: 'error', HC704: 'error'
|
|
64
|
+
HC701: 'error', HC704: 'error',
|
|
65
|
+
HC1102: 'error'
|
|
60
66
|
}
|
|
61
67
|
export const levelOf = (id) => CHECK_LEVEL[toHealthId(id)] || 'warning'
|
|
62
68
|
|
|
@@ -144,13 +150,30 @@ export function runHealth (manifest) {
|
|
|
144
150
|
})
|
|
145
151
|
}
|
|
146
152
|
}
|
|
153
|
+
// site checks (#152): once per site and run, stored as manifest.site; a finding's step is site:<host>
|
|
154
|
+
const site = []
|
|
155
|
+
for (const o of manifest?.site?.origins || []) {
|
|
156
|
+
const host = (() => { try { return new URL(o.origin).host } catch { return String(o.origin) } })()
|
|
157
|
+
const row = { origin: o.origin, host, checks: 0, error: 0, warning: 0 }
|
|
158
|
+
for (const c of o.checks || []) {
|
|
159
|
+
if (c.na || c.verdict === 'not_evaluated') continue
|
|
160
|
+
checks++; row.checks++
|
|
161
|
+
const p = (packs.site ||= { checks: 0, error: 0, warning: 0, info: 0 })
|
|
162
|
+
p.checks++
|
|
163
|
+
if (c.verdict !== 'fail') continue
|
|
164
|
+
const severity = c.level || levelOf(c.id)
|
|
165
|
+
counts[severity]++; p[severity]++; row[severity]++
|
|
166
|
+
findings.push({ id: findingId(`site:${host}`, c.id), step: `site:${host}`, title: host, check: c.id, pack: 'site', text: c.text, severity, verdict: c.verdict, evidence: c.evidence ? String(c.evidence).slice(0, 300) : null, cells: [], site: true, ...(packHelp(c.id) ? { help: { en: packHelp(c.id, 'en'), cs: packHelp(c.id, 'cs') } } : {}) })
|
|
167
|
+
}
|
|
168
|
+
site.push(row)
|
|
169
|
+
}
|
|
147
170
|
findings.sort((a, b) => severityRank(b.severity) - severityRank(a.severity) || a.step.localeCompare(b.step) || a.check.localeCompare(b.check))
|
|
148
|
-
return { checks, legacy, counts, packs, findings }
|
|
171
|
+
return { checks, legacy, counts, packs, findings, ...(site.length ? { site } : {}) }
|
|
149
172
|
}
|
|
150
173
|
|
|
151
|
-
/** The counts a run summary keeps (summary.health), from the manifest nodes. Null when nothing was checked. */
|
|
152
|
-
export function healthSummary (nodes) {
|
|
153
|
-
const h = runHealth({ nodes })
|
|
174
|
+
/** The counts a run summary keeps (summary.health), from the manifest nodes and site checks. Null when nothing was checked. */
|
|
175
|
+
export function healthSummary (nodes, site = null) {
|
|
176
|
+
const h = runHealth({ nodes, site })
|
|
154
177
|
if (!h.checks) return null
|
|
155
178
|
return { checks: h.checks, ...h.counts, packs: h.packs }
|
|
156
179
|
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// The health index of a run (#155, #154): every health check result per step, locale, page and check, folded into
|
|
2
|
+
// groups for dashboards (GET …/runs/<id>/health/aggregate) and the list of results that fail or are unsure, keyed
|
|
3
|
+
// step|locale|check|page so a later run of the same flow can say what is new, fixed or still open (…/health/diff).
|
|
4
|
+
// A page is a stack item (its id); a plain step is its own page (empty page id). Viewports fold into their worst
|
|
5
|
+
// verdict, so one result is one page in one locale for one check.
|
|
6
|
+
//
|
|
7
|
+
// Pure: no Node APIs. The service builds the index once per shared run and keeps it with the run's files
|
|
8
|
+
// (worker/src/health-index.js); the shapes are documented in help/reference/health-api.md.
|
|
9
|
+
import { healthOfNode, levelOf, findingSeverity, severityRank } from './health-core.mjs'
|
|
10
|
+
import { worst } from './manifest-core.mjs'
|
|
11
|
+
|
|
12
|
+
export const HEALTH_INDEX_SCHEMA = 'kaloko.health-index.v1'
|
|
13
|
+
export const HEALTH_AGGREGATE_SCHEMA = 'kaloko.health-aggregate.v1'
|
|
14
|
+
export const HEALTH_DIFF_SCHEMA = 'kaloko.health-diff.v1'
|
|
15
|
+
/** What a dashboard may group by; step is the template of a stack step. */
|
|
16
|
+
export const AGGREGATE_DIMS = ['locale', 'step', 'check', 'pack']
|
|
17
|
+
export const DEFAULT_BY = ['locale', 'step', 'check']
|
|
18
|
+
export const VERDICTS = ['pass', 'fail', 'needs_review', 'not_evaluated']
|
|
19
|
+
/** Affected pages kept per locale, step and check; an answer shows at most this many per group. */
|
|
20
|
+
export const TOP_KEEP = 20
|
|
21
|
+
/** Open results a run keeps (worst first); a site with more is cut and says so (`truncated`). */
|
|
22
|
+
export const OPEN_MAX = 50000
|
|
23
|
+
|
|
24
|
+
const blank = () => ({ pass: 0, fail: 0, needs_review: 0, not_evaluated: 0 })
|
|
25
|
+
const cut = (s, n = 200) => s == null ? null : String(s).slice(0, n)
|
|
26
|
+
|
|
27
|
+
/** The key of one result: stable across runs while the step, locale, check and page keep their ids. */
|
|
28
|
+
export const healthKey = (step, locale, check, page) => [step, locale, check, page].join('|')
|
|
29
|
+
/** step|locale|check|page → { step, locale, check, page }; the page may contain "|" (it is last). */
|
|
30
|
+
export function parseHealthKey (key) {
|
|
31
|
+
const [step, locale, check, ...rest] = String(key).split('|')
|
|
32
|
+
return { step, locale, check, page: rest.join('|') }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const firstUrl = (vps) => { for (const c of Object.values(vps || {})) if (c?.url) return c.url; return null }
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The index of a run manifest. `stacks` maps a stack step's id to its items index (steps/<node>/items.json); a stack
|
|
39
|
+
* step without one counts as one page. Returns { schema, checks: { HC…: { text, pack, level } }, totals, groups:
|
|
40
|
+
* [{ locale, step, check, counts, top: [{ page, path, url, verdict, evidence }] }], open: [[key, severity, url]],
|
|
41
|
+
* open_total, truncated, passed }. passed says which checks passed on which page: { "step|locale": { sigs: ["HC103,HC112",
|
|
42
|
+
* …], pages: { <page>: <index into sigs> } } }; only a result that passed counts as fixed later (passedIn).
|
|
43
|
+
*/
|
|
44
|
+
export function buildHealthIndex (manifest, stacks = {}) {
|
|
45
|
+
const checks = Object.create(null); const groups = new Map(); const open = []; const pages = new Set(); const ok = new Map()
|
|
46
|
+
const totals = { results: 0, ...blank() }
|
|
47
|
+
const add = (step, locale, page, path, url, check, verdict, evidence) => {
|
|
48
|
+
if (!VERDICTS.includes(verdict)) return
|
|
49
|
+
pages.add(`${step}|${page}`)
|
|
50
|
+
const gk = `${locale}\u0000${step}\u0000${check}`
|
|
51
|
+
let g = groups.get(gk)
|
|
52
|
+
if (!g) { g = { locale, step, check, counts: blank(), fails: [], unsure: [] }; groups.set(gk, g) }
|
|
53
|
+
g.counts[verdict]++; totals[verdict]++; totals.results++
|
|
54
|
+
if (verdict === 'pass') { const k = `${step}|${locale}\u0000${page}`; if (!ok.has(k)) ok.set(k, []); ok.get(k).push(check) }
|
|
55
|
+
const severity = findingSeverity(verdict, check)
|
|
56
|
+
if (!severity) return
|
|
57
|
+
const list = verdict === 'fail' ? g.fails : g.unsure
|
|
58
|
+
if (list.length < TOP_KEEP) list.push({ page, ...(path ? { path } : {}), url: url || null, verdict, ...(evidence ? { evidence: cut(evidence) } : {}) })
|
|
59
|
+
open.push([healthKey(step, locale, check, page), severity, url || null])
|
|
60
|
+
}
|
|
61
|
+
for (const n of manifest?.nodes || []) {
|
|
62
|
+
if (n.kind === 'decision') continue
|
|
63
|
+
const live = healthOfNode(n).list.filter((c) => !c.not_applicable)
|
|
64
|
+
if (!live.length) continue
|
|
65
|
+
for (const c of live) checks[c.id] ||= { text: c.text || null, pack: c.pack || 'other', level: c.level || levelOf(c.id) }
|
|
66
|
+
const idx = n.kind === 'stack' && Object.hasOwn(stacks, n.id) ? stacks[n.id] : null
|
|
67
|
+
if (idx?.items) {
|
|
68
|
+
// the items index keeps a check under the id it was walked with (AC302 before the HC ids)
|
|
69
|
+
const ids = new Map(live.map((c) => [c.stored_id || c.id, c.id]))
|
|
70
|
+
for (const it of idx.items) {
|
|
71
|
+
const per = new Map()
|
|
72
|
+
for (const [cell, r] of Object.entries(it.results || {})) {
|
|
73
|
+
const locale = cell.split('|')[0]
|
|
74
|
+
for (const [stored, id] of ids) {
|
|
75
|
+
const x = r?.[stored]
|
|
76
|
+
if (!x) continue
|
|
77
|
+
const k = `${locale}\u0000${id}`
|
|
78
|
+
const was = per.get(k)
|
|
79
|
+
if (!was || worst([was.v, x[0]]) !== was.v) per.set(k, { locale, id, v: x[0], e: x[1] })
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
for (const { locale, id, v, e } of per.values()) add(n.id, locale, String(it.id), it.path || null, it.url || firstUrl(it.captures?.[locale]), id, v, e)
|
|
83
|
+
}
|
|
84
|
+
continue
|
|
85
|
+
}
|
|
86
|
+
for (const c of live) {
|
|
87
|
+
const locales = Object.entries(c.by_locale || {})
|
|
88
|
+
if (!locales.length) { add(n.id, '', '', n.path || null, null, c.id, c.verdict, c.evidence || c.reason); continue }
|
|
89
|
+
for (const [locale, vps] of locales) {
|
|
90
|
+
const rs = Object.values(vps || {}).filter(Boolean)
|
|
91
|
+
const v = worst(rs.map((r) => r.verdict).filter(Boolean))
|
|
92
|
+
const bad = rs.find((r) => r.verdict === v)
|
|
93
|
+
add(n.id, locale, '', n.path || null, firstUrl(n.captures?.[locale]), c.id, v, bad?.evidence || bad?.reason)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
open.sort((a, b) => severityRank(b[1]) - severityRank(a[1]) || (a[0] < b[0] ? -1 : 1))
|
|
98
|
+
// pages that passed the same checks share one signature: a stack of 400 pages keeps a handful of strings
|
|
99
|
+
// null-prototype objects: a step or page id such as "constructor" is data, never a property of Object
|
|
100
|
+
const passed = Object.create(null)
|
|
101
|
+
for (const [k, list] of ok) {
|
|
102
|
+
const [where, page] = k.split('\u0000')
|
|
103
|
+
const w = (passed[where] ||= { sigs: [], pages: Object.create(null) })
|
|
104
|
+
const sig = [...new Set(list)].sort().join(',')
|
|
105
|
+
let i = w.sigs.indexOf(sig)
|
|
106
|
+
if (i < 0) { i = w.sigs.length; w.sigs.push(sig) }
|
|
107
|
+
w.pages[page] = i
|
|
108
|
+
}
|
|
109
|
+
return {
|
|
110
|
+
schema: HEALTH_INDEX_SCHEMA, checks, totals: { ...totals, pages: pages.size },
|
|
111
|
+
groups: [...groups.values()].map(({ fails, unsure, ...g }) => ({ ...g, top: [...fails, ...unsure].slice(0, TOP_KEEP) })),
|
|
112
|
+
open: open.slice(0, OPEN_MAX), open_total: open.length, truncated: open.length > OPEN_MAX, passed
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** `?by=locale,step` → the dimensions in canonical order; unknown names throw. Empty → the default. */
|
|
117
|
+
export function parseBy (value) {
|
|
118
|
+
const list = String(value ?? '').split(',').map((s) => s.trim()).filter(Boolean)
|
|
119
|
+
const bad = list.find((d) => !AGGREGATE_DIMS.includes(d))
|
|
120
|
+
if (bad) throw new Error(`Unknown dimension "${bad.slice(0, 30)}": group by ${AGGREGATE_DIMS.join(', ')}.`)
|
|
121
|
+
return list.length ? AGGREGATE_DIMS.filter((d) => list.includes(d)) : DEFAULT_BY
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Counts per group of an index: `by` dimensions, `filter` { locale, step, check, pack } (exact values), up to `top`
|
|
126
|
+
* affected pages per group (fails first) and `limit` groups, the most failing first. Group ids are the dimension
|
|
127
|
+
* values ("locale=de;step=eshop;check=HC112", "all" without dimensions), stable across runs.
|
|
128
|
+
*/
|
|
129
|
+
export function aggregateHealth (index, { by = DEFAULT_BY, filter = {}, top = 5, limit = 500 } = {}) {
|
|
130
|
+
const out = new Map(); const totals = { results: 0, ...blank() }
|
|
131
|
+
const want = Object.entries(filter).filter(([k, v]) => AGGREGATE_DIMS.includes(k) && v != null && v !== '')
|
|
132
|
+
const keep = Math.min(TOP_KEEP, Math.max(0, Number(top) || 0))
|
|
133
|
+
for (const g of index?.groups || []) {
|
|
134
|
+
const vals = { locale: g.locale, step: g.step, check: g.check, pack: index.checks?.[g.check]?.pack || 'other' }
|
|
135
|
+
if (want.some(([k, v]) => vals[k] !== String(v))) continue
|
|
136
|
+
const id = by.map((d) => `${d}=${vals[d]}`).join(';') || 'all'
|
|
137
|
+
let o = out.get(id)
|
|
138
|
+
if (!o) { o = { id, ...Object.fromEntries(by.map((d) => [d, vals[d]])), results: 0, counts: blank(), affected: 0, top: [] }; out.set(id, o) }
|
|
139
|
+
for (const v of VERDICTS) { o.counts[v] += g.counts[v] || 0; totals[v] += g.counts[v] || 0 }
|
|
140
|
+
if (keep) for (const t of g.top) o.top.push({ step: g.step, locale: g.locale, check: g.check, ...t })
|
|
141
|
+
}
|
|
142
|
+
const groups = [...out.values()].map((o) => {
|
|
143
|
+
o.results = VERDICTS.reduce((s, v) => s + o.counts[v], 0)
|
|
144
|
+
o.affected = o.counts.fail + o.counts.needs_review
|
|
145
|
+
o.top = o.top.sort((a, b) => (a.verdict === 'fail' ? 0 : 1) - (b.verdict === 'fail' ? 0 : 1)).slice(0, keep)
|
|
146
|
+
return o
|
|
147
|
+
}).sort((a, b) => b.counts.fail - a.counts.fail || b.counts.needs_review - a.counts.needs_review || (a.id < b.id ? -1 : 1))
|
|
148
|
+
totals.results = VERDICTS.reduce((s, v) => s + totals[v], 0)
|
|
149
|
+
const max = Math.max(1, Number(limit) || 500)
|
|
150
|
+
return { by, totals, group_count: groups.length, truncated: groups.length > max, groups: groups.slice(0, max) }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** True when the run checked this result (step|locale|check|page) and it passed. */
|
|
154
|
+
export function passedIn (index, key) {
|
|
155
|
+
const { step, locale, check, page } = parseHealthKey(key)
|
|
156
|
+
const where = `${step}|${locale}`
|
|
157
|
+
// a stored index comes back from JSON with plain objects: own keys only
|
|
158
|
+
const w = index?.passed && Object.hasOwn(index.passed, where) ? index.passed[where] : null
|
|
159
|
+
const i = w?.pages && Object.hasOwn(w.pages, page) ? w.pages[page] : null
|
|
160
|
+
return Number.isInteger(i) && typeof w.sigs?.[i] === 'string' && w.sigs[i].split(',').includes(check)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const entry = ([key, severity, url]) => ({ id: key, ...parseHealthKey(key), severity: severity || null, url: url || null })
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* What changed between the open results of `before` ([[key, severity, url]]: the previous run's open results, or what
|
|
167
|
+
* the flow keeps open) and the index `cur`: new (open now, not before), still open (both; `was` names the earlier
|
|
168
|
+
* severity when it changed), fixed (open before, checked now and passed) and unchecked (open before, not checked now:
|
|
169
|
+
* a rotating sample, a locale left out, an unsure evaluator, a cut list; it stays open). Without `before` everything
|
|
170
|
+
* open is new and `previous` is false.
|
|
171
|
+
*/
|
|
172
|
+
export function diffHealth (cur, before) {
|
|
173
|
+
const now = new Map((cur?.open || []).map((o) => [o[0], o]))
|
|
174
|
+
if (!before) return { previous: false, new: [...now.values()].map(entry), fixed: [], still_open: [], unchecked: [] }
|
|
175
|
+
const prev = new Map(before.map((o) => [o[0], o]))
|
|
176
|
+
const fresh = []; const still = []; const fixed = []; const unchecked = []
|
|
177
|
+
for (const [k, o] of now) {
|
|
178
|
+
const b = prev.get(k)
|
|
179
|
+
if (!b) fresh.push(entry(o))
|
|
180
|
+
else still.push({ ...entry(o), ...(b[1] && b[1] !== o[1] ? { was: b[1] } : {}) })
|
|
181
|
+
}
|
|
182
|
+
for (const [k, o] of prev) if (!now.has(k)) (passedIn(cur, k) ? fixed : unchecked).push(entry(o))
|
|
183
|
+
return { previous: true, new: fresh, fixed, still_open: still, unchecked }
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Diff counts: new, fixed, still open, unchecked and how many still open got worse. */
|
|
187
|
+
export const diffCounts = (d) => ({ new: d.new.length, fixed: d.fixed.length, still_open: d.still_open.length, unchecked: d.unchecked.length, worse: d.still_open.filter((e) => e.was && severityRank(e.severity) > severityRank(e.was)).length })
|