kaloko 0.4.8 → 0.4.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +92 -5
  2. package/package.json +1 -1
  3. package/schemas/config.v1.json +33 -0
  4. package/schemas/scenario.v1.json +39 -0
  5. package/schemas/spec.v1.json +60 -0
  6. package/skills/kaloko/SKILL.md +24 -3
  7. package/skills/kaloko/reference/acceptance.md +50 -2
  8. package/skills/kaloko/reference/ci.md +70 -0
  9. package/skills/kaloko/reference/collaboration.md +9 -1
  10. package/skills/kaloko/reference/design.md +35 -2
  11. package/skills/kaloko/reference/environments.md +5 -0
  12. package/skills/kaloko/reference/evaluation.md +46 -3
  13. package/skills/kaloko/reference/execution.md +74 -0
  14. package/skills/kaloko/reference/import.md +66 -0
  15. package/skills/kaloko/reference/mcp.md +4 -1
  16. package/src/cli.mjs +428 -99
  17. package/src/lib/affected-core.mjs +242 -0
  18. package/src/lib/affected.mjs +233 -0
  19. package/src/lib/build.mjs +30 -5
  20. package/src/lib/capture.mjs +43 -1
  21. package/src/lib/ci.mjs +207 -0
  22. package/src/lib/config.mjs +2 -0
  23. package/src/lib/design-cli.mjs +7 -3
  24. package/src/lib/devspec.mjs +821 -0
  25. package/src/lib/doctor.mjs +7 -0
  26. package/src/lib/drift.mjs +16 -8
  27. package/src/lib/evaluate.mjs +98 -14
  28. package/src/lib/git.mjs +6 -2
  29. package/src/lib/heal.mjs +207 -0
  30. package/src/lib/import.mjs +374 -0
  31. package/src/lib/init-detect.mjs +219 -0
  32. package/src/lib/keyboard.mjs +261 -0
  33. package/src/lib/manifest-core.mjs +16 -5
  34. package/src/lib/onboarding.mjs +15 -8
  35. package/src/lib/pack-help.mjs +7 -0
  36. package/src/lib/packs.mjs +9 -2
  37. package/src/lib/png.mjs +61 -0
  38. package/src/lib/pr.mjs +24 -2
  39. package/src/lib/probe.mjs +57 -0
  40. package/src/lib/recheck.mjs +3 -1
  41. package/src/lib/scenario.mjs +2 -1
  42. package/src/lib/service.mjs +7 -1
  43. package/src/lib/spec-cli.mjs +90 -0
  44. package/src/lib/stability-core.mjs +114 -0
  45. package/src/lib/stability.mjs +249 -0
  46. package/src/lib/unzip.mjs +31 -0
  47. package/src/lib/upload.mjs +114 -0
  48. package/src/lib/vision-core.mjs +132 -0
  49. package/src/lib/vision.mjs +127 -0
  50. package/templates/kaloko.config.yml +6 -0
  51. package/viewer/index.html +500 -44
