kaloko 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/README.md +119 -0
  2. package/bin/kaloko.mjs +4 -0
  3. package/bin/qawalk.mjs +4 -0
  4. package/package.json +42 -0
  5. package/schemas/bundle.v1.json +19 -0
  6. package/schemas/config.v1.json +268 -0
  7. package/schemas/revision.v1.json +36 -0
  8. package/schemas/run.v1.json +549 -0
  9. package/schemas/scenario.v1.json +668 -0
  10. package/schemas/tokens.v1.json +102 -0
  11. package/skills/kaloko/SKILL.md +94 -0
  12. package/skills/kaloko/reference/acceptance.md +208 -0
  13. package/skills/kaloko/reference/apps.md +161 -0
  14. package/skills/kaloko/reference/collaboration.md +218 -0
  15. package/skills/kaloko/reference/design.md +143 -0
  16. package/skills/kaloko/reference/documentation.md +72 -0
  17. package/skills/kaloko/reference/environments.md +140 -0
  18. package/skills/kaloko/reference/evaluation.md +62 -0
  19. package/skills/kaloko/reference/execution.md +75 -0
  20. package/skills/kaloko/reference/mcp.md +69 -0
  21. package/skills/kaloko/reference/stacks.md +122 -0
  22. package/src/cli.mjs +1344 -0
  23. package/src/lib/access.mjs +169 -0
  24. package/src/lib/apps/capture.mjs +59 -0
  25. package/src/lib/apps/drivers.mjs +284 -0
  26. package/src/lib/apps/helper.swift +102 -0
  27. package/src/lib/apps/tree.mjs +222 -0
  28. package/src/lib/browser-daemon.mjs +41 -0
  29. package/src/lib/browser.mjs +161 -0
  30. package/src/lib/build.mjs +149 -0
  31. package/src/lib/calibrate.mjs +97 -0
  32. package/src/lib/capture.mjs +233 -0
  33. package/src/lib/compare.mjs +88 -0
  34. package/src/lib/config.mjs +117 -0
  35. package/src/lib/console.mjs +13 -0
  36. package/src/lib/coverage.mjs +75 -0
  37. package/src/lib/design-cli.mjs +440 -0
  38. package/src/lib/design-core.mjs +163 -0
  39. package/src/lib/design.mjs +164 -0
  40. package/src/lib/doctor.mjs +149 -0
  41. package/src/lib/draft.mjs +76 -0
  42. package/src/lib/drift.mjs +145 -0
  43. package/src/lib/evaluate.mjs +222 -0
  44. package/src/lib/git.mjs +70 -0
  45. package/src/lib/htmlview.mjs +130 -0
  46. package/src/lib/jev.mjs +95 -0
  47. package/src/lib/mail.mjs +182 -0
  48. package/src/lib/manifest-core.mjs +127 -0
  49. package/src/lib/pack-help.mjs +55 -0
  50. package/src/lib/packs.mjs +121 -0
  51. package/src/lib/pr.mjs +78 -0
  52. package/src/lib/probe.mjs +379 -0
  53. package/src/lib/recheck.mjs +70 -0
  54. package/src/lib/run.mjs +85 -0
  55. package/src/lib/scenario.mjs +98 -0
  56. package/src/lib/schema.mjs +25 -0
  57. package/src/lib/secrets.mjs +59 -0
  58. package/src/lib/service.mjs +52 -0
  59. package/src/lib/stack.mjs +130 -0
  60. package/src/lib/tokens.mjs +352 -0
  61. package/src/lib/util.mjs +97 -0
  62. package/templates/ACCEPTANCE.md +55 -0
  63. package/templates/cs/ACCEPTANCE.md +55 -0
  64. package/templates/kaloko.config.yml +76 -0
  65. package/templates/mail/letter-opener.mjs +47 -0
  66. package/templates/scenario.yml +119 -0
  67. package/templates/steps/consent.mjs +33 -0
  68. package/viewer/index.html +2772 -0
