kaloko 0.5.0 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cli.mjs CHANGED
@@ -18,7 +18,8 @@ import { configureGate } from './lib/linkcheck.mjs'
18
18
  import { rememberEnvironment } from './lib/secrets.mjs'
19
19
  import { evaluateRun, semanticPrefetch } from './lib/evaluate.mjs'
20
20
  import { buildManifest, writePreview, attentionLines } from './lib/build.mjs'
21
- import { stabilityCmd } from './lib/stability.mjs'
21
+ import { stabilityCmd, pullDrawn } from './lib/stability.mjs'
22
+ import { signoffCmd } from './lib/signoff.mjs'
22
23
  import { localized, readJson, UserError, globToRegExp, failureStreak, expiryNote } from './lib/util.mjs'
23
24
  import { packageRoot } from './lib/schema.mjs'
24
25
  import { compareRuns, formatComparison } from './lib/compare.mjs'
@@ -68,10 +69,11 @@ Project
68
69
  draft <url> [url…] [--id SITE-X] [--title t] [--out qa/flows/site.yml] [--force]
69
70
  First plan + read-only production scenario for public pages, drafted from the
70
71
  pages (title, description, H1) with the seo, a11y, perf and console packs
71
- init [--agent claude|codex|all] [--org <slug>] [--no-detect] [--no-scenario]
72
+ init [--agent claude|codex|all] [--org <slug>] [--no-detect] [--no-scenario] [--docs=off|changelog|full]
72
73
  Create ${CONFIG_FILE} and install the kaloko skill for your agent; detects the
73
74
  framework, dev server URL, locales and main routes, and drafts a first scenario
74
- (qa/flows/main-pages.yml) when the project has none
75
+ (qa/flows/main-pages.yml) when the project has none; asks "Keep a visual changelog?"
76
+ (--docs: changelog adds CHANGELOG.md and the config block, full also docs/ folders)
75
77
  ci github|gitlab [--scenario a.yml,b.yml] [--env preview] [--trigger deployment|pull_request] [--no-affected]
76
78
  [--out file] [--print] [--force]
77
79
  Write a workflow that walks what each pull request affects on its preview
@@ -217,10 +219,31 @@ Results
217
219
  Regions that change on every run or move while the page is idle (clocks, ads,
218
220
  carousels): ignore suggestions per step in <run>/stability.yml; --apply adds
219
221
  them to the scenario (ignore:), and pixel diffs then skip them
222
+ signoff <run-id> [--out dir] [--lang cs|en] [--json]
223
+ The signed acceptance record of an accepted run (Business): JSON and PDF with who
224
+ approved what and when, criteria results and capture hashes, checked against the
225
+ service's public key
226
+ signoff --verify <file.json|file.pdf> [--key file|url] [--json]
227
+ Check a record someone handed over (no token needed); exit 1 when it does not verify
228
+ stability --pull [--scenario id] [--json]
229
+ Regions people drew on screenshots on kaloko.app (step detail → Page → Draw a
230
+ region) go into the scenario's ignore: of their steps; walk them again after
220
231
  compare [--run id] [--baseline id | --design] [--json]
221
232
  Text diff of two runs: step statuses, criterion verdicts, scores and evidence
222
233
  (default: current run against the scenario baseline, else the previous run)
223
234
 
235
+ Changelog (Keep a Changelog in CHANGELOG.md; pictures are step references kaloko:<scenario>/<step>)
236
+ changelog draft [--product p] [--since <tag|version>] [--apply] [--json] [--offline]
237
+ The Unreleased entry since the last release: merged pull requests and commits
238
+ (Conventional Commits when used, gh for titles) grouped Added / Changed / Fixed /
239
+ Removed, plus before/after pictures of the screens accepted runs show differently;
240
+ printed, or --apply writes it into CHANGELOG.md (not when a release tool owns it)
241
+ release [<version>] [--product p] [--date YYYY-MM-DD] [--runs id,id] [--no-write] [--dry-run] [--json]
242
+ Record a release on Kaloko: the version (default: from git tags, package.json,
243
+ release-please or changesets), its CHANGELOG.md entry (Unreleased becomes the
244
+ version unless --no-write) and the accepted runs that are its pictures; prints the
245
+ illustrated changelog page
246
+
224
247
  Design (environments with kind: design; live HTML steps in a folder)
225
248
  start --scenario <file> --env <design env> A run of the design folder (same scenario as development and QA)
226
249
  walk [--steps a,b] [--no-cache] Render the steps; unchanged revisions reuse their captures
@@ -257,6 +280,15 @@ Design (environments with kind: design; live HTML steps in a folder)
257
280
  Common options: --run <id|dir> (or KALOKO_RUN) --config <file> KALOKO_AGENT=<name> labels writes by an agent
