@lemoncode/lemony 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.mjs CHANGED
@@ -1,11 +1,11 @@
1
1
  #!/usr/bin/env node
2
- import { execFile } from "node:child_process";
2
+ import { execFile, spawn } from "node:child_process";
3
3
  import { access, appendFile, chmod, lstat, mkdir, open, readFile, readdir, rename, rm, rmdir, stat, writeFile } from "node:fs/promises";
4
4
  import { basename, delimiter, dirname, extname, join, relative, resolve, sep } from "node:path";
5
5
  import { argv, cwd, env, exit, stderr, stdin, stdout } from "node:process";
6
6
  import { createInterface } from "node:readline/promises";
7
7
  import { promisify } from "node:util";
8
- import { isMap, parse, parseDocument } from "yaml";
8
+ import { parse, parseDocument } from "yaml";
9
9
  import { z } from "zod";
10
10
  import { createHash, randomBytes } from "node:crypto";
11
11
  import { constants } from "node:fs";
@@ -27,7 +27,8 @@ const DEPRECATED_IMPLEMENTATION_KEYS = ["pre_commit_review"];
27
27
  const HARNESS_CONFIG_SCHEMA_FILENAME = "harness.config.schema.json";
28
28
  const CONFIG_KEY_SINCE = {
29
29
  gates: "0.3.0",
30
- "implementation.auto_commit": "0.4.0"
30
+ "implementation.auto_commit": "0.4.0",
31
+ "design_tokens.verify": "0.5.0"
31
32
  };
32
33
  const TASK_STORAGE_REPO_PLACEHOLDER = "OWNER/REPO";
33
34
  const TARGETS = ["claude-code"];
@@ -120,7 +121,10 @@ const taskStorageSchema = z.object({
120
121
  }).strict();
121
122
  const rollbackSchema = z.object({ keep_snapshots: z.union([z.int().positive(), z.literal("unlimited")]).default(3) }).strict().prefault({});
122
123
  const telemetrySchema = z.object({ enabled: z.boolean().default(true) }).strict().prefault({});