@@ -0,0 +1,25 @@
1
+ import Ajv2020 from 'ajv/dist/2020.js'
2
+ import { readFileSync } from 'node:fs'
3
+ import { fileURLToPath } from 'node:url'
4
+ import { join, dirname } from 'node:path'
5
+
6
+ const root = join(dirname(fileURLToPath(import.meta.url)), '..', '..')
7
+ const ajv = new Ajv2020({ allErrors: true, strict: false, useDefaults: true })
8
+ const cache = new Map()
9
+
10
+ export function validator (name) {
11
+ if (!cache.has(name)) {
12
+ const schema = JSON.parse(readFileSync(join(root, 'schemas', `${name}.json`), 'utf8'))
13
+ cache.set(name, ajv.compile(schema))
14
+ }
15
+ return cache.get(name)
16
+ }
17
+
18
+ /** Returns a list of human-readable problems; empty when valid. */
19
+ export function schemaErrors (name, data) {
20
+ const validate = validator(name)
21
+ if (validate(data)) return []
22
+ return validate.errors.map((e) => `${e.instancePath || '/'} ${e.message}${e.params?.allowedValues ? ` (${e.params.allowedValues.join(', ')})` : ''}`)
23
+ }
24
+
25
+ export const packageRoot = root
@@ -0,0 +1,59 @@
1
+ // Secrets are named in the config (`*_env`, `{ env: NAME }`) and read from the process
2
+ // environment only when needed. Every value read here is remembered, so captures, HTML and
3
+ // logs can be scrubbed before they are written or shared.
4
+ import { UserError } from './util.mjs'
5
+
6
+ const known = new Set()
7
+
8
+ /** Value of a secret variable; throws with the variable name (never the value) when it is missing. */
9
+ export function secret (name, what, env = process.env) {
10
+ const value = env[name]
11
+ if (value == null || value === '') throw new UserError(`Set ${name}${what ? ` (${what})` : ''} in the environment, the project .env or ~/.config/kaloko/.env.`)
12
+ remember(value)
13
+ return value
14
+ }
15
+
16
+ /** Names of the variables a config fragment refers to that are not set. */
17
+ export function missing (names, env = process.env) {
18
+ return names.filter((n) => n && (env[n] == null || env[n] === ''))
19
+ }
20
+
21
+ export function remember (value) {
22
+ // very short values (a PIN, "x") would scrub ordinary words from the evidence
23
+ if (typeof value === 'string' && value.length >= 4) known.add(value)
24
+ }
25
+
26
+ /** Replaces every remembered secret (and its base64 / URL-encoded forms) with a marker. */
27
+ export function redact (text) {
28
+ if (typeof text !== 'string' || !known.size) return text
29
+ // longest first: one secret's form may be part of another's (a password inside a Basic pair)
30
+ const forms = [...new Set([...known].flatMap((v) => [v, encodeURIComponent(v), Buffer.from(v).toString('base64')]))].sort((a, b) => b.length - a.length)
31
+ let out = text
32
+ for (const form of forms) if (out.includes(form)) out = out.split(form).join('[redacted]')
33
+ return out
34
+ }
35
+
36
+ /** Deep copy with every string redacted (capture records, console entries). */
37
+ export function redactDeep (value) {
38
+ if (typeof value === 'string') return redact(value)
39
+ if (Array.isArray(value)) return value.map(redactDeep)
40
+ if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, redactDeep(v)]))
41
+ return value
42
+ }
43
+
44
+ export const _forget = () => known.clear() // tests
45
+
46
+ /**
47
+ * Remembers every secret an environment refers to that is set (`*_env` except user names,
48
+ * `{ env: NAME }` header values), so captures are scrubbed even when a person or an agent
49
+ * typed the password and the CLI never read it itself.
50
+ */
51
+ export function rememberEnvironment (envConfig, env = process.env) {
52
+ const visit = (node, key) => {
53
+ if (!node || typeof node !== 'object') return
54
+ for (const [k, v] of Object.entries(node)) {
55
+ if (typeof v === 'string' && ((k.endsWith('_env') && k !== 'user_env') || (k === 'env' && key !== 'base_url'))) { if (env[v]) remember(env[v]) } else visit(v, k)
56
+ }
57
+ }
58
+ visit({ access: envConfig.access, http_auth: envConfig.http_auth, accounts: envConfig.accounts, mail: envConfig.mail })
59
+ }
@@ -0,0 +1,52 @@
1
+ // Client of the Kaloko service API (kaloko.app or KALOKO_URL) with the organization token.
2
+ import { UserError } from './util.mjs'
3
+
4
+ /** Base URL of the service; configs written before the rename name app.qawalk.com, which now means kaloko.app. */
5
+ export function serviceUrl (config, vars = process.env) {
6
+ const url = (vars.KALOKO_URL || config.share?.url || 'https://kaloko.app').replace(/\/$/, '')
7
+ return url.replace(/^https:\/\/app\.qawalk\.com$/, 'https://kaloko.app')
8
+ }
9
+
10
+ export function service (config) {
11
+ const base = serviceUrl(config) + '/api/v1'
12
+ const tokenEnv = config.share?.token_env || 'KALOKO_TOKEN'
13
+ const token = process.env[tokenEnv]
14
+ if (!token) throw new UserError(`Set ${tokenEnv} (create a token in Kaloko → Settings).`)
15
+ // an agent names itself (KALOKO_AGENT, e.g. "claude-code"); the service shows it in presence, history and audit (#42)
16
+ const headers = { Authorization: `Bearer ${token}`, ...(process.env.KALOKO_AGENT ? { 'X-QAwalk-Agent': process.env.KALOKO_AGENT.slice(0, 60) } : {}) }
17
+ const check = async (res, what) => {
18
+ if (res.ok) return res.json()
19
+ const body = await res.json().catch(() => ({}))
20
+ throw new UserError(`${what} → HTTP ${res.status}: ${body.error || res.statusText}`)
21
+ }
22
+ // the service limits requests per token (HTTP 429, nothing was processed): wait as told and try again
23
+ // read-your-writes across requests: the service answers with a database bookmark the next request sends back
24
+ let bookmark = null
25
+ const limited = async (send) => {
26
+ for (let attempt = 0; ; attempt++) {
27
+ const res = await send()
28
+ const bm = res.headers?.get?.('X-D1-Bookmark'); if (bm) { bookmark = bm; headers['X-D1-Bookmark'] = bm }
29
+ if (res.status !== 429 || attempt >= 6) return res
30
+ const wait = Math.min(60, Number(res.headers.get('Retry-After')) || 2 ** attempt)
31
+ await new Promise((r) => setTimeout(r, wait * 1000))
32
+ }
33
+ }
34
+ return {
35
+ async request (method, path, body) {
36
+ const res = await limited(() => fetch(base + path, { method, headers: { ...headers, ...(body ? { 'Content-Type': 'application/json' } : {}) }, body: body ? JSON.stringify(body) : undefined }))
37
+ return check(res, `${method} ${path}`)
38
+ },
39
+ async fetch (path) {
40
+ const res = await fetch(base + path, { headers })
41
+ if (!res.ok) { const body = await res.json().catch(() => ({})); throw new UserError(`GET ${path} → HTTP ${res.status}: ${body.error || res.statusText}`) }
42
+ return res
43
+ },
44
+ async upload (path, data, name, extra = {}) {
45
+ for (let attempt = 0; ; attempt++) {
46
+ const res = await limited(() => fetch(base + path, { method: 'PUT', headers: { ...headers, ...extra }, body: data }))
47
+ if (res.ok || attempt >= 2 || res.status < 500) return check(res, `upload ${name}`)
48
+ await new Promise((r) => setTimeout(r, 1000 * (attempt + 1)))
49
+ }
50
+ }
51
+ }
52
+ }
@@ -0,0 +1,130 @@
1
+ // Stack steps: one step that stands for many pages of the same template (magazine articles, product pages, an SEO
2
+ // landing catalogue). The pages come from urls, a file, a sitemap, a crawl of a listing or a script; every page is
3
+ // captured with the step's criteria and a few index fields, and the canvas shows them as a stack with an index.
4
+ import { readFile } from 'node:fs/promises'
5
+ import { resolve, extname } from 'node:path'
6
+ import { pathToFileURL } from 'node:url'
7
+ import { createHash } from 'node:crypto'
8
+ import { UserError } from './util.mjs'
9
+
10
+ export const DEFAULT_LIMIT = 500
11
+ export const DEFAULT_SEMANTIC_SAMPLE = 20
12
+ export const DEFAULT_FIELDS = [
13
+ { id: 'title', label: 'Title', from: 'title' },
14
+ { id: 'h1', label: 'H1', from: 'h1' },
15
+ { id: 'words', label: 'Words', from: 'words' },
16
+ { id: 'http', label: 'HTTP', from: 'http' }
17
+ ]
18
+ export const fieldsOf = (node) => (node.fields?.length ? node.fields.map((f) => ({ label: f.label || f.id, from: f.from || f.id, ...f })) : DEFAULT_FIELDS)
19
+
20
+ const slug = (s) => String(s || '').toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
21
+ /** Stable, readable id of a page: the path as a slug (≤ 60 chars) plus a short hash when needed. */
22
+ export function itemId (path, taken) {
23
+ let base = slug(path).slice(0, 60) || 'root'
24
+ if (taken.has(base)) base = `${base.slice(0, 52)}-${createHash('sha1').update(path).digest('hex').slice(0, 7)}`
25
+ taken.add(base)
26
+ return base
27
+ }
28
+
29
+ const pathOf = (u, origin) => { try { const x = new URL(u, origin); return x.origin === origin ? x.pathname + x.search : x.href } catch { return String(u) } }
30
+
31
+ /** Parses <loc> entries of a sitemap or sitemap index. */
32
+ export function sitemapLocs (xml) {
33
+ const locs = [...String(xml).matchAll(/<loc>\s*([^<\s]+)\s*<\/loc>/gi)].map((m) => m[1].replace(/&amp;/g, '&'))
34
+ return { index: /<sitemapindex[\s>]/i.test(xml), locs }
35
+ }
36
+
37
+ /** Reads a list file: text (one per line), CSV (first column) or JSON ([path] or [{path|url, title, key}]). */
38
+ export async function listFile (file) {
39
+ const text = await readFile(file, 'utf8').catch(() => { throw new UserError(`items.file ${file} cannot be read.`) })
40
+ if (extname(file) === '.json') {
41
+ const data = JSON.parse(text)
42
+ return (Array.isArray(data) ? data : data.items || []).map((x) => (typeof x === 'string' ? { path: x } : { path: x.path || x.url, title: x.title, key: x.key }))
43
+ }
44
+ return text.split(/\r?\n/).map((l) => l.trim()).filter((l) => l && !l.startsWith('#')).map((l) => ({ path: extname(file) === '.csv' ? l.split(/[,;]/)[0].replace(/^"|"$/g, '') : l })).filter((x) => x.path && !/^(path|url)$/i.test(x.path))
45
+ }
46
+
47
+ /**
48
+ * The pages of a stack step, deduplicated, filtered (match/exclude), limited: [{ id, path, url, title? }].
49
+ * `base` is the environment's URL for the locale; `page` is used for crawl and script sources. `match` and
50
+ * `exclude` are regexes on the path with its query; `{locale}` in any source string is the walked locale.
51
+ */
52
+ export async function resolveItems ({ node, base, page, scenarioDir, env, locale, limit, fetchImpl = fetch }) {
53
+ // `{locale}` in any string of the source (sitemap, match, crawl.from, file…) becomes the walked locale
54
+ const sub = (v) => (typeof v === 'string' ? v.replaceAll('{locale}', locale || '') : Array.isArray(v) ? v.map(sub) : v && typeof v === 'object' ? Object.fromEntries(Object.entries(v).map(([k, x]) => [k, sub(x)])) : v)
55
+ const src = sub(node.items || {})
56
+ const origin = new URL(base).origin
57
+ const abs = (p) => new URL(p, base.endsWith('/') ? base : base + '/').href.replace(/^(https?:\/\/[^/]+)\/\//, '$1/')
58
+ const toUrl = (p) => (/^https?:\/\//.test(p) ? p : new URL(p, origin).href)
59
+ let raw = []
60
+ if (src.urls) raw = src.urls.map((p) => ({ path: p }))
61
+ else if (src.file) raw = await listFile(resolve(scenarioDir, src.file))
62
+ else if (src.sitemap) {
63
+ const get = async (u) => { const r = await fetchImpl(u, { headers: { 'User-Agent': 'Kaloko' } }); if (!r.ok) throw new UserError(`items.sitemap ${u}: HTTP ${r.status}`); return r.text() }
64
+ const first = sitemapLocs(await get(toUrl(src.sitemap)))
65
+ let locs = first.locs
66
+ if (first.index) { locs = []; for (const u of first.locs.slice(0, 50)) locs.push(...sitemapLocs(await get(u)).locs) }
67
+ raw = locs.map((u) => ({ path: u }))
68
+ } else if (src.crawl) {
69
+ if (!page) throw new UserError('items.crawl needs a browser page.')
70
+ let url = toUrl(src.crawl.from)
71
+ for (let i = 0; i < (src.crawl.pages || 1) && url; i++) {
72
+ await page.goto(url, { waitUntil: 'domcontentloaded' })
73
+ const found = await page.$$eval(src.crawl.links, (as) => as.map((a) => ({ href: a.href, title: (a.innerText || a.title || '').trim().slice(0, 200) })))
74
+ raw.push(...found.map((f) => ({ path: f.href, title: f.title || undefined })))
75
+ url = src.crawl.next ? await page.$eval(src.crawl.next, (a) => a.href).catch(() => null) : null
76
+ }
77
+ } else if (src.script) {
78
+ const mod = await import(pathToFileURL(resolve(scenarioDir, src.script)).href)
79
+ const fn = mod.default || mod.items
80
+ if (typeof fn !== 'function') throw new UserError(`items.script ${src.script} must export a default function.`)
81
+ raw = (await fn({ page, env, locale, url: abs })) || []
82
+ raw = raw.map((x) => (typeof x === 'string' ? { path: x } : { path: x.path || x.url, title: x.title, key: x.key }))
83
+ }
84
+ const match = src.match ? new RegExp(src.match) : null
85
+ const exclude = src.exclude ? new RegExp(src.exclude) : null
86
+ const seen = new Set(); const taken = new Set(); const out = []
87
+ const max = limit || src.limit || DEFAULT_LIMIT
88
+ for (const r of raw) {
89
+ if (!r.path) continue
90
+ const path = pathOf(r.path, origin)
91
+ const test = path.startsWith('http') ? (({ pathname, search }) => pathname + search)(new URL(path)) : path // path with its query
92
+ if (match && !match.test(test)) continue
93
+ if (exclude && exclude.test(test)) continue
94
+ if (seen.has(path)) continue
95
+ seen.add(path)
96
+ out.push({ id: r.key ? itemId(String(r.key), taken) : itemId(path, taken), path, url: path.startsWith('http') ? path : toUrl(path), ...(r.title ? { title: r.title } : {}) })
97
+ if (out.length >= max) break
98
+ }
99
+ return { items: out, total: seen.size, truncated: out.length >= max && raw.length > out.length }
100
+ }
101
+
102
+ /** In-page extraction of the index fields (self-contained; runs through page.evaluate). */
103
+ export function readFields (specs) {
104
+ const text = (el) => (el ? (el.innerText || el.textContent || '').replace(/\s+/g, ' ').trim() : null)
105
+ const meta = (sel) => document.querySelector(sel)?.getAttribute('content') || null
106
+ const out = {}
107
+ for (const f of specs) {
108
+ const from = f.from || f.id
109
+ try {
110
+ if (from === 'title') out[f.id] = document.title || null
111
+ else if (from === 'h1') out[f.id] = text(document.querySelector('h1'))
112
+ else if (from === 'description') out[f.id] = meta("meta[name='description']")
113
+ else if (from === 'canonical') out[f.id] = document.querySelector("link[rel='canonical']")?.href || null
114
+ else if (from === 'lang') out[f.id] = document.documentElement.lang || null
115
+ else if (from === 'url') out[f.id] = location.href
116
+ else if (from === 'path') out[f.id] = location.pathname + location.search
117
+ else if (from === 'words') { const root = document.querySelector('main, article, [role=main]') || document.body; out[f.id] = (text(root) || '').split(/\s+/).filter(Boolean).length }
118
+ else if (from === 'http') out[f.id] = null // filled from the navigation response
119
+ else if (from.startsWith('count:')) out[f.id] = document.querySelectorAll(from.slice(6)).length
120
+ else {
121
+ const at = from.lastIndexOf('@')
122
+ const sel = at > 0 ? from.slice(0, at) : from
123
+ const el = document.querySelector(sel)
124
+ out[f.id] = at > 0 ? el?.getAttribute(from.slice(at + 1)) ?? null : text(el)
125
+ }
126
+ if (typeof out[f.id] === 'string') out[f.id] = out[f.id].slice(0, 300)
127
+ } catch { out[f.id] = null }
128
+ }
129
+ return out
130
+ }
@@ -0,0 +1,352 @@
1
+ // Design tokens (#42): W3C DTCG token sets, resolved to name → value, and the checks that keep live
2
+ // files and captured pages on the set. Pure except checkTokens, which reads a design folder.
3
+ import { readFile, readdir } from 'node:fs/promises'
4
+ import { join, relative, sep } from 'node:path'
5
+
6
+ // Both the older string values ("#2E6BFF", "16px") and the DTCG 2025.10 shapes are read: colors as
7
+ // { colorSpace, components, alpha, hex }, dimensions and durations as { value, unit }, composite types
8
+ // (typography, shadow, border, transition, gradient), aliases as {group.token} or { "$ref": "#/json/pointer" },
9
+ // groups with $extends, $root and $deprecated. Modes come from a resolver document (sets, modifiers,
10
+ // resolutionOrder) or, as before, from top-level groups named like a mode.
11
+
12
+ const isToken = (v) => v && typeof v === 'object' && !Array.isArray(v) && '$value' in v
13
+ const isGroup = (v) => v && typeof v === 'object' && !Array.isArray(v) && !('$value' in v)
14
+ const clone = (v) => (v == null ? v : JSON.parse(JSON.stringify(v)))
15
+ /** Deep merge for $extends and resolver sets: later wins, groups merge, tokens replace. */
16
+ function merge (base, over) {
17
+ const out = clone(base) || {}
18
+ for (const [k, v] of Object.entries(over || {})) out[k] = isGroup(v) && isGroup(out[k]) ? merge(out[k], v) : clone(v)
19
+ return out
20
+ }
21
+ /** RFC 6901 JSON Pointer in a document ("#/a/b/0"). */
22
+ export function pointer (doc, ref) {
23
+ if (typeof ref !== 'string' || !ref.startsWith('#')) return undefined
24
+ let cur = doc
25
+ for (const raw of ref.slice(1).split('/').slice(1)) {
26
+ const k = raw.replace(/~1/g, '/').replace(/~0/g, '~')
27
+ if (cur == null || typeof cur !== 'object' || !(k in cur)) return undefined
28
+ cur = cur[k]
29
+ }
30
+ return cur
31
+ }
32
+ /** A group or token by dotted path ("color.brand"). */
33
+ function byPath (doc, path) {
34
+ let cur = doc
35
+ for (const k of path.split('.')) { if (cur == null || typeof cur !== 'object' || !(k in cur)) return undefined; cur = cur[k] }
36
+ return cur
37
+ }
38
+
39
+ /** Applies $extends (a group inherits another group, local keys override). */
40
+ function applyExtends (doc, node = doc, depth = 0) {
41
+ if (!isGroup(node) || depth > 20) return node
42
+ let out = {}
43
+ for (const [k, v] of Object.entries(node)) out[k] = isGroup(v) ? applyExtends(doc, v, depth + 1) : v
44
+ const ext = node.$extends
45
+ if (typeof ext === 'string') {
46
+ const target = ext.startsWith('#') ? pointer(doc, ext) : byPath(doc, ext.replace(/^\{|\}$/g, ''))
47
+ if (isGroup(target)) { const { $extends, ...local } = out; out = merge(applyExtends(doc, target, depth + 1), local) }
48
+ }
49
+ return out
50
+ }
51
+
52
+ /** Is this a DTCG resolver document (sets, modifiers, resolutionOrder)? */
53
+ export const isResolver = (doc) => !!doc && typeof doc === 'object' && Array.isArray(doc.resolutionOrder)
54
+
55
+ /** Modifier contexts of a resolver (light, dark, …), or the legacy mode groups of a set. */
56
+ export function modesOf (set) {
57
+ if (isResolver(set)) return [...new Set(Object.values(set.modifiers || {}).flatMap((m) => Object.keys(m.contexts || {})))]
58
+ const declared = set?.$extensions?.['qawalk.modes']
59
+ if (Array.isArray(declared)) return declared.filter((m) => set[m])
60
+ return ['light', 'dark', 'high-contrast'].filter((m) => set?.[m] && typeof set[m] === 'object' && !('$value' in set[m]))
61
+ }
62
+
63
+ /**
64
+ * One token set out of a resolver: the resolution order merged, each modifier at the context the input picks
65
+ * (a string mode picks that context in every modifier that has it; otherwise the modifier's default or first).
66
+ * Sources are inline token objects or { $ref } into the resolver itself; files are inlined by inlineResolver.
67
+ */
68
+ export function resolveResolver (doc, input = null) {
69
+ const want = typeof input === 'string' ? input : null
70
+ const pick = (name, mod) => {
71
+ const ctx = mod.contexts || {}
72
+ const chosen = (input && typeof input === 'object' && input[name]) || (want && ctx[want] ? want : null) || mod.default || Object.keys(ctx)[0]
73
+ return ctx[chosen] || []
74
+ }
75
+ const sourcesOf = (list) => (list || []).map((src) => (src && src.$ref ? pointer(doc, src.$ref) : src)).filter(isGroup)
76
+ let out = {}
77
+ for (const item of doc.resolutionOrder || []) {
78
+ const ref = item?.$ref || ''
79
+ const target = ref ? pointer(doc, ref) : item
80
+ if (!target) continue
81
+ if (ref.startsWith('#/modifiers/') || (!ref && target.contexts)) { for (const s of sourcesOf(pick(ref.split('/')[2] || target.name || '', target))) out = merge(out, s) }
82
+ else if (Array.isArray(target.sources)) { for (const s of sourcesOf(target.sources)) out = merge(out, s) }
83
+ else if (isGroup(target)) out = merge(out, target)
84
+ }
85
+ return out
86
+ }
87
+
88
+ /** Replaces file sources ({ "$ref": "light.json" }) of a resolver by their content, so it can be stored alone. */
89
+ export async function inlineResolver (doc, readFileRef) {
90
+ const out = clone(doc)
91
+ const inline = async (list) => Promise.all((list || []).map(async (src) => (src?.$ref && !src.$ref.startsWith('#') ? JSON.parse(await readFileRef(src.$ref)) : src)))
92
+ for (const set of Object.values(out.sets || {})) set.sources = await inline(set.sources)
93
+ for (const mod of Object.values(out.modifiers || {})) for (const k of Object.keys(mod.contexts || {})) mod.contexts[k] = await inline(mod.contexts[k])
94
+ out.resolutionOrder = await Promise.all(out.resolutionOrder.map(async (item) => (item?.$ref && !item.$ref.startsWith('#') ? JSON.parse(await readFileRef(item.$ref)) : item)))
95
+ return out
96
+ }
97
+
98
+ /**
99
+ * Tokens of a set as [{ name, type, value, raw, deprecated, description }]: aliases and JSON Pointer references
100
+ * resolved, $extends applied, `$root` tokens named after their group, `value` CSS-ready (composite types: an
101
+ * object of CSS-ready parts). A mode picks a resolver context or a legacy mode group.
102
+ */
103
+ export function flattenTokens (set, mode = null) {
104
+ let doc
105
+ if (isResolver(set)) doc = resolveResolver(set, mode)
106
+ else {
107
+ const modes = modesOf(set)
108
+ const base = Object.fromEntries(Object.entries(set || {}).filter(([k]) => !modes.includes(k)))
109
+ doc = mode && set?.[mode] ? merge(base, set[mode]) : base
110
+ }
111
+ doc = applyExtends(doc)
112
+ const out = new Map()
113
+ const walk = (obj, path, inherited, deprecated) => {
114
+ for (const [k, v] of Object.entries(obj || {})) {
115
+ if (k.startsWith('$') && k !== '$root') continue
116
+ const dep = v && typeof v === 'object' && '$deprecated' in v ? v.$deprecated : deprecated
117
+ if (isToken(v)) out.set((k === '$root' ? path : [...path, k]).join('.'), { type: v.$type || inherited || null, raw: v.$value, deprecated: dep || false, description: v.$description || null })
118
+ else if (isGroup(v)) walk(v, [...path, k], v.$type || inherited, dep)
119
+ }
120
+ }
121
+ walk(doc, [], null, false)
122
+ const resolve = (value, depth = 0) => {
123
+ if (depth > 10) return value
124
+ if (typeof value === 'string') {
125
+ const m = value.match(/^\{([^}]+)\}$/)
126
+ if (!m) return value
127
+ const t = out.get(m[1].replace(/\.\$root$/, ''))
128
+ return t ? resolve(t.raw, depth + 1) : value
129
+ }
130
+ if (value && typeof value === 'object' && typeof value.$ref === 'string') { const at = pointer(doc, value.$ref); return resolve(isToken(at) ? at.$value : at, depth + 1) } // a pointer at a token means its value
131
+ if (Array.isArray(value)) return value.map((x) => resolve(x, depth + 1))
132
+ if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([k, x]) => [k, resolve(x, depth + 1)]))
133
+ return value
134
+ }
135
+ return [...out].map(([name, t]) => {
136
+ const raw = resolve(t.raw)
137
+ return { name, type: t.type, value: cssValue(t.type, raw), raw, deprecated: t.deprecated, description: t.description }
138
+ })
139
+ }
140
+
141
+ const WEIGHTS = { thin: 100, hairline: 100, 'extra-light': 200, 'ultra-light': 200, light: 300, normal: 400, regular: 400, book: 400, medium: 500, 'semi-bold': 600, 'demi-bold': 600, bold: 700, 'extra-bold': 800, 'ultra-bold': 800, black: 900, heavy: 900, 'extra-black': 950, 'ultra-black': 950 }
142
+ const hex2 = (n) => Math.max(0, Math.min(255, Math.round(n))).toString(16).padStart(2, '0')
143
+ /** A DTCG color object as #rrggbb[aa]: srgb, srgb-linear and hsl are converted, other spaces use their hex. */
144
+ export function colorObject (c) {
145
+ if (!c || typeof c !== 'object') return null
146
+ const a = c.alpha == null ? 1 : Number(c.alpha)
147
+ const withAlpha = (hex) => (hex ? (a < 1 ? hex.slice(0, 7) + hex2(a * 255) : hex.slice(0, 7)) : null)
148
+ const [x, y, z] = (c.components || []).map((v) => (v === 'none' ? 0 : Number(v)))
149
+ const space = String(c.colorSpace || '').toLowerCase()
150
+ const ok = [x, y, z].every(Number.isFinite)
151
+ if (space === 'srgb' && ok) return withAlpha('#' + [x, y, z].map((v) => hex2(v * 255)).join(''))
152
+ if (space === 'srgb-linear' && ok) { const g = (v) => (v <= 0.0031308 ? 12.92 * v : 1.055 * v ** (1 / 2.4) - 0.055); return withAlpha('#' + [x, y, z].map((v) => hex2(g(v) * 255)).join('')) }
153
+ if (space === 'hsl' && ok) {
154
+ const s = y / 100, l = z / 100, k = (n) => (n + x / 30) % 12, f = (n) => l - s * Math.min(l, 1 - l) * Math.max(-1, Math.min(k(n) - 3, 9 - k(n), 1))
155
+ return withAlpha('#' + [f(0), f(8), f(4)].map((v) => hex2(v * 255)).join(''))
156
+ }
157
+ return c.hex ? withAlpha(normColor(c.hex)) : null
158
+ }
159
+ const unitValue = (v) => (v && typeof v === 'object' && 'value' in v ? `${v.value}${v.unit || ''}` : v)
160
+ /** A resolved DTCG value as CSS: strings and numbers stay, objects of known types become CSS values or parts. */
161
+ export function cssValue (type, v) {
162
+ if (v == null) return v
163
+ if (type === 'color' || (typeof v === 'object' && 'colorSpace' in v)) return typeof v === 'string' ? v : colorObject(v)
164
+ if (type === 'dimension' || type === 'duration' || (typeof v === 'object' && 'unit' in v && 'value' in v)) return unitValue(v)
165
+ if (type === 'fontFamily') return Array.isArray(v) ? v.join(', ') : v
166
+ if (type === 'fontWeight') return typeof v === 'string' ? (WEIGHTS[v.toLowerCase()] ?? v) : v
167
+ if (type === 'cubicBezier' && Array.isArray(v)) return `cubic-bezier(${v.join(', ')})`
168
+ if (type === 'typography' && typeof v === 'object') return { fontFamily: cssValue('fontFamily', v.fontFamily), fontSize: cssValue('dimension', v.fontSize), fontWeight: cssValue('fontWeight', v.fontWeight), lineHeight: v.lineHeight, letterSpacing: cssValue('dimension', v.letterSpacing) }
169
+ if (type === 'border' && typeof v === 'object') return { color: cssValue('color', v.color), width: cssValue('dimension', v.width), style: typeof v.style === 'string' ? v.style : null }
170
+ if (type === 'shadow') return [].concat(v).map((s) => ({ color: cssValue('color', s.color), offsetX: cssValue('dimension', s.offsetX), offsetY: cssValue('dimension', s.offsetY), blur: cssValue('dimension', s.blur), spread: cssValue('dimension', s.spread) }))
171
+ if (type === 'gradient' && Array.isArray(v)) return v.map((s) => ({ color: cssValue('color', s.color), position: s.position }))
172
+ if (type === 'transition' && typeof v === 'object') return { duration: unitValue(v.duration), delay: unitValue(v.delay), timingFunction: cssValue('cubicBezier', v.timingFunction) }
173
+ return v
174
+ }
175
+
176
+ /** Structural problems of a set or resolver: names, references that point nowhere, unreadable values. */
177
+ export function validateTokens (set) {
178
+ if (!set || typeof set !== 'object' || Array.isArray(set)) return ['A token set is a JSON object.']
179
+ const problems = []
180
+ const modes = modesOf(set)
181
+ const docs = isResolver(set) ? (modes.length ? modes.map((m) => resolveResolver(set, m)) : [resolveResolver(set)]) : [set]
182
+ const names = (obj, path = []) => {
183
+ for (const [k, v] of Object.entries(obj || {})) {
184
+ if (k.startsWith('$')) continue
185
+ if (/[{}.]/.test(k)) problems.push(`${[...path, k].join('.')}: a name must not contain { } or .`)
186
+ if (isGroup(v)) names(v, [...path, k])
187
+ }
188
+ }
189
+ for (const doc of docs) {
190
+ names(doc)
191
+ for (const t of flattenTokens(doc)) {
192
+ for (const r of JSON.stringify(t.raw ?? null).match(/"\{[^}"]+\}"/g) || []) problems.push(`${t.name}: ${r.slice(1, -1)} points at no token`)
193
+ if (t.type === 'color' && !normColor(t.value)) problems.push(`${t.name}: the color value cannot be read`)
194
+ if ((t.type === 'dimension' || t.type === 'duration') && typeof t.value !== 'string' && typeof t.value !== 'number') problems.push(`${t.name}: a ${t.type} needs { value, unit }`)
195
+ }
196
+ }
197
+ return [...new Set(problems)]
198
+ }
199
+
200
+ // ---- value normalisation ----
201
+
202
+ const NAMED = { white: '#ffffff', black: '#000000' }
203
+ export function normColor (v) {
204
+ if (v && typeof v === 'object') return colorObject(v)
205
+ if (typeof v !== 'string') return null
206
+ const s = v.trim().toLowerCase()
207
+ if (NAMED[s]) return NAMED[s]
208
+ let m = s.match(/^#([0-9a-f]{3,8})$/)
209
+ if (m) {
210
+ let h = m[1]
211
+ if (h.length === 3 || h.length === 4) h = h.split('').map((c) => c + c).join('')
212
+ return '#' + h.slice(0, 6) + (h.length === 8 && h.slice(6) !== 'ff' ? h.slice(6) : '')
213
+ }
214
+ m = s.match(/^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)(?:[\s,/]+([\d.]+%?))?\s*\)$/)
215
+ if (m) {
216
+ const hex = (n) => Math.max(0, Math.min(255, Math.round(Number(n)))).toString(16).padStart(2, '0')
217
+ const a = m[4] == null ? 1 : m[4].endsWith('%') ? Number(m[4].slice(0, -1)) / 100 : Number(m[4])
218
+ return '#' + hex(m[1]) + hex(m[2]) + hex(m[3]) + (a < 1 ? hex(a * 255) : '')
219
+ }
220
+ return null
221
+ }
222
+ export function normDimension (v, rootPx = 16) {
223
+ if (typeof v === 'number') return v
224
+ if (typeof v !== 'string') return null
225
+ const m = v.trim().match(/^(-?[\d.]+)(px|rem|em)?$/)
226
+ if (!m) return null
227
+ const n = Number(m[1])
228
+ return m[2] === 'rem' || m[2] === 'em' ? n * rootPx : n
229
+ }
230
+ const normFont = (v) => String(Array.isArray(v) ? v[0] : v || '').split(',')[0].trim().replace(/^["']|["']$/g, '').toLowerCase()
231
+
232
+ /** Lookup tables of a flattened set by kind. */
233
+ export function tokenIndex (flatIn) {
234
+ const colors = new Map(), dims = new Map(), fonts = new Map(), deprecated = new Map()
235
+ // composite tokens contribute their parts (typography.fontSize, border.color, shadow colors and offsets);
236
+ // deprecated tokens are no suggestion, and a value only they have is reported as deprecated
237
+ const flat = []
238
+ for (const t of flatIn) {
239
+ if (t.deprecated) { const c = normColor(t.value); if (c) deprecated.set(c, { name: t.name, note: typeof t.deprecated === 'string' ? t.deprecated : null }); continue }
240
+ if (t.value && typeof t.value === 'object') {
241
+ for (const part of [].concat(t.value)) for (const [k, v] of Object.entries(part || {})) {
242
+ if (v == null) continue
243
+ const type = k === 'color' ? 'color' : k === 'fontFamily' ? 'fontFamily' : /size|width|offset|blur|spread|spacing/i.test(k) ? 'dimension' : null
244
+ if (type) flat.push({ name: `${t.name}.${k}`, type, value: v })
245
+ }
246
+ } else flat.push(t)
247
+ }
248
+ for (const t of flat) {
249
+ const c = (t.type === 'color' || !t.type) ? normColor(t.value) : null
250
+ if (c) { if (!colors.has(c)) colors.set(c, t.name); continue }
251
+ if (t.type === 'fontFamily' || /font.?family/i.test(t.name)) { const f = normFont(t.value); if (f && !fonts.has(f)) fonts.set(f, t.name); continue }
252
+ const d = normDimension(t.value?.value != null ? `${t.value.value}${t.value.unit || ''}` : t.value)
253
+ // several tokens may share a value (8px spacing, 8px radius): keep all names, pick by property later
254
+ if (d != null && (t.type === 'dimension' || t.type === 'fontSize' || t.type === 'spacing' || t.type === 'borderRadius' || !t.type)) dims.set(d, [...(dims.get(d) || []), t.name])
255
+ }
256
+ return { colors, dims, fonts, deprecated }
257
+ }
258
+
259
+ const rgb = (hex) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16))
260
+ function nearestColor (c, colors) {
261
+ let best = null, dist = Infinity
262
+ const [r, g, b] = rgb(c)
263
+ for (const [hex, name] of colors) { const [x, y, z] = rgb(hex); const d = (r - x) ** 2 + (g - y) ** 2 + (b - z) ** 2; if (d < dist) { dist = d; best = name } }
264
+ return best
265
+ }
266
+ /** Token names that suit a CSS property (font sizes for font-size, radii for border-radius, …). */
267
+ export const kindFor = (prop) => /radius/.test(prop) ? /radius|round|corner/i : /font-size|line-height|letter/.test(prop) ? /font|text|size|type|leading|line/i : /padding|margin|gap|inset/.test(prop) ? /space|spacing|gap|gutter|inset|size/i : null
268
+ function nearestDim (n, dims, want = null) {
269
+ let best = null, dist = Infinity
270
+ for (const [v, names] of dims) {
271
+ const name = want ? names.find((x) => want.test(x)) : names[0]
272
+ if (!name) continue
273
+ const d = Math.abs(v - n); if (d < dist) { dist = d; best = name }
274
+ }
275
+ return best
276
+ }
277
+
278
+ const COLOR_PROPS = /^(color|background|background-color|border(-top|-right|-bottom|-left)?(-color)?|outline(-color)?|fill|stroke|box-shadow|text-decoration-color|caret-color|accent-color)$/
279
+ const DIM_PROPS = /^(font-size|margin(-top|-right|-bottom|-left|-inline|-block)?|padding(-top|-right|-bottom|-left|-inline|-block)?|gap|row-gap|column-gap|border-radius|border(-top|-right|-bottom|-left)?-(left-|right-)?radius)$/
280
+ const IGNORE = new Set(['0', '0px', 'auto', 'inherit', 'initial', 'unset', 'none', 'transparent', 'currentcolor', '100%', '50%', 'revert'])
281
+
282
+ /**
283
+ * Checks one declaration value against the token index; returns offenders [{ value, suggest }].
284
+ * var(--…) and calc() with vars are fine: the value then comes from the set by construction.
285
+ */
286
+ export function checkDeclaration (property, value, index) {
287
+ const prop = property.trim().toLowerCase()
288
+ const v = value.trim().replace(/\s*!important$/, '')
289
+ if (!v || v.includes('var(')) return []
290
+ const out = []
291
+ if (COLOR_PROPS.test(prop)) {
292
+ for (const m of v.matchAll(/#[0-9a-f]{3,8}\b|rgba?\([^)]*\)|\b(white|black)\b/gi)) {
293
+ const c = normColor(m[0])
294
+ if (c && !index.colors.has(c)) out.push({ value: m[0], suggest: nearestColor(c, index.colors), ...(index.deprecated?.has(c) ? { deprecated: index.deprecated.get(c).name, note: index.deprecated.get(c).note } : {}) })
295
+ }
296
+ } else if (DIM_PROPS.test(prop)) {
297
+ for (const part of v.split(/\s+/)) {
298
+ if (IGNORE.has(part.toLowerCase())) continue
299
+ const d = normDimension(part)
300
+ if (d != null && d !== 0 && !index.dims.has(Math.abs(d))) out.push({ value: part, suggest: nearestDim(Math.abs(d), index.dims, kindFor(prop)) })
301
+ }
302
+ } else if (prop === 'font-family') {
303
+ const f = normFont(v)
304
+ if (f && !['inherit', 'system-ui', 'sans-serif', 'serif', 'monospace'].includes(f) && index.fonts.size && !index.fonts.has(f)) out.push({ value: v, suggest: [...index.fonts.values()][0] })
305
+ }
306
+ return out
307
+ }
308
+
309
+ /** Declarations of a CSS text with line numbers (comments removed; good enough for design files). */
310
+ export function declarations (css) {
311
+ const out = []
312
+ const text = css.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, ' '))
313
+ const re = /([a-z-]+)\s*:\s*([^;{}]+)(?=;|})/gi
314
+ let m
315
+ while ((m = re.exec(text))) {
316
+ if (m[1].startsWith('--')) continue
317
+ out.push({ property: m[1], value: m[2], line: text.slice(0, m.index).split('\n').length })
318
+ }
319
+ return out
320
+ }
321
+
322
+ async function files (dir, base = dir) {
323
+ const out = []
324
+ for (const e of await readdir(dir, { withFileTypes: true }).catch(() => [])) {
325
+ if (e.name.startsWith('.')) continue
326
+ const full = join(dir, e.name)
327
+ if (e.isDirectory()) out.push(...await files(full, base))
328
+ else if (/\.(css|html)$/i.test(e.name)) out.push(relative(base, full).split(sep).join('/'))
329
+ }
330
+ return out
331
+ }
332
+
333
+ /** Raw values outside the token set across the CSS and HTML (style blocks and attributes) of a design folder. */
334
+ export async function checkTokens ({ dir, tokens, mode = null }) {
335
+ const index = tokenIndex(flattenTokens(tokens, mode))
336
+ const offenders = []
337
+ let checked = 0
338
+ for (const file of await files(dir)) {
339
+ const text = await readFile(join(dir, file), 'utf8')
340
+ const chunks = file.endsWith('.css') ? [{ css: text, offset: 0 }] : [
341
+ ...[...text.matchAll(/<style[^>]*>([\s\S]*?)<\/style>/gi)].map((m) => ({ css: m[1], offset: text.slice(0, m.index).split('\n').length - 1 })),
342
+ ...[...text.matchAll(/\sstyle\s*=\s*"([^"]*)"/gi)].map((m) => ({ css: `x{${m[1]}}`, offset: text.slice(0, m.index).split('\n').length - 1 }))
343
+ ]
344
+ for (const { css, offset } of chunks) {
345
+ for (const d of declarations(css)) {
346
+ checked++
347
+ for (const o of checkDeclaration(d.property, d.value, index)) offenders.push({ file, line: d.line + offset, property: d.property, ...o })
348
+ }
349
+ }
350
+ }
351
+ return { checked, offenders, tokens: { colors: index.colors.size, dimensions: index.dims.size, fonts: index.fonts.size } }
352
+ }