@lemoncode/lemony 0.4.1 → 0.5.1

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,14 +1,14 @@
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
8
  import { parse, parseDocument } from "yaml";
9
+ import { constants } from "node:fs";
9
10
  import { z } from "zod";
10
11
  import { createHash, randomBytes } from "node:crypto";
11
- import { constants } from "node:fs";
12
12
  //#region src/events/append.ts
13
13
  const appendEvent = async (eventsPath, event) => {
14
14
  await mkdir(dirname(eventsPath), { recursive: true });
@@ -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"];
@@ -46,6 +47,136 @@ const PATHS_DEFAULTS = {
46
47
  playbooks_global: "~/.claude/playbooks"
47
48
  };
48
49
  //#endregion
50
+ //#region src/fs/exists.ts
51
+ const pathExists = async (path) => {
52
+ try {
53
+ await stat(path);
54
+ return true;
55
+ } catch {
56
+ return false;
57
+ }
58
+ };
59
+ const entryExists = async (path) => {
60
+ try {
61
+ await lstat(path);
62
+ return true;
63
+ } catch (error) {
64
+ if (error.code === "ENOENT") return false;
65
+ throw error;
66
+ }
67
+ };
68
+ //#endregion
69
+ //#region src/fs/list-files.ts
70
+ const listFiles = async (dir) => {
71
+ const entries = await readdir(dir, { withFileTypes: true });
72
+ return (await Promise.all(entries.map(async (entry) => {
73
+ if (entry.isDirectory()) return (await listFiles(join(dir, entry.name))).map((rel) => join(entry.name, rel));
74
+ return [entry.name];
75
+ }))).flat();
76
+ };
77
+ //#endregion
78
+ //#region src/fs/prune-empty-dirs.ts
79
+ const pruneEmptyDirs = async (repoRoot, removedFiles) => {
80
+ const dirs = /* @__PURE__ */ new Set();
81
+ for (const relPath of removedFiles) {
82
+ let dir = dirname(relPath);
83
+ while (dir !== "." && dir !== sep) {
84
+ dirs.add(dir);
85
+ dir = dirname(dir);
86
+ }
87
+ }
88
+ const ordered = [...dirs].toSorted((a, b) => b.length - a.length);
89
+ for (const dir of ordered) try {
90
+ await rmdir(join(repoRoot, dir));
91
+ } catch {}
92
+ };
93
+ //#endregion
94
+ //#region src/fs/fs.constant.ts
95
+ const SPECIAL_FILE_REFUSAL = "the entry is not a regular file (a FIFO, a socket or a device), so it was not read.";
96
+ const DANGLING_WRITE_REFUSAL = "the entry is a symlink whose target is missing, so it was not written (writing through it would create the file it points at).";
97
+ const SPECIAL_FILE_WRITE_REFUSAL = "the entry is not a regular file (a FIFO, a socket or a device), so it was not written.";
98
+ //#endregion
99
+ //#region src/fs/read-regular-file.ts
100
+ const readRegularFile = async (path) => {
101
+ let handle;
102
+ try {
103
+ handle = await open(path, constants.O_RDONLY | constants.O_NONBLOCK);
104
+ } catch (error) {
105
+ const target = await stat(path).catch(() => void 0);
106
+ if (target && !target.isFile() && !target.isDirectory()) throw new Error(SPECIAL_FILE_REFUSAL, { cause: error });
107
+ throw error;
108
+ }
109
+ try {
110
+ const target = await handle.stat();
111
+ if (!target.isFile() && !target.isDirectory()) throw new Error(SPECIAL_FILE_REFUSAL);
112
+ return await handle.readFile("utf8");
113
+ } finally {
114
+ await handle.close();
115
+ }
116
+ };
117
+ //#endregion
118
+ //#region src/fs/write-managed.ts
119
+ const writeManaged = async (root, relPath, content, executable) => {
120
+ const dest = join(root, relPath);
121
+ await mkdir(dirname(dest), { recursive: true });
122
+ await writeFile(dest, content);
123
+ await chmod(dest, executable ? 493 : 420);
124
+ };
125
+ //#endregion
126
+ //#region src/fs/write-regular-file.ts
127
+ const writeRegularFile = async (path, text) => {
128
+ let handle;
129
+ try {
130
+ handle = await openForWrite(path);
131
+ } catch (error) {
132
+ const target = await stat(path).catch(() => void 0);
133
+ if (target && !target.isFile() && !target.isDirectory()) throw new Error(SPECIAL_FILE_WRITE_REFUSAL, { cause: error });
134
+ throw error;
135
+ }
136
+ try {
137
+ if (!(await handle.stat()).isFile()) throw new Error(SPECIAL_FILE_WRITE_REFUSAL);
138
+ await handle.writeFile(text, "utf8");
139
+ } finally {
140
+ await handle.close();
141
+ }
142
+ };
143
+ const WRITE = constants.O_WRONLY | constants.O_TRUNC | constants.O_NONBLOCK;
144
+ const openForWrite = async (path) => {
145
+ try {
146
+ return await open(path, WRITE);
147
+ } catch (error) {
148
+ if (error.code !== "ENOENT") throw error;
149
+ }
150
+ try {
151
+ return await open(path, WRITE | constants.O_CREAT | constants.O_EXCL);
152
+ } catch (error) {
153
+ const link = await lstat(path).catch(() => void 0);
154
+ if (error.code === "EEXIST" && link?.isSymbolicLink()) throw new Error(DANGLING_WRITE_REFUSAL, { cause: error });
155
+ throw error;
156
+ }
157
+ };
158
+ //#endregion
159
+ //#region src/fs/symlink-guard.ts
160
+ const assertNoSymlinkTraversal = async (root, relPaths) => {
161
+ const components = /* @__PURE__ */ new Set();
162
+ for (const relPath of relPaths) {
163
+ let current = "";
164
+ for (const part of relPath.split(sep)) {
165
+ if (part.length === 0) continue;
166
+ current = current ? join(current, part) : part;
167
+ components.add(current);
168
+ }
169
+ }
170
+ const offenders = (await Promise.all([...components].map(async (rel) => {
171
+ try {
172
+ return (await lstat(join(root, rel))).isSymbolicLink() ? rel : null;
173
+ } catch {
174
+ return null;
175
+ }
176
+ }))).filter((rel) => rel !== null);
177
+ if (offenders.length > 0) throw new Error("Refusing to write — a managed path traverses a symlink (a redirect could land outside the repo):\n" + offenders.map((rel) => ` ${rel}`).join("\n"));
178
+ };
179
+ //#endregion
49
180
  //#region src/config/config-error.ts
50
181
  const formatConfigError = (schema, error) => {
51
182
  return error.issues.flatMap((issue) => formatIssue(schema, issue)).join("\n");
@@ -120,7 +251,10 @@ const taskStorageSchema = z.object({
120
251
  }).strict();
121
252
  const rollbackSchema = z.object({ keep_snapshots: z.union([z.int().positive(), z.literal("unlimited")]).default(3) }).strict().prefault({});
122
253
  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({});
254
+ const designTokensSchema = z.object({
255
+ scan_extensions: z.array(z.string()).default([]),
256
+ verify: z.string().trim().min(1).optional()
257
+ }).strict().prefault({});
124
258
  const mergeSchema = z.object({
125
259
  checks_timeout_secs: z.int().positive().default(600),
126
260
  allow_no_checks: z.boolean().default(false)
@@ -145,7 +279,7 @@ const readHarnessConfig = async (repoRoot) => {
145
279
  const configPath = join(repoRoot, HARNESS_CONFIG_FILENAME);
146
280
  let raw;
147
281
  try {
148
- raw = await readFile(configPath, "utf8");
282
+ raw = await readRegularFile(configPath);
149
283
  } catch (cause) {
150
284
  if (cause.code === "ENOENT") throw new Error(`${HARNESS_CONFIG_FILENAME} not found at ${repoRoot}. Run \`lemony install\` first.`, { cause });
151
285
  throw new Error(`Cannot read ${HARNESS_CONFIG_FILENAME} at ${repoRoot}: ${cause instanceof Error ? cause.message : String(cause)}`, { cause });
@@ -175,6 +309,21 @@ const readHarnessConfig = async (repoRoot) => {
175
309
  if (!result.success) throw new Error(`${HARNESS_CONFIG_FILENAME} is invalid:\n${formatConfigError(harnessConfigSchema, result.error)}`);
176
310
  return result.data;
177
311
  };
312
+ const readOptionalHarnessConfig = async (repoRoot) => {
313
+ let present;
314
+ try {
315
+ present = await entryExists(join(repoRoot, HARNESS_CONFIG_FILENAME));
316
+ } catch (error) {
317
+ throw new Error(`Cannot read ${HARNESS_CONFIG_FILENAME} at ${repoRoot}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
318
+ }
319
+ if (!present) return void 0;
320
+ try {
321
+ return await readHarnessConfig(repoRoot);
322
+ } catch (error) {
323
+ if (error.cause?.code === "ENOENT") throw new Error(`Cannot read ${HARNESS_CONFIG_FILENAME} at ${repoRoot}: the entry is there but its target is missing (a broken symlink?).`, { cause: error });
324
+ throw error;
325
+ }
326
+ };
178
327
  //#endregion
179
328
  //#region src/config/compare-version.ts
180
329
  const PRERELEASE_RANK = {
@@ -257,8 +406,6 @@ const pointerScalar = z.union([
257
406
  z.null()
258
407
  ]).optional();
259
408
  const pointerFrontmatterSchema = z.object({
260
- active_task: pointerScalar,
261
- branch: pointerScalar,
262
409
  session_start_ts: pointerScalar,
263
410
  last_close_ts: pointerScalar
264
411
  });
@@ -993,70 +1140,6 @@ const failureReason$1 = (result) => {
993
1140
  return `\`gh\` failed (exit ${result.code})${suffix}${detail ? `: ${detail}` : ""}`;
994
1141
  };
995
1142
  //#endregion
996
- //#region src/fs/exists.ts
997
- const pathExists = async (path) => {
998
- try {
999
- await stat(path);
1000
- return true;
1001
- } catch {
1002
- return false;
1003
- }
1004
- };
1005
- //#endregion
1006
- //#region src/fs/list-files.ts
1007
- const listFiles = async (dir) => {
1008
- const entries = await readdir(dir, { withFileTypes: true });
1009
- return (await Promise.all(entries.map(async (entry) => {
1010
- if (entry.isDirectory()) return (await listFiles(join(dir, entry.name))).map((rel) => join(entry.name, rel));
1011
- return [entry.name];
1012
- }))).flat();
1013
- };
1014
- //#endregion
1015
- //#region src/fs/prune-empty-dirs.ts
1016
- const pruneEmptyDirs = async (repoRoot, removedFiles) => {
1017
- const dirs = /* @__PURE__ */ new Set();
1018
- for (const relPath of removedFiles) {
1019
- let dir = dirname(relPath);
1020
- while (dir !== "." && dir !== sep) {
1021
- dirs.add(dir);
1022
- dir = dirname(dir);
1023
- }
1024
- }
1025
- const ordered = [...dirs].toSorted((a, b) => b.length - a.length);
1026
- for (const dir of ordered) try {
1027
- await rmdir(join(repoRoot, dir));
1028
- } catch {}
1029
- };
1030
- //#endregion
1031
- //#region src/fs/write-managed.ts
1032
- const writeManaged = async (root, relPath, content, executable) => {
1033
- const dest = join(root, relPath);
1034
- await mkdir(dirname(dest), { recursive: true });
1035
- await writeFile(dest, content);
1036
- await chmod(dest, executable ? 493 : 420);
1037
- };
1038
- //#endregion
1039
- //#region src/fs/symlink-guard.ts
1040
- const assertNoSymlinkTraversal = async (root, relPaths) => {
1041
- const components = /* @__PURE__ */ new Set();
1042
- for (const relPath of relPaths) {
1043
- let current = "";
1044
- for (const part of relPath.split(sep)) {
1045
- if (part.length === 0) continue;
1046
- current = current ? join(current, part) : part;
1047
- components.add(current);
1048
- }
1049
- }
1050
- const offenders = (await Promise.all([...components].map(async (rel) => {
1051
- try {
1052
- return (await lstat(join(root, rel))).isSymbolicLink() ? rel : null;
1053
- } catch {
1054
- return null;
1055
- }
1056
- }))).filter((rel) => rel !== null);
1057
- if (offenders.length > 0) throw new Error("Refusing to write — a managed path traverses a symlink (a redirect could land outside the repo):\n" + offenders.map((rel) => ` ${rel}`).join("\n"));
1058
- };
1059
- //#endregion
1060
1143
  //#region src/design-tokens/design-tokens.constant.ts
1061
1144
  const DESIGN_TOKENS_FILE = "docs/design-tokens.json";
1062
1145
  const SCANNED_EXTENSIONS = [
@@ -1099,6 +1182,19 @@ const IGNORED_DIRS = /* @__PURE__ */ new Set([
1099
1182
  ".cache",
1100
1183
  ".claude"
1101
1184
  ]);
1185
+ const NESTED_TOO_DEEPLY = `${DESIGN_TOKENS_FILE} nests deeper than 64 levels, the most Lemony reads.`;
1186
+ //#endregion
1187
+ //#region src/design-tokens/read-token-file.ts
1188
+ const readOptionalTokenFile = async (repoRoot) => {
1189
+ const tokenPath = join(repoRoot, DESIGN_TOKENS_FILE);
1190
+ try {
1191
+ if (!await entryExists(tokenPath)) return void 0;
1192
+ return await readRegularFile(tokenPath);
1193
+ } catch (error) {
1194
+ if (error.code === "ENOENT") throw new Error(`Cannot read ${DESIGN_TOKENS_FILE} at ${repoRoot}: the entry is there but its target is missing (a broken symlink?).`, { cause: error });
1195
+ throw new Error(`Cannot read ${DESIGN_TOKENS_FILE} at ${repoRoot}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
1196
+ }
1197
+ };
1102
1198
  //#endregion
1103
1199
  //#region src/design-tokens/design-tokens.model.ts
1104
1200
  const TOKEN_TIERS = [
@@ -1107,6 +1203,46 @@ const TOKEN_TIERS = [
1107
1203
  "component"
1108
1204
  ];
1109
1205
  //#endregion
1206
+ //#region src/design-tokens/contrast.constant.ts
1207
+ const CONTRAST_EXTENSION = "com.lemony.contrast";
1208
+ const MODES_EXTENSION = "com.lemony.modes";
1209
+ const BASE_MODE = "base";
1210
+ const DEFAULT_LEVEL = "text";
1211
+ const WCAG_FLOORS = {
1212
+ text: 4.5,
1213
+ large: 3,
1214
+ "non-text": 3
1215
+ };
1216
+ //#endregion
1217
+ //#region src/design-tokens/nesting-depth.ts
1218
+ const exceedsNestingLimit = (value, limit = 64) => {
1219
+ const pending = [{
1220
+ node: value,
1221
+ depth: 1
1222
+ }];
1223
+ for (let next = pending.pop(); next !== void 0; next = pending.pop()) {
1224
+ const { node, depth } = next;
1225
+ if (typeof node !== "object" || node === null) continue;
1226
+ if (depth > limit) return true;
1227
+ for (const child of Object.values(node)) pending.push({
1228
+ node: child,
1229
+ depth: depth + 1
1230
+ });
1231
+ }
1232
+ return false;
1233
+ };
1234
+ //#endregion
1235
+ //#region src/design-tokens/non-finite-number.ts
1236
+ const nonFiniteNumberFaults = (value) => nonFiniteNumbers(value, []).map(({ path, number }) => `${DESIGN_TOKENS_FILE} has a number at "${path}" too large for JSON to carry: it reads as ${number}, and rewriting the file would turn it into null.`);
1237
+ const nonFiniteNumbers = (value, path) => {
1238
+ if (typeof value === "number") return Number.isFinite(value) ? [] : [{
1239
+ path: path.join("."),
1240
+ number: value
1241
+ }];
1242
+ if (typeof value !== "object" || value === null) return [];
1243
+ return Object.entries(value).flatMap(([key, child]) => nonFiniteNumbers(child, [...path, key]));
1244
+ };
1245
+ //#endregion
1110
1246
  //#region src/design-tokens/token-parse.ts
1111
1247
  const aliasTarget = (value) => {
1112
1248
  if (typeof value !== "string") return void 0;
@@ -1117,15 +1253,28 @@ const isRecord = (value) => typeof value === "object" && value !== null && !Arra
1117
1253
  //#endregion
1118
1254
  //#region src/design-tokens/design-tokens.ts
1119
1255
  const runValidate = async (inputs) => {
1120
- const tokenPath = join(inputs.repoRoot, DESIGN_TOKENS_FILE);
1121
- if (!await pathExists(tokenPath)) return {
1256
+ let raw;
1257
+ try {
1258
+ raw = await readOptionalTokenFile(inputs.repoRoot);
1259
+ } catch (error) {
1260
+ return {
1261
+ ok: false,
1262
+ tokensFound: true,
1263
+ tokenCount: 0,
1264
+ filesScanned: 0,
1265
+ violations: [{
1266
+ kind: "unreadable-token-file",
1267
+ message: error instanceof Error ? error.message : String(error)
1268
+ }]
1269
+ };
1270
+ }
1271
+ if (raw === void 0) return {
1122
1272
  ok: true,
1123
1273
  tokensFound: false,
1124
1274
  tokenCount: 0,
1125
1275
  filesScanned: 0,
1126
1276
  violations: []
1127
1277
  };
1128
- const raw = await readFile(tokenPath, "utf8");
1129
1278
  const fileCheck = validateTokenFile(raw);
1130
1279
  if (!fileCheck.ok) return {
1131
1280
  ok: false,
@@ -1135,6 +1284,19 @@ const runValidate = async (inputs) => {
1135
1284
  violations: fileCheck.violations
1136
1285
  };
1137
1286
  const scanRoot = inputs.scanDir ? join(inputs.repoRoot, inputs.scanDir) : inputs.repoRoot;
1287
+ if (inputs.scanDir) {
1288
+ const unreadable = await describeUnreadableDir(scanRoot);
1289
+ if (unreadable !== void 0) return {
1290
+ ok: false,
1291
+ tokensFound: true,
1292
+ tokenCount: fileCheck.tokenCount,
1293
+ filesScanned: 0,
1294
+ violations: [{
1295
+ kind: "unreadable-scan-root",
1296
+ message: `--scan=${inputs.scanDir} ${unreadable} — nothing was scanned.`
1297
+ }]
1298
+ };
1299
+ }
1138
1300
  const extensions = mergeExtensions(inputs.extraExtensions);
1139
1301
  const sourceViolations = await scanSource(inputs.repoRoot, scanRoot, extensions);
1140
1302
  return {
@@ -1145,6 +1307,13 @@ const runValidate = async (inputs) => {
1145
1307
  violations: sourceViolations.violations
1146
1308
  };
1147
1309
  };
1310
+ const summarizeTokenFileViolations = (raw) => summarizeMessages(tokenFileViolationMessages(raw));
1311
+ const tokenFileViolationMessages = (raw) => validateTokenFile(raw).violations.map((violation) => violation.message);
1312
+ const summarizeMessages = (messages) => {
1313
+ const [first, ...more] = messages;
1314
+ if (first === void 0) return void 0;
1315
+ return `${first}${more.length > 0 ? ` (and ${more.length} more)` : ""}`;
1316
+ };
1148
1317
  const validateTokenFile = (raw) => {
1149
1318
  let parsed;
1150
1319
  try {
@@ -1167,7 +1336,19 @@ const validateTokenFile = (raw) => {
1167
1336
  message: `${DESIGN_TOKENS_FILE} must be a JSON object with primitive/semantic/component tiers.`
1168
1337
  }]
1169
1338
  };
1339
+ if (exceedsNestingLimit(parsed)) return {
1340
+ ok: false,
1341
+ tokenCount: 0,
1342
+ violations: [{
1343
+ kind: "malformed-token-file",
1344
+ message: NESTED_TOO_DEEPLY
1345
+ }]
1346
+ };
1170
1347
  const violations = [];
1348
+ for (const message of nonFiniteNumberFaults(parsed)) violations.push({
1349
+ kind: "malformed-token-file",
1350
+ message
1351
+ });
1171
1352
  for (const key of Object.keys(parsed)) {
1172
1353
  if (key.startsWith("$")) continue;
1173
1354
  if (!TOKEN_TIERS.includes(key)) violations.push({
@@ -1195,6 +1376,10 @@ const validateTokenFile = (raw) => {
1195
1376
  violations
1196
1377
  };
1197
1378
  };
1379
+ const modeValues = (extensions) => {
1380
+ const modes = isRecord(extensions) ? extensions[MODES_EXTENSION] : void 0;
1381
+ return isRecord(modes) ? modes : {};
1382
+ };
1198
1383
  const collectTokens$1 = (node, tier, path, out, violations) => {
1199
1384
  for (const [key, child] of Object.entries(node)) {
1200
1385
  if (key.startsWith("$")) continue;
@@ -1209,7 +1394,8 @@ const collectTokens$1 = (node, tier, path, out, violations) => {
1209
1394
  if ("$value" in child) {
1210
1395
  out.set(childPath, {
1211
1396
  tier,
1212
- value: child.$value
1397
+ value: child.$value,
1398
+ modes: modeValues(child["$extensions"])
1213
1399
  });
1214
1400
  const stray = Object.keys(child).filter((k) => !k.startsWith("$"));
1215
1401
  if (stray.length > 0) violations.push({
@@ -1221,6 +1407,13 @@ const collectTokens$1 = (node, tier, path, out, violations) => {
1221
1407
  };
1222
1408
  const collectAliasViolations = (tokens) => {
1223
1409
  const violations = [];
1410
+ for (const [path, token] of tokens) for (const [mode, value] of Object.entries(token.modes)) {
1411
+ const alias = aliasTarget(value);
1412
+ if (alias !== void 0 && !tokens.has(alias)) violations.push({
1413
+ kind: "unresolved-alias",
1414
+ message: `token "${path}" references "{${alias}}" in mode "${mode}", which does not exist.`
1415
+ });
1416
+ }
1224
1417
  for (const [path, token] of tokens) {
1225
1418
  const alias = aliasTarget(token.value);
1226
1419
  if (alias === void 0) continue;
@@ -1243,9 +1436,16 @@ const collectAliasViolations = (tokens) => {
1243
1436
  message: `token "${path}" has an alias chain that never resolves to a literal value (alias cycle).`
1244
1437
  });
1245
1438
  }
1439
+ for (const [path, token] of tokens) for (const [mode, value] of Object.entries(token.modes)) {
1440
+ if (aliasTarget(value) === void 0) continue;
1441
+ if (aliasChainCycles(path, tokens, mode)) violations.push({
1442
+ kind: "cyclic-alias",
1443
+ message: `token "${path}" has an alias chain in mode "${mode}" that never resolves to a literal value (alias cycle).`
1444
+ });
1445
+ }
1246
1446
  return violations;
1247
1447
  };
1248
- const aliasChainCycles = (start, tokens) => {
1448
+ const aliasChainCycles = (start, tokens, mode) => {
1249
1449
  const seen = /* @__PURE__ */ new Set();
1250
1450
  let current = start;
1251
1451
  while (current !== void 0) {
@@ -1253,7 +1453,7 @@ const aliasChainCycles = (start, tokens) => {
1253
1453
  seen.add(current);
1254
1454
  const token = tokens.get(current);
1255
1455
  if (token === void 0) return false;
1256
- current = aliasTarget(token.value);
1456
+ current = aliasTarget(mode !== void 0 && Object.hasOwn(token.modes, mode) ? token.modes[mode] : token.value);
1257
1457
  }
1258
1458
  return false;
1259
1459
  };
@@ -1266,16 +1466,38 @@ const mergeExtensions = (extra) => {
1266
1466
  }
1267
1467
  return set;
1268
1468
  };
1469
+ const codeOf = (error) => error.code ?? String(error);
1470
+ const describeUnreadableDir = async (dir) => {
1471
+ try {
1472
+ await readdir(dir);
1473
+ } catch (error) {
1474
+ const code = codeOf(error);
1475
+ if (code === "ENOENT") return "does not exist";
1476
+ if (code === "ENOTDIR") return "is not a directory";
1477
+ return `cannot be listed (${code})`;
1478
+ }
1479
+ try {
1480
+ await access(dir, constants.R_OK | constants.X_OK);
1481
+ return;
1482
+ } catch (error) {
1483
+ return `can be listed but not searched (${codeOf(error)})`;
1484
+ }
1485
+ };
1269
1486
  const scanSource = async (repoRoot, scanRoot, extensions) => {
1270
1487
  if (!await pathExists(scanRoot)) return {
1271
1488
  filesScanned: 0,
1272
1489
  violations: []
1273
1490
  };
1274
- const files = (await collectSourceFiles(scanRoot, extensions)).toSorted();
1275
- const violations = [];
1491
+ const unlistable = [];
1492
+ const files = (await collectSourceFiles(scanRoot, extensions, unlistable)).toSorted();
1493
+ const violations = unlistable.map(({ dir, code }) => ({
1494
+ kind: "unreadable-source-dir",
1495
+ message: `directory cannot be listed (${code}) — nothing under it was scanned.`,
1496
+ file: relative(repoRoot, dir).split(sep).join("/") || "."
1497
+ })).toSorted((a, b) => a.file.localeCompare(b.file));
1276
1498
  let filesScanned = 0;
1277
1499
  for (const file of files) {
1278
- const content = await readFile(file, "utf8").catch(() => void 0);
1500
+ const content = await readRegularFile(file).catch(() => void 0);
1279
1501
  if (content === void 0) continue;
1280
1502
  filesScanned += 1;
1281
1503
  const relPath = relative(repoRoot, file).split(sep).join("/");
@@ -1286,13 +1508,22 @@ const scanSource = async (repoRoot, scanRoot, extensions) => {
1286
1508
  violations
1287
1509
  };
1288
1510
  };
1289
- const collectSourceFiles = async (dir, extensions) => {
1290
- const entries = await readdir(dir, { withFileTypes: true });
1511
+ const collectSourceFiles = async (dir, extensions, unlistable) => {
1512
+ let entries;
1513
+ try {
1514
+ entries = await readdir(dir, { withFileTypes: true });
1515
+ } catch (error) {
1516
+ unlistable.push({
1517
+ dir,
1518
+ code: codeOf(error)
1519
+ });
1520
+ return [];
1521
+ }
1291
1522
  return (await Promise.all(entries.map(async (entry) => {
1292
1523
  const full = join(dir, entry.name);
1293
1524
  if (entry.isDirectory()) {
1294
1525
  if (IGNORED_DIRS.has(entry.name)) return [];
1295
- return collectSourceFiles(full, extensions);
1526
+ return collectSourceFiles(full, extensions, unlistable);
1296
1527
  }
1297
1528
  return extensions.has(extname(entry.name).toLowerCase()) ? [full] : [];
1298
1529
  }))).flat();
@@ -1450,6 +1681,7 @@ const flatten = (color, backdrop) => ({
1450
1681
  b: color.b * color.a + backdrop.b * (1 - color.a),
1451
1682
  a: 1
1452
1683
  });
1684
+ const compositeOver = (color, ground) => flatten(color, ground.a < 1 ? flatten(ground, WHITE) : ground);
1453
1685
  const linearizeChannel = (channel) => {
1454
1686
  const s = channel / 255;
1455
1687
  return s <= .03928 ? s / 12.92 : ((s + .055) / 1.055) ** 2.4;
@@ -1465,17 +1697,6 @@ const contrastRatio = (foreground, background) => {
1465
1697
  return Math.round((light + .05) / (dark + .05) * 100) / 100;
1466
1698
  };
1467
1699
  //#endregion
1468
- //#region src/design-tokens/contrast.constant.ts
1469
- const CONTRAST_EXTENSION = "com.lemony.contrast";
1470
- const MODES_EXTENSION = "com.lemony.modes";
1471
- const BASE_MODE = "base";
1472
- const DEFAULT_LEVEL = "text";
1473
- const WCAG_FLOORS = {
1474
- text: 4.5,
1475
- large: 3,
1476
- "non-text": 3
1477
- };
1478
- //#endregion
1479
1700
  //#region src/design-tokens/contrast.model.ts
1480
1701
  const CONTRAST_LEVELS = [
1481
1702
  "text",
@@ -1485,36 +1706,57 @@ const CONTRAST_LEVELS = [
1485
1706
  //#endregion
1486
1707
  //#region src/design-tokens/contrast.ts
1487
1708
  const runContrast = async (inputs) => {
1488
- const tokenPath = join(inputs.repoRoot, DESIGN_TOKENS_FILE);
1489
- if (!await pathExists(tokenPath)) return emptyResult$1(false);
1490
- const raw = await readFile(tokenPath, "utf8");
1709
+ let raw;
1710
+ let unreadable;
1711
+ try {
1712
+ raw = await readOptionalTokenFile(inputs.repoRoot);
1713
+ } catch (error) {
1714
+ unreadable = error instanceof Error ? error.message : String(error);
1715
+ }
1716
+ const verify = inputs.verify ? await runVerifier(inputs.verify) : void 0;
1717
+ const withVerify = (result) => ({
1718
+ ...result,
1719
+ ok: result.ok && (verify === void 0 || verify.code === 0),
1720
+ ...verify === void 0 ? {} : { verify }
1721
+ });
1722
+ if (unreadable !== void 0) return withVerify({
1723
+ ...emptyResult$1(true),
1724
+ ok: false,
1725
+ problems: [unreadable]
1726
+ });
1727
+ if (raw === void 0) return withVerify(emptyResult$1(false));
1491
1728
  let parsed;
1492
1729
  try {
1493
1730
  parsed = JSON.parse(raw);
1494
1731
  } catch (error) {
1495
- return {
1732
+ return withVerify({
1496
1733
  ...emptyResult$1(true),
1497
1734
  ok: false,
1498
1735
  problems: [`${DESIGN_TOKENS_FILE} is not valid JSON (${error instanceof Error ? error.message : String(error)}) — run \`design-tokens validate\` first.`]
1499
- };
1736
+ });
1500
1737
  }
1501
- if (!isRecord(parsed)) return {
1738
+ if (!isRecord(parsed)) return withVerify({
1502
1739
  ...emptyResult$1(true),
1503
1740
  ok: false,
1504
1741
  problems: [`${DESIGN_TOKENS_FILE} must be a JSON object — run \`design-tokens validate\` first.`]
1505
- };
1742
+ });
1743
+ if (exceedsNestingLimit(parsed)) return withVerify({
1744
+ ...emptyResult$1(true),
1745
+ ok: false,
1746
+ problems: [`${NESTED_TOO_DEEPLY} Run \`design-tokens validate\` first.`]
1747
+ });
1506
1748
  const tokens = collectTokens(parsed);
1507
1749
  const problems = [];
1508
- const specs = discoverPairs(tokens, problems);
1509
1750
  const modes = declaredModes(tokens);
1751
+ const specs = discoverPairs(tokens, modes, problems);
1510
1752
  const pairs = measurePairs(specs, modes, tokens);
1511
- return {
1753
+ return withVerify({
1512
1754
  ok: problems.length === 0 && pairs.every((pair) => pair.passes),
1513
1755
  tokensFound: true,
1514
1756
  pairsChecked: pairs.length,
1515
1757
  pairs,
1516
1758
  problems
1517
- };
1759
+ });
1518
1760
  };
1519
1761
  const emptyResult$1 = (tokensFound) => ({
1520
1762
  ok: true,
@@ -1552,21 +1794,19 @@ const declaredModes = (tokens) => {
1552
1794
  for (const token of tokens.values()) {
1553
1795
  const modes = readExtension(token.node, MODES_EXTENSION);
1554
1796
  if (isRecord(modes)) {
1555
- for (const name of Object.keys(modes)) if (name.trim().length > 0) names.add(name);
1797
+ for (const name of Object.keys(modes)) if (name.trim().length > 0 && name !== "base") names.add(name);
1556
1798
  }
1557
1799
  }
1558
1800
  return [...names].toSorted();
1559
1801
  };
1560
- const discoverPairs = (tokens, problems) => {
1561
- const merged = /* @__PURE__ */ new Map();
1562
- const add = (spec) => {
1563
- merged.set(`${spec.foreground}|${spec.background}`, spec);
1564
- };
1565
- for (const spec of conventionPairs(tokens)) add(spec);
1566
- for (const spec of extensionPairs(tokens, problems)) add(spec);
1567
- return [...merged.values()];
1802
+ const pairKey = (spec) => `${spec.foreground}|${spec.background}`;
1803
+ const discoverPairs = (tokens, modes, problems) => {
1804
+ const convention = new Map(conventionPairs(tokens, modes, problems).map((spec) => [pairKey(spec), spec]));
1805
+ const explicit = extensionPairs(tokens, modes, problems);
1806
+ for (const spec of explicit) convention.delete(pairKey(spec));
1807
+ return [...convention.values(), ...explicit];
1568
1808
  };
1569
- const conventionPairs = (tokens) => {
1809
+ const conventionPairs = (tokens, modes, problems) => {
1570
1810
  const pairs = [];
1571
1811
  for (const [path, token] of tokens) {
1572
1812
  if (token.tier !== "semantic") continue;
@@ -1577,81 +1817,212 @@ const conventionPairs = (tokens) => {
1577
1817
  if (!tokens.has(basePath)) continue;
1578
1818
  if (!resolveColor(path, "base", tokens)) continue;
1579
1819
  if (!resolveColor(basePath, "base", tokens)) continue;
1820
+ const broken = [{
1821
+ end: path,
1822
+ other: basePath
1823
+ }, {
1824
+ end: basePath,
1825
+ other: path
1826
+ }].flatMap(({ end, other }) => modes.flatMap((mode) => {
1827
+ const fault = modeAliasBreak(end, mode, tokens);
1828
+ if (fault === void 0) return [];
1829
+ return fault === "too-deep" ? [`token "${end}" has a "${mode}" mode alias chain of 64 hops or more, which contrast does not follow, so its convention pair with "${other}" is not measured — shorten the chain.`] : [`token "${end}" has a "${mode}" mode alias that does not resolve (a missing token or an alias cycle), so its convention pair with "${other}" is not measured — run \`design-tokens validate\`.`];
1830
+ }));
1831
+ if (broken.length > 0) {
1832
+ problems.push(...broken);
1833
+ continue;
1834
+ }
1580
1835
  pairs.push({
1581
1836
  foreground: path,
1582
1837
  background: basePath,
1583
1838
  level: DEFAULT_LEVEL,
1839
+ floors: {},
1584
1840
  source: "convention"
1585
1841
  });
1586
1842
  }
1587
1843
  return pairs;
1588
1844
  };
1589
- const extensionPairs = (tokens, problems) => {
1845
+ const DECLARATION_SHAPE = "{ against: \"{token.path}\" | [\"{token.path}\", …], level?, floors?, modes?, over? }";
1846
+ const DECLARATION_KEYS = /* @__PURE__ */ new Set([
1847
+ "against",
1848
+ "level",
1849
+ "floors",
1850
+ "modes",
1851
+ "over"
1852
+ ]);
1853
+ const extensionPairs = (tokens, modes, problems) => {
1590
1854
  const pairs = [];
1591
1855
  for (const [path, token] of tokens) {
1592
1856
  const declaration = readExtension(token.node, CONTRAST_EXTENSION);
1593
1857
  if (declaration === void 0) continue;
1594
1858
  const entries = Array.isArray(declaration) ? declaration : [declaration];
1859
+ const declared = /* @__PURE__ */ new Set();
1595
1860
  for (const entry of entries) {
1596
- if (!isRecord(entry) || typeof entry["against"] !== "string") {
1597
- problems.push(`token "${path}" has a malformed ${CONTRAST_EXTENSION} declaration — expected { against: "{token.path}", level? }.`);
1598
- continue;
1599
- }
1600
- const against = aliasTarget(entry["against"]) ?? entry["against"];
1601
- if (against === path) {
1602
- problems.push(`token "${path}" declares contrast against itself — a pair needs two different tokens.`);
1861
+ if (!isRecord(entry)) {
1862
+ problems.push(`token "${path}" has a malformed ${CONTRAST_EXTENSION} declaration — expected ${DECLARATION_SHAPE}.`);
1603
1863
  continue;
1604
1864
  }
1605
- if (!tokens.has(against)) {
1606
- problems.push(`token "${path}" contrast-against "${entry["against"]}" does not resolve to a known token.`);
1607
- continue;
1608
- }
1609
- if (!resolveColor(against, "base", tokens)) {
1610
- problems.push(`token "${path}" contrast-against "${entry["against"]}" is not a colour token.`);
1865
+ const unknownKeys = Object.keys(entry).filter((key) => !DECLARATION_KEYS.has(key));
1866
+ if (unknownKeys.length > 0) {
1867
+ problems.push(`token "${path}" has unknown ${CONTRAST_EXTENSION} key(s) ${unknownKeys.map((key) => `"${key}"`).join(", ")} — expected ${DECLARATION_SHAPE}.`);
1611
1868
  continue;
1612
1869
  }
1870
+ const before = problems.length;
1871
+ const backgrounds = tokenPathList(entry["against"], path, "against", problems);
1872
+ if (backgrounds.length === 0) continue;
1613
1873
  if (!resolveColor(path, "base", tokens)) {
1614
1874
  problems.push(`token "${path}" declares a contrast pair but is not itself a colour token.`);
1615
1875
  continue;
1616
1876
  }
1617
- pairs.push({
1618
- foreground: path,
1619
- background: against,
1620
- level: normalizeLevel(entry["level"], path, problems),
1621
- source: "extension"
1622
- });
1877
+ const level = normalizeLevel(entry["level"], path, problems);
1878
+ const floors = normalizeFloors(entry["floors"], path, modes, problems);
1879
+ const ruleModes = normalizeModes(entry["modes"], path, modes, problems);
1880
+ const measuredModes = (ruleModes ?? modes).filter((mode) => mode !== BASE_MODE);
1881
+ const ownDefects = unresolvedModes(path, measuredModes, tokens);
1882
+ for (const mode of ownDefects) {
1883
+ const problem = `token "${path}" declares a contrast pair but its "${mode}" mode override does not resolve to a colour.`;
1884
+ if (!problems.includes(problem)) problems.push(problem);
1885
+ }
1886
+ if (ruleModes !== void 0) {
1887
+ 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.`);
1888
+ }
1889
+ const grounds = entry["over"] === void 0 ? [] : tokenPathList(entry["over"], path, "over", problems);
1890
+ const colourGrounds = [];
1891
+ 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.`);
1892
+ else if (isColourTarget(ground, path, "over", measuredModes, tokens, problems)) colourGrounds.push(ground);
1893
+ const colourBackgrounds = [];
1894
+ for (const against of backgrounds) if (against === path) problems.push(`token "${path}" declares contrast against itself — a pair needs two different tokens.`);
1895
+ else if (isColourTarget(against, path, "against", measuredModes, tokens, problems)) colourBackgrounds.push(against);
1896
+ if (ownDefects.length > 0 || problems.length > before) continue;
1897
+ for (const background of colourBackgrounds) for (const over of colourGrounds.length === 0 ? [void 0] : colourGrounds) {
1898
+ const key = `${background}|${over ?? ""}`;
1899
+ if (declared.has(key)) problems.push(`token "${path}" declares contrast against "${background}"${over === void 0 ? "" : ` over "${over}"`} more than once — one declaration per pair.`);
1900
+ declared.add(key);
1901
+ }
1902
+ if (ownDefects.length > 0 || problems.length > before) continue;
1903
+ for (const background of colourBackgrounds) {
1904
+ const base = {
1905
+ foreground: path,
1906
+ background,
1907
+ level,
1908
+ floors,
1909
+ source: "extension"
1910
+ };
1911
+ const withModes = ruleModes === void 0 ? base : {
1912
+ ...base,
1913
+ modes: ruleModes
1914
+ };
1915
+ if (colourGrounds.length === 0) pairs.push(withModes);
1916
+ else for (const over of colourGrounds) pairs.push({
1917
+ ...withModes,
1918
+ over
1919
+ });
1920
+ }
1623
1921
  }
1624
1922
  }
1625
1923
  return pairs;
1626
1924
  };
1925
+ const tokenPathList = (raw, path, field, problems) => {
1926
+ const list = Array.isArray(raw) ? raw : [raw];
1927
+ if (list.length === 0 || !list.every((item) => typeof item === "string")) {
1928
+ 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}).`);
1929
+ return [];
1930
+ }
1931
+ return list.map((item) => aliasTarget(item) ?? item);
1932
+ };
1933
+ const isColourTarget = (target, path, field, modes, tokens, problems) => {
1934
+ if (!tokens.has(target)) {
1935
+ problems.push(`token "${path}" contrast-${field} "${target}" does not resolve to a known token.`);
1936
+ return false;
1937
+ }
1938
+ if (!resolveColor(target, "base", tokens)) {
1939
+ problems.push(`token "${path}" contrast-${field} "${target}" is not a colour token.`);
1940
+ return false;
1941
+ }
1942
+ const unresolved = unresolvedModes(target, modes, tokens);
1943
+ for (const mode of unresolved) problems.push(`token "${path}" contrast-${field} "${target}" has a "${mode}" mode override that does not resolve to a colour.`);
1944
+ return unresolved.length === 0;
1945
+ };
1946
+ const unresolvedModes = (target, modes, tokens) => modes.filter((mode) => resolveColor(target, mode, tokens) === void 0);
1627
1947
  const normalizeLevel = (raw, path, problems) => {
1628
1948
  if (raw === void 0) return DEFAULT_LEVEL;
1629
1949
  if (typeof raw === "string" && CONTRAST_LEVELS.includes(raw)) return raw;
1630
1950
  problems.push(`token "${path}" has an unknown contrast level "${String(raw)}" — use ${CONTRAST_LEVELS.join("/")}.`);
1631
1951
  return DEFAULT_LEVEL;
1632
1952
  };
1953
+ const normalizeFloors = (raw, path, modes, problems) => {
1954
+ if (raw === void 0) return {};
1955
+ if (!isRecord(raw)) {
1956
+ problems.push(`token "${path}" has malformed contrast floors — expected { base?: number, <mode>?: number }.`);
1957
+ return {};
1958
+ }
1959
+ const floors = Object.create(null);
1960
+ for (const [mode, value] of Object.entries(raw)) {
1961
+ if (mode !== "base" && !modes.includes(mode)) {
1962
+ problems.push(`token "${path}" sets a contrast floor for mode "${mode}", which no ${MODES_EXTENSION} block declares — use ${[BASE_MODE, ...modes].join("/")}.`);
1963
+ continue;
1964
+ }
1965
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 1 || value > 21) {
1966
+ problems.push(`token "${path}" has an invalid contrast floor for mode "${mode}" (${String(value)}) — a WCAG ratio is a number between 1 and 21.`);
1967
+ continue;
1968
+ }
1969
+ floors[mode] = value;
1970
+ }
1971
+ return floors;
1972
+ };
1973
+ const normalizeModes = (raw, path, modes, problems) => {
1974
+ if (raw === void 0) return void 0;
1975
+ if (!Array.isArray(raw) || raw.length === 0 || !raw.every((item) => typeof item === "string")) {
1976
+ problems.push(`token "${path}" has malformed contrast modes — expected a non-empty array of mode names.`);
1977
+ return;
1978
+ }
1979
+ const unknown = raw.filter((mode) => mode !== "base" && !modes.includes(mode));
1980
+ if (unknown.length > 0) {
1981
+ 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("/")}.`);
1982
+ return;
1983
+ }
1984
+ return [...new Set(raw)];
1985
+ };
1986
+ const floorFor = (spec, mode) => ownFloor(spec.floors, mode) ?? ownFloor(spec.floors, "base") ?? WCAG_FLOORS[spec.level];
1987
+ const ownFloor = (floors, mode) => Object.hasOwn(floors, mode) ? floors[mode] : void 0;
1633
1988
  const measurePairs = (specs, modes, tokens) => {
1634
1989
  const rows = [];
1635
1990
  for (const spec of specs) {
1636
- const baseFg = resolveColor(spec.foreground, BASE_MODE, tokens);
1637
- const baseBg = resolveColor(spec.background, BASE_MODE, tokens);
1638
- if (!baseFg || !baseBg) continue;
1639
- rows.push(makeRow(spec, BASE_MODE, baseFg, baseBg));
1640
- for (const mode of modes) {
1641
- const fg = resolveColor(spec.foreground, mode, tokens) ?? baseFg;
1642
- const bg = resolveColor(spec.background, mode, tokens) ?? baseBg;
1643
- if (sameColor(fg, baseFg) && sameColor(bg, baseBg)) continue;
1644
- rows.push(makeRow(spec, mode, fg, bg));
1991
+ const base = resolvePair(spec, BASE_MODE, tokens);
1992
+ if (!base) continue;
1993
+ const explicit = spec.modes !== void 0;
1994
+ const rowModes = spec.modes ?? ["base", ...modes];
1995
+ for (const mode of rowModes) {
1996
+ const colours = mode === "base" ? base : resolvePair(spec, mode, tokens) ?? base;
1997
+ if (!explicit && mode !== "base" && sameColor(colours.fg, base.fg) && sameColor(colours.bg, base.bg) && floorFor(spec, mode) === floorFor(spec, "base")) continue;
1998
+ rows.push(makeRow(spec, mode, colours.fg, colours.bg));
1645
1999
  }
1646
2000
  }
1647
2001
  return rows;
1648
2002
  };
2003
+ const resolvePair = (spec, mode, tokens) => {
2004
+ const resolveEnd = (path) => resolveColor(path, mode, tokens) ?? (mode === "base" ? void 0 : resolveColor(path, "base", tokens));
2005
+ const fg = resolveEnd(spec.foreground);
2006
+ const against = resolveEnd(spec.background);
2007
+ if (!fg || !against) return void 0;
2008
+ if (spec.over === void 0) return {
2009
+ fg,
2010
+ bg: against
2011
+ };
2012
+ const ground = resolveEnd(spec.over);
2013
+ if (!ground) return void 0;
2014
+ return {
2015
+ fg,
2016
+ bg: compositeOver(against, ground)
2017
+ };
2018
+ };
1649
2019
  const makeRow = (spec, mode, foreground, background) => {
1650
2020
  const ratio = contrastRatio(foreground, background);
1651
- const floor = WCAG_FLOORS[spec.level];
2021
+ const floor = floorFor(spec, mode);
1652
2022
  return {
1653
2023
  foreground: spec.foreground,
1654
2024
  background: spec.background,
2025
+ ...spec.over === void 0 ? {} : { over: spec.over },
1655
2026
  mode,
1656
2027
  level: spec.level,
1657
2028
  ratio,
@@ -1675,35 +2046,129 @@ const resolveColor = (path, mode, tokens, seen = /* @__PURE__ */ new Set()) => {
1675
2046
  if (alias !== void 0) return resolveColor(alias, mode, tokens, seen);
1676
2047
  return typeof value === "string" ? parseColor(value) : void 0;
1677
2048
  };
2049
+ const modeAliasBreak = (path, mode, tokens) => {
2050
+ const seen = /* @__PURE__ */ new Set();
2051
+ let current = path;
2052
+ while (current !== void 0) {
2053
+ if (seen.has(current)) return "unresolved";
2054
+ seen.add(current);
2055
+ const token = tokens.get(current);
2056
+ if (!token) return "unresolved";
2057
+ const modes = readExtension(token.node, MODES_EXTENSION);
2058
+ const value = isRecord(modes) && Object.hasOwn(modes, mode) ? modes[mode] : token.value;
2059
+ current = aliasTarget(value);
2060
+ }
2061
+ return seen.size > 64 ? "too-deep" : void 0;
2062
+ };
2063
+ const runVerifier = async (hook) => {
2064
+ const result = await hook.run("sh", ["-c", hook.command]);
2065
+ return {
2066
+ command: hook.command,
2067
+ ...result
2068
+ };
2069
+ };
1678
2070
  //#endregion
1679
2071
  //#region src/design-tokens/design-sync.constant.ts
1680
2072
  const DESIGN_TOOL_EXTENSION = "com.lemony.design-tool";
2073
+ const NEUTRAL_STRING_VALUES = "The neutral format carries every value as a string (e.g. \"1024\", \"#3b82f6\") — fix the adapter that wrote the file.";
2074
+ const NEUTRAL_MODES_OBJECT = "It maps each mode name to that mode's value (e.g. {\"dark\": \"#000000\"}) — fix the adapter that wrote the file.";
2075
+ const NEUTRAL_VALUE_OR_REF = "Each variable carries a string \"value\" (a literal) or a non-empty string \"ref\" (the name of the variable it aliases) — fix the adapter that wrote the file.";
2076
+ const NEUTRAL_NAME_SHAPE = "A name is dot-separated segments, like \"primitive.color.brand\" — fix the adapter that wrote the file.";
2077
+ const VALIDATE_HINT = "Run `lemony design-tokens validate` for the full list.";
2078
+ const IMPORT_INVALID_HINT = "Change the `--only` slice (add what it references), or fix the file first; nothing was written.";
2079
+ const IMPORT_NOT_CARRIED_HINT = "add the token in the design tool (or in docs/design-tokens.json) first, or leave out what references it; nothing was written.";
2080
+ const IMPORT_PREVIEW_INVALID_HINT = "`--apply` refuses any slice that leaves it failing: include what repairs it, or fix the file first.";
2081
+ const IMPORT_TOO_DEEP_HINT = "Shorten the dotted names of the variables that land that deep, or leave them out with `--only`; nothing was written.";
2082
+ //#endregion
2083
+ //#region src/design-tokens/read-required-file.ts
2084
+ const readRequiredFile = async (path, label) => {
2085
+ try {
2086
+ return await readRegularFile(path);
2087
+ } catch (error) {
2088
+ if (error.code === "ENOENT") {
2089
+ if (!await entryExists(path)) throw new Error(`${label} file not found: ${path}`, { cause: error });
2090
+ throw new Error(`Cannot read ${label} file at ${path}: the entry is there but its target is missing (a broken symlink?).`, { cause: error });
2091
+ }
2092
+ throw new Error(`Cannot read ${label} file at ${path}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
2093
+ }
2094
+ };
1681
2095
  //#endregion
1682
2096
  //#region src/design-tokens/neutral-mapping.ts
1683
2097
  const KNOWN_TIERS = new Set(TOKEN_TIERS);
1684
2098
  const dtcgToNeutral = (parsed) => {
2099
+ const nodes = tokenNodes(parsed);
2100
+ const unsynced = unsyncedReasons(nodes);
1685
2101
  const variables = [];
1686
- for (const tier of TOKEN_TIERS) {
1687
- const group = parsed[tier];
1688
- if (isRecord(group)) walk(group, tier, variables);
1689
- }
2102
+ for (const [path, node] of nodes) if (!unsynced.has(path)) variables.push(toVariable$1(path, node));
1690
2103
  return {
1691
2104
  schemaVersion: 1,
1692
2105
  variables: variables.toSorted(byName)
1693
2106
  };
1694
2107
  };
1695
- const byName = (a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
2108
+ const unsyncedTokens = (parsed) => [...unsyncedReasons(tokenNodes(parsed))].map(([path, reason]) => ({
2109
+ path,
2110
+ reason
2111
+ })).toSorted((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
2112
+ const tokenNodes = (parsed) => {
2113
+ const nodes = /* @__PURE__ */ new Map();
2114
+ for (const tier of TOKEN_TIERS) {
2115
+ const group = parsed[tier];
2116
+ if (isRecord(group)) walk(group, tier, nodes);
2117
+ }
2118
+ return nodes;
2119
+ };
1696
2120
  const walk = (node, path, out) => {
1697
2121
  for (const [key, child] of Object.entries(node)) {
1698
2122
  if (key.startsWith("$") || !isRecord(child)) continue;
1699
2123
  const childPath = `${path}.${key}`;
1700
- if ("$value" in child) out.push(toVariable$1(childPath, child));
2124
+ if ("$value" in child) out.set(childPath, child);
1701
2125
  else walk(child, childPath, out);
1702
2126
  }
1703
2127
  };
2128
+ const unsyncedReasons = (nodes) => {
2129
+ const reasons = /* @__PURE__ */ new Map();
2130
+ for (const [path, node] of nodes) {
2131
+ const reason = ownUnsyncedReason(node);
2132
+ if (reason !== void 0) reasons.set(path, reason);
2133
+ }
2134
+ let grew = true;
2135
+ while (grew) {
2136
+ grew = false;
2137
+ for (const [path, node] of nodes) {
2138
+ if (reasons.has(path)) continue;
2139
+ const target = aliasTargetsOf(node).find((name) => reasons.has(name));
2140
+ if (target === void 0) continue;
2141
+ reasons.set(path, `an alias to ${target}`);
2142
+ grew = true;
2143
+ }
2144
+ }
2145
+ return reasons;
2146
+ };
2147
+ const ownUnsyncedReason = (node) => {
2148
+ if (isCompositeValue(node["$value"])) return "a composite value";
2149
+ const modes = modesExtension(node);
2150
+ if (modes === void 0) return void 0;
2151
+ if (!isRecord(modes)) return "a modes extension that is not an object";
2152
+ for (const [name, value] of Object.entries(modes)) {
2153
+ if (name.trim().length === 0) return "a mode with a blank name";
2154
+ if (name === "__proto__") return "a mode named \"__proto__\"";
2155
+ if (!isScalar(value)) return `mode "${name}" holding neither text, a number nor a boolean`;
2156
+ }
2157
+ };
2158
+ const aliasTargetsOf = (node) => {
2159
+ const modes = modesExtension(node);
2160
+ return [node["$value"], ...isRecord(modes) ? Object.values(modes) : []].map(aliasTarget).filter((target) => target !== void 0);
2161
+ };
2162
+ const modesExtension = (node) => {
2163
+ const extensions = node["$extensions"];
2164
+ return isRecord(extensions) ? extensions[MODES_EXTENSION] : void 0;
2165
+ };
2166
+ const isCompositeValue = (value) => typeof value === "object" && value !== null;
2167
+ const isScalar = (value) => typeof value === "string" || typeof value === "number" || typeof value === "boolean";
2168
+ const byName = (a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
1704
2169
  const toVariable$1 = (name, node) => {
1705
2170
  const type = typeof node["$type"] === "string" ? node["$type"] : "";
1706
- const modes = readModes(node);
2171
+ const modes = readModes$1(node);
1707
2172
  const alias = aliasTarget(node["$value"]);
1708
2173
  const base = {
1709
2174
  name,
@@ -1720,37 +2185,64 @@ const toVariable$1 = (name, node) => {
1720
2185
  };
1721
2186
  };
1722
2187
  const stringifyValue = (value) => value === void 0 || value === null ? "" : String(value);
1723
- const readModes = (node) => {
1724
- const extensions = node["$extensions"];
1725
- const modes = isRecord(extensions) ? extensions[MODES_EXTENSION] : void 0;
2188
+ const readModes$1 = (node) => {
2189
+ const modes = modesExtension(node);
1726
2190
  if (!isRecord(modes)) return void 0;
1727
2191
  const out = {};
1728
- for (const [name, value] of Object.entries(modes)) if (name.trim().length > 0 && typeof value === "string") out[name] = value;
2192
+ for (const [name, value] of Object.entries(modes)) if (isScalar(value)) out[name] = String(value);
1729
2193
  return Object.keys(out).length > 0 ? out : void 0;
1730
2194
  };
1731
- const toIncoming = (variable) => {
1732
- const isAlias = variable.ref !== void 0;
1733
- const value = isAlias ? `{${variable.ref}}` : variable.value ?? "";
1734
- const type = variable.type;
2195
+ const landingPath = (variable, known) => {
1735
2196
  const first = variable.name.split(".")[0] ?? "";
1736
- if (KNOWN_TIERS.has(first)) return withModes({
1737
- path: variable.name,
1738
- tier: first,
1739
- type,
1740
- value
1741
- }, variable);
1742
- const tier = isAlias ? "semantic" : "primitive";
1743
- return withModes({
1744
- path: `${tier}.${variable.name}`,
1745
- tier,
1746
- type,
2197
+ if (KNOWN_TIERS.has(first)) return variable.name;
2198
+ const existing = existingTierPath(variable.name, known);
2199
+ if (existing !== void 0) return existing;
2200
+ return `${variable.ref === void 0 ? "primitive" : "semantic"}.${variable.name}`;
2201
+ };
2202
+ const existingTierPath = (bare, known) => {
2203
+ const matches = TOKEN_TIERS.map((tier) => `${tier}.${bare}`).filter((path) => known.has(path));
2204
+ return matches.length === 1 ? matches[0] : void 0;
2205
+ };
2206
+ const refResolver = (neutral, known) => {
2207
+ const landings = /* @__PURE__ */ new Map();
2208
+ for (const variable of neutral.variables) landings.set(variable.name, landingPath(variable, known));
2209
+ return (ref) => {
2210
+ const first = ref.split(".")[0] ?? "";
2211
+ if (KNOWN_TIERS.has(first)) return ref;
2212
+ return landings.get(ref) ?? existingTierPath(ref, known) ?? ref;
2213
+ };
2214
+ };
2215
+ const toIncoming = (variable, resolveRef, known) => {
2216
+ const value = variable.ref === void 0 ? variable.value ?? "" : `{${resolveRef(variable.ref)}}`;
2217
+ const path = landingPath(variable, known);
2218
+ const token = {
2219
+ path,
2220
+ tier: path.split(".")[0],
2221
+ type: variable.type,
1747
2222
  value
1748
- }, variable);
2223
+ };
2224
+ if (!variable.modes) return token;
2225
+ const modes = {};
2226
+ for (const [mode, modeValue] of Object.entries(variable.modes)) {
2227
+ const alias = aliasTarget(modeValue);
2228
+ modes[mode] = alias === void 0 ? modeValue : `{${resolveRef(alias)}}`;
2229
+ }
2230
+ return {
2231
+ ...token,
2232
+ modes
2233
+ };
2234
+ };
2235
+ const incomingByPath = (neutral, known) => {
2236
+ const resolveRef = refResolver(neutral, known);
2237
+ const tokens = neutral.variables.map((variable) => ({
2238
+ exact: KNOWN_TIERS.has(variable.name.split(".")[0] ?? ""),
2239
+ token: toIncoming(variable, resolveRef, known)
2240
+ }));
2241
+ const byPath = /* @__PURE__ */ new Map();
2242
+ for (const { exact, token } of tokens) if (exact) byPath.set(token.path, token);
2243
+ for (const { exact, token } of tokens) if (!exact && !byPath.has(token.path)) byPath.set(token.path, token);
2244
+ return byPath;
1749
2245
  };
1750
- const withModes = (token, variable) => variable.modes ? {
1751
- ...token,
1752
- modes: variable.modes
1753
- } : token;
1754
2246
  const comparisonKey = (type, value, modes) => JSON.stringify({
1755
2247
  type,
1756
2248
  value,
@@ -1765,41 +2257,78 @@ const sortedModes = (modes) => {
1765
2257
  const diffNeutralAgainstJson = (neutral, parsed) => {
1766
2258
  const current = /* @__PURE__ */ new Map();
1767
2259
  for (const variable of dtcgToNeutral(parsed).variables) current.set(variable.name, variable);
2260
+ const unsynced = new Map(unsyncedTokens(parsed).map(({ path, reason }) => [path, reason]));
1768
2261
  const changes = [];
1769
- for (const variable of neutral.variables) {
1770
- const incoming = toIncoming(variable);
2262
+ for (const incoming of incomingByPath(neutral, /* @__PURE__ */ new Set([...current.keys(), ...unsynced.keys()])).values()) {
2263
+ const reason = unsynced.get(incoming.path);
2264
+ if (reason !== void 0) {
2265
+ changes.push({
2266
+ path: incoming.path,
2267
+ tier: incoming.tier,
2268
+ kind: "changed",
2269
+ before: `(outside the sync: ${reason})`,
2270
+ after: incoming.value
2271
+ });
2272
+ continue;
2273
+ }
1771
2274
  const existing = current.get(incoming.path);
1772
2275
  const after = incoming.value;
1773
2276
  const before = existing ? neutralValue(existing) : void 0;
2277
+ const kind = classifyImport(existing, incoming);
1774
2278
  changes.push({
1775
2279
  path: incoming.path,
1776
2280
  tier: incoming.tier,
1777
- kind: classifyImport(existing, incoming, variable),
2281
+ kind,
1778
2282
  ...before !== void 0 ? { before } : {},
1779
- after
2283
+ after,
2284
+ ...existing && kind === "changed" ? changeDetail(existing, incoming) : {}
1780
2285
  });
1781
2286
  }
1782
2287
  return changes.toSorted((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
1783
2288
  };
2289
+ const changeDetail = (existing, incoming) => {
2290
+ const detail = {};
2291
+ if (existing.type !== incoming.type) detail.typeChange = {
2292
+ before: existing.type,
2293
+ after: incoming.type
2294
+ };
2295
+ const before = existing.modes ?? {};
2296
+ const after = incoming.modes ?? {};
2297
+ const modeChanges = [.../* @__PURE__ */ new Set([...Object.keys(before), ...Object.keys(after)])].toSorted().filter((mode) => before[mode] !== after[mode]).map((mode) => ({
2298
+ mode,
2299
+ ...before[mode] !== void 0 ? { before: before[mode] } : {},
2300
+ ...after[mode] !== void 0 ? { after: after[mode] } : {}
2301
+ }));
2302
+ if (modeChanges.length > 0) detail.modeChanges = modeChanges;
2303
+ return detail;
2304
+ };
1784
2305
  const neutralValue = (variable) => variable.ref !== void 0 ? `{${variable.ref}}` : variable.value ?? "";
1785
- const classifyImport = (existing, incoming, variable) => {
2306
+ const classifyImport = (existing, incoming) => {
1786
2307
  if (!existing) return "new";
1787
- return comparisonKey(existing.type, neutralValue(existing), existing.modes) === comparisonKey(incoming.type, incoming.value, variable.modes) ? "unchanged" : "changed";
2308
+ return comparisonKey(existing.type, neutralValue(existing), existing.modes) === comparisonKey(incoming.type, incoming.value, incoming.modes) ? "unchanged" : "changed";
1788
2309
  };
1789
2310
  const mergeNeutralIntoJson = (neutral, parsed, only) => {
1790
2311
  const merged = structuredClone(parsed);
1791
2312
  const onlySet = only ? new Set(only) : void 0;
1792
2313
  const written = [];
1793
2314
  const skipped = [];
1794
- for (const variable of neutral.variables) {
1795
- const incoming = toIncoming(variable);
2315
+ const unsyncedLanded = [];
2316
+ const current = new Map(dtcgToNeutral(parsed).variables.map((variable) => [variable.name, variable]));
2317
+ const unsynced = new Set(unsyncedTokens(parsed).map(({ path }) => path));
2318
+ for (const incoming of incomingByPath(neutral, /* @__PURE__ */ new Set([...current.keys(), ...unsynced])).values()) {
1796
2319
  if (onlySet && !onlySet.has(incoming.path)) continue;
2320
+ if (unsynced.has(incoming.path)) {
2321
+ unsyncedLanded.push(incoming.path);
2322
+ continue;
2323
+ }
2324
+ if (classifyImport(current.get(incoming.path), incoming) === "unchanged") continue;
1797
2325
  (setToken(merged, incoming) ? written : skipped).push(incoming.path);
1798
2326
  }
1799
2327
  return {
1800
2328
  merged,
1801
2329
  written: written.toSorted(),
1802
- skipped: skipped.toSorted()
2330
+ skipped: skipped.toSorted(),
2331
+ unsynced: unsyncedLanded.toSorted()
1803
2332
  };
1804
2333
  };
1805
2334
  const isTokenNode = (value) => isRecord(value) && "$value" in value;
@@ -1822,17 +2351,44 @@ const setToken = (root, incoming) => {
1822
2351
  if (!isRecord(next)) node[segment] = {};
1823
2352
  node = node[segment];
1824
2353
  }
1825
- const leaf = { $value: incoming.value };
2354
+ const existing = node[leafKey];
2355
+ const leaf = isRecord(existing) ? { ...existing } : {};
2356
+ const typeBefore = typeof leaf["$type"] === "string" ? leaf["$type"] : "";
2357
+ const sameType = incoming.type === typeBefore;
2358
+ const keep = (before, text) => sameType ? keptScalar(before, text) : text;
2359
+ leaf["$value"] = keep(leaf["$value"], incoming.value);
1826
2360
  if (incoming.type.length > 0) leaf["$type"] = incoming.type;
1827
- if (incoming.modes) leaf["$extensions"] = { [MODES_EXTENSION]: incoming.modes };
2361
+ else delete leaf["$type"];
2362
+ const extensions = isRecord(leaf["$extensions"]) ? { ...leaf["$extensions"] } : {};
2363
+ if (incoming.modes) {
2364
+ const before = extensions[MODES_EXTENSION];
2365
+ const modes = {};
2366
+ for (const [mode, value] of Object.entries(incoming.modes)) modes[mode] = keep(isRecord(before) && Object.hasOwn(before, mode) ? before[mode] : void 0, value);
2367
+ extensions[MODES_EXTENSION] = modes;
2368
+ } else delete extensions[MODES_EXTENSION];
2369
+ if (Object.keys(extensions).length > 0) leaf["$extensions"] = extensions;
2370
+ else delete leaf["$extensions"];
1828
2371
  node[leafKey] = leaf;
1829
2372
  return true;
1830
2373
  };
2374
+ const keptScalar = (before, incoming) => (typeof before === "number" || typeof before === "boolean") && String(before) === incoming ? before : incoming;
2375
+ const danglingAliasTargets = (parsed, paths) => {
2376
+ const { variables } = dtcgToNeutral(parsed);
2377
+ const names = new Set(variables.map((variable) => variable.name));
2378
+ const wanted = new Set(paths);
2379
+ const dangling = /* @__PURE__ */ new Set();
2380
+ for (const variable of variables) {
2381
+ if (!wanted.has(variable.name)) continue;
2382
+ const targets = [variable.ref, ...Object.values(variable.modes ?? {}).map(aliasTarget)];
2383
+ for (const target of targets) if (target !== void 0 && !names.has(target)) dangling.add(target);
2384
+ }
2385
+ return [...dangling].toSorted();
2386
+ };
1831
2387
  const upsertPlan = (parsed, toolState) => {
1832
- const current = /* @__PURE__ */ new Map();
1833
- for (const variable of toolState?.variables ?? []) current.set(variable.name, variable);
2388
+ const { variables } = dtcgToNeutral(parsed);
2389
+ const current = toolState ? incomingByPath(toolState, /* @__PURE__ */ new Set([...variables.map((variable) => variable.name), ...unsyncedTokens(parsed).map(({ path }) => path)])) : /* @__PURE__ */ new Map();
1834
2390
  const plan = [];
1835
- for (const variable of dtcgToNeutral(parsed).variables) plan.push({
2391
+ for (const variable of variables) plan.push({
1836
2392
  name: variable.name,
1837
2393
  kind: classifyExport(variable, current)
1838
2394
  });
@@ -1841,45 +2397,78 @@ const upsertPlan = (parsed, toolState) => {
1841
2397
  const classifyExport = (projected, current) => {
1842
2398
  const existing = current.get(projected.name);
1843
2399
  if (!existing) return "create";
1844
- return comparisonKey(projected.type, neutralValue(projected), projected.modes) === comparisonKey(existing.type, neutralValue(existing), existing.modes) ? "unchanged" : "update";
2400
+ return comparisonKey(projected.type, neutralValue(projected), projected.modes) === comparisonKey(existing.type, existing.value, existing.modes) ? "unchanged" : "update";
1845
2401
  };
1846
2402
  //#endregion
1847
2403
  //#region src/design-tokens/sync-io.ts
1848
- const parseNeutralFile = (raw) => {
2404
+ const parseNeutralFile = (raw, label) => {
1849
2405
  let parsed;
1850
2406
  try {
1851
2407
  parsed = JSON.parse(raw);
1852
2408
  } catch (error) {
1853
- throw new Error(`neutral file is not valid JSON: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
2409
+ throw new Error(`${label} file is not valid JSON: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
1854
2410
  }
1855
- if (!isRecord(parsed) || !Array.isArray(parsed["variables"])) throw new Error("neutral file must be a JSON object with a \"variables\" array.");
2411
+ if (!isRecord(parsed) || !Array.isArray(parsed["variables"])) throw new Error(`${label} file must be a JSON object with a "variables" array.`);
1856
2412
  const schemaVersion = typeof parsed["schemaVersion"] === "number" ? parsed["schemaVersion"] : 0;
1857
2413
  const provider = typeof parsed["provider"] === "string" ? parsed["provider"] : void 0;
2414
+ const variables = parsed["variables"].flatMap((entry) => toVariable(entry, label));
2415
+ const seen = /* @__PURE__ */ new Set();
2416
+ for (const { name } of variables) {
2417
+ if (seen.has(name)) throw new Error(`${label} file: variable "${name}" appears more than once — fix the adapter that wrote the file.`);
2418
+ seen.add(name);
2419
+ }
1858
2420
  return {
1859
2421
  schemaVersion,
1860
2422
  ...provider ? { provider } : {},
1861
- variables: parsed["variables"].flatMap(toVariable)
2423
+ variables
1862
2424
  };
1863
2425
  };
1864
- const toVariable = (raw) => {
2426
+ const toVariable = (raw, label) => {
1865
2427
  if (!isRecord(raw) || typeof raw["name"] !== "string" || raw["name"].trim().length === 0) return [];
1866
- const type = typeof raw["type"] === "string" ? raw["type"] : "";
2428
+ const name = raw["name"];
1867
2429
  const variable = {
1868
- name: raw["name"],
1869
- type
1870
- };
1871
- if (typeof raw["ref"] === "string") variable.ref = raw["ref"];
1872
- else if (typeof raw["value"] === "string") variable.value = raw["value"];
1873
- const modes = readStringMap(raw["modes"]);
2430
+ name,
2431
+ type: typeof raw["type"] === "string" ? raw["type"] : ""
2432
+ };
2433
+ const refuse = (fault, hint) => /* @__PURE__ */ new Error(`${label} file: variable "${name}" ${fault}. ${hint}`);
2434
+ const nameFault = tokenNameFault(name);
2435
+ if (nameFault !== void 0) throw refuse(nameFault, NEUTRAL_NAME_SHAPE);
2436
+ if (raw["ref"] !== void 0) {
2437
+ if (typeof raw["ref"] !== "string") throw refuse(`has ${jsonKind(raw["ref"])} ref`, NEUTRAL_VALUE_OR_REF);
2438
+ if (raw["ref"].trim().length === 0) throw refuse("has an empty ref", NEUTRAL_VALUE_OR_REF);
2439
+ variable.ref = raw["ref"];
2440
+ } else if (typeof raw["value"] === "string") variable.value = raw["value"];
2441
+ else if (raw["value"] !== void 0) throw refuse(`has ${jsonKind(raw["value"])} value`, NEUTRAL_STRING_VALUES);
2442
+ else throw refuse("has neither a value nor a ref", NEUTRAL_VALUE_OR_REF);
2443
+ const modes = readModes(raw["modes"], name, label);
1874
2444
  if (modes) variable.modes = modes;
1875
2445
  return [variable];
1876
2446
  };
1877
- const readStringMap = (raw) => {
1878
- if (!isRecord(raw)) return void 0;
2447
+ const readModes = (raw, name, label) => {
2448
+ if (raw === void 0) return void 0;
2449
+ if (!isRecord(raw)) throw new Error(`${label} file: variable "${name}" has ${jsonKind(raw)} "modes". ${NEUTRAL_MODES_OBJECT}`);
1879
2450
  const out = {};
1880
- for (const [key, value] of Object.entries(raw)) if (key.trim().length > 0 && typeof value === "string") out[key] = value;
2451
+ for (const [mode, value] of Object.entries(raw)) {
2452
+ if (mode.trim().length === 0 || mode === "__proto__") throw new Error(`${label} file: variable "${name}" has a mode named ${JSON.stringify(mode)}, which no mode can be named. ${NEUTRAL_MODES_OBJECT}`);
2453
+ if (typeof value !== "string") throw new Error(`${label} file: variable "${name}" has ${jsonKind(value)} value in mode "${mode}". ${NEUTRAL_STRING_VALUES}`);
2454
+ out[mode] = value;
2455
+ }
1881
2456
  return Object.keys(out).length > 0 ? out : void 0;
1882
2457
  };
2458
+ const tokenNameFault = (name) => {
2459
+ const segments = name.split(".");
2460
+ if (segments.some((segment) => segment.trim().length === 0)) return "has an empty segment in its name";
2461
+ if (segments.some((segment) => segment.startsWith("$"))) return "has a segment starting with \"$\", which DTCG keeps for its own keys";
2462
+ if (segments.includes("__proto__")) return "has a \"__proto__\" segment, which no token can be named";
2463
+ if (segments.length === 1 && TIERS.has(name)) return `names only the tier "${name}", with no token under it`;
2464
+ };
2465
+ const TIERS = new Set(TOKEN_TIERS);
2466
+ const jsonKind = (value) => {
2467
+ if (value === null) return "null";
2468
+ if (Array.isArray(value)) return "an array";
2469
+ const kind = typeof value;
2470
+ return kind === "object" ? "an object" : `a ${kind}`;
2471
+ };
1883
2472
  const readJsonObject = (raw) => {
1884
2473
  let parsed;
1885
2474
  try {
@@ -1888,6 +2477,7 @@ const readJsonObject = (raw) => {
1888
2477
  throw new Error(`${DESIGN_TOKENS_FILE} is not valid JSON (${error instanceof Error ? error.message : String(error)}) — run \`design-tokens validate\` first.`, { cause: error });
1889
2478
  }
1890
2479
  if (!isRecord(parsed)) throw new Error(`${DESIGN_TOKENS_FILE} must be a JSON object — run \`design-tokens validate\` first.`);
2480
+ if (exceedsNestingLimit(parsed)) throw new Error(`${NESTED_TOO_DEEPLY} Run \`design-tokens validate\` first.`);
1891
2481
  return parsed;
1892
2482
  };
1893
2483
  const writeJsonFile = (value) => `${JSON.stringify(value, null, 2)}\n`;
@@ -1899,17 +2489,16 @@ const runImport = async (inputs) => {
1899
2489
  tokensFound: false,
1900
2490
  changes: [],
1901
2491
  applied: false,
2492
+ refused: false,
1902
2493
  written: [],
1903
2494
  skipped: [],
1904
- problems: []
1905
- };
1906
- if (!await pathExists(inputs.neutralPath)) return {
1907
- ...empty,
1908
- problems: [`neutral file not found: ${inputs.neutralPath}`]
2495
+ unsynced: [],
2496
+ problems: [],
2497
+ warnings: []
1909
2498
  };
1910
2499
  let neutral;
1911
2500
  try {
1912
- neutral = parseNeutralFile(await readFile(inputs.neutralPath, "utf8"));
2501
+ neutral = parseNeutralFile(await readRequiredFile(inputs.neutralPath, "neutral"), "neutral");
1913
2502
  } catch (error) {
1914
2503
  return {
1915
2504
  ...empty,
@@ -1920,37 +2509,79 @@ const runImport = async (inputs) => {
1920
2509
  ...empty,
1921
2510
  problems: [`neutral file schemaVersion ${neutral.schemaVersion} is not supported (expected 1).`]
1922
2511
  };
1923
- const tokenPath = join(inputs.repoRoot, DESIGN_TOKENS_FILE);
1924
- const tokensFound = await pathExists(tokenPath);
2512
+ let tokensFound = false;
2513
+ let raw;
1925
2514
  let parsed;
1926
2515
  try {
1927
- parsed = tokensFound ? readJsonObject(await readFile(tokenPath, "utf8")) : {};
2516
+ raw = await readOptionalTokenFile(inputs.repoRoot);
2517
+ tokensFound = raw !== void 0;
2518
+ parsed = raw === void 0 ? {} : readJsonObject(raw);
1928
2519
  } catch (error) {
1929
2520
  return {
1930
2521
  ...empty,
1931
- tokensFound,
2522
+ tokensFound: true,
1932
2523
  problems: [asMessage$1(error)]
1933
2524
  };
1934
2525
  }
1935
2526
  const changes = diffNeutralAgainstJson(neutral, parsed);
1936
- if (!inputs.apply) return {
2527
+ if (!inputs.apply) {
2528
+ const faults = summarizeMessages(raw === void 0 ? [] : tokenFileViolationMessages(raw));
2529
+ return {
2530
+ ...empty,
2531
+ tokensFound,
2532
+ changes,
2533
+ warnings: faults === void 0 ? [] : [`${DESIGN_TOKENS_FILE} already fails validation: ${faults} ${IMPORT_PREVIEW_INVALID_HINT} ${VALIDATE_HINT}`]
2534
+ };
2535
+ }
2536
+ const unmatched = (inputs.only ?? []).filter((path) => !changes.some((change) => change.path === path));
2537
+ if (unmatched.length > 0) return {
2538
+ ...empty,
1937
2539
  tokensFound,
1938
2540
  changes,
1939
- applied: false,
1940
- written: [],
1941
- skipped: [],
1942
- problems: []
2541
+ refused: true,
2542
+ problems: [`--only names ${unmatched.length === 1 ? "a path" : "paths"} no variable in the neutral file lands at: ${unmatched.join(", ")}. Use the tiered paths the preview prints; nothing was written.`]
1943
2543
  };
1944
- const { merged, written, skipped } = mergeNeutralIntoJson(neutral, parsed, inputs.only);
1945
- await mkdir(dirname(tokenPath), { recursive: true });
1946
- await writeFile(tokenPath, writeJsonFile(merged));
2544
+ const { merged, written, skipped, unsynced } = mergeNeutralIntoJson(neutral, parsed, inputs.only);
2545
+ if (exceedsNestingLimit(merged)) return {
2546
+ ...empty,
2547
+ tokensFound,
2548
+ changes,
2549
+ refused: true,
2550
+ problems: [`not applied — with this import, ${DESIGN_TOKENS_FILE} would fail validation: ${NESTED_TOO_DEEPLY} ${IMPORT_TOO_DEEP_HINT}`]
2551
+ };
2552
+ const text = writeJsonFile(merged);
2553
+ const mergedFaults = [...nonFiniteNumberFaults(merged), ...tokenFileViolationMessages(text)];
2554
+ if (mergedFaults.length > 0) {
2555
+ const onDisk = new Set(raw === void 0 ? [] : tokenFileViolationMessages(raw));
2556
+ const introduced = mergedFaults.filter((fault) => !onDisk.has(fault));
2557
+ const landings = new Set(changes.map((change) => change.path));
2558
+ const dangling = danglingAliasTargets(merged, written);
2559
+ const leftBehind = dangling.filter((target) => landings.has(target));
2560
+ const notCarried = dangling.filter((target) => !landings.has(target));
2561
+ const hint = notCarried.length === 0 ? IMPORT_INVALID_HINT : `${leftBehind.length > 0 ? `Add ${leftBehind.join(", ")} to the \`--only\` slice. ` : ""}The neutral file carries no variable that lands at ${notCarried.join(", ")}, so no \`--only\` slice can supply ${notCarried.length === 1 ? "it" : "them"}: ${IMPORT_NOT_CARRIED_HINT}`;
2562
+ return {
2563
+ ...empty,
2564
+ tokensFound,
2565
+ changes,
2566
+ refused: true,
2567
+ problems: [introduced.length > 0 ? `not applied — with this import, ${DESIGN_TOKENS_FILE} would fail validation: ${summarizeMessages(introduced)} ${hint}` : `not applied — ${DESIGN_TOKENS_FILE} already fails validation and this import does not repair it: ${summarizeMessages(mergedFaults)} ${VALIDATE_HINT}`]
2568
+ };
2569
+ }
2570
+ if (written.length > 0) {
2571
+ const tokenPath = join(inputs.repoRoot, DESIGN_TOKENS_FILE);
2572
+ await mkdir(dirname(tokenPath), { recursive: true });
2573
+ await writeFile(tokenPath, text);
2574
+ }
1947
2575
  return {
1948
2576
  tokensFound,
1949
2577
  changes,
1950
2578
  applied: true,
2579
+ refused: false,
1951
2580
  written,
1952
2581
  skipped,
1953
- problems: []
2582
+ unsynced,
2583
+ problems: [],
2584
+ warnings: []
1954
2585
  };
1955
2586
  };
1956
2587
  const asMessage$1 = (error) => error instanceof Error ? error.message : String(error);
@@ -2004,15 +2635,22 @@ const runExport = async (inputs) => {
2004
2635
  const empty = {
2005
2636
  tokensFound: false,
2006
2637
  plan: [],
2638
+ unprojected: [],
2007
2639
  hash: "",
2008
2640
  recorded: false,
2009
2641
  problems: []
2010
2642
  };
2011
- const tokenPath = join(inputs.repoRoot, DESIGN_TOKENS_FILE);
2012
- if (!await pathExists(tokenPath)) return empty;
2013
2643
  let parsed;
2014
2644
  try {
2015
- parsed = readJsonObject(await readFile(tokenPath, "utf8"));
2645
+ const raw = await readOptionalTokenFile(inputs.repoRoot);
2646
+ if (raw === void 0) return empty;
2647
+ parsed = readJsonObject(raw);
2648
+ const invalid = summarizeTokenFileViolations(raw);
2649
+ if (invalid !== void 0) return {
2650
+ ...empty,
2651
+ tokensFound: true,
2652
+ problems: [`${DESIGN_TOKENS_FILE} fails validation: ${invalid} ${VALIDATE_HINT}`]
2653
+ };
2016
2654
  } catch (error) {
2017
2655
  return {
2018
2656
  ...empty,
@@ -2028,35 +2666,46 @@ const runExport = async (inputs) => {
2028
2666
  };
2029
2667
  const hash = projectionHash(parsed);
2030
2668
  let toolState;
2031
- if (inputs.toolStatePath !== void 0) {
2032
- if (!await pathExists(inputs.toolStatePath)) return {
2669
+ if (inputs.toolStatePath !== void 0) try {
2670
+ toolState = parseNeutralFile(await readRequiredFile(inputs.toolStatePath, "tool-state"), "tool-state");
2671
+ } catch (error) {
2672
+ return {
2673
+ ...empty,
2674
+ tokensFound: true,
2675
+ hash,
2676
+ problems: [asMessage(error)]
2677
+ };
2678
+ }
2679
+ const plan = upsertPlan(parsed, toolState);
2680
+ const unprojected = unsyncedTokens(parsed);
2681
+ if (inputs.outPath !== void 0) {
2682
+ const projection = dtcgToNeutral(parsed);
2683
+ projection.provider = binding.provider;
2684
+ if (await sameEntry(inputs.outPath, join(inputs.repoRoot, "docs/design-tokens.json"))) return {
2033
2685
  ...empty,
2034
2686
  tokensFound: true,
2035
2687
  hash,
2036
- problems: [`tool-state file not found: ${inputs.toolStatePath}`]
2688
+ problems: [`Cannot write the projection to ${inputs.outPath}: it is ${DESIGN_TOKENS_FILE} itself, which the projection would replace. Name another path.`]
2037
2689
  };
2038
2690
  try {
2039
- toolState = parseNeutralFile(await readFile(inputs.toolStatePath, "utf8"));
2691
+ await writeRegularFile(inputs.outPath, writeNeutralFile(projection));
2040
2692
  } catch (error) {
2041
2693
  return {
2042
2694
  ...empty,
2043
2695
  tokensFound: true,
2044
2696
  hash,
2045
- problems: [asMessage(error)]
2697
+ problems: [`Cannot write the projection to ${inputs.outPath}: ${asMessage(error)}`]
2046
2698
  };
2047
2699
  }
2048
2700
  }
2049
- const plan = upsertPlan(parsed, toolState);
2050
- if (inputs.outPath !== void 0) {
2051
- const projection = dtcgToNeutral(parsed);
2052
- projection.provider = binding.provider;
2053
- await writeFile(inputs.outPath, writeNeutralFile(projection));
2054
- }
2055
2701
  if (inputs.record) {
2056
- await writeFile(tokenPath, writeJsonFile(stampBaseline(parsed, hash)));
2702
+ const stamped = writeJsonFile(stampBaseline(parsed, hash));
2703
+ const tokenPath = join(inputs.repoRoot, DESIGN_TOKENS_FILE);
2704
+ await writeFile(tokenPath, stamped);
2057
2705
  return {
2058
2706
  tokensFound: true,
2059
2707
  plan,
2708
+ unprojected,
2060
2709
  hash,
2061
2710
  recorded: true,
2062
2711
  problems: []
@@ -2065,13 +2714,14 @@ const runExport = async (inputs) => {
2065
2714
  return {
2066
2715
  tokensFound: true,
2067
2716
  plan,
2717
+ unprojected,
2068
2718
  hash,
2069
2719
  recorded: false,
2070
2720
  problems: []
2071
2721
  };
2072
2722
  };
2073
2723
  const stampBaseline = (parsed, hash) => {
2074
- const clone = structuredClone(parsed);
2724
+ const clone = { ...parsed };
2075
2725
  const extensions = isRecord(clone["$extensions"]) ? { ...clone["$extensions"] } : {};
2076
2726
  const binding = isRecord(extensions["com.lemony.design-tool"]) ? { ...extensions[DESIGN_TOOL_EXTENSION] } : {};
2077
2727
  binding["lastProjected"] = hash;
@@ -2079,6 +2729,10 @@ const stampBaseline = (parsed, hash) => {
2079
2729
  clone["$extensions"] = extensions;
2080
2730
  return clone;
2081
2731
  };
2732
+ const sameEntry = async (a, b) => {
2733
+ const [left, right] = await Promise.all([a, b].map((path) => stat(path).catch(() => void 0)));
2734
+ return left !== void 0 && right !== void 0 && left.dev === right.dev && left.ino === right.ino;
2735
+ };
2082
2736
  const asMessage = (error) => error instanceof Error ? error.message : String(error);
2083
2737
  //#endregion
2084
2738
  //#region src/review-ledger/review-ledger.constant.ts
@@ -2095,13 +2749,14 @@ const SPEC_SIDE_KINDS = [
2095
2749
  "malformed-risk-marker",
2096
2750
  "malformed-task-refs",
2097
2751
  "unknown-risk-class",
2098
- "dangling-requirement-ref"
2752
+ "dangling-requirement-ref",
2753
+ "unticked-completed-task"
2099
2754
  ];
2100
2755
  const GROUP_HEADER = /^##\s+Group\s+(\d+)\b/;
2101
2756
  const RISK_MARKER = /\[risk:\s*([^\]]*)\]\s*$/;
2102
2757
  const RISK_MARKER_ALL = /\[risk:/gi;
2103
2758
  const RISK_MARKER_LOOKALIKE = /\[\s*risks?\s*:/i;
2104
- const TASK_LINE = /^\s*[-*+]\s*\[[ xX]\]\s*(?:\*\*|__|`)?\s*(T\d+)\b/;
2759
+ const TASK_LINE = /^\s*[-*+]\s*\[([ xX])\]\s*(?:\*\*|__|`)?\s*(T\d+)\b/;
2105
2760
  const TASK_REFS = /^\(((?:R\d+)(?:\s*,\s*R\d+)*)\)/;
2106
2761
  const TASK_REFS_LOOKALIKE_ALL = /\(\s*R\d+/g;
2107
2762
  const TASK_TITLE_WRAPPER = /^\s*[-*+]\s*\[[ xX]\]\s*(\*\*|__)/;
@@ -2156,7 +2811,7 @@ const parseTasksSpec = (text) => {
2156
2811
  const blockEnd = taskBlockEnd(lines, index);
2157
2812
  const parts = lines.slice(index, blockEnd).map((part) => part.trim());
2158
2813
  index = blockEnd - 1;
2159
- const task = parseTaskBlock(match[1] ?? "", parts);
2814
+ const task = parseTaskBlock(match[2] ?? "", (match[1] ?? " ") !== " ", parts);
2160
2815
  if (task.malformedRefs) malformedTaskRefs.push(task.id);
2161
2816
  if (current) {
2162
2817
  current.tasks.push(task.task);
@@ -2197,7 +2852,7 @@ const taskBlockEnd = (lines, start) => {
2197
2852
  }
2198
2853
  return end;
2199
2854
  };
2200
- const parseTaskBlock = (id, parts) => {
2855
+ const parseTaskBlock = (id, ticked, parts) => {
2201
2856
  const block = parts.join(" ");
2202
2857
  const opener = refsOpener(parts, block);
2203
2858
  const refs = opener === void 0 ? null : declarationAt(parts, block, opener);
@@ -2206,6 +2861,7 @@ const parseTaskBlock = (id, parts) => {
2206
2861
  id,
2207
2862
  task: {
2208
2863
  id,
2864
+ ticked,
2209
2865
  requirementRefs: [...new Set(requirementRefs)]
2210
2866
  },
2211
2867
  malformedRefs: refs === null && hasRefLookalike(parts)
@@ -2424,6 +3080,7 @@ const runLedgerValidate = async (inputs) => {
2424
3080
  });
2425
3081
  }
2426
3082
  }
3083
+ checkTicks(spec.groups, address, problems);
2427
3084
  const slice = sliceForGroups(groups);
2428
3085
  result.basis = slice.basis;
2429
3086
  const dangling = await collectDanglingRefs(requirementsPath, slice.ids, problems);
@@ -2459,6 +3116,17 @@ const resolveGroups = (groups, address, problems) => {
2459
3116
  message: `tasks.md declares ${groups.length} group(s); step ${address.step} has none. One step is one group.`
2460
3117
  });
2461
3118
  };
3119
+ const checkTicks = (groups, address, problems) => {
3120
+ const passed = address.kind === "full-pass" ? groups : groups.filter((group) => group.index <= address.step);
3121
+ for (const group of passed) for (const task of group.tasks) {
3122
+ if (task.ticked) continue;
3123
+ problems.push({
3124
+ kind: "unticked-completed-task",
3125
+ 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.`,
3126
+ subject: task.id
3127
+ });
3128
+ }
3129
+ };
2462
3130
  const collectDanglingRefs = async (requirementsPath, sliceIds, problems) => {
2463
3131
  const referenced = sliceIds.filter((id) => id.startsWith("R"));
2464
3132
  if (referenced.length === 0) return [];
@@ -2570,19 +3238,7 @@ const checkMutantsFloor = (ledger, owedFiles, anchor, problems) => {
2570
3238
  });
2571
3239
  }
2572
3240
  };
2573
- const collectDeclaredGates = async (repoRoot) => {
2574
- if (!await configEntryExists(join(repoRoot, "harness.config.yml"))) return;
2575
- return (await readHarnessConfig(repoRoot)).gates;
2576
- };
2577
- const configEntryExists = async (path) => {
2578
- try {
2579
- await lstat(path);
2580
- return true;
2581
- } catch (error) {
2582
- if (error.code === "ENOENT") return false;
2583
- throw error;
2584
- }
2585
- };
3241
+ const collectDeclaredGates = async (repoRoot) => (await readOptionalHarnessConfig(repoRoot))?.gates;
2586
3242
  const owedGates = (declared) => ({
2587
3243
  basis: declared === void 0 ? "undeclared" : "config",
2588
3244
  required: [...new Set(declared ?? [])],
@@ -3409,7 +4065,7 @@ const inspectDevDependency = async (repoRoot) => {
3409
4065
  const pkgPath = join(repoRoot, "package.json");
3410
4066
  if (!await pathExists(pkgPath)) return "no-package-json";
3411
4067
  try {
3412
- const pkg = JSON.parse(await readFile(pkgPath, "utf8"));
4068
+ const pkg = JSON.parse(await readRegularFile(pkgPath));
3413
4069
  return Object.hasOwn(pkg.dependencies ?? {}, "@lemoncode/lemony") || Object.hasOwn(pkg.devDependencies ?? {}, "@lemoncode/lemony") ? "declared" : "missing";
3414
4070
  } catch {
3415
4071
  return "no-package-json";
@@ -3680,14 +4336,27 @@ const checkHooks = async (repoRoot) => {
3680
4336
  const settingsPath = join(repoRoot, SETTINGS_FILE);
3681
4337
  let raw;
3682
4338
  try {
3683
- raw = await readFile(settingsPath, "utf8");
3684
- } catch {
3685
- return {
4339
+ raw = await readRegularFile(settingsPath);
4340
+ } catch (error) {
4341
+ const code = error.code;
4342
+ if (code === "ENOENT" && await lstat(settingsPath).then(() => true, () => false)) return {
4343
+ name,
4344
+ status: "fail",
4345
+ detail: ".claude/settings.json is a symlink whose target is missing.",
4346
+ remediation: "Remove the symlink, then run `lemony repair`."
4347
+ };
4348
+ if (code === "ENOENT") return {
3686
4349
  name,
3687
4350
  status: "fail",
3688
4351
  detail: ".claude/settings.json not found.",
3689
4352
  remediation: "Run `lemony repair`."
3690
4353
  };
4354
+ return {
4355
+ name,
4356
+ status: "fail",
4357
+ detail: `Cannot read .claude/settings.json: ${code === void 0 ? error.message : `${code}.`}`,
4358
+ remediation: "Replace it with a readable file (or remove it), then run `lemony repair`."
4359
+ };
3691
4360
  }
3692
4361
  let commands;
3693
4362
  try {
@@ -3844,29 +4513,46 @@ const checkCapabilities = async (deps, config) => {
3844
4513
  };
3845
4514
  const checkDesignToolDrift = async (deps) => {
3846
4515
  const name = "design-tool-drift";
3847
- const tokenPath = join(deps.repoRoot, DESIGN_TOKENS_FILE);
3848
- if (!await pathExists(tokenPath)) return {
4516
+ let raw;
4517
+ try {
4518
+ raw = await readOptionalTokenFile(deps.repoRoot);
4519
+ } catch (error) {
4520
+ return {
4521
+ name,
4522
+ status: "fail",
4523
+ detail: error.message,
4524
+ remediation: "Run `lemony design-tokens validate` to find the problem."
4525
+ };
4526
+ }
4527
+ if (raw === void 0) return {
3849
4528
  name,
3850
4529
  status: "info",
3851
4530
  detail: `No ${DESIGN_TOKENS_FILE} — token sync not in use.`
3852
4531
  };
3853
4532
  let parsed;
3854
4533
  try {
3855
- parsed = JSON.parse(await readFile(tokenPath, "utf8"));
4534
+ parsed = JSON.parse(raw);
3856
4535
  } catch (error) {
3857
4536
  return {
3858
4537
  name,
3859
- status: "warn",
3860
- detail: `Could not read ${DESIGN_TOKENS_FILE}: ${error.message}`,
4538
+ status: "fail",
4539
+ detail: `${DESIGN_TOKENS_FILE} is not valid JSON: ${error.message}`,
3861
4540
  remediation: "Run `lemony design-tokens validate` to find the problem."
3862
4541
  };
3863
4542
  }
3864
4543
  if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return {
3865
4544
  name,
3866
- status: "warn",
4545
+ status: "fail",
3867
4546
  detail: `${DESIGN_TOKENS_FILE} is not a JSON object.`,
3868
4547
  remediation: "Run `lemony design-tokens validate` to find the problem."
3869
4548
  };
4549
+ const invalid = summarizeTokenFileViolations(raw);
4550
+ if (invalid !== void 0) return {
4551
+ name,
4552
+ status: "fail",
4553
+ detail: `${DESIGN_TOKENS_FILE} fails validation: ${invalid}`,
4554
+ remediation: "Run `lemony design-tokens validate` to find the problem."
4555
+ };
3870
4556
  const report = driftReport(parsed);
3871
4557
  switch (report.state) {
3872
4558
  case "n/a": return {
@@ -3979,43 +4665,44 @@ const isReadable = async (path) => {
3979
4665
  }
3980
4666
  };
3981
4667
  //#endregion
4668
+ //#region src/status/parse-task-branch.ts
4669
+ const TASK_BRANCH = /^harness\/(\d+)-.+$/;
4670
+ const parseTaskBranch = (branch) => branch === null ? null : TASK_BRANCH.exec(branch)?.[1] ?? null;
4671
+ //#endregion
3982
4672
  //#region src/status/status.ts
3983
4673
  const PAUSED_LABEL = "harness:status:paused-for-clarification";
3984
4674
  const runStatus = async (deps) => {
3985
4675
  const { repoRoot } = deps;
3986
4676
  const config = await readHarnessConfig(repoRoot);
3987
- const pointer = await readPointer(repoRoot, deps.readGitUserEmail);
4677
+ const lastSession = await readPointer(repoRoot, deps.readGitUserEmail);
3988
4678
  const { branch, behind } = await deps.gitBehind(repoRoot);
3989
4679
  const provider = createTaskTrackerProvider(config.task_storage.type, deps.runCommand);
3990
4680
  const openDiscoveries = await countOpenDiscoveries(provider, config.task_storage.repo);
3991
4681
  return {
3992
4682
  vendorVersion: config.vendor_version,
3993
4683
  taskStorageRepo: config.task_storage.repo,
3994
- activeTask: pointer.activeTask,
3995
- branch: branch ?? pointer.branch,
4684
+ activeTask: parseTaskBranch(branch),
4685
+ branch,
3996
4686
  behind,
3997
- lastSession: pointer.lastSession,
4687
+ lastSession,
3998
4688
  openDiscoveries,
3999
4689
  designToolDrift: await readDriftState(repoRoot)
4000
4690
  };
4001
4691
  };
4002
4692
  const readDriftState = async (repoRoot) => {
4003
- const tokenPath = join(repoRoot, DESIGN_TOKENS_FILE);
4004
- if (!await pathExists(tokenPath)) return null;
4005
4693
  try {
4006
- const parsed = JSON.parse(await readFile(tokenPath, "utf8"));
4694
+ const raw = await readOptionalTokenFile(repoRoot);
4695
+ if (raw === void 0) return null;
4696
+ const parsed = JSON.parse(raw);
4007
4697
  if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
4698
+ if (summarizeTokenFileViolations(raw) !== void 0) return null;
4008
4699
  return driftReport(parsed).state;
4009
4700
  } catch {
4010
4701
  return null;
4011
4702
  }
4012
4703
  };
4013
4704
  const readPointer = async (repoRoot, readGitUserEmail) => {
4014
- const empty = {
4015
- activeTask: null,
4016
- branch: null,
4017
- lastSession: null
4018
- };
4705
+ const empty = null;
4019
4706
  const email = await readGitUserEmail(repoRoot);
4020
4707
  if (!email) return empty;
4021
4708
  const slug = email.split("@")[0] ?? "";
@@ -4023,7 +4710,7 @@ const readPointer = async (repoRoot, readGitUserEmail) => {
4023
4710
  const pointerPath = join(repoRoot, STATE_DIR, `current-${slug}.md`);
4024
4711
  let raw;
4025
4712
  try {
4026
- raw = await readFile(pointerPath, "utf8");
4713
+ raw = await readRegularFile(pointerPath);
4027
4714
  } catch {
4028
4715
  return empty;
4029
4716
  }
@@ -4031,12 +4718,7 @@ const readPointer = async (repoRoot, readGitUserEmail) => {
4031
4718
  if (!match) return empty;
4032
4719
  const parsed = pointerFrontmatterSchema.safeParse(parse(match[1] ?? ""));
4033
4720
  if (!parsed.success) return empty;
4034
- const front = parsed.data;
4035
- return {
4036
- activeTask: normalize(front.active_task),
4037
- branch: normalize(front.branch),
4038
- lastSession: normalize(front.last_close_ts)
4039
- };
4721
+ return normalize(parsed.data.last_close_ts);
4040
4722
  };
4041
4723
  const normalize = (value) => {
4042
4724
  if (value === null || value === void 0) return null;
@@ -4103,12 +4785,12 @@ const COMMANDS = [
4103
4785
  },
4104
4786
  {
4105
4787
  name: "design-tokens",
4106
- summary: "Validate the 3-tier design-token file (and gate UI source against hardcoded values), check WCAG contrast of token pairs, or sync tokens with a design tool (consume-if-exists; never created).",
4788
+ summary: "Validate the 3-tier design-token file (and gate UI source against hardcoded values), check WCAG contrast of token pairs, or sync tokens with a design tool (consume-if-exists: the gates never create the file; `import --apply` bootstraps it).",
4107
4789
  usage: "lemony design-tokens <validate [--scan=<dir>] | contrast | import --from=<file> [--apply] [--only=<paths>] | export [--tool-state=<file>] [--out=<file>] [--record]>"
4108
4790
  },
4109
4791
  {
4110
4792
  name: "review-ledger",
4111
- 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).",
4793
+ 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).",
4112
4794
  usage: "lemony review-ledger validate --task-id=<id> --anchor=<oid> (--step=<N> | --full-pass)"
4113
4795
  },
4114
4796
  {
@@ -4673,54 +5355,6 @@ const materializeVendorFiles = async (ctx) => {
4673
5355
  ];
4674
5356
  };
4675
5357
  //#endregion
4676
- //#region src/install/gitignore.ts
4677
- const GITIGNORE_BEGIN = "# BEGIN lemony";
4678
- const GITIGNORE_END = "# END lemony";
4679
- const GITIGNORE_BLOCK = [
4680
- GITIGNORE_BEGIN,
4681
- ".claude/state/current-*.md",
4682
- ".claude/state/.ptr.*",
4683
- ".claude/state/sessions/",
4684
- ".claude/state/events.jsonl",
4685
- ".claude/state/telemetry-cursor.json",
4686
- ".claude/state/telemetry-consent.json",
4687
- ".claude/state/.telemetry-notice-shown",
4688
- ".claude/state/telemetry-rejected.jsonl",
4689
- ".claude/.harness/snapshots/",
4690
- GITIGNORE_END
4691
- ].join("\n");
4692
- const appendGitignoreBlock = async (repoRoot) => {
4693
- const gitignorePath = join(repoRoot, ".gitignore");
4694
- const current = await pathExists(gitignorePath) ? await readFile(gitignorePath, "utf8") : "";
4695
- const beginIdx = current.indexOf(GITIGNORE_BEGIN);
4696
- const endIdx = current.indexOf(GITIGNORE_END, beginIdx);
4697
- if (beginIdx !== -1 && endIdx !== -1) {
4698
- const next = current.slice(0, beginIdx) + GITIGNORE_BLOCK + current.slice(endIdx + 12);
4699
- if (next === current) return null;
4700
- await writeFile(gitignorePath, next);
4701
- return ".gitignore";
4702
- }
4703
- const prefix = current.length === 0 ? "" : current.endsWith("\n") ? "\n" : "\n\n";
4704
- await writeFile(gitignorePath, `${current}${prefix}${GITIGNORE_BLOCK}\n`);
4705
- return ".gitignore";
4706
- };
4707
- const removeGitignoreBlock = async (repoRoot) => {
4708
- const relPath = ".gitignore";
4709
- const gitignorePath = join(repoRoot, relPath);
4710
- if (!await pathExists(gitignorePath)) return null;
4711
- const lines = (await readFile(gitignorePath, "utf8")).split("\n");
4712
- const startLine = lines.findIndex((line) => line.trim() === GITIGNORE_BEGIN);
4713
- const endLine = startLine === -1 ? -1 : lines.findIndex((line, index) => index > startLine && line.trim() === GITIGNORE_END);
4714
- if (startLine === -1 || endLine === -1) return null;
4715
- const result = [...lines.slice(0, startLine), ...lines.slice(endLine + 1)].join("\n").replace(/\n+$/, "\n");
4716
- if (result.trim() === "") {
4717
- await rm(gitignorePath, { force: true });
4718
- return relPath;
4719
- }
4720
- await writeFile(gitignorePath, result);
4721
- return relPath;
4722
- };
4723
- //#endregion
4724
5358
  //#region src/install/merge-settings.ts
4725
5359
  const mergeSettingsHooks = async (templatePath, destPath) => {
4726
5360
  const templateRaw = await readFile(templatePath, "utf8");
@@ -4728,7 +5362,7 @@ const mergeSettingsHooks = async (templatePath, destPath) => {
4728
5362
  if (template.hooks === void 0) throw new Error(`settings template at ${templatePath} has no "hooks" key — vendor authority is the hooks block.`);
4729
5363
  let existing = {};
4730
5364
  if (await pathExists(destPath)) {
4731
- const existingRaw = await readFile(destPath, "utf8");
5365
+ const existingRaw = await readSettings(destPath, mergeContext(destPath));
4732
5366
  if (existingRaw.trim().length > 0) try {
4733
5367
  existing = JSON.parse(existingRaw);
4734
5368
  } catch (cause) {
@@ -4746,7 +5380,7 @@ const mergeSettingsHooks = async (templatePath, destPath) => {
4746
5380
  };
4747
5381
  const removeSettingsHooks = async (settingsPath) => {
4748
5382
  if (!await pathExists(settingsPath)) return "absent";
4749
- const raw = await readFile(settingsPath, "utf8");
5383
+ const raw = await readSettings(settingsPath, removeContext(settingsPath));
4750
5384
  if (raw.trim().length === 0) return "absent";
4751
5385
  let parsed;
4752
5386
  try {
@@ -4763,6 +5397,24 @@ const removeSettingsHooks = async (settingsPath) => {
4763
5397
  await writeFile(settingsPath, `${JSON.stringify(rest, null, 2)}\n`);
4764
5398
  return "stripped";
4765
5399
  };
5400
+ const mergeContext = (path) => `Cannot merge the harness hooks into ${path}`;
5401
+ const removeContext = (path) => `Cannot remove the harness hooks from ${path}`;
5402
+ const assertSettingsNotSpecial = async (path, action) => {
5403
+ const target = await stat(path).catch(() => void 0);
5404
+ if (target === void 0 || target.isFile()) return;
5405
+ const context = action === "merge" ? mergeContext(path) : removeContext(path);
5406
+ const reason = target.isDirectory() ? "the entry is a directory." : SPECIAL_FILE_REFUSAL;
5407
+ throw new Error(`${context}: ${reason} ${REMOVE_IT}`);
5408
+ };
5409
+ const REMOVE_IT = "Remove it before re-running.";
5410
+ const readSettings = async (path, context) => {
5411
+ try {
5412
+ return await readRegularFile(path);
5413
+ } catch (error) {
5414
+ if (error.code !== void 0) throw error;
5415
+ throw new Error(`${context}: ${error.message} ${REMOVE_IT}`, { cause: error });
5416
+ }
5417
+ };
4766
5418
  const collectCommands = (hooks) => {
4767
5419
  const result = /* @__PURE__ */ new Set();
4768
5420
  if (!hooks || typeof hooks !== "object") return result;
@@ -4787,6 +5439,54 @@ const diffLostCommands = (existingHooks, templateHooks) => {
4787
5439
  return lost.toSorted();
4788
5440
  };
4789
5441
  //#endregion
5442
+ //#region src/install/gitignore.ts
5443
+ const GITIGNORE_BEGIN = "# BEGIN lemony";
5444
+ const GITIGNORE_END = "# END lemony";
5445
+ const GITIGNORE_BLOCK = [
5446
+ GITIGNORE_BEGIN,
5447
+ ".claude/state/current-*.md",
5448
+ ".claude/state/.ptr.*",
5449
+ ".claude/state/sessions/",
5450
+ ".claude/state/events.jsonl",
5451
+ ".claude/state/telemetry-cursor.json",
5452
+ ".claude/state/telemetry-consent.json",
5453
+ ".claude/state/.telemetry-notice-shown",
5454
+ ".claude/state/telemetry-rejected.jsonl",
5455
+ ".claude/.harness/snapshots/",
5456
+ GITIGNORE_END
5457
+ ].join("\n");
5458
+ const appendGitignoreBlock = async (repoRoot) => {
5459
+ const gitignorePath = join(repoRoot, ".gitignore");
5460
+ const current = await pathExists(gitignorePath) ? await readFile(gitignorePath, "utf8") : "";
5461
+ const beginIdx = current.indexOf(GITIGNORE_BEGIN);
5462
+ const endIdx = current.indexOf(GITIGNORE_END, beginIdx);
5463
+ if (beginIdx !== -1 && endIdx !== -1) {
5464
+ const next = current.slice(0, beginIdx) + GITIGNORE_BLOCK + current.slice(endIdx + 12);
5465
+ if (next === current) return null;
5466
+ await writeFile(gitignorePath, next);
5467
+ return ".gitignore";
5468
+ }
5469
+ const prefix = current.length === 0 ? "" : current.endsWith("\n") ? "\n" : "\n\n";
5470
+ await writeFile(gitignorePath, `${current}${prefix}${GITIGNORE_BLOCK}\n`);
5471
+ return ".gitignore";
5472
+ };
5473
+ const removeGitignoreBlock = async (repoRoot) => {
5474
+ const relPath = ".gitignore";
5475
+ const gitignorePath = join(repoRoot, relPath);
5476
+ if (!await pathExists(gitignorePath)) return null;
5477
+ const lines = (await readFile(gitignorePath, "utf8")).split("\n");
5478
+ const startLine = lines.findIndex((line) => line.trim() === GITIGNORE_BEGIN);
5479
+ const endLine = startLine === -1 ? -1 : lines.findIndex((line, index) => index > startLine && line.trim() === GITIGNORE_END);
5480
+ if (startLine === -1 || endLine === -1) return null;
5481
+ const result = [...lines.slice(0, startLine), ...lines.slice(endLine + 1)].join("\n").replace(/\n+$/, "\n");
5482
+ if (result.trim() === "") {
5483
+ await rm(gitignorePath, { force: true });
5484
+ return relPath;
5485
+ }
5486
+ await writeFile(gitignorePath, result);
5487
+ return relPath;
5488
+ };
5489
+ //#endregion
4790
5490
  //#region src/install/resync.ts
4791
5491
  const resyncSpecialCased = async (repoRoot, templateRoot) => {
4792
5492
  const { lostCommands } = await mergeSettingsHooks(join(templateRoot, CLAUDE_DIR, "settings.json.tpl"), join(repoRoot, SETTINGS_FILE));
@@ -4843,6 +5543,7 @@ const runInstall = async (options) => {
4843
5543
  ...COMMITTED_STATE_DIRS.map((dir) => join(STATE_DIR, dir, ".gitkeep")),
4844
5544
  HARNESS_CONFIG_FILENAME
4845
5545
  ]);
5546
+ await assertSettingsNotSpecial(join(repoRoot, SETTINGS_FILE), "merge");
4846
5547
  const readClient = async (relPath) => {
4847
5548
  const filePath = join(repoRoot, relPath);
4848
5549
  if (!await pathExists(filePath)) return null;
@@ -4991,6 +5692,7 @@ const runReconcile = async (inputs) => {
4991
5692
  SETTINGS_FILE,
4992
5693
  ".gitignore"
4993
5694
  ]);
5695
+ await assertSettingsNotSpecial(join(repoRoot, SETTINGS_FILE), "merge");
4994
5696
  const actions = await runEngine({
4995
5697
  baseline,
4996
5698
  vendorNew,
@@ -5147,6 +5849,7 @@ const runUninstall = async (options) => {
5147
5849
  HARNESS_DIR,
5148
5850
  HARNESS_CONFIG_FILENAME
5149
5851
  ]);
5852
+ await assertSettingsNotSpecial(join(repoRoot, SETTINGS_FILE), "remove");
5150
5853
  await Promise.all(removedFiles.map((relPath) => rm(join(repoRoot, relPath), { force: true })));
5151
5854
  await pruneEmptyDirs(repoRoot, removedFiles);
5152
5855
  const settings = await removeSettingsHooks(join(repoRoot, SETTINGS_FILE));
@@ -5358,8 +6061,14 @@ const PACKAGE_JSON_PATH = join(PACKAGE_ROOT, "package.json");
5358
6061
  const execFileAsync = promisify(execFile);
5359
6062
  const parseFlag = (args, name) => {
5360
6063
  const prefix = `--${name}=`;
5361
- const value = args.find((arg) => arg.startsWith(prefix))?.slice(prefix.length);
5362
- return value ? value : void 0;
6064
+ const joined = args.find((arg) => arg.startsWith(prefix))?.slice(prefix.length);
6065
+ if (joined !== void 0) return joined ? joined : void 0;
6066
+ const at = args.indexOf(`--${name}`);
6067
+ const next = at === -1 ? void 0 : args[at + 1];
6068
+ return next !== void 0 && next !== "" && !next.startsWith("--") ? next : void 0;
6069
+ };
6070
+ const rejectValuelessFlags = (verb, args, names) => {
6071
+ for (const name of names) if (args.some((arg) => arg === `--${name}` || arg.startsWith(`--${name}=`)) && parseFlag(args, name) === void 0) throw new Error(`${verb}: --${name} was given without a value.`);
5363
6072
  };
5364
6073
  const readHarnessVersion = async () => {
5365
6074
  const raw = await readFile(PACKAGE_JSON_PATH, "utf8");
@@ -5389,6 +6098,32 @@ const makeRunCommand = () => async (cmd, args) => {
5389
6098
  };
5390
6099
  }
5391
6100
  };
6101
+ const makeVerifierRunCommand = () => (cmd, args) => new Promise((resolveResult) => {
6102
+ const child = spawn(cmd, args, { stdio: [
6103
+ "ignore",
6104
+ "pipe",
6105
+ "pipe"
6106
+ ] });
6107
+ const out = [];
6108
+ const err = [];
6109
+ child.stdout.on("data", (chunk) => out.push(chunk));
6110
+ child.stderr.on("data", (chunk) => err.push(chunk));
6111
+ child.on("error", (error) => {
6112
+ resolveResult({
6113
+ code: error.code === "ENOENT" ? 127 : 1,
6114
+ stdout: Buffer.concat(out).toString("utf8"),
6115
+ stderr: `${Buffer.concat(err).toString("utf8")}${error.message}\n`
6116
+ });
6117
+ });
6118
+ child.on("close", (code, signal) => {
6119
+ resolveResult({
6120
+ code: code ?? 1,
6121
+ stdout: Buffer.concat(out).toString("utf8"),
6122
+ stderr: Buffer.concat(err).toString("utf8"),
6123
+ ...signal === null ? {} : { signal }
6124
+ });
6125
+ });
6126
+ });
5392
6127
  const buildResolveDeps = () => {
5393
6128
  const rl = createInterface({
5394
6129
  input: stdin,
@@ -5413,10 +6148,10 @@ const gitBehind = async (repoRoot) => {
5413
6148
  repoRoot,
5414
6149
  "symbolic-ref",
5415
6150
  "--quiet",
5416
- "--short",
5417
6151
  "HEAD"
5418
6152
  ]);
5419
- const branch = branchResult.code === 0 ? branchResult.stdout.trim() || null : null;
6153
+ const ref = branchResult.code === 0 ? branchResult.stdout.trim() : "";
6154
+ const branch = ref.startsWith("refs/heads/") ? ref.slice(11) || null : null;
5420
6155
  const headRef = await run("git", [
5421
6156
  "-C",
5422
6157
  repoRoot,
@@ -5778,18 +6513,18 @@ const designTokens = async (args) => {
5778
6513
  throw new Error(`Usage: lemony design-tokens <validate [--scan=<dir>] | contrast | import --from=<file> [--apply] [--only=<paths>] | export [--tool-state=<file>] [--out=<file>] [--record]>. Unknown action "${action}".`);
5779
6514
  };
5780
6515
  const designTokensValidate = async (args) => {
5781
- const config = await readHarnessConfig(cwd()).catch(() => void 0);
6516
+ const config = await readOptionalHarnessConfig(cwd());
5782
6517
  const result = await runValidate({
5783
6518
  repoRoot: cwd(),
5784
6519
  scanDir: parseFlag(args, "scan"),
5785
6520
  extraExtensions: config?.design_tokens.scan_extensions
5786
6521
  });
5787
6522
  if (!result.tokensFound) {
5788
- console.log(`No docs/design-tokens.json — design-tokens validate skipped (consume-if-exists; the harness never creates it).`);
6523
+ console.log(`No docs/design-tokens.json — design-tokens validate skipped (consume-if-exists; the gates never create it).`);
5789
6524
  return;
5790
6525
  }
5791
6526
  if (result.ok) {
5792
- console.log(`design-tokens valid: ${result.tokenCount} token(s), ${result.filesScanned} file(s) scanned, no hardcoded values.`);
6527
+ console.log(result.filesScanned === 0 ? `design-tokens valid: ${result.tokenCount} token(s), but 0 file(s) scanned — no readable source with a scanned extension was found, so nothing was checked for hardcoded values.` : `design-tokens valid: ${result.tokenCount} token(s), ${result.filesScanned} file(s) scanned, no hardcoded values.`);
5793
6528
  return;
5794
6529
  }
5795
6530
  console.error(`design-tokens validate found ${result.violations.length} violation(s):`);
@@ -5799,26 +6534,77 @@ const designTokensValidate = async (args) => {
5799
6534
  }
5800
6535
  exit(1);
5801
6536
  };
6537
+ const verifierEnding = (outcome) => outcome.signal === void 0 ? `exited ${outcome.code}` : `was killed by ${outcome.signal}`;
6538
+ const quotedCommand = (command) => {
6539
+ const lines = command.trimEnd().split("\n");
6540
+ return lines.length > 1 ? `\`${lines[0]} …\`` : `\`${command}\``;
6541
+ };
6542
+ const drained = (stream) => new Promise((resolve) => {
6543
+ stream.once("error", () => resolve());
6544
+ stream.write("", () => resolve());
6545
+ });
6546
+ const contrastFailedLine = (pairCheck, verify) => {
6547
+ if (verify === void 0) return `design-tokens contrast failed: ${pairCheck}.`;
6548
+ const outcome = verify.code === 0 && verify.signal === void 0 ? "passed (exited 0)" : verifierEnding(verify);
6549
+ return `design-tokens contrast failed: ${pairCheck}; design_tokens.verify ${quotedCommand(verify.command)} ${outcome}.`;
6550
+ };
5802
6551
  const designTokensContrast = async () => {
5803
- const result = await runContrast({ repoRoot: cwd() });
6552
+ const verify = (await readOptionalHarnessConfig(cwd()))?.design_tokens.verify;
6553
+ const result = await runContrast({
6554
+ repoRoot: cwd(),
6555
+ ...verify === void 0 ? {} : { verify: {
6556
+ command: verify,
6557
+ run: makeVerifierRunCommand()
6558
+ } }
6559
+ });
6560
+ const failures = result.pairs.filter((pair) => !pair.passes);
6561
+ const forwardVerifier = () => {
6562
+ if (result.verify === void 0) return;
6563
+ const { stdout: out, stderr: err } = result.verify;
6564
+ if (out.length > 0) stdout.write(out.endsWith("\n") ? out : `${out}\n`);
6565
+ if (err.length > 0) stderr.write(err.endsWith("\n") ? err : `${err}\n`);
6566
+ };
6567
+ const failedVerifier = result.verify !== void 0 && result.verify.code !== 0 ? result.verify : void 0;
5804
6568
  if (!result.tokensFound) {
5805
- console.log(`No docs/design-tokens.json — design-tokens contrast skipped (consume-if-exists; the harness never creates it).`);
6569
+ console.log(result.verify === void 0 ? `No docs/design-tokens.json — design-tokens contrast skipped (consume-if-exists; the gates never create it).` : `No docs/design-tokens.json — design-tokens contrast pair check skipped (consume-if-exists; the gates never create it); running the declared design_tokens.verify.`);
6570
+ if (result.verify === void 0) return;
6571
+ if (failedVerifier) console.error(`design-tokens contrast found 1 issue(s):`);
6572
+ forwardVerifier();
6573
+ await Promise.all([drained(stdout), drained(stderr)]);
6574
+ if (failedVerifier) {
6575
+ console.error(` design_tokens.verify ${quotedCommand(failedVerifier.command)} ${verifierEnding(failedVerifier)}.`);
6576
+ console.error(contrastFailedLine("pair check skipped (no docs/design-tokens.json)", failedVerifier));
6577
+ process.exitCode = 1;
6578
+ return;
6579
+ }
6580
+ console.log(`design-tokens verify OK: ${quotedCommand(result.verify.command)} exited 0.`);
5806
6581
  return;
5807
6582
  }
5808
- const failures = result.pairs.filter((pair) => !pair.passes);
5809
6583
  if (result.ok) {
5810
6584
  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.`);
6585
+ forwardVerifier();
6586
+ if (result.verify !== void 0) console.log(`design-tokens verify OK: ${quotedCommand(result.verify.command)} exited 0.`);
5811
6587
  return;
5812
6588
  }
5813
- console.error(`design-tokens contrast found ${failures.length + result.problems.length} issue(s):`);
5814
- for (const pair of failures) console.error(` ${pair.foreground} on ${pair.background} (${pair.mode}, ${pair.level}): ${pair.ratio}:1 < ${pair.floor}:1`);
6589
+ console.error(`design-tokens contrast found ${failures.length + result.problems.length + (failedVerifier ? 1 : 0)} issue(s):`);
6590
+ for (const pair of failures) {
6591
+ const over = pair.over === void 0 ? "" : ` over ${pair.over}`;
6592
+ console.error(` ${pair.foreground} on ${pair.background}${over} (${pair.mode}, ${pair.level}): ${pair.ratio}:1 < ${pair.floor}:1`);
6593
+ }
5815
6594
  for (const problem of result.problems) console.error(` ${problem}`);
5816
- exit(1);
6595
+ forwardVerifier();
6596
+ await Promise.all([drained(stdout), drained(stderr)]);
6597
+ if (failedVerifier) console.error(` design_tokens.verify ${quotedCommand(failedVerifier.command)} ${verifierEnding(failedVerifier)}.`);
6598
+ const pairIssues = failures.length + result.problems.length;
6599
+ console.error(contrastFailedLine(pairIssues > 0 ? result.pairsChecked === 0 ? `pair check measured no pair (${pairIssues} issue(s) above)` : `pair check found ${pairIssues} issue(s)` : result.pairsChecked === 0 ? "pair check found no pairs" : "pair check passed", result.verify));
6600
+ process.exitCode = 1;
5817
6601
  };
5818
6602
  const designTokensImport = async (args) => {
5819
6603
  const from = parseFlag(args, "from");
5820
6604
  if (from === void 0) throw new Error("Usage: lemony design-tokens import --from=<neutral-file> [--apply] [--only=<dotted,paths>]");
6605
+ rejectValuelessFlags("design-tokens import", args, ["only"]);
5821
6606
  const only = parseFlag(args, "only")?.split(",").map((path) => path.trim()).filter((path) => path.length > 0);
6607
+ if (only?.length === 0) throw new Error("design-tokens import: --only names no path.");
5822
6608
  const result = await runImport({
5823
6609
  repoRoot: cwd(),
5824
6610
  neutralPath: resolve(cwd(), from),
@@ -5826,26 +6612,43 @@ const designTokensImport = async (args) => {
5826
6612
  only
5827
6613
  });
5828
6614
  if (result.problems.length > 0) {
5829
- console.error("design-tokens import could not run:");
6615
+ console.error(result.refused ? "design-tokens import --apply refused:" : "design-tokens import could not run:");
5830
6616
  for (const problem of result.problems) console.error(` ${problem}`);
5831
- exit(1);
6617
+ if (result.changes.length > 0) printImportPreview(result);
6618
+ process.exitCode = 1;
6619
+ return;
5832
6620
  }
5833
6621
  if (result.applied) {
5834
6622
  console.log(`design-tokens import applied: wrote ${result.written.length} token(s) to ${DESIGN_TOKENS_FILE}.`);
5835
6623
  if (result.skipped.length > 0) console.log(` skipped ${result.skipped.length} path(s) that would clobber an existing group or token — resolve by hand: ${result.skipped.join(", ")}`);
6624
+ if (result.unsynced.length > 0) console.log(` not applied: ${result.unsynced.length} token(s) outside the design-tool sync, which one tool value cannot replace: ${result.unsynced.join(", ")}`);
5836
6625
  return;
5837
6626
  }
6627
+ const landing = printImportPreview(result);
6628
+ for (const warning of result.warnings) console.error(` Warning: ${warning}`);
6629
+ if (landing > 0) console.log(`Curate the slice, then: lemony design-tokens import --from=${from} --apply --only=<paths>`);
6630
+ };
6631
+ const printImportPreview = (result) => {
5838
6632
  const landing = result.changes.filter((change) => change.kind !== "unchanged");
5839
6633
  const news = landing.filter((change) => change.kind === "new").length;
5840
6634
  const changed = landing.length - news;
5841
6635
  console.log(`design-tokens import preview: ${news} new, ${changed} changed, ${result.changes.length - landing.length} unchanged.`);
5842
6636
  for (const change of landing) {
5843
- const detail = change.kind === "changed" ? `${change.before} → ${change.after}` : change.after;
6637
+ const detail = change.kind === "changed" ? changedDetail(change) : change.after;
5844
6638
  console.log(` [${change.kind}] ${change.path} (${change.tier}): ${detail}`);
5845
6639
  }
5846
- if (landing.length > 0) console.log(`Curate the slice, then: lemony design-tokens import --from=${from} --apply --only=<paths>`);
6640
+ return landing.length;
6641
+ };
6642
+ const changedDetail = (change) => {
6643
+ const parts = [];
6644
+ if (change.before !== change.after) parts.push(`${shown(change.before)} → ${shown(change.after)}`);
6645
+ if (change.typeChange) parts.push(`$type ${shown(change.typeChange.before)} → ${shown(change.typeChange.after)}`);
6646
+ for (const mode of change.modeChanges ?? []) parts.push(`${mode.mode}: ${shown(mode.before)} → ${shown(mode.after)}`);
6647
+ return parts.join("; ");
5847
6648
  };
6649
+ const shown = (value) => value === void 0 || value === "" ? "(none)" : value;
5848
6650
  const designTokensExport = async (args) => {
6651
+ rejectValuelessFlags("design-tokens export", args, ["tool-state", "out"]);
5849
6652
  const toolState = parseFlag(args, "tool-state");
5850
6653
  const out = parseFlag(args, "out");
5851
6654
  if (out !== void 0 && args.includes("--record")) {
@@ -5859,7 +6662,7 @@ const designTokensExport = async (args) => {
5859
6662
  record: args.includes("--record")
5860
6663
  });
5861
6664
  if (!result.tokensFound) {
5862
- console.log(`No ${DESIGN_TOKENS_FILE} — design-tokens export skipped (consume-if-exists; the harness never creates it).`);
6665
+ console.log(`No ${DESIGN_TOKENS_FILE} — design-tokens export skipped (consume-if-exists; export never creates it).`);
5863
6666
  return;
5864
6667
  }
5865
6668
  if (result.problems.length > 0) {
@@ -5874,6 +6677,7 @@ const designTokensExport = async (args) => {
5874
6677
  const creates = result.plan.filter((entry) => entry.kind === "create").length;
5875
6678
  const updates = result.plan.filter((entry) => entry.kind === "update").length;
5876
6679
  console.log(`design-tokens export plan: ${creates} to create, ${updates} to update (tool-only variables are never deleted).`);
6680
+ if (result.unprojected.length > 0) console.log(` not projected: ${result.unprojected.length} token(s) a design tool variable cannot hold: ${result.unprojected.map(({ path, reason }) => `${path} (${reason})`).join(", ")}`);
5877
6681
  if (out !== void 0) console.log(` projection written to ${out}`);
5878
6682
  };
5879
6683
  const REVIEW_LEDGER_USAGE = "Usage: lemony review-ledger validate --task-id=<id> --anchor=<oid> (--step=<N> | --full-pass).";