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.
@@ -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, evaluateWithJev, verdictFor, DEFAULTS, PROMPT_VERSION, SIGNAL_NAMES, purposeOf } from './jev.mjs'
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 { appliesToState } from './manifest-core.mjs'
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 ? 'typesafe-jev' : null,
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
- export async function evaluateRun ({ run, scenario, config, root, fetchImpl }) {
80
- const settings = { ...DEFAULTS, ...(config.evaluators?.jev || {}), thresholds: { ...DEFAULTS.thresholds, ...(config.evaluators?.jev?.thresholds || {}) } }
81
- const cacheDir = join(resolve(root, config.output), '.cache', 'jev')
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 the first N pages (items.semantic_sample, default 20); cost stays bounded
105
- const sampled = new Map()
106
- for (const e of latest) {
107
- if (!e.item) continue
108
- const node = nodes.get(e.node); if (!node) continue
109
- const n = node.items?.semantic_sample ?? 20
110
- const set = sampled.get(e.node) || new Set(); sampled.set(e.node, set)
111
- if (set.size < n) set.add(e.item)
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: settings.model, signals }) : null
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 Promise.all(Array.from({ length: Math.max(1, Math.min(settings.concurrency || 6, queue.length)) }, async () => {
140
- for (let w = queue.shift(); w; w = queue.shift()) {
141
- w.jev = report.hostedStop && !process.env[settings.api_key_env] ? { skipped: report.hostedStop } : await evaluateWithJev({ request: w.request, settings, cacheDir, fetchImpl, hosted })
142
- 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 }
143
- // signals alone are best effort: only captures with semantic criteria report why they were not evaluated
144
- 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}`)
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 || settings.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
- } : (jev ? { provider: 'typesafe-jev', skipped: jev.skipped || null, error: jev.error || null } : null),
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 = { ...DEFAULTS, ...(config.evaluators?.jev || {}), thresholds: { ...DEFAULTS.thresholds, ...(config.evaluators?.jev?.thresholds || {}) } }
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 < (settings.concurrency || 6) && queue.length) {
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 evaluateWithJev({ request: buildRequest({ node, capture, criteria: crit, model: settings.model, signals }), settings, cacheDir, hosted })
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
+ }
@@ -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 })