@@ -0,0 +1,242 @@
1
+ // Affected steps from changed files (#14): pure functions, no file system or Node APIs, shared by the CLI
2
+ // (`kaloko affected`, `walk --affected`) and the service (MCP get_affected_steps).
3
+ //
4
+ // A changed file reaches a step in one of these ways, and the selection says which:
5
+ // - the step's `affects:` globs name the file (or `affects: always`);
6
+ // - the file is a route file of a known framework (Next.js, Nuxt, SvelteKit, Remix, Astro, Rails) and its route
7
+ // matches a path of the step: its `path` or a URL captured for it in an earlier run;
8
+ // - the file is a step script or the scenario itself changed for that step (the caller passes those);
9
+ // - the file is global (layouts, global styles, dependencies, i18n messages): every step, or every step of one locale;
10
+ // - the file is imported by a route file (the CLI follows imports; the caller passes the route files it found).
11
+ // Files that are none of these are listed as unmapped, never silently dropped.
12
+
13
+ /** Minimal glob: `*` within a segment, `**` across segments, `?` one character. */
14
+ export function globRe (glob) {
15
+ let out = ''
16
+ for (let i = 0; i < glob.length; i++) {
17
+ const c = glob[i]
18
+ if (c === '*' && glob[i + 1] === '*') { out += '.*'; i++; if (glob[i + 1] === '/') i++ } else if (c === '*') out += '[^/]*'
19
+ else if (c === '?') out += '[^/]'
20
+ else out += c.replace(/[.+^${}()|[\]\\]/g, '\\$&')
21
+ }
22
+ return new RegExp('^' + out + '$')
23
+ }
24
+
25
+ const CODE = /\.(m?[jt]sx?|cjs|vue|svelte|astro|rb|erb|haml|slim|php|py|css|scss|sass|less|html?)$/i
26
+ const IGNORED = [
27
+ /(^|\/)(test|tests|spec|specs|__tests__|e2e|cypress|playwright|\.storybook|docs?|\.github|\.vscode|\.idea)\//,
28
+ /\.(test|spec|stories)\.[a-z]+$/i, /(^|\/)(README|CHANGELOG|LICENSE|CONTRIBUTING)[^/]*$/i, /\.md$/i,
29
+ /(^|\/)\.(gitignore|gitattributes|editorconfig|prettierrc[^/]*|eslintrc[^/]*|nvmrc|tool-versions)$/, /(^|\/)kaloko\.(ci\.)?config\.yml$/
30
+ ]
31
+ const GLOBAL = [
32
+ /^(package\.json|package-lock\.json|pnpm-lock\.yaml|yarn\.lock|bun\.lockb?|Gemfile|Gemfile\.lock)$/,
33
+ /(^|\/)(tailwind|postcss|next|nuxt|svelte|vite|astro|remix)\.config\.[a-z]+$/,
34
+ /(^|\/)(globals?|app|main|application)\.(css|scss|sass|less)$/, /^(src\/)?styles\//, /^(src\/)?(assets\/)?(css|styles)\/(global|base|reset|variables|tokens)[^/]*$/,
35
+ /^public\//, /^static\//,
36
+ /^(src\/)?app\/(layout|template|not-found|error|global-error)\.[jt]sx?$/, /^(src\/)?pages\/_(app|document)\.[jt]sx?$/,
37
+ /^(app\/)?(app|error)\.vue$/, /^(app\/)?layouts\//, /^src\/app\.(html|d\.ts)$/, /^src\/hooks(\.(server|client))?\.[jt]s$/, /^src\/routes\/\+layout(\.server)?\.(svelte|[jt]s)$/,
38
+ /^app\/root\.[jt]sx?$/, /^src\/layouts\//,
39
+ /^app\/views\/(layouts|shared|application)\//, /^app\/controllers\/application_controller\.rb$/, /^app\/helpers\/application_helper\.rb$/, /^config\/routes(\.rb|\/)/, /^app\/assets\/(stylesheets|config)\//
40
+ ]
41
+ // i18n messages: every step, but only in the file's locale when the name says which
42
+ const MESSAGES = /(^|\/)(messages|locales?|lang|i18n|translations)\/(?:.*\/)?([a-z]{2}(?:[-_][A-Za-z]{2})?)(?:\.[a-z]+)?\.(json|ya?ml|po|js|ts)$/
43
+
44
+ /** Route segments: dynamic `[id]`, `$id`, `:id` become `:id`; catch-all `[...slug]` and `$` become `**`. */
45
+ function segment (s, style) {
46
+ if (style === 'next' || style === 'nuxt' || style === 'svelte' || style === 'astro') {
47
+ if (/^\(.*\)$/.test(s) || s.startsWith('@')) return null // route groups and parallel slots are not in the URL
48
+ if (/^\[\[?\.\.\..+\]\]?$/.test(s)) return '**'
49
+ if (/^\[\[.+\]\]$/.test(s)) return '**' // optional parameter: this path and below
50
+ const m = s.match(/^\[(.+?)(=[^\]]+)?\]$/); if (m) return `:${m[1]}`
51
+ return s
52
+ }
53
+ if (style === 'remix') {
54
+ if (s === '_index' || s === 'index') return ''
55
+ if (s.startsWith('_')) return null // pathless layout
56
+ if (s === '$') return '**'
57
+ if (s.startsWith('$')) return `:${s.slice(1)}`
58
+ return s.replace(/^\((.+)\)$/, '$1').replace(/_$/, '')
59
+ }
60
+ return s
61
+ }
62
+ const join = (parts) => '/' + parts.filter((p) => p !== null && p !== '').join('/')
63
+
64
+ /**
65
+ * The route a file serves, by framework convention; { route, scope: 'page'|'prefix', framework } or null.
66
+ * `prefix` means the file shapes every page under the route (layouts, controllers, colocated components).
67
+ */
68
+ export function routeOf (file) {
69
+ let m
70
+ // Next.js app router
71
+ if ((m = file.match(/^(?:src\/)?app\/(?:(.*)\/)?(page|route|layout|template|loading|error|not-found|default)\.(?:m?[jt]sx?|mdx)$/))) {
72
+ const parts = (m[1] || '').split('/').filter(Boolean)
73
+ if (parts.some((p) => p.startsWith('_'))) return null
74
+ return { route: join(parts.map((p) => segment(p, 'next'))), scope: ['page', 'route'].includes(m[2]) ? 'page' : 'prefix', framework: 'next' }
75
+ }
76
+ // Remix / React Router flat routes
77
+ if ((m = file.match(/^app\/routes\/(.+?)(?:\/route)?\.[jt]sx?$/))) {
78
+ const parts = m[1].split(/[./]/).map((p) => segment(p, 'remix'))
79
+ return { route: join(parts), scope: 'page', framework: 'remix' }
80
+ }
81
+ // SvelteKit
82
+ if ((m = file.match(/^src\/routes\/(?:(.*)\/)?\+(page|layout|server|error)(?:\.server)?\.(svelte|[jt]s)$/))) {
83
+ return { route: join((m[1] || '').split('/').filter(Boolean).map((p) => segment(p, 'svelte'))), scope: ['page', 'server'].includes(m[2]) ? 'page' : 'prefix', framework: 'sveltekit' }
84
+ }
85
+ // Nuxt (pages/*.vue, Nuxt 4 app/pages/*.vue)
86
+ if ((m = file.match(/^(?:app\/)?pages\/(.+)\.vue$/))) {
87
+ const parts = m[1].split('/').map((p) => (p === 'index' ? '' : segment(p, 'nuxt')))
88
+ return { route: join(parts), scope: 'page', framework: 'nuxt' }
89
+ }
90
+ // Next.js pages router, Astro, Gatsby
91
+ if ((m = file.match(/^(src\/)?pages\/(.+)\.(m?[jt]sx?|astro|mdx?)$/))) {
92
+ const style = m[3] === 'astro' || m[1] ? 'astro' : 'next'
93
+ const parts = m[2].split('/')
94
+ if (parts.some((p) => p.startsWith('_'))) return null
95
+ return { route: join(parts.map((p) => (p === 'index' ? '' : segment(p, style)))), scope: 'page', framework: m[3] === 'astro' ? 'astro' : 'next-pages' }
96
+ }
97
+ // Rails: views, controllers and helpers of one controller shape its pages (/admin/products/…)
98
+ if ((m = file.match(/^app\/views\/(.+)\/[^/]+$/))) return { route: '/' + m[1], scope: 'prefix', framework: 'rails' }
99
+ if ((m = file.match(/^app\/controllers\/(.+)_controller\.rb$/))) return { route: '/' + m[1], scope: 'prefix', framework: 'rails' }
100
+ if ((m = file.match(/^app\/helpers\/(.+)_helper\.rb$/))) return { route: '/' + m[1], scope: 'prefix', framework: 'rails' }
101
+ // a component colocated in a route folder shapes that route and everything below it
102
+ if ((m = file.match(/^(?:src\/)?app\/(.+)\/[^/]+\.(?:m?[jt]sx?|css|scss)$/)) && !/(^|\/)(api|lib|components|utils|hooks|composables|stores|plugins|middleware|styles|types|javascript|assets|models|controllers|views|helpers|jobs|mailers|channels|services|policies|serializers)(\/|$)/.test(m[1]) && !m[1].split('/').some((p) => p.startsWith('_'))) {
103
+ return { route: join(m[1].split('/').map((p) => segment(p, 'next'))), scope: 'prefix', framework: 'next' }
104
+ }
105
+ if ((m = file.match(/^src\/routes\/(.+)\/[^/+][^/]*\.(svelte|[jt]s)$/))) return { route: join(m[1].split('/').map((p) => segment(p, 'svelte'))), scope: 'prefix', framework: 'sveltekit' }
106
+ return null
107
+ }
108
+
109
+ /** How one changed file is treated: ignored, global (optionally for one locale), a route, code to follow, or other. */
110
+ export function classify (file) {
111
+ if (IGNORED.some((re) => re.test(file))) return { kind: 'ignored' }
112
+ const msg = file.match(MESSAGES)
113
+ if (msg) return { kind: 'global', locale: msg[3].replace('_', '-').toLowerCase() }
114
+ if (GLOBAL.some((re) => re.test(file))) return { kind: 'global' }
115
+ const r = routeOf(file)
116
+ if (r) return { kind: 'route', ...r }
117
+ if (CODE.test(file)) return { kind: 'code' }
118
+ return { kind: 'other' }
119
+ }
120
+
121
+ /** RegExp for URL paths a route reaches: `:x` one segment, `**` anything below, prefix scope this path and below. */
122
+ export function routeRe (route, scope = 'page') {
123
+ const body = route.replace(/\/$/, '').split('/').filter(Boolean).map((s) => s === '**' ? '(?:/.*)?' : '/' + (s.startsWith(':') ? '[^/]+' : s.replace(/[.+^${}()|[\]\\*?]/g, '\\$&'))).join('')
124
+ if (scope === 'prefix') return new RegExp(`^${body}(?:/.*)?$`)
125
+ return new RegExp(`^${body || '/'}${body ? '/?' : ''}$`)
126
+ }
127
+
128
+ /** URL path of a step path or captured URL: no origin, query or hash; `{random}` stands for any one segment. */
129
+ export function pathOf (value) {
130
+ if (!value) return null
131
+ let p = String(value)
132
+ try { if (/^[a-z][a-z0-9+.-]*:\/\//i.test(p)) p = new URL(p).pathname } catch { return null }
133
+ p = p.split(/[?#]/)[0]
134
+ if (!p.startsWith('/')) p = '/' + p
135
+ return p.length > 1 ? p.replace(/\/+$/, '') : p
136
+ }
137
+
138
+ /** Does a route reach this path (with or without a leading locale segment such as /cs/…)? */
139
+ export function routeMatches (route, scope, path, locales = []) {
140
+ const re = routeRe(route, scope)
141
+ const p = path.replaceAll('{random}', 'x')
142
+ if (re.test(p)) return true
143
+ const [first, ...rest] = p.split('/').filter(Boolean)
144
+ const loc = first && (locales.includes(first) || /^[a-z]{2}(-[a-zA-Z]{2})?$/.test(first))
145
+ return !!loc && re.test('/' + rest.join('/'))
146
+ }
147
+
148
+ /** Paths of a step: its `path` (every locale) plus URLs captured for it earlier ({ [step]: [url…] }). */
149
+ export function stepPaths (node, captured = {}) {
150
+ const raw = node.path == null ? [] : typeof node.path === 'string' ? [node.path] : Object.values(node.path)
151
+ return [...new Set([...raw, ...(captured[node.id] || [])].map(pathOf).filter(Boolean))]
152
+ }
153
+
154
+ /** Steps a step depends on to be reached: predecessors over main edges (not back edges), transitively. */
155
+ export function upstreamOf (scenario, ids) {
156
+ const order = new Map(scenario.nodes.map((n, i) => [n.id, i]))
157
+ const into = new Map()
158
+ for (const e of scenario.edges || []) if ((e.kind || 'main') === 'main' && order.get(e.from) < order.get(e.to)) (into.get(e.to) || into.set(e.to, []).get(e.to)).push(e.from)
159
+ const out = new Map()
160
+ const visit = (id, of) => { for (const p of into.get(id) || []) if (!ids.has(p) && !out.has(p)) { out.set(p, of); visit(p, of) } }
161
+ for (const id of ids) visit(id, id)
162
+ return out
163
+ }
164
+
165
+ /**
166
+ * Selects the steps changed files reach.
167
+ * changes: [{ file, kind?, via? }] (`via`: the route file an imported module reaches, from the CLI's import graph)
168
+ * extra: { [step]: [reason] } reasons the caller found itself (changed step scripts, changed scenario steps)
169
+ * captured: { [step]: [url] } URLs captured in earlier runs
170
+ * Returns { steps: [{ id, reasons }], upstream: [{ id, of }], always, unmapped, ignored, global, locales, all }
171
+ */
172
+ export function selectAffected ({ scenario, changes, captured = {}, extra = {}, upstream = true }) {
173
+ const walkable = scenario.nodes.filter((n) => n.kind !== 'decision')
174
+ const reasons = new Map()
175
+ const add = (id, why) => { const r = reasons.get(id) || []; if (!r.includes(why)) r.push(why); reasons.set(id, r) }
176
+ const unmapped = []; const ignored = []; const global = []
177
+ const localeHits = new Set(); let everyLocale = false
178
+ const locales = scenario.locales || []
179
+ const always = walkable.filter((n) => n.affects === 'always').map((n) => n.id)
180
+ const globs = walkable.filter((n) => Array.isArray(n.affects)).map((n) => ({ id: n.id, res: n.affects.map((g) => [g, globRe(g)]) }))
181
+ const paths = new Map(walkable.map((n) => [n.id, stepPaths(n, captured)]))
182
+ for (const ch of changes) {
183
+ const file = ch.file
184
+ let hit = false
185
+ for (const g of globs) for (const [glob, re] of g.res) if (re.test(file)) { add(g.id, `${file} matches affects: ${glob}`); hit = true; everyLocale = true }
186
+ const c = ch.kind ? ch : { ...ch, ...classify(ch.via || file) }
187
+ if (c.kind === 'ignored' && !hit) { ignored.push(file); continue }
188
+ if (c.kind === 'global') {
189
+ global.push(c.locale ? `${file} (${c.locale})` : file)
190
+ if (c.locale) localeHits.add(c.locale); else everyLocale = true
191
+ for (const n of walkable) add(n.id, `${ch.via ? `${file} → imported by ${ch.via}` : file} is shared by every page${c.locale ? ` in ${c.locale}` : ''}`)
192
+ continue
193
+ }
194
+ if (c.kind === 'route') {
195
+ const label = `${ch.via ? `${file} → imported by ${ch.via}` : file} → route ${c.route}${c.scope === 'prefix' ? '/…' : ''}`
196
+ for (const n of walkable) {
197
+ const p = (paths.get(n.id) || []).find((x) => routeMatches(c.route, c.scope, x, locales))
198
+ if (p) { add(n.id, `${label} (${stepPaths(n).includes(p) ? 'step path' : 'captured earlier'} ${p})`); hit = true }
199
+ }
200
+ if (!hit) unmapped.push(`${file} (route ${c.route}: no step shows it)`)
201
+ everyLocale = true
202
+ continue
203
+ }
204
+ if (!hit) unmapped.push(file); else everyLocale = true
205
+ }
206
+ for (const [id, list] of Object.entries(extra)) for (const why of list) { add(id, why); everyLocale = true }
207
+ for (const id of always) if (!reasons.has(id)) add(id, 'always walked (affects: always)')
208
+ const picked = new Set(reasons.keys())
209
+ const onlyAlways = (id) => always.includes(id) && reasons.get(id).length === 1 && reasons.get(id)[0].startsWith('always walked')
210
+ const ups = upstream ? upstreamOf(scenario, new Set([...picked].filter((id) => !onlyAlways(id)))) : new Map()
211
+ for (const id of picked) ups.delete(id)
212
+ const order = new Map(scenario.nodes.map((n, i) => [n.id, i]))
213
+ const steps = [...reasons].map(([id, r]) => ({ id, reasons: r })).sort((a, b) => order.get(a.id) - order.get(b.id))
214
+ const real = steps.filter((s) => !onlyAlways(s.id))
215
+ return {
216
+ steps,
217
+ upstream: [...ups].map(([id, of]) => ({ id, of })).sort((a, b) => order.get(a.id) - order.get(b.id)),
218
+ always,
219
+ unmapped, ignored, global,
220
+ // only i18n messages of some locales changed: the walk can stay in those locales
221
+ locales: !everyLocale && localeHits.size ? [...localeHits] : null,
222
+ all: real.length > 0 && real.length === walkable.length,
223
+ none: real.length === 0
224
+ }
225
+ }
226
+
227
+ /** The ids to walk, in scenario order: selected, always and upstream steps. */
228
+ export const selectedIds = (scenario, sel) => scenario.nodes.map((n) => n.id).filter((id) => sel.steps.some((s) => s.id === id) || sel.upstream.some((u) => u.id === id))
229
+
230
+ /** Human explanation of a selection, one line per step. */
231
+ export function formatAffected (sel, { scenario, base = null, changed = 0 } = {}) {
232
+ const lines = [`Affected steps of ${scenario.id}${base ? ` (changes against ${base}` : ' ('}${changed} changed file(s)):`]
233
+ if (sel.none && !sel.always.length) lines.push(' none: no changed file reaches a step of this scenario')
234
+ for (const s of sel.steps) lines.push(` ✓ ${s.id.padEnd(24)} ${s.reasons.slice(0, 3).join('; ')}${s.reasons.length > 3 ? `; +${s.reasons.length - 3} more` : ''}`)
235
+ for (const u of sel.upstream) lines.push(` ↑ ${u.id.padEnd(24)} walked first to reach ${u.of} (upstream; --no-upstream skips it)`)
236
+ const skipped = scenario.nodes.filter((n) => n.kind !== 'decision' && !sel.steps.some((s) => s.id === n.id) && !sel.upstream.some((u) => u.id === n.id)).map((n) => n.id)
237
+ if (skipped.length) lines.push(` · not walked: ${skipped.join(', ')}`)
238
+ if (sel.locales) lines.push(` locales: ${sel.locales.join(', ')} (only their messages changed)`)
239
+ if (sel.unmapped.length) lines.push(` ? unmapped (no route, import or affects: glob reaches a step): ${sel.unmapped.slice(0, 8).join(', ')}${sel.unmapped.length > 8 ? `, +${sel.unmapped.length - 8} more` : ''}\n add affects: [<glob>] to the steps they shape, or walk everything (--if-unmapped all)`)
240
+ if (sel.ignored.length) lines.push(` ignored (tests, docs, CI): ${sel.ignored.length} file(s)`)
241
+ return lines.join('\n')
242
+ }
@@ -0,0 +1,233 @@
1
+ // `kaloko affected` and `walk --affected` (#14): changed files from git, imports followed to route files, URLs
2
+ // captured in earlier runs (local and, with a token, the newest shared run), then the pure selection.
3
+ import { execFile } from 'node:child_process'
4
+ import { promisify } from 'node:util'
5
+ import { readdir, readFile, stat, cp, mkdir } from 'node:fs/promises'
6
+ import { join, relative, resolve, dirname } from 'node:path'
7
+ import yaml from 'js-yaml'
8
+ import { classify, selectAffected, pathOf } from './affected-core.mjs'
9
+ import { readJson, readJsonl, appendJsonl, writeJson } from './util.mjs'
10
+ import { listRuns } from './run.mjs'
11
+
12
+ const exec = promisify(execFile)
13
+ async function git (cwd, args) {
14
+ try { return (await exec('git', args, { cwd, timeout: 20000, maxBuffer: 16 * 1024 * 1024 })).stdout.trim() } catch { return null }
15
+ }
16
+
17
+ /** The ref changes are measured against: --base, then CI variables, then origin/main or main. */
18
+ export async function baseRef (cwd, explicit, vars = process.env) {
19
+ const candidates = [
20
+ explicit,
21
+ vars.KALOKO_BASE,
22
+ vars.GITHUB_BASE_REF && `origin/${vars.GITHUB_BASE_REF}`,
23
+ vars.CI_MERGE_REQUEST_DIFF_BASE_SHA,
24
+ vars.CI_MERGE_REQUEST_TARGET_BRANCH_NAME && `origin/${vars.CI_MERGE_REQUEST_TARGET_BRANCH_NAME}`,
25
+ 'origin/main', 'origin/master', 'main', 'master'
26
+ ].filter(Boolean)
27
+ for (const ref of candidates) if (await git(cwd, ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`])) return ref
28
+ return null
29
+ }
30
+
31
+ /** Files changed since the merge base with `base`, plus uncommitted and untracked ones, relative to `root`. */
32
+ export async function changedFiles (root, base) {
33
+ const top = await git(root, ['rev-parse', '--show-toplevel'])
34
+ if (!top) return { base: null, files: [] }
35
+ const ref = await baseRef(root, base)
36
+ const mb = ref ? await git(root, ['merge-base', 'HEAD', ref]) : null
37
+ const lists = await Promise.all([
38
+ mb ? git(root, ['diff', '--name-only', mb, 'HEAD']) : '',
39
+ git(root, ['diff', '--name-only', 'HEAD']),
40
+ git(root, ['ls-files', '--others', '--exclude-standard', '--full-name'])
41
+ ])
42
+ // git names files from the top of the repository; the project may be a folder of it (monorepos)
43
+ const prefix = (await git(root, ['rev-parse', '--show-prefix'])) || ''
44
+ const files = [...new Set(lists.flatMap((l) => (l || '').split('\n').filter(Boolean)))].filter((f) => f.startsWith(prefix)).map((f) => f.slice(prefix.length)).sort()
45
+ return { base: ref, mergeBase: mb, files, top }
46
+ }
47
+
48
+ const SOURCE = /\.(m?[jt]sx?|cjs|vue|svelte|astro)$/
49
+ const SKIP_DIRS = new Set(['node_modules', '.git', '.next', '.nuxt', '.svelte-kit', '.output', 'dist', 'build', 'out', 'coverage', 'tmp', 'vendor', '.turbo', '.cache', 'public'])
50
+ const SPEC = /(?:import|export)\s[^'"`]*?from\s*['"]([^'"]+)['"]|import\s*\(\s*['"]([^'"]+)['"]\s*\)|require\s*\(\s*['"]([^'"]+)['"]\s*\)|import\s+['"]([^'"]+)['"]/g
51
+
52
+ async function sourceFiles (root, limit = 30000) {
53
+ const out = []
54
+ const walk = async (dir) => {
55
+ if (out.length >= limit) return
56
+ let entries = []
57
+ try { entries = await readdir(dir, { withFileTypes: true }) } catch { return }
58
+ for (const e of entries) {
59
+ if (e.isDirectory()) { if (!SKIP_DIRS.has(e.name) && !e.name.startsWith('.')) await walk(join(dir, e.name)) } else if (SOURCE.test(e.name)) out.push(join(dir, e.name))
60
+ }
61
+ }
62
+ await walk(root)
63
+ return out
64
+ }
65
+
66
+ /** Path aliases: tsconfig/jsconfig `paths` (one level), else @/ and ~/ to src/ or the root. */
67
+ async function aliases (root) {
68
+ const out = []
69
+ for (const f of ['tsconfig.json', 'jsconfig.json']) {
70
+ try {
71
+ const text = (await readFile(join(root, f), 'utf8')).replace(/^\s*\/\/.*$/gm, '').replace(/,(\s*[}\]])/g, '$1')
72
+ const o = JSON.parse(text).compilerOptions || {}
73
+ for (const [k, v] of Object.entries(o.paths || {})) if (k.endsWith('/*') && v?.[0]?.endsWith('/*')) out.push([k.slice(0, -1), resolve(root, o.baseUrl || '.', v[0].slice(0, -1)) + '/'])
74
+ } catch {}
75
+ }
76
+ const src = await stat(join(root, 'src')).then((s) => s.isDirectory(), () => false)
77
+ for (const a of ['@/', '~/', '$lib/']) if (!out.some(([k]) => k === a)) out.push([a, a === '$lib/' ? join(root, 'src', 'lib') + '/' : (src ? join(root, 'src') : root) + '/'])
78
+ return out
79
+ }
80
+
81
+ const EXTS = ['', '.ts', '.tsx', '.js', '.jsx', '.mjs', '.vue', '.svelte', '.astro', '/index.ts', '/index.tsx', '/index.js', '/index.jsx', '/index.vue']
82
+
83
+ /** Reverse import graph of the project's JS/TS/Vue/Svelte sources: file → files importing it (relative to root). */
84
+ export async function importGraph (root) {
85
+ const files = await sourceFiles(root)
86
+ const known = new Set(files)
87
+ const alias = await aliases(root)
88
+ const resolveSpec = (from, spec) => {
89
+ let base = null
90
+ if (spec.startsWith('.')) base = resolve(dirname(from), spec)
91
+ else { const a = alias.find(([k]) => spec.startsWith(k)); if (a) base = a[1] + spec.slice(a[0].length) }
92
+ if (!base) return null
93
+ const stripped = base.replace(/\.(js|mjs|jsx)$/, '')
94
+ for (const b of [base, stripped]) for (const e of EXTS) if (known.has(b + e)) return b + e
95
+ return null
96
+ }
97
+ const into = new Map()
98
+ for (const f of files) {
99
+ let text
100
+ try { const s = await stat(f); if (s.size > 1024 * 1024) continue; text = await readFile(f, 'utf8') } catch { continue }
101
+ for (const m of text.matchAll(SPEC)) {
102
+ const target = resolveSpec(f, m[1] || m[2] || m[3] || m[4])
103
+ if (!target || target === f) continue
104
+ const k = relative(root, target); const v = relative(root, f)
105
+ ;(into.get(k) || into.set(k, new Set()).get(k)).add(v)
106
+ }
107
+ }
108
+ return into
109
+ }
110
+
111
+ /** Route or global files that import `file`, directly or through other modules (breadth first, depth ≤ 6). */
112
+ export function importersReaching (graph, file, depth = 6, max = 40) {
113
+ const found = []; const seen = new Set([file]); let frontier = [file]
114
+ for (let d = 0; d < depth && frontier.length && found.length < max; d++) {
115
+ const next = []
116
+ for (const f of frontier) {
117
+ for (const imp of graph.get(f) || []) {
118
+ if (seen.has(imp)) continue
119
+ seen.add(imp)
120
+ const c = classify(imp)
121
+ if (c.kind === 'route' || c.kind === 'global') found.push(imp); else if (c.kind === 'code') next.push(imp)
122
+ }
123
+ }
124
+ frontier = next
125
+ }
126
+ return found
127
+ }
128
+
129
+ /** URLs captured for each step in earlier runs of the scenario: local run folders, then the newest shared run. */
130
+ export async function capturedRoutes ({ root, output, scenarioId, client = null, org = null }) {
131
+ const out = {}
132
+ const addRun = (m) => {
133
+ for (const n of m.nodes || []) for (const vps of Object.values(n.captures || {})) for (const c of Object.values(vps || {})) {
134
+ const p = pathOf(c?.url); if (p && /^https?:/.test(c.url)) (out[n.id] ||= new Set()).add(p)
135
+ }
136
+ }
137
+ const local = (await listRuns(root, output)).filter((r) => r.scenario?.id === scenarioId).sort((a, b) => b.started_at.localeCompare(a.started_at)).slice(0, 5)
138
+ for (const m of local) addRun(m)
139
+ let shared = null
140
+ if (client && org) {
141
+ try {
142
+ const [latest] = await client.request('GET', `/orgs/${org}/runs?scenario=${encodeURIComponent(scenarioId)}&limit=1`)
143
+ if (latest) { addRun(await client.request('GET', `/orgs/${org}/runs/${latest.id}`)); shared = latest.id }
144
+ } catch {}
145
+ }
146
+ return { routes: Object.fromEntries(Object.entries(out).map(([k, v]) => [k, [...v]])), local: local.length, shared }
147
+ }
148
+
149
+ /** Steps whose script or definition changed (script files relative to the scenario, the scenario against the base). */
150
+ async function scenarioChanges ({ root, scenario, scenarioFile, files, mergeBase, top }) {
151
+ const extra = {}
152
+ const add = (id, why) => (extra[id] ||= []).push(why)
153
+ const dir = dirname(scenarioFile)
154
+ for (const n of scenario.nodes) {
155
+ const s = n.execution?.script && relative(root, resolve(dir, n.execution.script))
156
+ if (s && files.includes(s)) add(n.id, `step script ${s} changed`)
157
+ }
158
+ const rel = relative(root, scenarioFile)
159
+ if (files.includes(rel)) {
160
+ let before = null
161
+ if (mergeBase && top) { const text = await git(root, ['show', `${mergeBase}:./${relative(root, scenarioFile)}`]); try { before = text ? yaml.load(text) : null } catch {} }
162
+ const old = new Map((before?.nodes || []).map((n) => [n.id, JSON.stringify(n)]))
163
+ // compared as written (packs unexpanded), so a pack update in the CLI does not mark every step
164
+ const now = yaml.load(await readFile(scenarioFile, 'utf8'))
165
+ for (const n of scenario.nodes) {
166
+ if (n.kind === 'decision') continue
167
+ const raw = JSON.stringify((now?.nodes || []).find((x) => x.id === n.id) ?? n)
168
+ if (!old.has(n.id)) add(n.id, before ? 'new step in the scenario' : 'the scenario is new')
169
+ else if (old.get(n.id) !== raw) add(n.id, 'step changed in the scenario')
170
+ }
171
+ }
172
+ return extra
173
+ }
174
+
175
+ /**
176
+ * The full selection for one scenario. `files` overrides git (agents and CI may pass the list they know).
177
+ * Returns { sel, base, files, routes }.
178
+ */
179
+ export async function affectedFor ({ root, config, scenario, scenarioFile, base, files = null, client = null, upstream = true }) {
180
+ const diff = files ? { base: base || null, files, mergeBase: null, top: null } : await changedFiles(root, base)
181
+ const scenarioRel = relative(root, scenarioFile)
182
+ const graphNeeded = diff.files.some((f) => classify(f).kind === 'code' && SOURCE.test(f))
183
+ const graph = graphNeeded ? await importGraph(root) : new Map()
184
+ const changes = []
185
+ for (const file of diff.files) {
186
+ if (file === scenarioRel) continue
187
+ const c = classify(file)
188
+ if (c.kind === 'code' && SOURCE.test(file)) {
189
+ const via = importersReaching(graph, file)
190
+ if (via.length) { for (const v of via) changes.push({ file, via: v }); continue }
191
+ }
192
+ changes.push({ file })
193
+ }
194
+ // the scenario's own steps folder is mechanics of the walk, never a page of the app
195
+ const stepsDir = relative(root, dirname(scenarioFile)) + '/'
196
+ const appChanges = changes.filter((c) => !c.file.startsWith(stepsDir) || c.via)
197
+ const extra = await scenarioChanges({ root, scenario, scenarioFile, files: diff.files, mergeBase: diff.mergeBase, top: diff.top })
198
+ const { routes, local, shared } = await capturedRoutes({ root, output: config.output, scenarioId: scenario.id, client, org: config.org })
199
+ const sel = selectAffected({ scenario, changes: appChanges, captured: routes, extra, upstream })
200
+ return { sel, base: diff.base, files: diff.files, runs: { local, shared } }
201
+ }
202
+
203
+ /**
204
+ * Copies the newest earlier local run's captures of the steps that are not walked into this run (marked
205
+ * carried_from), so the canvas shows the whole flow. Returns { from, steps } or null.
206
+ */
207
+ export async function carryCaptures ({ root, output, run, scenario, walked }) {
208
+ const base = resolve(root, output)
209
+ let names = []
210
+ try { names = (await readdir(base, { withFileTypes: true })).filter((e) => e.isDirectory() && !e.name.startsWith('.') && e.name !== run.meta.id).map((e) => e.name) } catch { return null }
211
+ const metas = []
212
+ for (const name of names) { const m = await readJson(join(base, name, 'meta.json'), null); if (m?.scenario?.id === scenario.id && m.environment?.name === run.meta.environment.name) metas.push(m) }
213
+ metas.sort((a, b) => b.started_at.localeCompare(a.started_at))
214
+ const already = new Set((await readJsonl(join(run.dir, 'captures.jsonl'))).map((c) => c.node))
215
+ for (const prev of metas) {
216
+ const entries = await readJsonl(join(base, prev.id, 'captures.jsonl'))
217
+ const take = entries.filter((e) => !walked.has(e.node) && !already.has(e.node) && run.meta.scope.locales.includes(e.locale) && (!e.viewport || run.meta.scope.viewports[e.viewport]))
218
+ if (!take.length) continue
219
+ const steps = [...new Set(take.map((e) => e.node))]
220
+ for (const id of steps) {
221
+ await mkdir(join(run.dir, 'steps'), { recursive: true })
222
+ await cp(join(base, prev.id, 'steps', id), join(run.dir, 'steps', id), { recursive: true, force: true }).catch(() => {})
223
+ }
224
+ for (const e of take) {
225
+ if (e.type === 'attachment') await cp(join(base, prev.id, e.file), join(run.dir, e.file), { force: true }).catch(() => {})
226
+ if (e.type === 'capture') { const cap = await readJson(join(run.dir, e.file), null); if (cap) await writeJson(join(run.dir, e.file), { ...cap, carried_from: prev.id }) }
227
+ await appendJsonl(join(run.dir, 'captures.jsonl'), { ...e, carried_from: prev.id })
228
+ }
229
+ return { from: prev.id, steps }
230
+ }
231
+ return null
232
+ }
233
+
package/src/lib/build.mjs CHANGED
@@ -29,9 +29,14 @@ export async function buildManifest ({ run, scenario, config, root, catalog = []
29
29
  const cap = await readJson(join(run.dir, c.file))
30
30
  const stored = await readJson(runPaths(run.dir).step(c.node, c.locale, c.viewport, 'eval.json'), null)
31
31
  const ev = stored?.source === evaluationSource(node, cap) ? stored : null
32
- caps.push({ locale: c.locale, viewport: c.viewport, cap, ev, files: { png: cap.files.png, thumb: cap.files.thumb || null, html: cap.files.html || null, tree: cap.files.tree || null, eval: `steps/${c.node}/${c.locale}/${c.viewport}.eval.json` }, ...(cap.live ? { live: cap.live } : {}) })
32
+ // the step spec travels as its own file (Dev mode on screenshots, `kaloko spec`, drift on the service)
33
+ const spec = cap.spec ? `steps/${c.node}/${c.locale}/${c.viewport}.spec.json` : null
34
+ if (spec) await writeJson(join(run.dir, spec), cap.spec)
35
+ caps.push({ locale: c.locale, viewport: c.viewport, cap, ev, files: { png: cap.files.png, thumb: cap.files.thumb || null, html: cap.files.html || null, tree: cap.files.tree || null, eval: `steps/${c.node}/${c.locale}/${c.viewport}.eval.json`, spec }, ...(cap.live ? { live: cap.live } : {}) })
33
36
  }
34
- nodes.push(assembleNode({ node, pos: pos.get(node.id), locales, viewportNames, captures: caps, attachments: attachments.filter((a) => a.node === node.id) }))
37
+ // focus screenshots of the keyboard walk (#19): only those of the current captures, not of earlier walks
38
+ const shots = new Set(caps.flatMap((c) => (c.cap.keyboard?.shots || []).map((s) => s.file)))
39
+ nodes.push(assembleNode({ node, pos: pos.get(node.id), locales, viewportNames, captures: caps, attachments: attachments.filter((a) => a.node === node.id && (a.kind !== 'focus' || shots.has(a.file))) }))
35
40
  }
36
41
  const summary = summarize(nodes, scenario.sessions)
37
42
 
@@ -126,19 +131,39 @@ async function buildStack ({ run, node, base, captures, attachments }) {
126
131
  return {
127
132
  ...base, status, by_locale, captures: {}, criteria, semantic: {}, metadata: {}, session: node.session || null,
128
133
  execution: node.execution ? { strategy: node.execution.strategy, instructions: node.execution.instructions } : { strategy: 'playwright', instructions: 'Open every page of the stack and capture it.' },
129
- attachments: attachments.filter((a) => a.node === node.id).map((a) => ({ type: a.kind, title: a.title, file: a.file, locale: a.locale })),
134
+ attachments: attachments.filter((a) => a.node === node.id && a.kind !== 'focus').map((a) => ({ type: a.kind, title: a.title, file: a.file, locale: a.locale })),
130
135
  stack: { count: items.length, items: file, summary, fields, sample, semantic_sample: semanticSample, source: ['urls', 'file', 'sitemap', 'crawl', 'script'].find((k) => src[k]) || null, stale: items.filter((it) => it.stale).length }
131
136
  }
132
137
  }
133
138
 
139
+ /**
140
+ * Lines for `kaloko build`: semantic criteria that failed or need a person, per step, locale and viewport, with the
141
+ * score and the one-sentence reason (#17). At most `max` lines.
142
+ */
143
+ export function attentionLines (manifest, max = 20) {
144
+ const out = []
145
+ for (const n of manifest.nodes || []) {
146
+ for (const c of n.criteria || []) {
147
+ if (c.type !== 'semantic' || !['fail', 'needs_review'].includes(c.verdict)) continue
148
+ for (const [l, vps] of Object.entries(c.by_locale || {})) {
149
+ for (const [vp, r] of Object.entries(vps)) {
150
+ if (!['fail', 'needs_review'].includes(r.verdict)) continue
151
+ out.push(` ${r.verdict === 'fail' ? '✕' : '?'} ${n.id} ${l}/${vp} ${c.id}${r.score != null ? ` ${Math.round(r.score * 100)} %${r.vision ? ' (with screenshot)' : ''}` : ''}${r.reason ? `: ${r.reason}` : r.note ? `: ${r.note}` : ''}`)
152
+ }
153
+ }
154
+ }
155
+ }
156
+ return out.length > max ? [...out.slice(0, max), ` … ${out.length - max} more on the canvas`] : out
157
+ }
158
+
134
159
  export async function writePreview ({ run, manifest }) {
135
160
  const template = await readFile(join(packageRoot, 'viewer', 'index.html'), 'utf8')
136
161
  // the local preview opens from file://, where fetch cannot read files: stack indexes go inline
137
162
  // file:// cannot fetch: stack indexes and app UI trees travel inside the preview
138
163
  const withTrees = async (n) => {
139
- if (!Object.values(n.captures || {}).some((vps) => Object.values(vps).some((c) => c?.tree))) return n
164
+ if (!Object.values(n.captures || {}).some((vps) => Object.values(vps).some((c) => c?.tree || c?.spec))) return n
140
165
  const captures = {}
141
- for (const [l, vps] of Object.entries(n.captures)) { captures[l] = {}; for (const [vp, c] of Object.entries(vps)) captures[l][vp] = c?.tree ? { ...c, tree_inline: await readJson(join(run.dir, c.tree), null) } : c }
166
+ for (const [l, vps] of Object.entries(n.captures)) { captures[l] = {}; for (const [vp, c] of Object.entries(vps)) captures[l][vp] = c?.tree || c?.spec ? { ...c, ...(c.tree ? { tree_inline: await readJson(join(run.dir, c.tree), null) } : {}), ...(c.spec ? { spec_inline: await readJson(join(run.dir, c.spec), null) } : {}) } : c }
142
167
  return { ...n, captures }
143
168
  }
144
169
  // design runs: live steps open from the merged tree, where links between steps resolve (design.mjs copyLive)
@@ -2,6 +2,7 @@ import { writeFile, mkdir, rm } from 'node:fs/promises'
2
2
  import { dirname, relative, join } from 'node:path'
3
3
  import { buildHtmlView } from './htmlview.mjs'
4
4
  import { probe } from './probe.mjs'
5
+ import { pageSpecScript } from './devspec.mjs'
5
6
  import { writeJson, readJsonl } from './util.mjs'
6
7
  import { addCapture, runPaths } from './run.mjs'
7
8
 
@@ -37,6 +38,15 @@ export const toWebp = (page, png, opts = {}) => encodeImage(page, png, { ...opts
37
38
  * Capture options: scenario-wide `capture:` merged with the node's. Documentation runs use
38
39
  * { format: png, scale: 2, mask: [selectors], annotate: true }; daily acceptance keeps the WebP default.
39
40
  */
41
+ /** The page spec (src/lib/devspec.mjs), kept under ~250 kB so it fits a design revision's capture record. */
42
+ export async function stepSpec (page, tokens = null) {
43
+ try {
44
+ const spec = await page.evaluate(pageSpecScript(tokens))
45
+ while (spec.elements.length > 20 && JSON.stringify(spec).length > 250000) spec.elements = spec.elements.slice(0, Math.floor(spec.elements.length * 0.8))
46
+ return spec
47
+ } catch { return null }
48
+ }
49
+
40
50
  export function captureOptions (scenario, node) {
41
51
  const o = { ...(scenario?.capture || {}), ...(node?.capture || {}) }
42
52
  const q = Number(o.quality)
@@ -56,6 +66,8 @@ import { consoleEntriesFor } from './console.mjs'
56
66
  import { redact, redactDeep } from './secrets.mjs'
57
67
  import { readFields } from './stack.mjs'
58
68
  import { checkLinks, checkHreflang, requester, walkCache, NETWORK_ASSERTS, isolatedRequest, cacheScope } from './linkcheck.mjs'
69
+ import { keyboardChecks, needsKeyboard, KEYBOARD_ASSERTS } from './keyboard.mjs'
70
+ import { repeatRegions, resolveIgnore } from './stability.mjs'
59
71
 
60
72
  /** Probe arguments derived from the node's criteria. */
61
73
  export function probeArgs (node, maxChars, design = false) {
@@ -74,6 +86,9 @@ export function probeArgs (node, maxChars, design = false) {
74
86
  // md-menu, listboxes, tooltips). A step that leaves one open wants it on the capture.
75
87
  const OVERLAY = '[role=menu], [role=listbox], [role=tooltip], .MuiPopover-root, .MuiPopper-root, .MuiMenu-root, md-menu-content, .md-open-menu-container.md-active, .md-panel-outer-wrapper.md-panel-is-showing'
76
88
 
89
+ const STACK_KEYBOARD = new Map()
90
+ const STACK_KEYBOARD_PAGES = 5
91
+
77
92
  export async function settle (page) {
78
93
  try { await page.waitForLoadState('networkidle', { timeout: 5000 }) } catch {}
79
94
  try { await page.evaluate(() => document.fonts?.ready) } catch {}
@@ -95,7 +110,7 @@ export async function settle (page) {
95
110
  * Captures the current state of `page` for one step and locale in every
96
111
  * requested viewport: full-page PNG, rendered HTML, probe results.
97
112
  */
98
- export async function captureStep ({ page, node, locale, viewports, run, consoleFile, maxChars = 90000, since, extra = {}, format = null, options = null, designSummary = false, htmlView = null, item = null, fields = null }) {
113
+ export async function captureStep ({ page, node, locale, viewports, run, consoleFile, maxChars = 90000, since, extra = {}, format = null, options = null, designSummary = false, htmlView = null, item = null, fields = null, tokens = null, stability = 0 }) {
99
114
  const opts = { ...(options || captureOptions(null, node)), ...(format ? { format } : {}) }
100
115
  // a page of a stack step lives under steps/<node>/items/<item>/…; its capture record names node and item
101
116
  const stepId = item ? `${node.id}/items/${item.id}` : node.id
@@ -132,6 +147,12 @@ export async function captureStep ({ page, node, locale, viewports, run, console
132
147
  throw error
133
148
  }
134
149
  }
150
+ // the step spec (Dev mode, `kaloko spec`, drift): elements with roles, names and styles mapped to the tokens
151
+ if (probeArgs(node, maxChars, designSummary).design && !item) result.spec = await stepSpec(page, tokens)
152
+ // regions the scenario ignores in pixel comparisons (#20), as page rectangles of this capture
153
+ const ignore = await resolveIgnore(page, node, locale, viewport)
154
+ // what moves while the page sits still (walk --stability N): repeated screenshots, compared here
155
+ const unstable = stability > 1 ? await repeatRegions(page, { n: Math.min(6, stability), fullPage: opts.fullPage }).catch((error) => ({ error: String(error.message).slice(0, 200) })) : null
135
156
  // numbered marks on the highlighted elements, in criteria order (first box of each)
136
157
  const annotations = opts.annotate ? Object.entries(result.boxes || {}).filter(([, list]) => list?.length).map(([criterion, list], i) => ({ n: i + 1, criterion, x: list[0].x, y: list[0].y })) : []
137
158
  let image = paths.step(stepId, locale, viewport, opts.format)
@@ -168,6 +189,24 @@ export async function captureStep ({ page, node, locale, viewports, run, console
168
189
  : { ok: errors.length === 0, evidence: errors.length ? errors.slice(0, 3).join(' | ') : 'no console errors' }
169
190
  }
170
191
  }
192
+ // keyboard and screen-reader checks (#19): a Tab walk and the accessibility tree, after the screenshot and after
193
+ // the console was read (focus handlers of the page must not count as its errors)
194
+ let keyboard = null
195
+ // stack steps walk the keyboard on their first few pages only (one template, hundreds of pages)
196
+ const kbKey = `${run.dir}|${node.id}|${viewport}`
197
+ const kbSampled = !item || (STACK_KEYBOARD.set(kbKey, (STACK_KEYBOARD.get(kbKey) || 0) + 1).get(kbKey) <= STACK_KEYBOARD_PAGES)
198
+ if (needsKeyboard(node) && !kbSampled) {
199
+ for (const c of node.criteria) if (KEYBOARD_ASSERTS.has(c.check.assert)) result.checks[c.id] = { ok: null, evidence: `keyboard walk runs on the first ${STACK_KEYBOARD_PAGES} pages of a stack step` }
200
+ }
201
+ if (needsKeyboard(node) && kbSampled) {
202
+ const kb = await keyboardChecks(page, node.criteria, {
203
+ saveShot: async (n, png) => { const f = paths.step(stepId, locale, viewport, `focus-${n}.png`); await writeFile(f, png); return relative(run.dir, f) }
204
+ })
205
+ Object.assign(result.checks, kb.checks)
206
+ result.boxes = { ...result.boxes, ...kb.boxes }
207
+ keyboard = kb.keyboard
208
+ for (const s of keyboard?.shots || []) await addCapture(run.dir, { type: 'attachment', kind: 'focus', node: node.id, ...(item ? { item: item.id } : {}), locale, viewport, title: `Focus ${s.n}: ${s.name}`, file: s.file, at: new Date().toISOString() })
209
+ }
171
210
  // experimental HTML view (#42): a scrubbed, script-free static copy with rehosted assets
172
211
  let view = null
173
212
  if (htmlView) {
@@ -189,6 +228,9 @@ export async function captureStep ({ page, node, locale, viewports, run, console
189
228
  ...extra,
190
229
  http_status: extra.http_status ?? result.http_status ?? null,
191
230
  capture: { format: String(image).split('.').pop(), scale: opts.scale, masked: opts.mask.length, annotations, ...(opts.fullPage ? {} : { full_page: false }) },
231
+ ...(ignore.length ? { ignore } : {}),
232
+ ...(unstable ? { unstable } : {}),
233
+ ...(keyboard ? { keyboard } : {}),
192
234
  ...(item ? { item: { id: item.id, path: item.path, ...(item.title ? { title: item.title } : {}), ...(item.purpose ? { purpose: item.purpose } : {}) } } : {}),
193
235
  ...(fields ? { fields: { ...(await page.evaluate(readFields, fields).catch(() => ({}))), ...(fields.some((f) => (f.from || f.id) === 'http') ? Object.fromEntries(fields.filter((f) => (f.from || f.id) === 'http').map((f) => [f.id, extra.http_status ?? result.http_status ?? null])) : {}) } } : {}),
194
236
  files: { png: relative(run.dir, image), image: relative(run.dir, image), thumb: thumbWritten ? relative(run.dir, thumb) : null, html: relative(run.dir, html) }