123
- const designTokensSchema = z.object({ scan_extensions: z.array(z.string()).default([]) }).strict().prefault({});
124
+ const designTokensSchema = z.object({
125
+ scan_extensions: z.array(z.string()).default([]),
126
+ verify: z.string().trim().min(1).optional()
127
+ }).strict().prefault({});
124
128
  const mergeSchema = z.object({
125
129
  checks_timeout_secs: z.int().positive().default(600),
126
130
  allow_no_checks: z.boolean().default(false)
@@ -230,8 +234,6 @@ const setConfigValues = (rawYaml, updates) => {
230
234
  }
231
235
  for (const key of DEPRECATED_CONFIG_KEYS) if (doc.has(key)) doc.delete(key);
232
236
  for (const key of DEPRECATED_PATHS_KEYS) if (doc.hasIn(["paths", key])) doc.deleteIn(["paths", key]);
233
- const pathsNode = doc.get("paths", true);
234
- if (isMap(pathsNode) && pathsNode.items.length === 0) doc.delete("paths");
235
237
  for (const key of DEPRECATED_IMPLEMENTATION_KEYS) if (doc.hasIn(["implementation", key])) doc.deleteIn(["implementation", key]);
236
238
  return doc.toString();
237
239
  };
@@ -259,8 +261,6 @@ const pointerScalar = z.union([
259
261
  z.null()
260
262
  ]).optional();
261
263
  const pointerFrontmatterSchema = z.object({
262
- active_task: pointerScalar,
263
- branch: pointerScalar,
264
264
  session_start_ts: pointerScalar,
265
265
  last_close_ts: pointerScalar
266
266
  });
@@ -1452,6 +1452,7 @@ const flatten = (color, backdrop) => ({
1452
1452
  b: color.b * color.a + backdrop.b * (1 - color.a),
1453
1453
  a: 1
1454
1454
  });
1455
+ const compositeOver = (color, ground) => flatten(color, ground.a < 1 ? flatten(ground, WHITE) : ground);
1455
1456
  const linearizeChannel = (channel) => {
1456
1457
  const s = channel / 255;
1457
1458
  return s <= .03928 ? s / 12.92 : ((s + .055) / 1.055) ** 2.4;
@@ -1507,15 +1508,17 @@ const runContrast = async (inputs) => {
1507
1508
  };
1508
1509
  const tokens = collectTokens(parsed);
1509
1510
  const problems = [];
1510
- const specs = discoverPairs(tokens, problems);
1511
1511
  const modes = declaredModes(tokens);
1512
+ const specs = discoverPairs(tokens, modes, problems);
1512
1513
  const pairs = measurePairs(specs, modes, tokens);
1514
+ const verify = inputs.verify ? await runVerifier(inputs.verify) : void 0;
1513
1515
  return {
1514
- ok: problems.length === 0 && pairs.every((pair) => pair.passes),
1516
+ ok: problems.length === 0 && pairs.every((pair) => pair.passes) && (verify === void 0 || verify.code === 0),
1515
1517
  tokensFound: true,
1516
1518
  pairsChecked: pairs.length,
1517
1519
  pairs,
1518
- problems
1520
+ problems,
1521
+ ...verify === void 0 ? {} : { verify }
1519
1522
  };
1520
1523
  };
1521
1524
  const emptyResult$1 = (tokensFound) => ({
@@ -1554,19 +1557,17 @@ const declaredModes = (tokens) => {
1554
1557
  for (const token of tokens.values()) {
1555
1558
  const modes = readExtension(token.node, MODES_EXTENSION);
1556
1559
  if (isRecord(modes)) {
1557
- for (const name of Object.keys(modes)) if (name.trim().length > 0) names.add(name);
1560
+ for (const name of Object.keys(modes)) if (name.trim().length > 0 && name !== "base") names.add(name);
1558
1561
  }
1559
1562
  }
1560
1563
  return [...names].toSorted();
1561
1564
  };
1562
- const discoverPairs = (tokens, problems) => {
1563
- const merged = /* @__PURE__ */ new Map();
1564
- const add = (spec) => {
1565
- merged.set(`${spec.foreground}|${spec.background}`, spec);
1566
- };
1567
- for (const spec of conventionPairs(tokens)) add(spec);
1568
- for (const spec of extensionPairs(tokens, problems)) add(spec);
1569
- return [...merged.values()];
1565
+ const pairKey = (spec) => `${spec.foreground}|${spec.background}`;
1566
+ const discoverPairs = (tokens, modes, problems) => {
1567
+ const convention = new Map(conventionPairs(tokens).map((spec) => [pairKey(spec), spec]));
1568
+ const explicit = extensionPairs(tokens, modes, problems);
1569
+ for (const spec of explicit) convention.delete(pairKey(spec));
1570
+ return [...convention.values(), ...explicit];
1570
1571
  };
1571
1572
  const conventionPairs = (tokens) => {
1572
1573
  const pairs = [];
@@ -1583,77 +1584,193 @@ const conventionPairs = (tokens) => {
1583
1584
  foreground: path,
1584
1585
  background: basePath,
1585
1586
  level: DEFAULT_LEVEL,
1587
+ floors: {},
1586
1588
  source: "convention"
1587
1589
  });
1588
1590
  }
1589
1591
  return pairs;
1590
1592
  };
1591
- const extensionPairs = (tokens, problems) => {
1593
+ const DECLARATION_SHAPE = "{ against: \"{token.path}\" | [\"{token.path}\", …], level?, floors?, modes?, over? }";
1594
+ const DECLARATION_KEYS = /* @__PURE__ */ new Set([
1595
+ "against",
1596
+ "level",
1597
+ "floors",
1598
+ "modes",
1599
+ "over"
1600
+ ]);
1601
+ const extensionPairs = (tokens, modes, problems) => {
1592
1602
  const pairs = [];
1593
1603
  for (const [path, token] of tokens) {
1594
1604
  const declaration = readExtension(token.node, CONTRAST_EXTENSION);
1595
1605
  if (declaration === void 0) continue;
1596
1606
  const entries = Array.isArray(declaration) ? declaration : [declaration];
1607
+ const declared = /* @__PURE__ */ new Set();
1597
1608
  for (const entry of entries) {
1598
- if (!isRecord(entry) || typeof entry["against"] !== "string") {
1599
- problems.push(`token "${path}" has a malformed ${CONTRAST_EXTENSION} declaration — expected { against: "{token.path}", level? }.`);
1600
- continue;
1601
- }
1602
- const against = aliasTarget(entry["against"]) ?? entry["against"];
1603
- if (against === path) {
1604
- problems.push(`token "${path}" declares contrast against itself — a pair needs two different tokens.`);
1605
- continue;
1606
- }
1607
- if (!tokens.has(against)) {
1608
- problems.push(`token "${path}" contrast-against "${entry["against"]}" does not resolve to a known token.`);
1609
+ if (!isRecord(entry)) {
1610
+ problems.push(`token "${path}" has a malformed ${CONTRAST_EXTENSION} declaration — expected ${DECLARATION_SHAPE}.`);
1609
1611
  continue;
1610
1612
  }
1611
- if (!resolveColor(against, "base", tokens)) {
1612
- problems.push(`token "${path}" contrast-against "${entry["against"]}" is not a colour token.`);
1613
+ const unknownKeys = Object.keys(entry).filter((key) => !DECLARATION_KEYS.has(key));
1614
+ if (unknownKeys.length > 0) {
1615
+ problems.push(`token "${path}" has unknown ${CONTRAST_EXTENSION} key(s) ${unknownKeys.map((key) => `"${key}"`).join(", ")} — expected ${DECLARATION_SHAPE}.`);
1613
1616
  continue;
1614
1617
  }
1618
+ const before = problems.length;
1619
+ const backgrounds = tokenPathList(entry["against"], path, "against", problems);
1620
+ if (backgrounds.length === 0) continue;
1615
1621
  if (!resolveColor(path, "base", tokens)) {
1616
1622
  problems.push(`token "${path}" declares a contrast pair but is not itself a colour token.`);
1617
1623
  continue;
1618
1624
  }
1619
- pairs.push({
1620
- foreground: path,
1621
- background: against,
1622
- level: normalizeLevel(entry["level"], path, problems),
1623
- source: "extension"
1624
- });
1625
+ const level = normalizeLevel(entry["level"], path, problems);
1626
+ const floors = normalizeFloors(entry["floors"], path, modes, problems);
1627
+ const ruleModes = normalizeModes(entry["modes"], path, modes, problems);
1628
+ const measuredModes = (ruleModes ?? modes).filter((mode) => mode !== BASE_MODE);
1629
+ const ownDefects = unresolvedModes(path, measuredModes, tokens);
1630
+ for (const mode of ownDefects) {
1631
+ const problem = `token "${path}" declares a contrast pair but its "${mode}" mode override does not resolve to a colour.`;
1632
+ if (!problems.includes(problem)) problems.push(problem);
1633
+ }
1634
+ if (ruleModes !== void 0) {
1635
+ for (const mode of Object.keys(floors)) if (mode !== "base" && !ruleModes.includes(mode)) problems.push(`token "${path}" sets a contrast floor for mode "${mode}" but restricts the rule to ${ruleModes.map((name) => `"${name}"`).join("/")} — the floor would never apply.`);
1636
+ }
1637
+ const grounds = entry["over"] === void 0 ? [] : tokenPathList(entry["over"], path, "over", problems);
1638
+ const colourGrounds = [];
1639
+ for (const ground of grounds) if (ground === path || backgrounds.includes(ground)) problems.push(`token "${path}" composites over "${ground}", which is an end of the pair itself — \`over\` names the opaque ground beneath the background.`);
1640
+ else if (isColourTarget(ground, path, "over", measuredModes, tokens, problems)) colourGrounds.push(ground);
1641
+ const colourBackgrounds = [];
1642
+ for (const against of backgrounds) if (against === path) problems.push(`token "${path}" declares contrast against itself — a pair needs two different tokens.`);
1643
+ else if (isColourTarget(against, path, "against", measuredModes, tokens, problems)) colourBackgrounds.push(against);
1644
+ if (ownDefects.length > 0 || problems.length > before) continue;
1645
+ for (const background of colourBackgrounds) for (const over of colourGrounds.length === 0 ? [void 0] : colourGrounds) {
1646
+ const key = `${background}|${over ?? ""}`;
1647
+ if (declared.has(key)) problems.push(`token "${path}" declares contrast against "${background}"${over === void 0 ? "" : ` over "${over}"`} more than once — one declaration per pair.`);
1648
+ declared.add(key);
1649
+ }
1650
+ if (ownDefects.length > 0 || problems.length > before) continue;
1651
+ for (const background of colourBackgrounds) {
1652
+ const base = {
1653
+ foreground: path,
1654
+ background,
1655
+ level,
1656
+ floors,
1657
+ source: "extension"
1658
+ };
1659
+ const withModes = ruleModes === void 0 ? base : {
1660
+ ...base,
1661
+ modes: ruleModes
1662
+ };
1663
+ if (colourGrounds.length === 0) pairs.push(withModes);
1664
+ else for (const over of colourGrounds) pairs.push({
1665
+ ...withModes,
1666
+ over
1667
+ });
1668
+ }
1625
1669
  }
1626
1670
  }
1627
1671
  return pairs;
1628
1672
  };
1673
+ const tokenPathList = (raw, path, field, problems) => {
1674
+ const list = Array.isArray(raw) ? raw : [raw];
1675
+ if (list.length === 0 || !list.every((item) => typeof item === "string")) {
1676
+ problems.push(`token "${path}" has a malformed ${CONTRAST_EXTENSION} declaration — \`${field}\` must be a token path or a non-empty array of them (expected ${DECLARATION_SHAPE}).`);
1677
+ return [];
1678
+ }
1679
+ return list.map((item) => aliasTarget(item) ?? item);
1680
+ };
1681
+ const isColourTarget = (target, path, field, modes, tokens, problems) => {
1682
+ if (!tokens.has(target)) {
1683
+ problems.push(`token "${path}" contrast-${field} "${target}" does not resolve to a known token.`);
1684
+ return false;
1685
+ }
1686
+ if (!resolveColor(target, "base", tokens)) {
1687
+ problems.push(`token "${path}" contrast-${field} "${target}" is not a colour token.`);
1688
+ return false;
1689
+ }
1690
+ const unresolved = unresolvedModes(target, modes, tokens);
1691
+ for (const mode of unresolved) problems.push(`token "${path}" contrast-${field} "${target}" has a "${mode}" mode override that does not resolve to a colour.`);
1692
+ return unresolved.length === 0;
1693
+ };
1694
+ const unresolvedModes = (target, modes, tokens) => modes.filter((mode) => resolveColor(target, mode, tokens) === void 0);
1629
1695
  const normalizeLevel = (raw, path, problems) => {
1630
1696
  if (raw === void 0) return DEFAULT_LEVEL;
1631
1697
  if (typeof raw === "string" && CONTRAST_LEVELS.includes(raw)) return raw;
1632
1698
  problems.push(`token "${path}" has an unknown contrast level "${String(raw)}" — use ${CONTRAST_LEVELS.join("/")}.`);
1633
1699
  return DEFAULT_LEVEL;
1634
1700
  };
1701
+ const normalizeFloors = (raw, path, modes, problems) => {
1702
+ if (raw === void 0) return {};
1703
+ if (!isRecord(raw)) {
1704
+ problems.push(`token "${path}" has malformed contrast floors — expected { base?: number, <mode>?: number }.`);
1705
+ return {};
1706
+ }
1707
+ const floors = Object.create(null);
1708
+ for (const [mode, value] of Object.entries(raw)) {
1709
+ if (mode !== "base" && !modes.includes(mode)) {
1710
+ problems.push(`token "${path}" sets a contrast floor for mode "${mode}", which no ${MODES_EXTENSION} block declares — use ${[BASE_MODE, ...modes].join("/")}.`);
1711
+ continue;
1712
+ }
1713
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 1 || value > 21) {
1714
+ problems.push(`token "${path}" has an invalid contrast floor for mode "${mode}" (${String(value)}) — a WCAG ratio is a number between 1 and 21.`);
1715
+ continue;
1716
+ }
1717
+ floors[mode] = value;
1718
+ }
1719
+ return floors;
1720
+ };
1721
+ const normalizeModes = (raw, path, modes, problems) => {
1722
+ if (raw === void 0) return void 0;
1723
+ if (!Array.isArray(raw) || raw.length === 0 || !raw.every((item) => typeof item === "string")) {
1724
+ problems.push(`token "${path}" has malformed contrast modes — expected a non-empty array of mode names.`);
1725
+ return;
1726
+ }
1727
+ const unknown = raw.filter((mode) => mode !== "base" && !modes.includes(mode));
1728
+ if (unknown.length > 0) {
1729
+ problems.push(`token "${path}" restricts contrast to mode(s) ${unknown.map((mode) => `"${mode}"`).join(", ")}, which no ${MODES_EXTENSION} block declares — use ${[BASE_MODE, ...modes].join("/")}.`);
1730
+ return;
1731
+ }
1732
+ return [...new Set(raw)];
1733
+ };
1734
+ const floorFor = (spec, mode) => ownFloor(spec.floors, mode) ?? ownFloor(spec.floors, "base") ?? WCAG_FLOORS[spec.level];
1735
+ const ownFloor = (floors, mode) => Object.hasOwn(floors, mode) ? floors[mode] : void 0;
1635
1736
  const measurePairs = (specs, modes, tokens) => {
1636
1737
  const rows = [];
1637
1738
  for (const spec of specs) {
1638
- const baseFg = resolveColor(spec.foreground, BASE_MODE, tokens);
1639
- const baseBg = resolveColor(spec.background, BASE_MODE, tokens);
1640
- if (!baseFg || !baseBg) continue;
1641
- rows.push(makeRow(spec, BASE_MODE, baseFg, baseBg));
1642
- for (const mode of modes) {
1643
- const fg = resolveColor(spec.foreground, mode, tokens) ?? baseFg;
1644
- const bg = resolveColor(spec.background, mode, tokens) ?? baseBg;
1645
- if (sameColor(fg, baseFg) && sameColor(bg, baseBg)) continue;
1646
- rows.push(makeRow(spec, mode, fg, bg));
1739
+ const base = resolvePair(spec, BASE_MODE, tokens);
1740
+ if (!base) continue;
1741
+ const explicit = spec.modes !== void 0;
1742
+ const rowModes = spec.modes ?? ["base", ...modes];
1743
+ for (const mode of rowModes) {
1744
+ const colours = mode === "base" ? base : resolvePair(spec, mode, tokens) ?? base;
1745
+ if (!explicit && mode !== "base" && sameColor(colours.fg, base.fg) && sameColor(colours.bg, base.bg) && floorFor(spec, mode) === floorFor(spec, "base")) continue;
1746
+ rows.push(makeRow(spec, mode, colours.fg, colours.bg));
1647
1747
  }
1648
1748
  }
1649
1749
  return rows;
1650
1750
  };
1751
+ const resolvePair = (spec, mode, tokens) => {
1752
+ const resolveEnd = (path) => resolveColor(path, mode, tokens) ?? (mode === "base" ? void 0 : resolveColor(path, "base", tokens));
1753
+ const fg = resolveEnd(spec.foreground);
1754
+ const against = resolveEnd(spec.background);
1755
+ if (!fg || !against) return void 0;
1756
+ if (spec.over === void 0) return {
1757
+ fg,
1758
+ bg: against
1759
+ };
1760
+ const ground = resolveEnd(spec.over);
1761
+ if (!ground) return void 0;
1762
+ return {
1763
+ fg,
1764
+ bg: compositeOver(against, ground)
1765
+ };
1766
+ };
1651
1767
  const makeRow = (spec, mode, foreground, background) => {
1652
1768
  const ratio = contrastRatio(foreground, background);
1653
- const floor = WCAG_FLOORS[spec.level];
1769
+ const floor = floorFor(spec, mode);
1654
1770
  return {
1655
1771
  foreground: spec.foreground,
1656
1772
  background: spec.background,
1773
+ ...spec.over === void 0 ? {} : { over: spec.over },
1657
1774
  mode,
1658
1775
  level: spec.level,
1659
1776
  ratio,
@@ -1677,6 +1794,13 @@ const resolveColor = (path, mode, tokens, seen = /* @__PURE__ */ new Set()) => {
1677
1794
  if (alias !== void 0) return resolveColor(alias, mode, tokens, seen);
1678
1795
  return typeof value === "string" ? parseColor(value) : void 0;
1679
1796
  };
1797
+ const runVerifier = async (hook) => {
1798
+ const result = await hook.run("sh", ["-c", hook.command]);
1799
+ return {
1800
+ command: hook.command,
1801
+ ...result
1802
+ };
1803
+ };
1680
1804
  //#endregion
1681
1805
  //#region src/design-tokens/design-sync.constant.ts
1682
1806
  const DESIGN_TOOL_EXTENSION = "com.lemony.design-tool";
@@ -2097,16 +2221,18 @@ const SPEC_SIDE_KINDS = [
2097
2221
  "malformed-risk-marker",
2098
2222
  "malformed-task-refs",
2099
2223
  "unknown-risk-class",
2100
- "dangling-requirement-ref"
2224
+ "dangling-requirement-ref",
2225
+ "unticked-completed-task"
2101
2226
  ];
2102
2227
  const GROUP_HEADER = /^##\s+Group\s+(\d+)\b/;
2103
2228
  const RISK_MARKER = /\[risk:\s*([^\]]*)\]\s*$/;
2104
2229
  const RISK_MARKER_ALL = /\[risk:/gi;
2105
2230
  const RISK_MARKER_LOOKALIKE = /\[\s*risks?\s*:/i;
2106
- const TASK_LINE = /^\s*[-*+]\s*\[[ xX]\]\s*(?:\*\*|__|`)?\s*(T\d+)\b/;
2231
+ const TASK_LINE = /^\s*[-*+]\s*\[([ xX])\]\s*(?:\*\*|__|`)?\s*(T\d+)\b/;
2107
2232
  const TASK_REFS = /^\(((?:R\d+)(?:\s*,\s*R\d+)*)\)/;
2108
- const TASK_REFS_LOOKALIKE = /\(\s*R\d+/;
2109
- const TASK_REFS_AFTER_TITLE = /(?:\*\*|__)\s*(\(\s*R\d+)/;
2233
+ const TASK_REFS_LOOKALIKE_ALL = /\(\s*R\d+/g;
2234
+ const TASK_TITLE_WRAPPER = /^\s*[-*+]\s*\[[ xX]\]\s*(\*\*|__)/;
2235
+ const TASK_REFS_AFTER_TITLE_CLOSE = /^\s*(\(\s*R\d+)/;
2110
2236
  const REQUIREMENT_LINE = /^\s*-\s*\*\*(R\d+)\*\*/;
2111
2237
  const TEST_FILE = /(^|\/)__tests__\/|\.(spec|test)\.[cm]?[jt]sx?$/;
2112
2238
  //#endregion
@@ -2157,7 +2283,7 @@ const parseTasksSpec = (text) => {
2157
2283
  const blockEnd = taskBlockEnd(lines, index);
2158
2284
  const parts = lines.slice(index, blockEnd).map((part) => part.trim());
2159
2285
  index = blockEnd - 1;
2160
- const task = parseTaskBlock(match[1] ?? "", parts);
2286
+ const task = parseTaskBlock(match[2] ?? "", (match[1] ?? " ") !== " ", parts);
2161
2287
  if (task.malformedRefs) malformedTaskRefs.push(task.id);
2162
2288
  if (current) {
2163
2289
  current.tasks.push(task.task);
@@ -2198,7 +2324,7 @@ const taskBlockEnd = (lines, start) => {
2198
2324
  }
2199
2325
  return end;
2200
2326
  };
2201
- const parseTaskBlock = (id, parts) => {
2327
+ const parseTaskBlock = (id, ticked, parts) => {
2202
2328
  const block = parts.join(" ");
2203
2329
  const opener = refsOpener(parts, block);
2204
2330
  const refs = opener === void 0 ? null : declarationAt(parts, block, opener);
@@ -2207,9 +2333,10 @@ const parseTaskBlock = (id, parts) => {
2207
2333
  id,
2208
2334
  task: {
2209
2335
  id,
2336
+ ticked,
2210
2337
  requirementRefs: [...new Set(requirementRefs)]
2211
2338
  },
2212
- malformedRefs: refs === null && TASK_REFS_LOOKALIKE.test(block)
2339
+ malformedRefs: refs === null && hasRefLookalike(parts)
2213
2340
  };
2214
2341
  };
2215
2342
  const declarationAt = (parts, block, opener) => {
@@ -2230,17 +2357,40 @@ const declarationClosesLine = (parts, end) => {
2230
2357
  return /^(?:\*\*|__)/.test(tail) && lineEnds.has(end + 2);
2231
2358
  };
2232
2359
  const refsOpener = (parts, block) => {
2233
- const candidates = [];
2234
- const onFirstLine = TASK_REFS_LOOKALIKE.exec(parts[0] ?? "")?.index;
2235
- if (onFirstLine !== void 0) candidates.push(onFirstLine);
2236
- const afterTitle = TASK_REFS_AFTER_TITLE.exec(block);
2237
- if (afterTitle) candidates.push(afterTitle.index + afterTitle[0].lastIndexOf("("));
2238
- let offset = (parts[0] ?? "").length + 1;
2360
+ const first = parts[0] ?? "";
2361
+ const attempted = [];
2362
+ for (const lookalike of first.matchAll(TASK_REFS_LOOKALIKE_ALL)) {
2363
+ if (isLinkTarget(first, lookalike.index)) continue;
2364
+ attempted.push(lookalike.index);
2365
+ break;
2366
+ }
2367
+ const afterTitle = afterTitleClose(first, block);
2368
+ if (afterTitle !== void 0) attempted.push(afterTitle);
2369
+ if (attempted.length > 0) return Math.min(...attempted);
2370
+ const closing = [];
2371
+ let offset = first.length + 1;
2239
2372
  for (const part of parts.slice(1)) {
2240
- if (/^\(\s*R\d+/.test(part)) candidates.push(offset);
2373
+ for (const lookalike of part.matchAll(TASK_REFS_LOOKALIKE_ALL)) {
2374
+ if (isLinkTarget(part, lookalike.index)) continue;
2375
+ const at = offset + lookalike.index;
2376
+ if (declarationAt(parts, block, at) !== null) closing.push(at);
2377
+ }
2241
2378
  offset += part.length + 1;
2242
2379
  }
2243
- return candidates.length === 0 ? void 0 : Math.min(...candidates);
2380
+ return closing.length === 1 ? closing[0] : void 0;
2381
+ };
2382
+ const hasRefLookalike = (parts) => parts.some((part) => [...part.matchAll(TASK_REFS_LOOKALIKE_ALL)].some((lookalike) => !isLinkTarget(part, lookalike.index)));
2383
+ const isLinkTarget = (line, index) => line[index - 1] === "]";
2384
+ const afterTitleClose = (first, block) => {
2385
+ const wrapper = TASK_TITLE_WRAPPER.exec(first);
2386
+ const marker = wrapper?.[1];
2387
+ if (!wrapper || marker === void 0) return void 0;
2388
+ const close = block.indexOf(marker, wrapper[0].length);
2389
+ if (close < 0) return void 0;
2390
+ const tail = block.slice(close + marker.length);
2391
+ const opener = TASK_REFS_AFTER_TITLE_CLOSE.exec(tail);
2392
+ if (!opener) return void 0;
2393
+ return close + marker.length + opener[0].indexOf("(");
2244
2394
  };
2245
2395
  const parseRiskMarker = (line) => {
2246
2396
  const marker = RISK_MARKER.exec(line);
@@ -2381,7 +2531,7 @@ const runLedgerValidate = async (inputs) => {
2381
2531
  });
2382
2532
  for (const id of spec.malformedTaskRefs) problems.push({
2383
2533
  kind: "malformed-task-refs",
2384
- message: `${id} carries a "(R<n>"-shaped tail that is not a well-formed "(R1, R2)" ref list. Left as-is it reads as a task that references no requirement, and its slice shrinks to itself.`,
2534
+ message: `${id} carries a "(R<n>"-shaped ref list that is not a declaration: a declaration is one comma-separated "(R1, R2)" group that ends a line — no ranges ("R1–R3"), nothing after the closing paren on that line — at the end of the task's first line, right after the title's bold close, or the only group ending a continuation line. Left as-is it reads as a task that references no requirement, and its slice shrinks to itself.`,
2385
2535
  subject: id
2386
2536
  });
2387
2537
  const groups = resolveGroups(spec.groups, address, problems);
@@ -2402,6 +2552,7 @@ const runLedgerValidate = async (inputs) => {
2402
2552
  });
2403
2553
  }
2404
2554
  }
2555
+ checkTicks(spec.groups, address, problems);
2405
2556
  const slice = sliceForGroups(groups);
2406
2557
  result.basis = slice.basis;
2407
2558
  const dangling = await collectDanglingRefs(requirementsPath, slice.ids, problems);
@@ -2437,6 +2588,17 @@ const resolveGroups = (groups, address, problems) => {
2437
2588
  message: `tasks.md declares ${groups.length} group(s); step ${address.step} has none. One step is one group.`
2438
2589
  });
2439
2590
  };
2591
+ const checkTicks = (groups, address, problems) => {
2592
+ const passed = address.kind === "full-pass" ? groups : groups.filter((group) => group.index <= address.step);
2593
+ for (const group of passed) for (const task of group.tasks) {
2594
+ if (task.ticked) continue;
2595
+ problems.push({
2596
+ kind: "unticked-completed-task",
2597
+ message: `${task.id} (Group ${group.index}) is still "[ ]" in tasks.md, in a group the loop has passed. The Implementer ticks a task when it reaches green; an unticked task here is a forgotten mark or work that was never done — the human decides which.`,
2598
+ subject: task.id
2599
+ });
2600
+ }
2601
+ };
2440
2602
  const collectDanglingRefs = async (requirementsPath, sliceIds, problems) => {
2441
2603
  const referenced = sliceIds.filter((id) => id.startsWith("R"));
2442
2604
  if (referenced.length === 0) return [];
@@ -3039,7 +3201,8 @@ const prunePrefix = async (repoRoot, options = {}) => {
3039
3201
  }
3040
3202
  if (sentOffset > size) return { prunedBytes: 0 };
3041
3203
  const stateDir = stateDirOf(repoRoot);
3042
- const drainPath = join(stateDir, `${DRAINING_BASENAME}.${`${process.pid}-${randomBytes(4).toString("hex")}`}`);
3204
+ const id = `${process.pid}-${randomBytes(4).toString("hex")}`;
3205
+ const drainPath = join(stateDir, `${DRAINING_BASENAME}.${id}`);
3043
3206
  const metaPath = `${drainPath}.meta`;
3044
3207
  await mkdir(stateDir, { recursive: true });
3045
3208
  await writeFile(metaPath, JSON.stringify({ sent_offset: sentOffset }));
@@ -3080,14 +3243,15 @@ const appendRejects = async (repoRoot, rejects, now) => {
3080
3243
  const path = join(repoRoot, REJECTED_RELPATH);
3081
3244
  await mkdir(dirname(path), { recursive: true });
3082
3245
  const ts = now().toISOString();
3083
- await appendFile(path, `${rejects.map((reject) => {
3246
+ const body = rejects.map((reject) => {
3084
3247
  const entry = {
3085
3248
  ts,
3086
3249
  reason: reject.reason,
3087
3250
  line: reject.line
3088
3251
  };
3089
3252
  return JSON.stringify(entry);
3090
- }).join("\n")}\n`);
3253
+ }).join("\n");
3254
+ await appendFile(path, `${body}\n`);
3091
3255
  return rejects.length;
3092
3256
  };
3093
3257
  //#endregion
@@ -3759,7 +3923,8 @@ const checkVersionPin = (deps, config) => {
3759
3923
  };
3760
3924
  const checkCliResolution = async (deps) => {
3761
3925
  const name = "cli-resolution";
3762
- if (await isExecutable(join(deps.repoRoot, "node_modules", ".bin", "lemony"))) return {
3926
+ const localBin = join(deps.repoRoot, "node_modules", ".bin", LEMONY_BIN);
3927
+ if (await isExecutable(localBin)) return {
3763
3928
  name,
3764
3929
  status: "ok",
3765
3930
  detail: `Telemetry CLI resolves from node_modules/.bin/${LEMONY_BIN} (the durable setup).`
@@ -3954,22 +4119,26 @@ const isReadable = async (path) => {
3954
4119
  }
3955
4120
  };
3956
4121
  //#endregion
4122
+ //#region src/status/parse-task-branch.ts
4123
+ const TASK_BRANCH = /^harness\/(\d+)-.+$/;
4124
+ const parseTaskBranch = (branch) => branch === null ? null : TASK_BRANCH.exec(branch)?.[1] ?? null;
4125
+ //#endregion
3957
4126
  //#region src/status/status.ts
3958
4127
  const PAUSED_LABEL = "harness:status:paused-for-clarification";
3959
4128
  const runStatus = async (deps) => {
3960
4129
  const { repoRoot } = deps;
3961
4130
  const config = await readHarnessConfig(repoRoot);
3962
- const pointer = await readPointer(repoRoot, deps.readGitUserEmail);
4131
+ const lastSession = await readPointer(repoRoot, deps.readGitUserEmail);
3963
4132
  const { branch, behind } = await deps.gitBehind(repoRoot);
3964
4133
  const provider = createTaskTrackerProvider(config.task_storage.type, deps.runCommand);
3965
4134
  const openDiscoveries = await countOpenDiscoveries(provider, config.task_storage.repo);
3966
4135
  return {
3967
4136
  vendorVersion: config.vendor_version,
3968
4137
  taskStorageRepo: config.task_storage.repo,
3969
- activeTask: pointer.activeTask,
3970
- branch: branch ?? pointer.branch,
4138
+ activeTask: parseTaskBranch(branch),
4139
+ branch,
3971
4140
  behind,
3972
- lastSession: pointer.lastSession,
4141
+ lastSession,
3973
4142
  openDiscoveries,
3974
4143
  designToolDrift: await readDriftState(repoRoot)
3975
4144
  };
@@ -3986,11 +4155,7 @@ const readDriftState = async (repoRoot) => {
3986
4155
  }
3987
4156
  };
3988
4157
  const readPointer = async (repoRoot, readGitUserEmail) => {
3989
- const empty = {
3990
- activeTask: null,
3991
- branch: null,
3992
- lastSession: null
3993
- };
4158
+ const empty = null;
3994
4159
  const email = await readGitUserEmail(repoRoot);
3995
4160
  if (!email) return empty;
3996
4161
  const slug = email.split("@")[0] ?? "";
@@ -4006,12 +4171,7 @@ const readPointer = async (repoRoot, readGitUserEmail) => {
4006
4171
  if (!match) return empty;
4007
4172
  const parsed = pointerFrontmatterSchema.safeParse(parse(match[1] ?? ""));
4008
4173
  if (!parsed.success) return empty;
4009
- const front = parsed.data;
4010
- return {
4011
- activeTask: normalize(front.active_task),
4012
- branch: normalize(front.branch),
4013
- lastSession: normalize(front.last_close_ts)
4014
- };
4174
+ return normalize(parsed.data.last_close_ts);
4015
4175
  };
4016
4176
  const normalize = (value) => {
4017
4177
  if (value === null || value === void 0) return null;
@@ -4083,7 +4243,7 @@ const COMMANDS = [
4083
4243
  },
4084
4244
  {
4085
4245
  name: "review-ledger",
4086
- summary: "Validate the Reviewer's evidence ledger (the JSON sidecar under the task's state) against the spec's groups, the anchored diff and the declared gates: shape, criteria-vs-slice, unknown risk classes, the mutant floor, the gates floor (deterministic, agent-free; exits non-zero on an invalid ledger).",
4246
+ summary: "Validate the Reviewer's evidence ledger (the JSON sidecar under the task's state) against the spec's groups, the anchored diff and the declared gates: shape, criteria-vs-slice, unknown risk classes, the mutant floor, the gates floor, the tick check on passed groups (deterministic, agent-free; exits non-zero on an invalid ledger).",
4087
4247
  usage: "lemony review-ledger validate --task-id=<id> --anchor=<oid> (--step=<N> | --full-pass)"
4088
4248
  },
4089
4249
  {
@@ -4236,7 +4396,8 @@ const snapshotDestPaths = (version, bundle) => {
4236
4396
  ];
4237
4397
  };
4238
4398
  const writeSnapshot = async (repoRoot, version, bundle) => {
4239
- const staging = join(snapshotsRootDir(repoRoot), STAGING_DIR);
4399
+ const root = snapshotsRootDir(repoRoot);
4400
+ const staging = join(root, STAGING_DIR);
4240
4401
  await assertNoSymlinkTraversal(repoRoot, snapshotDestPaths(version, bundle));
4241
4402
  await rm(staging, {
4242
4403
  recursive: true,
@@ -4595,9 +4756,10 @@ const COMPANION_DOCS = [
4595
4756
  "partition"
4596
4757
  ];
4597
4758
  const renderFile = async (templatePath, relPath, vars) => {
4759
+ const template = await readFile(templatePath, "utf8");
4598
4760
  return {
4599
4761
  relPath,
4600
- content: renderTemplate(await readFile(templatePath, "utf8"), vars),
4762
+ content: renderTemplate(template, vars),
4601
4763
  executable: false
4602
4764
  };
4603
4765
  };
@@ -4673,7 +4835,8 @@ const appendGitignoreBlock = async (repoRoot) => {
4673
4835
  await writeFile(gitignorePath, next);
4674
4836
  return ".gitignore";
4675
4837
  }
4676
- await writeFile(gitignorePath, `${current}${current.length === 0 ? "" : current.endsWith("\n") ? "\n" : "\n\n"}${GITIGNORE_BLOCK}\n`);
4838
+ const prefix = current.length === 0 ? "" : current.endsWith("\n") ? "\n" : "\n\n";
4839
+ await writeFile(gitignorePath, `${current}${prefix}${GITIGNORE_BLOCK}\n`);
4677
4840
  return ".gitignore";
4678
4841
  };
4679
4842
  const removeGitignoreBlock = async (repoRoot) => {
@@ -4834,9 +4997,10 @@ const runInstall = async (options) => {
4834
4997
  const losers = await collectLosers(repoRoot, actions);
4835
4998
  let snapshotVersion = null;
4836
4999
  if (losers.length > 0) {
5000
+ const baseline = new Map(vendorFiles.map((file) => [file.relPath, file.content]));
4837
5001
  await writeSnapshot(repoRoot, vendorVersion, {
4838
5002
  working: losers,
4839
- baseline: new Map(vendorFiles.map((file) => [file.relPath, file.content]))
5003
+ baseline
4840
5004
  });
4841
5005
  snapshotVersion = vendorVersion;
4842
5006
  }
@@ -4893,7 +5057,8 @@ const runInstall = async (options) => {
4893
5057
  };
4894
5058
  };
4895
5059
  const collectLosers = (repoRoot, actions) => {
4896
- return collectWorkingFiles(repoRoot, actions.filter((action) => action.kind === "pick-vendor").map((action) => action.relPath));
5060
+ const losingPaths = actions.filter((action) => action.kind === "pick-vendor").map((action) => action.relPath);
5061
+ return collectWorkingFiles(repoRoot, losingPaths);
4897
5062
  };
4898
5063
  //#endregion
4899
5064
  //#region src/install/resolve-collision.ts
@@ -4932,12 +5097,14 @@ const runReconcile = async (inputs) => {
4932
5097
  const fromVersion = config.vendor_version;
4933
5098
  const target = config.target;
4934
5099
  const taskStorageRepo = config.task_storage.repo;
4935
- const predatedKeys = findPredatedKeys(await readFile(join(repoRoot, HARNESS_CONFIG_FILENAME), "utf8"), fromVersion, toVersion, inputs.configKeySince ?? CONFIG_KEY_SINCE);
5100
+ const rawConfig = await readFile(join(repoRoot, HARNESS_CONFIG_FILENAME), "utf8");
5101
+ const predatedKeys = findPredatedKeys(rawConfig, fromVersion, toVersion, inputs.configKeySince ?? CONFIG_KEY_SINCE);
4936
5102
  const baselineVersion = await findBaselineVersion(repoRoot);
4937
5103
  const baseline = baselineVersion ? await readBaseline(repoRoot, baselineVersion) : /* @__PURE__ */ new Map();
4938
5104
  const hadBaseline = baseline.size > 0;
4939
5105
  const capabilities = await scanRepo(repoRoot);
4940
- const skills = selectSkills(await readCatalogSkills(vendorRoot), capabilities);
5106
+ const catalogSkills = await readCatalogSkills(vendorRoot);
5107
+ const skills = selectSkills(catalogSkills, capabilities);
4941
5108
  const vendorNew = await materializeVendorFiles({
4942
5109
  vendorRoot,
4943
5110
  target,
@@ -4973,14 +5140,16 @@ const runReconcile = async (inputs) => {
4973
5140
  predatedKeys
4974
5141
  };
4975
5142
  if (hadBaseline) {
5143
+ const working = await collectWorkingFiles(repoRoot, [...baseline.keys()]);
4976
5144
  await writeSnapshot(repoRoot, fromVersion, {
4977
- working: await collectWorkingFiles(repoRoot, [...baseline.keys()]),
5145
+ working,
4978
5146
  baseline
4979
5147
  });
4980
5148
  await rotateSnapshots(repoRoot, config.rollback.keep_snapshots);
4981
5149
  }
4982
5150
  await applyActions(repoRoot, actions);
4983
- const { lostHookCommands } = await resyncSpecialCased(repoRoot, join(vendorRoot, "templates", target));
5151
+ const templateRoot = join(vendorRoot, "templates", target);
5152
+ const { lostHookCommands } = await resyncSpecialCased(repoRoot, templateRoot);
4984
5153
  const labelSync = inputs.runCommand ? await syncLabels(taskStorageRepo, createTaskTrackerProvider(config.task_storage.type, inputs.runCommand)) : null;
4985
5154
  await writeBaseline(repoRoot, toVersion, vendorNew);
4986
5155
  await writeConfigValues(repoRoot, configBumps);
@@ -5014,7 +5183,8 @@ const applyActions = async (repoRoot, actions) => {
5014
5183
  default: return;
5015
5184
  }
5016
5185
  }));
5017
- await pruneEmptyDirs(repoRoot, actions.filter((action) => action.kind === "prune").map((action) => action.relPath));
5186
+ const prunedPaths = actions.filter((action) => action.kind === "prune").map((action) => action.relPath);
5187
+ await pruneEmptyDirs(repoRoot, prunedPaths);
5018
5188
  };
5019
5189
  const summarize = (actions) => {
5020
5190
  const conflictedFiles = [];
@@ -5044,12 +5214,10 @@ const summarize = (actions) => {
5044
5214
  winner: "vendor"
5045
5215
  });
5046
5216
  break;
5047
- case "pick-client":
5048
- pickedFiles.push({
5049
- relPath: action.relPath,
5050
- winner: "client"
5051
- });
5052
- break;
5217
+ case "pick-client": pickedFiles.push({
5218
+ relPath: action.relPath,
5219
+ winner: "client"
5220
+ });
5053
5221
  }
5054
5222
  return {
5055
5223
  conflictedFiles,
@@ -5155,11 +5323,12 @@ const runRollback = async (options) => {
5155
5323
  await assertNoSymlinkTraversal(repoRoot, [...restorePaths, ...removePaths]);
5156
5324
  await Promise.all(bundle.working.map((file) => writeManaged(repoRoot, file.relPath, file.content, file.executable)));
5157
5325
  await Promise.all(removePaths.map((path) => rm(join(repoRoot, path), { force: true })));
5158
- await writeBaseline(repoRoot, target, [...bundle.baseline].map(([relPath, content]) => ({
5326
+ const baselineFiles = [...bundle.baseline].map(([relPath, content]) => ({
5159
5327
  relPath,
5160
5328
  content,
5161
5329
  executable: false
5162
- })));
5330
+ }));
5331
+ await writeBaseline(repoRoot, target, baselineFiles);
5163
5332
  await writeConfigValues(repoRoot, { vendor_version: target });
5164
5333
  return {
5165
5334
  repoRoot,
@@ -5355,6 +5524,31 @@ const makeRunCommand = () => async (cmd, args) => {
5355
5524
  };
5356
5525
  }
5357
5526
  };
5527
+ const makeVerifierRunCommand = () => (cmd, args) => new Promise((resolveResult) => {
5528
+ const child = spawn(cmd, args, { stdio: [
5529
+ "ignore",
5530
+ "pipe",
5531
+ "pipe"
5532
+ ] });
5533
+ const out = [];
5534
+ const err = [];
5535
+ child.stdout.on("data", (chunk) => out.push(chunk));
5536
+ child.stderr.on("data", (chunk) => err.push(chunk));
5537
+ child.on("error", (error) => {
5538
+ resolveResult({
5539
+ code: error.code === "ENOENT" ? 127 : 1,
5540
+ stdout: Buffer.concat(out).toString("utf8"),
5541
+ stderr: `${Buffer.concat(err).toString("utf8")}${error.message}\n`
5542
+ });
5543
+ });
5544
+ child.on("close", (code, signal) => {
5545
+ resolveResult({
5546
+ code: code ?? 1,
5547
+ stdout: Buffer.concat(out).toString("utf8"),
5548
+ stderr: `${Buffer.concat(err).toString("utf8")}${signal ? `killed by ${signal}\n` : ""}`
5549
+ });
5550
+ });
5551
+ });
5358
5552
  const buildResolveDeps = () => {
5359
5553
  const rl = createInterface({
5360
5554
  input: stdin,
@@ -5379,10 +5573,10 @@ const gitBehind = async (repoRoot) => {
5379
5573
  repoRoot,
5380
5574
  "symbolic-ref",
5381
5575
  "--quiet",
5382
- "--short",
5383
5576
  "HEAD"
5384
5577
  ]);
5385
- const branch = branchResult.code === 0 ? branchResult.stdout.trim() || null : null;
5578
+ const ref = branchResult.code === 0 ? branchResult.stdout.trim() : "";
5579
+ const branch = ref.startsWith("refs/heads/") ? ref.slice(11) || null : null;
5386
5580
  const headRef = await run("git", [
5387
5581
  "-C",
5388
5582
  repoRoot,
@@ -5700,16 +5894,18 @@ const telemetryEnable = async (repoRoot) => {
5700
5894
  for (const line of formatTelemetryStatus(report)) console.log(line);
5701
5895
  };
5702
5896
  const telemetrySend = async (repoRoot) => {
5897
+ const endpoint = env["LEMONY_TELEMETRY_ENDPOINT"] ?? "https://lemony-telemetry.lemoncode.workers.dev";
5703
5898
  const result = await sendTelemetry({
5704
5899
  repoRoot,
5705
- endpoint: env["LEMONY_TELEMETRY_ENDPOINT"] ?? "https://lemony-telemetry.lemoncode.workers.dev"
5900
+ endpoint
5706
5901
  });
5707
5902
  console.log(`telemetry ${result.outcome} (${result.segmentsSent} sent, ${result.quarantined} quarantined)`);
5708
5903
  };
5709
5904
  const telemetryFlush = async (repoRoot) => {
5905
+ const endpoint = env["LEMONY_TELEMETRY_ENDPOINT"] ?? "https://lemony-telemetry.lemoncode.workers.dev";
5710
5906
  const result = await sendTelemetry({
5711
5907
  repoRoot,
5712
- endpoint: env["LEMONY_TELEMETRY_ENDPOINT"] ?? "https://lemony-telemetry.lemoncode.workers.dev"
5908
+ endpoint
5713
5909
  });
5714
5910
  for (const line of formatTelemetryFlush(result)) console.log(line);
5715
5911
  };
@@ -5764,20 +5960,41 @@ const designTokensValidate = async (args) => {
5764
5960
  exit(1);
5765
5961
  };
5766
5962
  const designTokensContrast = async () => {
5767
- const result = await runContrast({ repoRoot: cwd() });
5963
+ const verify = (await pathExists(join(cwd(), "harness.config.yml")) ? await readHarnessConfig(cwd()) : void 0)?.design_tokens.verify;
5964
+ const result = await runContrast({
5965
+ repoRoot: cwd(),
5966
+ ...verify === void 0 ? {} : { verify: {
5967
+ command: verify,
5968
+ run: makeVerifierRunCommand()
5969
+ } }
5970
+ });
5768
5971
  if (!result.tokensFound) {
5769
5972
  console.log(`No docs/design-tokens.json — design-tokens contrast skipped (consume-if-exists; the harness never creates it).`);
5770
5973
  return;
5771
5974
  }
5772
5975
  const failures = result.pairs.filter((pair) => !pair.passes);
5976
+ const forwardVerifier = () => {
5977
+ if (result.verify === void 0) return;
5978
+ const { stdout: out, stderr: err } = result.verify;
5979
+ if (out.length > 0) stdout.write(out.endsWith("\n") ? out : `${out}\n`);
5980
+ if (err.length > 0) stderr.write(err.endsWith("\n") ? err : `${err}\n`);
5981
+ };
5773
5982
  if (result.ok) {
5774
5983
  console.log(result.pairsChecked === 0 ? `design-tokens contrast: no foreground/background pairs found (use the on-* convention or $extensions["com.lemony.contrast"]).` : `design-tokens contrast OK: ${result.pairsChecked} pair(s) meet their WCAG floor.`);
5984
+ forwardVerifier();
5985
+ if (result.verify !== void 0) console.log(`design-tokens verify OK: \`${result.verify.command}\` exited 0.`);
5775
5986
  return;
5776
5987
  }
5777
- console.error(`design-tokens contrast found ${failures.length + result.problems.length} issue(s):`);
5778
- for (const pair of failures) console.error(` ${pair.foreground} on ${pair.background} (${pair.mode}, ${pair.level}): ${pair.ratio}:1 < ${pair.floor}:1`);
5988
+ const failedVerifier = result.verify !== void 0 && result.verify.code !== 0 ? result.verify : void 0;
5989
+ console.error(`design-tokens contrast found ${failures.length + result.problems.length + (failedVerifier ? 1 : 0)} issue(s):`);
5990
+ for (const pair of failures) {
5991
+ const over = pair.over === void 0 ? "" : ` over ${pair.over}`;
5992
+ console.error(` ${pair.foreground} on ${pair.background}${over} (${pair.mode}, ${pair.level}): ${pair.ratio}:1 < ${pair.floor}:1`);
5993
+ }
5779
5994
  for (const problem of result.problems) console.error(` ${problem}`);
5780
- exit(1);
5995
+ forwardVerifier();
5996
+ if (failedVerifier) console.error(` design_tokens.verify \`${failedVerifier.command}\` exited ${failedVerifier.code}.`);
5997
+ process.exitCode = 1;
5781
5998
  };
5782
5999
  const designTokensImport = async (args) => {
5783
6000
  const from = parseFlag(args, "from");