258
281
  `
259
282
 
283
+ /** A comment's drawing for the terminal: "· box 120,340 300×80 px on desktop" (CSS px when the size is known). */
284
+ export function regionText (g) {
285
+ if (!g) return ''
286
+ const pct = (v) => `${Math.round(v * 100)}%`
287
+ const where = g.px ? `${g.px[0]},${g.px[1]} ${g.px[2]}×${g.px[3]} px` : `${pct(g.box[0])},${pct(g.box[1])} ${pct(g.box[2])}×${pct(g.box[3])}`
288
+ const kind = g.kind === 'arrow' ? 'arrow over' : g.kind === 'pen' ? 'drawing over' : 'box'
289
+ return ` · ${kind} ${where}${g.viewport ? ` on ${g.viewport}` : ''}`
290
+ }
291
+
260
292
  /** validate: scenarios the service still lists as active whose scenario file no longer exists here (#58). */
261
293
  async function warnRetired (args) {
262
294
  if (args.offline) return
@@ -754,6 +786,38 @@ const commands = {
754
786
  await writeFile(file, tpl)
755
787
  say(`Created ${CONFIG_FILE}${detected ? ` (local: ${detected.url})` : ''}. Edit org and the staging and production environments.`)
756
788
  } else if (!existsSync(join(cwd, LEGACY_CONFIG_FILE))) say(`${CONFIG_FILE} already exists; kept.`)
789
+ // the visual changelog (docs/DOCS-CHANGELOG.md): asked once; --docs=off|changelog|full for scripts and agents
790
+ let docsMode = typeof args.docs === 'string' ? args.docs : null
791
+ if (docsMode && !['off', 'changelog', 'full'].includes(docsMode)) throw new UserError('--docs must be off, changelog or full.')
792
+ if (!docsMode && process.stdin.isTTY && process.stdout.isTTY && !process.env.CI && !process.env.NODE_TEST_CONTEXT) {
793
+ const rl = (await import('node:readline/promises')).createInterface({ input: process.stdin, output: process.stdout })
794
+ const a = (await rl.question('Keep a visual changelog? (recommended) [Y/n] ')).trim().toLowerCase(); rl.close()
795
+ docsMode = a === 'n' || a === 'no' ? 'off' : 'changelog'
796
+ }
797
+ if (docsMode && docsMode !== 'off') {
798
+ const { docsConfigBlock, detectProducts, DOCS_SKELETON } = await import('./lib/changelog.mjs')
799
+ const { CHANGELOG_SKELETON } = await import('./lib/changelog-core.mjs')
800
+ const { mkdir: mkdirp } = await import('node:fs/promises')
801
+ const cfgFile = existsSync(file) ? file : join(cwd, LEGACY_CONFIG_FILE)
802
+ const cfg = await readFile(cfgFile, 'utf8')
803
+ const products = /^products:/m.test(cfg) ? [] : await detectProducts(cwd)
804
+ if (!/^docs:/m.test(cfg) && !/^products:/m.test(cfg)) { await writeFile(cfgFile, `${cfg.replace(/\n*$/, '\n')}${docsConfigBlock({ products })}`); say(`Added the changelog settings to ${relative(cwd, cfgFile)}${products.length ? ` with ${products.length} products (${products.map((p) => p.slug).join(', ')})` : ''}`) }
805
+ for (const f of products.length ? products.map((p) => join(cwd, p.dir, 'CHANGELOG.md')) : [join(cwd, 'CHANGELOG.md')]) {
806
+ if (existsSync(f)) continue
807
+ await mkdirp(dirname(f), { recursive: true })
808
+ await writeFile(f, CHANGELOG_SKELETON)
809
+ say(`Created ${relative(cwd, f)} (Keep a Changelog; kaloko changelog draft fills Unreleased, kaloko release records a version)`)
810
+ }
811
+ if (docsMode === 'full') {
812
+ for (const [rel, text] of Object.entries(DOCS_SKELETON)) {
813
+ const f = join(cwd, rel)
814
+ if (existsSync(f)) continue
815
+ await mkdirp(dirname(f), { recursive: true })
816
+ await writeFile(f, text)
817
+ }
818
+ say('Docs folders in docs/ (tutorials, how-to, reference, explanation) with an index')
819
+ }
820
+ } else if (!docsMode) say('Visual changelog: kaloko init --docs=changelog adds CHANGELOG.md and the config block (recommended).')
757
821
  const agent = typeof args.agent === 'string' ? args.agent : 'none'
758
822
  if (!['claude', 'codex', 'all', 'none'].includes(agent)) throw new UserError('--agent must be claude, codex, all or none.')
759
823
  const { cp, mkdir, appendFile, rm } = await import('node:fs/promises')
@@ -858,7 +922,9 @@ const commands = {
858
922
  const executor = { kind: args.executor ? 'agent' : 'cli', name: args.executor || 'kaloko-cli' }
859
923
  const { project, issue } = await projectAndIssue({ cwd: root, config, scenario, branch: git?.branch })
860
924
  const appVer = typeof args['app-version'] === 'string' ? args['app-version'].slice(0, 80) : await appVersion(root)
861
- const { dir, meta } = await createRun({ output: config.output, scenario, scenarioFile, env, scope, git, executor, root, project, issue, appVersion: appVer })
925
+ // the product the flow documents (visual changelog): its tag, or the only product of the config
926
+ const product = scenario.product || (Object.keys(config.products || {}).length === 1 ? Object.keys(config.products)[0] : null)
927
+ const { dir, meta } = await createRun({ output: config.output, scenario, scenarioFile, env, scope, git, executor, root, project, issue, appVersion: appVer, product })
862
928
  say(`Run ${meta.id}\n dir: ${relative(process.cwd(), dir)}\n env: ${env.name}${env.readonly ? ' (readonly)' : ''}\n locales: ${locales.join(', ')} viewports: ${vpNames.length > 12 ? `${vpNames.slice(0, 12).join(', ')} … (${vpNames.length})` : vpNames.join(', ')}${dimensionLine(scope)}\n project: ${project.slug}${issue ? ` issue: ${issue.repo ? issue.repo + '#' : '#'}${issue.number}` : ''}`)
863
929
  const addr = recipient(env.mail, meta.short)
864
930
  if (addr) say(` test e-mail: ${addr}`)
@@ -1485,6 +1551,8 @@ const commands = {
1485
1551
 
1486
1552
  async stability (args) {
1487
1553
  const { config, root, run, scenario } = await runContext(args)
1554
+ // regions drawn by hand on the canvas (kaloko.app) into the scenario's ignore:
1555
+ if (args.pull) { await pullDrawn({ root, config, run, args, say, client: service(config) }); return }
1488
1556
  await stabilityCmd({ root, config, run, scenario, args, say })
1489
1557
  },
1490
1558
 
@@ -1685,7 +1753,8 @@ const commands = {
1685
1753
  say(`Feedback for ${data.run.id} (${data.scenario}) — run verdict: ${data.verdict?.verdict || 'pending'}${data.verdict?.by ? ` by ${data.verdict.by}` : ''}${data.verdict?.note ? `: ${data.verdict.note}` : ''}${data.verdict?.awaiting?.length ? `\n awaiting approval from: ${data.verdict.awaiting.join(', ')}` : ''}${data.approved.length ? `\n approved: ${data.approved.join(', ')}` : ''}`)
1686
1754
  for (const c of data.criterion_verdicts || []) say(` ✕ ${c.node_id} ${c.criterion_id}${c.locale ? ` [${c.locale}]` : ''} — ${c.email}: ${c.note}`)
1687
1755
  const thread = (r, where) => {
1688
- say(` ${r.status === 'rejected' ? '✕' : '•'} [${r.id}] ${where}${r.locale ? ` [${r.locale}]` : ''}${r.pin ? ` · pinned at ${Math.round(r.pin.x * 100)}%,${Math.round(r.pin.y * 100)}%${r.pin.viewport ? ` (${r.pin.viewport})` : ''}` : ''}${r.anchor?.selector ? ` · on ${r.anchor.selector}` : ''} — ${r.email}${r.agent ? ` via ${r.agent}` : ''}: ${r.note}`)
1756
+ say(` ${r.status === 'rejected' ? '✕' : '•'} [${r.id}] ${where}${r.locale ? ` [${r.locale}]` : ''}${r.pin ? ` · pinned at ${Math.round(r.pin.x * 100)}%,${Math.round(r.pin.y * 100)}%${r.pin.viewport ? ` (${r.pin.viewport})` : ''}` : ''}${r.anchor?.selector ? ` · on ${r.anchor.selector}` : ''}${regionText(r.region)} — ${r.email}${r.agent ? ` via ${r.agent}` : ''}: ${r.note}`)
1757
+ for (const f of r.attachments || []) say(` attached ${f.name} (${f.type}): ${f.url || f.id}`)
1689
1758
  for (const x of r.replies || []) say(` ↳ ${x.email}${x.agent ? ` via ${x.agent}` : ''}: ${x.deleted ? '(deleted)' : x.note}`)
1690
1759
  }
1691
1760
  const flow = data.flow_comments || []
@@ -1708,6 +1777,22 @@ const commands = {
1708
1777
  * accepted run, returned, commented), the approvals carried over unchanged steps, and the review-mode link. For
1709
1778
  * agents: report it to the person; never approve on their behalf.
1710
1779
  */
1780
+ /**
1781
+ * The signed acceptance record of an accepted run: JSON and PDF downloaded and checked against the service's public
1782
+ * key; --verify checks a record someone handed over (no token, no project needed).
1783
+ */
1784
+ async signoff (args) {
1785
+ if (typeof args.verify === 'string') {
1786
+ const config = await context(args).then((c) => c.config).catch(() => ({}))
1787
+ return signoffCmd({ config, args, say })
1788
+ }
1789
+ const { config, root } = await context(args)
1790
+ let runId = args._[0] || (typeof args.run === 'string' ? args.run : null)
1791
+ if (!runId) { try { const r = await resolveRun({ root, output: config.output }); if (r.meta.shared) runId = r.meta.id } catch {} }
1792
+ if (!runId) throw new UserError('Usage: kaloko signoff <run-id> [--out dir] | kaloko signoff --verify <file.json|file.pdf>')
1793
+ return signoffCmd({ config, args, say, client: service(config), runId })
1794
+ },
1795
+
1711
1796
  async review (args) {
1712
1797
  const { config, root } = await context(args)
1713
1798
  const client = service(config)
@@ -1981,12 +2066,56 @@ const commands = {
1981
2066
  const { config, root, file } = await context(args)
1982
2067
  // engines the scenarios ask for (browsers: [webkit, firefox]), so doctor checks they are installed
1983
2068
  const engines = []
1984
- for (const f of await scenarioFiles(config, root).catch(() => [])) { try { engines.push(...((yaml.load(await readFile(f, 'utf8')) || {}).browsers || [])) } catch {} }
1985
- const r = await doctor({ config, root, file, fix: !!args.fix, engines })
2069
+ const scenarios = []
2070
+ for (const f of await scenarioFiles(config, root).catch(() => [])) { try { const s = yaml.load(await readFile(f, 'utf8')) || {}; engines.push(...(s.browsers || [])); scenarios.push({ file: relative(root, f), product: s.product || null, module: s.module || null }) } catch {} }
2071
+ const r = await doctor({ config, root, file, fix: !!args.fix, engines, scenarios })
1986
2072
  say(args.json ? JSON.stringify(r, null, 2) : formatDoctor(r))
1987
2073
  if (!r.ok) process.exitCode = 1
1988
2074
  },
1989
2075
 
2076
+ /** `kaloko changelog draft`: the Unreleased entry from merged work and the screens accepted runs show differently. */
2077
+ async changelog (args) {
2078
+ const sub = args._[0]
2079
+ if (sub !== 'draft') throw new UserError('Usage: kaloko changelog draft [--product p] [--since <tag|version>] [--apply] [--json] [--offline]')
2080
+ const { config, root } = await context(args)
2081
+ const { pickProduct, draftChangelog, draftText, applyToChangelog } = await import('./lib/changelog.mjs')
2082
+ const product = pickProduct(config, root, args.product)
2083
+ let client = null
2084
+ if (!args.offline) { try { client = service(config) } catch {} }
2085
+ const d = await draftChangelog({ root, config, product, since: typeof args.since === 'string' ? args.since : null, client })
2086
+ if (args.json) { say(JSON.stringify(d, null, 2)); return }
2087
+ const pictures = d.changes.reduce((n, s) => n + (s.steps?.length || 0), 0)
2088
+ const head = `${product.slug}: ${d.commits} change(s) since ${d.since.ref || 'the first commit'}${d.service === 'ok' ? `, ${pictures} changed screen(s) in accepted runs since ${d.previous ? `release ${d.previous.version}` : 'the first accepted runs'}` : d.service === 'offline' ? ' (no service: pictures of changed screens need a token)' : ` (service: ${d.service})`}; ${d.internal.length} internal left out.`
2089
+ if (args.apply) {
2090
+ const r = await applyToChangelog(d)
2091
+ say(`${head}\n${r.created ? 'Created' : 'Updated'} ${relative(process.cwd(), d.file) || d.file}: ${r.added} new item(s) in Unreleased. Edit them for people, then kaloko release <version>.`)
2092
+ return
2093
+ }
2094
+ console.error(head)
2095
+ say(draftText(d))
2096
+ },
2097
+
2098
+ /** `kaloko release <version>`: the version's changelog entry and its reference runs on the service. */
2099
+ async release (args) {
2100
+ const { config, root } = await context(args)
2101
+ if (!config.org) throw new UserError('Set org: in the config first.')
2102
+ const { pickProduct, releaseProduct } = await import('./lib/changelog.mjs')
2103
+ const product = pickProduct(config, root, args.product)
2104
+ const client = args['dry-run'] ? null : service(config)
2105
+ const r = await releaseProduct({ root, config, product, version: args._[0] || null, date: typeof args.date === 'string' ? args.date : null, client, write: !args['no-write'], runs: list(args.runs), dryRun: !!args['dry-run'] })
2106
+ if (args.json) { say(JSON.stringify(r, null, 2)); return }
2107
+ const file = relative(process.cwd(), r.file) || r.file
2108
+ const lines = [`${product.slug} ${r.version} (${r.date}) · version source: ${r.source.tool || r.source.source}${r.source.path ? ` (${r.source.path})` : ''}`]
2109
+ lines.push(r.section ? ` ${file}: ${r.finalized ? 'Unreleased became' : 'entry'} ${r.version}, ${r.items} item(s), ${r.refs} picture reference(s)` : ` ${file}: no entry for ${r.version}`)
2110
+ if (r.release) {
2111
+ lines.push(` ${r.release.created ? 'Recorded' : 'Updated'} on the service with ${r.release.runs.length} reference run(s)${r.release.versioned_runs ? `; ${r.release.versioned_runs} accepted run(s) now carry ${r.version}` : ''}`)
2112
+ lines.push(` ${r.release.url}`)
2113
+ } else lines.push(' Dry run: nothing sent.')
2114
+ for (const n of r.notes) lines.push(` ! ${n}`)
2115
+ if (r.finalized) lines.push(` Commit ${file} with the release.`)
2116
+ say(lines.join('\n'))
2117
+ },
2118
+
1990
2119
  async help () { say(HELP) }
1991
2120
  }
1992
2121
 
@@ -0,0 +1,323 @@
1
+ // The visual changelog (docs/DOCS-CHANGELOG.md, phase 1), the parts the CLI and the service share: step references
2
+ // in Markdown, Keep a Changelog parsing and editing, commit classification and the draft skeleton. No I/O here: the
3
+ // worker imports this file too (worker/src/changelog.js), so it stays plain JavaScript without Node built-ins.
4
+
5
+ // ---------- step references ----------
6
+
7
+ /** Scenario and step ids as the service accepts them (design.js SCENARIO_ID). */
8
+ const ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,119}$/
9
+ const PARAMS = {
10
+ locale: /^[A-Za-z0-9_-]{1,20}$/,
11
+ viewport: /^[A-Za-z0-9._-]{1,80}$/,
12
+ version: /^[A-Za-z0-9._+-]{1,80}$/,
13
+ state: /^[A-Za-z0-9._-]{1,60}$/
14
+ }
15
+ export const REF_SCHEME = 'kaloko:'
16
+
17
+ /**
18
+ * A step reference `kaloko:<scenario>/<step>[?locale=&viewport=&version=&state=]`, or the short `kaloko:<step>` that
19
+ * means a step of the product's default scenario. `version` is a released version, `latest` (default) or `prev` (the
20
+ * release before the one the text belongs to); `state` picks a variant of the capture (dark, webkit, reduce: the
21
+ * dimension values in the capture key). Returns null for anything else.
22
+ */
23
+ export function parseStepRef (ref) {
24
+ const s = String(ref || '').trim()
25
+ if (!s.startsWith(REF_SCHEME)) return null
26
+ const rest = s.slice(REF_SCHEME.length)
27
+ const qi = rest.indexOf('?')
28
+ const path = qi < 0 ? rest : rest.slice(0, qi)
29
+ let parts
30
+ try { parts = path.split('/').map((p) => decodeURIComponent(p)) } catch { return null }
31
+ if (!parts.length || parts.length > 2 || parts.some((p) => !ID.test(p))) return null
32
+ const out = { scenario: parts.length === 2 ? parts[0] : null, step: parts[parts.length - 1], locale: null, viewport: null, version: null, state: null }
33
+ const q = new URLSearchParams(qi < 0 ? '' : rest.slice(qi + 1))
34
+ for (const [k, v] of q) {
35
+ if (!(k in PARAMS)) return null
36
+ if (!PARAMS[k].test(v)) return null
37
+ out[k] = v
38
+ }
39
+ if (out.version === 'latest') out.version = null
40
+ return out
41
+ }
42
+
43
+ /** The reference text of a parsed (or hand-built) reference; parameters in a fixed order. */
44
+ export function formatStepRef ({ scenario = null, step, locale = null, viewport = null, version = null, state = null }) {
45
+ const path = `${scenario ? `${encodeURIComponent(scenario)}/` : ''}${encodeURIComponent(step)}`
46
+ const q = Object.entries({ locale, viewport, version, state }).filter(([, v]) => v != null && v !== '')
47
+ return `${REF_SCHEME}${path}${q.length ? `?${q.map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join('&')}` : ''}`
48
+ }
49
+
50
+ /** Every image with a step reference in a Markdown text: { alt, ref, parsed, index }; broken ones have parsed null. */
51
+ export function findStepRefs (markdown) {
52
+ const out = []
53
+ const re = /!\[([^\]]*)\]\((kaloko:[^)\s]+)\)/g
54
+ let m
55
+ while ((m = re.exec(String(markdown || '')))) out.push({ alt: m[1], ref: m[2], parsed: parseStepRef(m[2]), index: m.index })
56
+ return out
57
+ }
58
+
59
+ // ---------- versions ----------
60
+
61
+ /** "v2.4.0" → "2.4.0"; anything else stays as it is (trimmed). */
62
+ export const cleanVersion = (v) => String(v ?? '').trim().replace(/^v(?=\d)/, '')
63
+
64
+ /** Semantic Versioning order (prereleases before their release); versions that are not semver sort by text. */
65
+ export function compareVersions (a, b) {
66
+ const p = (v) => { const m = cleanVersion(v).match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/); return m ? { n: [Number(m[1]), Number(m[2]), Number(m[3])], pre: m[4] || null } : null }
67
+ const x = p(a); const y = p(b)
68
+ if (!x || !y) return String(a).localeCompare(String(b))
69
+ for (let i = 0; i < 3; i++) if (x.n[i] !== y.n[i]) return x.n[i] - y.n[i]
70
+ if (x.pre === y.pre) return 0
71
+ if (!x.pre) return 1
72
+ if (!y.pre) return -1
73
+ return x.pre.localeCompare(y.pre, 'en', { numeric: true })
74
+ }
75
+
76
+ /** The git tag of a version for a tag pattern ("v*" → "v2.4.0", "shop-v*" → "shop-v2.4.0"). */
77
+ export const tagFor = (pattern, version) => String(pattern || 'v*').replace('*', cleanVersion(version))
78
+ /** The version a tag names under a pattern, or null when the tag does not match it. */
79
+ export function versionOfTag (pattern, tag) {
80
+ const [pre, post = ''] = String(pattern || 'v*').split('*')
81
+ const t = String(tag || '')
82
+ if (!t.startsWith(pre) || !t.endsWith(post) || t.length <= pre.length + post.length) return null
83
+ return cleanVersion(t.slice(pre.length, t.length - post.length))
84
+ }
85
+
86
+ // ---------- Keep a Changelog ----------
87
+
88
+ export const GROUPS = ['Added', 'Changed', 'Deprecated', 'Removed', 'Fixed', 'Security']
89
+ const GROUP_ALIASES = { added: 'Added', new: 'Added', features: 'Added', 'minor changes': 'Added', changed: 'Changed', 'patch changes': 'Fixed', 'major changes': 'Changed', improvements: 'Changed', deprecated: 'Deprecated', removed: 'Removed', fixed: 'Fixed', 'bug fixes': 'Fixed', fixes: 'Fixed', security: 'Security', 'performance improvements': 'Changed' }
90
+ /** A group heading as written by people or release tools ("Bug Fixes", "Minor Changes") in Keep a Changelog terms. */
91
+ export const groupName = (h) => GROUP_ALIASES[String(h || '').trim().toLowerCase()] || String(h || '').trim()
92
+
93
+ export const CHANGELOG_HEAD = `# Changelog
94
+
95
+ All notable changes for the people who use this product. The format follows
96
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and versions follow
97
+ [Semantic Versioning](https://semver.org/). Pictures are step references
98
+ (\`![](kaloko:<scenario>/<step>)\`) that Kaloko shows from the accepted runs.
99
+ `
100
+ export const CHANGELOG_SKELETON = `${CHANGELOG_HEAD}
101
+ ## [Unreleased]
102
+ `
103
+
104
+ const RELEASE_HEADING = /^##\s+(.+?)\s*$/
105
+ const DATE = /(\d{4}-\d{2}-\d{2})/
106
+
107
+ /** "## [2.4.0] - 2026-10-12", "## [1.2.0](https://…compare…) (2026-10-12)", "## 1.2.0", "## [Unreleased]". */
108
+ export function parseHeading (line) {
109
+ const m = String(line).match(RELEASE_HEADING)
110
+ if (!m || /^#/.test(m[1])) return null
111
+ const text = m[1]
112
+ const bracket = text.match(/^\[([^\]]+)\]/)
113
+ const label = (bracket ? bracket[1] : text.split(/\s+/)[0]).trim()
114
+ if (/^unreleased$/i.test(label)) return { unreleased: true, version: null, date: null }
115
+ const version = cleanVersion(label)
116
+ if (!/^\d/.test(version)) return null
117
+ const rest = text.slice(bracket ? bracket[0].length : label.length)
118
+ return { unreleased: false, version, date: (rest.match(DATE) || [])[1] || null }
119
+ }
120
+
121
+ /** List items of a group body: each item keeps its continuation lines (pictures under it), without the "- ". */
122
+ export function listItems (body) {
123
+ const items = []
124
+ let cur = null
125
+ for (const line of String(body || '').split('\n')) {
126
+ const m = line.match(/^[-*+]\s+(.*)$/)
127
+ if (m) { if (cur) items.push(cur.trimEnd()); cur = m[1]; continue }
128
+ if (cur != null && (/^\s+\S/.test(line) || line.trim() === '')) { cur += `\n${line.replace(/^ {1,4}/, '')}`; continue }
129
+ if (cur != null) { items.push(cur.trimEnd()); cur = null }
130
+ }
131
+ if (cur) items.push(cur.trimEnd())
132
+ return items.map((x) => x.replace(/\n+$/, '')).filter(Boolean)
133
+ }
134
+
135
+ /** Groups of a release body: [{ name, heading, items }] plus text before the first group (notes). */
136
+ export function parseEntry (body) {
137
+ const groups = []; const notes = []
138
+ let cur = null
139
+ for (const line of String(body || '').replace(/\r/g, '').split('\n')) {
140
+ const h = line.match(/^###\s+(.+?)\s*$/)
141
+ if (h) { cur = { name: groupName(h[1]), heading: h[1].trim(), lines: [] }; groups.push(cur); continue }
142
+ if (cur) cur.lines.push(line); else notes.push(line)
143
+ }
144
+ return { notes: notes.join('\n').trim(), groups: groups.map((g) => ({ name: g.name, heading: g.heading, items: listItems(g.lines.join('\n')) })).filter((g) => g.items.length || g.name) }
145
+ }
146
+
147
+ /**
148
+ * A changelog file: { head (text before the first release heading), sections: [{ unreleased, version, date, heading,
149
+ * body, start, end }] (line numbers, end exclusive), links (the reference definitions at the bottom, by lower-case
150
+ * label), linksStart }.
151
+ */
152
+ export function parseChangelog (text) {
153
+ const lines = String(text || '').replace(/\r/g, '').split('\n')
154
+ let linksStart = lines.length
155
+ while (linksStart > 0 && (/^\[[^\]]+\]:\s*\S+/.test(lines[linksStart - 1]) || lines[linksStart - 1].trim() === '')) linksStart--
156
+ if (!lines.slice(linksStart).some((l) => /^\[[^\]]+\]:/.test(l))) linksStart = lines.length
157
+ const links = {}
158
+ for (const l of lines.slice(linksStart)) { const m = l.match(/^\[([^\]]+)\]:\s*(\S+)/); if (m) links[m[1].toLowerCase()] = m[2] }
159
+ const sections = []
160
+ for (let i = 0; i < linksStart; i++) {
161
+ const h = /^##\s/.test(lines[i]) ? parseHeading(lines[i]) : null
162
+ if (!h) continue
163
+ if (sections.length) sections[sections.length - 1].end = i
164
+ sections.push({ ...h, heading: lines[i], start: i, end: linksStart })
165
+ }
166
+ for (const s of sections) s.body = lines.slice(s.start + 1, s.end).join('\n').trim()
167
+ const head = lines.slice(0, sections.length ? sections[0].start : linksStart).join('\n').trimEnd()
168
+ return { head, sections, links, linksStart, lines }
169
+ }
170
+
171
+ /** The section of a version (or the Unreleased one for null), or null. */
172
+ export function sectionOf (text, version) {
173
+ const { sections } = parseChangelog(text)
174
+ const v = version == null ? null : cleanVersion(version)
175
+ return sections.find((s) => v == null ? s.unreleased : !s.unreleased && s.version === v) || null
176
+ }
177
+
178
+ /** The newest released version written in the file (null when none). */
179
+ export function latestVersionIn (text) {
180
+ const v = parseChangelog(text).sections.filter((s) => !s.unreleased).map((s) => s.version)
181
+ return v.sort(compareVersions).pop() || null
182
+ }
183
+
184
+ const norm = (item) => String(item).split('\n')[0].replace(/<!--[\s\S]*?-->/g, '').replace(/\s+/g, ' ').trim().toLowerCase()
185
+
186
+ /** Markdown of groups ({ Added: [items] } in Keep a Changelog order; other names after them). */
187
+ export function groupsMarkdown (groups) {
188
+ const names = [...GROUPS.filter((g) => groups[g]?.length), ...Object.keys(groups).filter((g) => !GROUPS.includes(g) && groups[g]?.length)]
189
+ return names.map((g) => `### ${g}\n\n${groups[g].map((it) => `- ${String(it).split('\n').map((l, i) => i && l ? ` ${l}` : l).join('\n')}`).join('\n')}`).join('\n\n')
190
+ }
191
+
192
+ /**
193
+ * Puts draft items into the Unreleased section of a changelog: items whose first line is already there are left
194
+ * out, groups that exist get the new items at their end, missing groups are added in Keep a Changelog order. A file
195
+ * without an Unreleased section gets one above the newest release; an empty file gets the skeleton.
196
+ * Returns { text, added } (the number of new items).
197
+ */
198
+ export function applyDraft (text, groups) {
199
+ let src = String(text || '')
200
+ if (!src.trim()) src = CHANGELOG_SKELETON
201
+ let parsed = parseChangelog(src)
202
+ let un = parsed.sections.find((s) => s.unreleased)
203
+ if (!un) {
204
+ const lines = parsed.lines
205
+ const at = parsed.sections.length ? parsed.sections[0].start : parsed.linksStart
206
+ const before = lines.slice(0, at); while (before.length && before[before.length - 1].trim() === '') before.pop()
207
+ src = [...before, '', '## [Unreleased]', '', ...lines.slice(at)].join('\n')
208
+ parsed = parseChangelog(src)
209
+ un = parsed.sections.find((s) => s.unreleased)
210
+ }
211
+ const existing = parseEntry(un.body)
212
+ const seen = new Set(parsed.sections.flatMap((s) => parseEntry(s.body).groups.flatMap((g) => g.items.map(norm))))
213
+ const merged = new Map(existing.groups.map((g) => [g.name, { heading: g.heading, items: [...g.items] }]))
214
+ let added = 0
215
+ for (const [name, items] of Object.entries(groups)) {
216
+ for (const it of items || []) {
217
+ if (seen.has(norm(it))) continue
218
+ seen.add(norm(it))
219
+ if (!merged.has(name)) merged.set(name, { heading: name, items: [] })
220
+ merged.get(name).items.push(it); added++
221
+ }
222
+ }
223
+ const order = [...GROUPS.filter((g) => merged.has(g)), ...[...merged.keys()].filter((g) => !GROUPS.includes(g))]
224
+ const body = [existing.notes, ...order.map((g) => { const x = merged.get(g); return groupsMarkdown({ [g]: x.items }).replace(/^### [^\n]+/, `### ${x.heading}`) })].filter(Boolean).join('\n\n')
225
+ const lines = parsed.lines
226
+ const out = [...lines.slice(0, un.start + 1), '', ...(body ? [body, ''] : []), ...lines.slice(un.end)]
227
+ return { text: out.join('\n').replace(/\n{3,}/g, '\n\n').replace(/\n*$/, '\n'), added }
228
+ }
229
+
230
+ /**
231
+ * Turns the Unreleased section into the release `version` of `date` and opens a new, empty Unreleased section above
232
+ * it. Compare links at the bottom ([unreleased]: …/compare/v1...HEAD) move along. Returns { text, changed, empty }.
233
+ */
234
+ export function finalizeRelease (text, version, date, { tagPattern = 'v*' } = {}) {
235
+ const v = cleanVersion(version)
236
+ const parsed = parseChangelog(text)
237
+ if (parsed.sections.some((s) => !s.unreleased && s.version === v)) return { text, changed: false, empty: false }
238
+ const un = parsed.sections.find((s) => s.unreleased)
239
+ if (!un) return { text, changed: false, empty: true }
240
+ const lines = [...parsed.lines]
241
+ lines.splice(un.start, 1, '## [Unreleased]', '', `## [${v}] - ${date}`)
242
+ let out = lines.join('\n')
243
+ const link = parsed.links.unreleased
244
+ const m = link && link.match(/^(.*\/compare\/)(.+)\.\.\.HEAD$/)
245
+ if (m) {
246
+ const tag = tagFor(tagPattern, v)
247
+ out = out.replace(/^\[unreleased\]:\s*\S+$/im, `[unreleased]: ${m[1]}${tag}...HEAD\n[${v}]: ${m[1]}${m[2]}...${tag}`)
248
+ }
249
+ return { text: out, changed: true, empty: !un.body.trim() }
250
+ }
251
+
252
+ // ---------- commits and the draft ----------
253
+
254
+ const CONVENTIONAL = /^(\w+)(?:\(([^)]*)\))?(!)?:\s*(.+)$/
255
+ const INTERNAL = new Set(['docs', 'test', 'tests', 'chore', 'ci', 'build', 'style', 'refactor', 'release', 'wip', 'deps'])
256
+ const TYPE_GROUP = { feat: 'Added', feature: 'Added', add: 'Added', fix: 'Fixed', bugfix: 'Fixed', hotfix: 'Fixed', perf: 'Changed', revert: 'Changed', remove: 'Removed', deprecate: 'Deprecated', security: 'Security', sec: 'Security' }
257
+
258
+ /**
259
+ * Where a commit or pull request title goes: { group, text, internal, breaking, pr }. Conventional Commits when the
260
+ * title uses them; otherwise the first verb decides (add → Added, fix → Fixed, remove → Removed, else Changed).
261
+ * Documentation, tests, chores, CI, refactors, merges and releases are internal.
262
+ */
263
+ export function classifyCommit (subject, body = '') {
264
+ let s = String(subject || '').trim()
265
+ let pr = null
266
+ const sq = s.match(/\s*\(#(\d+)\)\s*$/)
267
+ if (sq) { pr = Number(sq[1]); s = s.slice(0, sq.index).trim() }
268
+ const breaking = /BREAKING[ -]CHANGE/.test(body)
269
+ if (/^Merge\b|^Revert "|^v?\d+\.\d+\.\d+$|\[skip changelog\]|\[no changelog\]/i.test(s)) return { group: null, text: s, internal: true, breaking, pr }
270
+ const m = s.match(CONVENTIONAL)
271
+ if (m) {
272
+ const type = m[1].toLowerCase()
273
+ const text = sentence(m[4])
274
+ if (INTERNAL.has(type) && !m[3] && !breaking) return { group: null, text, internal: true, breaking: false, pr, type, scope: m[2] || null }
275
+ const group = TYPE_GROUP[type] || (/^(remove|drop|delete)\b/i.test(m[4]) ? 'Removed' : 'Changed')
276
+ return { group: m[3] || breaking ? (group === 'Added' ? 'Added' : 'Changed') : group, text, internal: false, breaking: !!m[3] || breaking, pr, type, scope: m[2] || null }
277
+ }
278
+ const verb = s.toLowerCase()
279
+ const group = /^(add|adds|added|new|introduce|support|allow|enable)\b/.test(verb) ? 'Added'
280
+ : /^(fix|fixes|fixed|correct|repair|resolve|prevent)\b/.test(verb) ? 'Fixed'
281
+ : /^(remove|removes|removed|drop|delete)\b/.test(verb) ? 'Removed'
282
+ : /^(deprecate)\b/.test(verb) ? 'Deprecated' : 'Changed'
283
+ return { group, text: sentence(s), internal: false, breaking, pr }
284
+ }
285
+ const sentence = (t) => { const x = String(t || '').trim().replace(/\.$/, ''); return x ? x[0].toUpperCase() + x.slice(1) : x }
286
+
287
+ /**
288
+ * The draft entry: items grouped Added / Changed / Fixed / Removed (Deprecated, Security when present).
289
+ *
290
+ * commits [{ subject, body, pr: { number, url } | null }] — merged pull requests or commits since the last release
291
+ * changes [{ scenario, title, steps: [{ step, title, reason: new|changed|removed, pr: number|null }] }] — what the
292
+ * accepted runs since the last release show differently (from the service)
293
+ * Pictures are step references: a changed step gets before and after (`?version=prev` is the previous release), a
294
+ * new step one picture. A step whose run came from a pull request goes under that pull request's item.
295
+ * Returns { groups, internal (titles left out), markdown }.
296
+ */
297
+ export function draftEntry ({ commits = [], changes = [], scenarioLabel = (s) => s.title || s.scenario } = {}) {
298
+ const groups = {}
299
+ const internal = []
300
+ const byPr = new Map()
301
+ const add = (g, item) => { (groups[g] ||= []).push(item); return groups[g].length - 1 }
302
+ for (const c of commits) {
303
+ const k = classifyCommit(c.subject, c.body)
304
+ if (k.internal) { internal.push(c.subject); continue }
305
+ const number = c.pr?.number || k.pr
306
+ const url = c.pr?.url || null
307
+ const text = `${k.text}${k.breaking ? ' (breaking: say what people have to do)' : ''}${number ? ` (${url ? `[#${number}](${url})` : `#${number}`})` : ''}`
308
+ const i = add(k.group, text)
309
+ if (number && !byPr.has(number)) byPr.set(number, { group: k.group, i })
310
+ }
311
+ for (const sc of changes) {
312
+ for (const st of sc.steps || []) {
313
+ const ref = (version) => formatStepRef({ scenario: sc.scenario, step: st.step, version })
314
+ const title = st.title || st.step
315
+ const pics = st.reason === 'new' ? `![${title}](${ref(null)})` : st.reason === 'removed' ? `![${title} before](${ref('prev')})` : `![${title} before](${ref('prev')}) ![${title} after](${ref(null)})`
316
+ const home = st.pr && byPr.get(st.pr)
317
+ if (home) { groups[home.group][home.i] += `\n${pics}`; continue }
318
+ const g = st.reason === 'new' ? 'Added' : st.reason === 'removed' ? 'Removed' : 'Changed'
319
+ add(g, `**${title}** in ${scenarioLabel(sc)} <!-- say what a person notices here -->\n${pics}`)
320
+ }
321
+ }
322
+ return { groups, internal, markdown: groupsMarkdown(groups) }
323
+ }