@lemoncode/lemony 0.5.0 → 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
@@ -6,9 +6,9 @@ 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 });
@@ -47,6 +47,136 @@ const PATHS_DEFAULTS = {
47
47
  playbooks_global: "~/.claude/playbooks"
48
48
  };
49
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
50
180
  //#region src/config/config-error.ts
51
181
  const formatConfigError = (schema, error) => {
52
182
  return error.issues.flatMap((issue) => formatIssue(schema, issue)).join("\n");
@@ -149,7 +279,7 @@ const readHarnessConfig = async (repoRoot) => {
149
279
  const configPath = join(repoRoot, HARNESS_CONFIG_FILENAME);
150
280
  let raw;
151
281
  try {
152
- raw = await readFile(configPath, "utf8");
282
+ raw = await readRegularFile(configPath);
153
283
  } catch (cause) {
154
284
  if (cause.code === "ENOENT") throw new Error(`${HARNESS_CONFIG_FILENAME} not found at ${repoRoot}. Run \`lemony install\` first.`, { cause });
155
285
  throw new Error(`Cannot read ${HARNESS_CONFIG_FILENAME} at ${repoRoot}: ${cause instanceof Error ? cause.message : String(cause)}`, { cause });
@@ -179,6 +309,21 @@ const readHarnessConfig = async (repoRoot) => {
179
309
  if (!result.success) throw new Error(`${HARNESS_CONFIG_FILENAME} is invalid:\n${formatConfigError(harnessConfigSchema, result.error)}`);
180
310
  return result.data;
181
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
+ };
182
327
  //#endregion
183
328
  //#region src/config/compare-version.ts
184
329
  const PRERELEASE_RANK = {
@@ -995,70 +1140,6 @@ const failureReason$1 = (result) => {
995
1140
  return `\`gh\` failed (exit ${result.code})${suffix}${detail ? `: ${detail}` : ""}`;
996
1141
  };
997
1142
  //#endregion
998
- //#region src/fs/exists.ts
999
- const pathExists = async (path) => {
1000
- try {
1001
- await stat(path);
1002
- return true;
1003
- } catch {
1004
- return false;
1005
- }
1006
- };
1007
- //#endregion
1008
- //#region src/fs/list-files.ts
1009
- const listFiles = async (dir) => {
1010
- const entries = await readdir(dir, { withFileTypes: true });
1011
- return (await Promise.all(entries.map(async (entry) => {
1012
- if (entry.isDirectory()) return (await listFiles(join(dir, entry.name))).map((rel) => join(entry.name, rel));
1013
- return [entry.name];
1014
- }))).flat();
1015
- };
1016
- //#endregion
1017
- //#region src/fs/prune-empty-dirs.ts
1018
- const pruneEmptyDirs = async (repoRoot, removedFiles) => {
1019
- const dirs = /* @__PURE__ */ new Set();
1020
- for (const relPath of removedFiles) {
1021
- let dir = dirname(relPath);
1022
- while (dir !== "." && dir !== sep) {
1023
- dirs.add(dir);
1024
- dir = dirname(dir);
1025
- }
1026
- }
1027
- const ordered = [...dirs].toSorted((a, b) => b.length - a.length);
1028
- for (const dir of ordered) try {
1029
- await rmdir(join(repoRoot, dir));
1030
- } catch {}
1031
- };
1032
- //#endregion
1033
- //#region src/fs/write-managed.ts
1034
- const writeManaged = async (root, relPath, content, executable) => {
1035
- const dest = join(root, relPath);
1036
- await mkdir(dirname(dest), { recursive: true });
1037
- await writeFile(dest, content);
1038
- await chmod(dest, executable ? 493 : 420);
1039
- };
1040
- //#endregion
1041
- //#region src/fs/symlink-guard.ts
1042
- const assertNoSymlinkTraversal = async (root, relPaths) => {
1043
- const components = /* @__PURE__ */ new Set();
1044
- for (const relPath of relPaths) {
1045
- let current = "";
1046
- for (const part of relPath.split(sep)) {
1047
- if (part.length === 0) continue;
1048
- current = current ? join(current, part) : part;
1049
- components.add(current);
1050
- }
1051
- }
1052
- const offenders = (await Promise.all([...components].map(async (rel) => {
1053
- try {
1054
- return (await lstat(join(root, rel))).isSymbolicLink() ? rel : null;
1055
- } catch {
1056
- return null;
1057
- }
1058
- }))).filter((rel) => rel !== null);
1059
- 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"));
1060
- };
1061
- //#endregion
1062
1143
  //#region src/design-tokens/design-tokens.constant.ts
1063
1144
  const DESIGN_TOKENS_FILE = "docs/design-tokens.json";
1064
1145
  const SCANNED_EXTENSIONS = [
@@ -1101,6 +1182,19 @@ const IGNORED_DIRS = /* @__PURE__ */ new Set([
1101
1182
  ".cache",
1102
1183
  ".claude"
1103
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
+ };
1104
1198
  //#endregion
1105
1199
  //#region src/design-tokens/design-tokens.model.ts
1106
1200
  const TOKEN_TIERS = [
@@ -1109,6 +1203,46 @@ const TOKEN_TIERS = [
1109
1203
  "component"
1110
1204
  ];
1111
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
1112
1246
  //#region src/design-tokens/token-parse.ts
1113
1247
  const aliasTarget = (value) => {
1114
1248
  if (typeof value !== "string") return void 0;
@@ -1119,15 +1253,28 @@ const isRecord = (value) => typeof value === "object" && value !== null && !Arra
1119
1253
  //#endregion
1120
1254
  //#region src/design-tokens/design-tokens.ts
1121
1255
  const runValidate = async (inputs) => {
1122
- const tokenPath = join(inputs.repoRoot, DESIGN_TOKENS_FILE);
1123
- 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 {
1124
1272
  ok: true,
1125
1273
  tokensFound: false,
1126
1274
  tokenCount: 0,
1127
1275
  filesScanned: 0,
1128
1276
  violations: []
1129
1277
  };
1130
- const raw = await readFile(tokenPath, "utf8");
1131
1278
  const fileCheck = validateTokenFile(raw);
1132
1279
  if (!fileCheck.ok) return {
1133
1280
  ok: false,
@@ -1137,6 +1284,19 @@ const runValidate = async (inputs) => {
1137
1284
  violations: fileCheck.violations
1138
1285
  };
1139
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
+ }
1140
1300
  const extensions = mergeExtensions(inputs.extraExtensions);
1141
1301
  const sourceViolations = await scanSource(inputs.repoRoot, scanRoot, extensions);
1142
1302
  return {
@@ -1147,6 +1307,13 @@ const runValidate = async (inputs) => {
1147
1307
  violations: sourceViolations.violations
1148
1308
  };
1149
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
+ };
1150
1317
  const validateTokenFile = (raw) => {
1151
1318
  let parsed;
1152
1319
  try {
@@ -1169,7 +1336,19 @@ const validateTokenFile = (raw) => {
1169
1336
  message: `${DESIGN_TOKENS_FILE} must be a JSON object with primitive/semantic/component tiers.`
1170
1337
  }]
1171
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
+ };
1172
1347
  const violations = [];
1348
+ for (const message of nonFiniteNumberFaults(parsed)) violations.push({
1349
+ kind: "malformed-token-file",
1350
+ message
1351
+ });
1173
1352
  for (const key of Object.keys(parsed)) {
1174
1353
  if (key.startsWith("$")) continue;
1175
1354
  if (!TOKEN_TIERS.includes(key)) violations.push({
@@ -1197,6 +1376,10 @@ const validateTokenFile = (raw) => {
1197
1376
  violations
1198
1377
  };
1199
1378
  };
1379
+ const modeValues = (extensions) => {
1380
+ const modes = isRecord(extensions) ? extensions[MODES_EXTENSION] : void 0;
1381
+ return isRecord(modes) ? modes : {};
1382
+ };
1200
1383
  const collectTokens$1 = (node, tier, path, out, violations) => {
1201
1384
  for (const [key, child] of Object.entries(node)) {
1202
1385
  if (key.startsWith("$")) continue;
@@ -1211,7 +1394,8 @@ const collectTokens$1 = (node, tier, path, out, violations) => {
1211
1394
  if ("$value" in child) {
1212
1395
  out.set(childPath, {
1213
1396
  tier,
1214
- value: child.$value
1397
+ value: child.$value,
1398
+ modes: modeValues(child["$extensions"])
1215
1399
  });
1216
1400
  const stray = Object.keys(child).filter((k) => !k.startsWith("$"));
1217
1401
  if (stray.length > 0) violations.push({
@@ -1223,6 +1407,13 @@ const collectTokens$1 = (node, tier, path, out, violations) => {
1223
1407
  };
1224
1408
  const collectAliasViolations = (tokens) => {
1225
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
+ }
1226
1417
  for (const [path, token] of tokens) {
1227
1418
  const alias = aliasTarget(token.value);
1228
1419
  if (alias === void 0) continue;
@@ -1245,9 +1436,16 @@ const collectAliasViolations = (tokens) => {
1245
1436
  message: `token "${path}" has an alias chain that never resolves to a literal value (alias cycle).`
1246
1437
  });
1247
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
+ }
1248
1446
  return violations;
1249
1447
  };
1250
- const aliasChainCycles = (start, tokens) => {
1448
+ const aliasChainCycles = (start, tokens, mode) => {
1251
1449
  const seen = /* @__PURE__ */ new Set();
1252
1450
  let current = start;
1253
1451
  while (current !== void 0) {
@@ -1255,7 +1453,7 @@ const aliasChainCycles = (start, tokens) => {
1255
1453
  seen.add(current);
1256
1454
  const token = tokens.get(current);
1257
1455
  if (token === void 0) return false;
1258
- current = aliasTarget(token.value);
1456
+ current = aliasTarget(mode !== void 0 && Object.hasOwn(token.modes, mode) ? token.modes[mode] : token.value);
1259
1457
  }
1260
1458
  return false;
1261
1459
  };
@@ -1268,16 +1466,38 @@ const mergeExtensions = (extra) => {
1268
1466
  }
1269
1467
  return set;
1270
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
+ };
1271
1486
  const scanSource = async (repoRoot, scanRoot, extensions) => {
1272
1487
  if (!await pathExists(scanRoot)) return {
1273
1488
  filesScanned: 0,
1274
1489
  violations: []
1275
1490
  };
1276
- const files = (await collectSourceFiles(scanRoot, extensions)).toSorted();
1277
- 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));
1278
1498
  let filesScanned = 0;
1279
1499
  for (const file of files) {
1280
- const content = await readFile(file, "utf8").catch(() => void 0);
1500
+ const content = await readRegularFile(file).catch(() => void 0);
1281
1501
  if (content === void 0) continue;
1282
1502
  filesScanned += 1;
1283
1503
  const relPath = relative(repoRoot, file).split(sep).join("/");
@@ -1288,13 +1508,22 @@ const scanSource = async (repoRoot, scanRoot, extensions) => {
1288
1508
  violations
1289
1509
  };
1290
1510
  };
1291
- const collectSourceFiles = async (dir, extensions) => {
1292
- 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
+ }
1293
1522
  return (await Promise.all(entries.map(async (entry) => {
1294
1523
  const full = join(dir, entry.name);
1295
1524
  if (entry.isDirectory()) {
1296
1525
  if (IGNORED_DIRS.has(entry.name)) return [];
1297
- return collectSourceFiles(full, extensions);
1526
+ return collectSourceFiles(full, extensions, unlistable);
1298
1527
  }
1299
1528
  return extensions.has(extname(entry.name).toLowerCase()) ? [full] : [];
1300
1529
  }))).flat();
@@ -1468,17 +1697,6 @@ const contrastRatio = (foreground, background) => {
1468
1697
  return Math.round((light + .05) / (dark + .05) * 100) / 100;
1469
1698
  };
1470
1699
  //#endregion
1471
- //#region src/design-tokens/contrast.constant.ts
1472
- const CONTRAST_EXTENSION = "com.lemony.contrast";
1473
- const MODES_EXTENSION = "com.lemony.modes";
1474
- const BASE_MODE = "base";
1475
- const DEFAULT_LEVEL = "text";
1476
- const WCAG_FLOORS = {
1477
- text: 4.5,
1478
- large: 3,
1479
- "non-text": 3
1480
- };
1481
- //#endregion
1482
1700
  //#region src/design-tokens/contrast.model.ts
1483
1701
  const CONTRAST_LEVELS = [
1484
1702
  "text",
@@ -1488,38 +1706,57 @@ const CONTRAST_LEVELS = [
1488
1706
  //#endregion
1489
1707
  //#region src/design-tokens/contrast.ts
1490
1708
  const runContrast = async (inputs) => {
1491
- const tokenPath = join(inputs.repoRoot, DESIGN_TOKENS_FILE);
1492
- if (!await pathExists(tokenPath)) return emptyResult$1(false);
1493
- 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));
1494
1728
  let parsed;
1495
1729
  try {
1496
1730
  parsed = JSON.parse(raw);
1497
1731
  } catch (error) {
1498
- return {
1732
+ return withVerify({
1499
1733
  ...emptyResult$1(true),
1500
1734
  ok: false,
1501
1735
  problems: [`${DESIGN_TOKENS_FILE} is not valid JSON (${error instanceof Error ? error.message : String(error)}) — run \`design-tokens validate\` first.`]
1502
- };
1736
+ });
1503
1737
  }
1504
- if (!isRecord(parsed)) return {
1738
+ if (!isRecord(parsed)) return withVerify({
1505
1739
  ...emptyResult$1(true),
1506
1740
  ok: false,
1507
1741
  problems: [`${DESIGN_TOKENS_FILE} must be a JSON object — run \`design-tokens validate\` first.`]
1508
- };
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
+ });
1509
1748
  const tokens = collectTokens(parsed);
1510
1749
  const problems = [];
1511
1750
  const modes = declaredModes(tokens);
1512
1751
  const specs = discoverPairs(tokens, modes, problems);
1513
1752
  const pairs = measurePairs(specs, modes, tokens);
1514
- const verify = inputs.verify ? await runVerifier(inputs.verify) : void 0;
1515
- return {
1516
- ok: problems.length === 0 && pairs.every((pair) => pair.passes) && (verify === void 0 || verify.code === 0),
1753
+ return withVerify({
1754
+ ok: problems.length === 0 && pairs.every((pair) => pair.passes),
1517
1755
  tokensFound: true,
1518
1756
  pairsChecked: pairs.length,
1519
1757
  pairs,
1520
- problems,
1521
- ...verify === void 0 ? {} : { verify }
1522
- };
1758
+ problems
1759
+ });
1523
1760
  };
1524
1761
  const emptyResult$1 = (tokensFound) => ({
1525
1762
  ok: true,
@@ -1564,12 +1801,12 @@ const declaredModes = (tokens) => {
1564
1801
  };
1565
1802
  const pairKey = (spec) => `${spec.foreground}|${spec.background}`;
1566
1803
  const discoverPairs = (tokens, modes, problems) => {
1567
- const convention = new Map(conventionPairs(tokens).map((spec) => [pairKey(spec), spec]));
1804
+ const convention = new Map(conventionPairs(tokens, modes, problems).map((spec) => [pairKey(spec), spec]));
1568
1805
  const explicit = extensionPairs(tokens, modes, problems);
1569
1806
  for (const spec of explicit) convention.delete(pairKey(spec));
1570
1807
  return [...convention.values(), ...explicit];
1571
1808
  };
1572
- const conventionPairs = (tokens) => {
1809
+ const conventionPairs = (tokens, modes, problems) => {
1573
1810
  const pairs = [];
1574
1811
  for (const [path, token] of tokens) {
1575
1812
  if (token.tier !== "semantic") continue;
@@ -1580,6 +1817,21 @@ const conventionPairs = (tokens) => {
1580
1817
  if (!tokens.has(basePath)) continue;
1581
1818
  if (!resolveColor(path, "base", tokens)) continue;
1582
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
+ }
1583
1835
  pairs.push({
1584
1836
  foreground: path,
1585
1837
  background: basePath,
@@ -1794,6 +2046,20 @@ const resolveColor = (path, mode, tokens, seen = /* @__PURE__ */ new Set()) => {
1794
2046
  if (alias !== void 0) return resolveColor(alias, mode, tokens, seen);
1795
2047
  return typeof value === "string" ? parseColor(value) : void 0;
1796
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
+ };
1797
2063
  const runVerifier = async (hook) => {
1798
2064
  const result = await hook.run("sh", ["-c", hook.command]);
1799
2065
  return {
@@ -1804,32 +2070,105 @@ const runVerifier = async (hook) => {
1804
2070
  //#endregion
1805
2071
  //#region src/design-tokens/design-sync.constant.ts
1806
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
+ };
1807
2095
  //#endregion
1808
2096
  //#region src/design-tokens/neutral-mapping.ts
1809
2097
  const KNOWN_TIERS = new Set(TOKEN_TIERS);
1810
2098
  const dtcgToNeutral = (parsed) => {
2099
+ const nodes = tokenNodes(parsed);
2100
+ const unsynced = unsyncedReasons(nodes);
1811
2101
  const variables = [];
1812
- for (const tier of TOKEN_TIERS) {
1813
- const group = parsed[tier];
1814
- if (isRecord(group)) walk(group, tier, variables);
1815
- }
2102
+ for (const [path, node] of nodes) if (!unsynced.has(path)) variables.push(toVariable$1(path, node));
1816
2103
  return {
1817
2104
  schemaVersion: 1,
1818
2105
  variables: variables.toSorted(byName)
1819
2106
  };
1820
2107
  };
1821
- 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
+ };
1822
2120
  const walk = (node, path, out) => {
1823
2121
  for (const [key, child] of Object.entries(node)) {
1824
2122
  if (key.startsWith("$") || !isRecord(child)) continue;
1825
2123
  const childPath = `${path}.${key}`;
1826
- if ("$value" in child) out.push(toVariable$1(childPath, child));
2124
+ if ("$value" in child) out.set(childPath, child);
1827
2125
  else walk(child, childPath, out);
1828
2126
  }
1829
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;
1830
2169
  const toVariable$1 = (name, node) => {
1831
2170
  const type = typeof node["$type"] === "string" ? node["$type"] : "";
1832
- const modes = readModes(node);
2171
+ const modes = readModes$1(node);
1833
2172
  const alias = aliasTarget(node["$value"]);
1834
2173
  const base = {
1835
2174
  name,
@@ -1846,37 +2185,64 @@ const toVariable$1 = (name, node) => {
1846
2185
  };
1847
2186
  };
1848
2187
  const stringifyValue = (value) => value === void 0 || value === null ? "" : String(value);
1849
- const readModes = (node) => {
1850
- const extensions = node["$extensions"];
1851
- const modes = isRecord(extensions) ? extensions[MODES_EXTENSION] : void 0;
2188
+ const readModes$1 = (node) => {
2189
+ const modes = modesExtension(node);
1852
2190
  if (!isRecord(modes)) return void 0;
1853
2191
  const out = {};
1854
- 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);
1855
2193
  return Object.keys(out).length > 0 ? out : void 0;
1856
2194
  };
1857
- const toIncoming = (variable) => {
1858
- const isAlias = variable.ref !== void 0;
1859
- const value = isAlias ? `{${variable.ref}}` : variable.value ?? "";
1860
- const type = variable.type;
2195
+ const landingPath = (variable, known) => {
1861
2196
  const first = variable.name.split(".")[0] ?? "";
1862
- if (KNOWN_TIERS.has(first)) return withModes({
1863
- path: variable.name,
1864
- tier: first,
1865
- type,
1866
- value
1867
- }, variable);
1868
- const tier = isAlias ? "semantic" : "primitive";
1869
- return withModes({
1870
- path: `${tier}.${variable.name}`,
1871
- tier,
1872
- 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,
1873
2222
  value
1874
- }, 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;
1875
2245
  };
1876
- const withModes = (token, variable) => variable.modes ? {
1877
- ...token,
1878
- modes: variable.modes
1879
- } : token;
1880
2246
  const comparisonKey = (type, value, modes) => JSON.stringify({
1881
2247
  type,
1882
2248
  value,
@@ -1891,41 +2257,78 @@ const sortedModes = (modes) => {
1891
2257
  const diffNeutralAgainstJson = (neutral, parsed) => {
1892
2258
  const current = /* @__PURE__ */ new Map();
1893
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]));
1894
2261
  const changes = [];
1895
- for (const variable of neutral.variables) {
1896
- 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
+ }
1897
2274
  const existing = current.get(incoming.path);
1898
2275
  const after = incoming.value;
1899
2276
  const before = existing ? neutralValue(existing) : void 0;
2277
+ const kind = classifyImport(existing, incoming);
1900
2278
  changes.push({
1901
2279
  path: incoming.path,
1902
2280
  tier: incoming.tier,
1903
- kind: classifyImport(existing, incoming, variable),
2281
+ kind,
1904
2282
  ...before !== void 0 ? { before } : {},
1905
- after
2283
+ after,
2284
+ ...existing && kind === "changed" ? changeDetail(existing, incoming) : {}
1906
2285
  });
1907
2286
  }
1908
2287
  return changes.toSorted((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
1909
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
+ };
1910
2305
  const neutralValue = (variable) => variable.ref !== void 0 ? `{${variable.ref}}` : variable.value ?? "";
1911
- const classifyImport = (existing, incoming, variable) => {
2306
+ const classifyImport = (existing, incoming) => {
1912
2307
  if (!existing) return "new";
1913
- 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";
1914
2309
  };
1915
2310
  const mergeNeutralIntoJson = (neutral, parsed, only) => {
1916
2311
  const merged = structuredClone(parsed);
1917
2312
  const onlySet = only ? new Set(only) : void 0;
1918
2313
  const written = [];
1919
2314
  const skipped = [];
1920
- for (const variable of neutral.variables) {
1921
- 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()) {
1922
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;
1923
2325
  (setToken(merged, incoming) ? written : skipped).push(incoming.path);
1924
2326
  }
1925
2327
  return {
1926
2328
  merged,
1927
2329
  written: written.toSorted(),
1928
- skipped: skipped.toSorted()
2330
+ skipped: skipped.toSorted(),
2331
+ unsynced: unsyncedLanded.toSorted()
1929
2332
  };
1930
2333
  };
1931
2334
  const isTokenNode = (value) => isRecord(value) && "$value" in value;
@@ -1948,17 +2351,44 @@ const setToken = (root, incoming) => {
1948
2351
  if (!isRecord(next)) node[segment] = {};
1949
2352
  node = node[segment];
1950
2353
  }
1951
- 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);
1952
2360
  if (incoming.type.length > 0) leaf["$type"] = incoming.type;
1953
- 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"];
1954
2371
  node[leafKey] = leaf;
1955
2372
  return true;
1956
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
+ };
1957
2387
  const upsertPlan = (parsed, toolState) => {
1958
- const current = /* @__PURE__ */ new Map();
1959
- 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();
1960
2390
  const plan = [];
1961
- for (const variable of dtcgToNeutral(parsed).variables) plan.push({
2391
+ for (const variable of variables) plan.push({
1962
2392
  name: variable.name,
1963
2393
  kind: classifyExport(variable, current)
1964
2394
  });
@@ -1967,45 +2397,78 @@ const upsertPlan = (parsed, toolState) => {
1967
2397
  const classifyExport = (projected, current) => {
1968
2398
  const existing = current.get(projected.name);
1969
2399
  if (!existing) return "create";
1970
- 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";
1971
2401
  };
1972
2402
  //#endregion
1973
2403
  //#region src/design-tokens/sync-io.ts
1974
- const parseNeutralFile = (raw) => {
2404
+ const parseNeutralFile = (raw, label) => {
1975
2405
  let parsed;
1976
2406
  try {
1977
2407
  parsed = JSON.parse(raw);
1978
2408
  } catch (error) {
1979
- 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 });
1980
2410
  }
1981
- 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.`);
1982
2412
  const schemaVersion = typeof parsed["schemaVersion"] === "number" ? parsed["schemaVersion"] : 0;
1983
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
+ }
1984
2420
  return {
1985
2421
  schemaVersion,
1986
2422
  ...provider ? { provider } : {},
1987
- variables: parsed["variables"].flatMap(toVariable)
2423
+ variables
1988
2424
  };
1989
2425
  };
1990
- const toVariable = (raw) => {
2426
+ const toVariable = (raw, label) => {
1991
2427
  if (!isRecord(raw) || typeof raw["name"] !== "string" || raw["name"].trim().length === 0) return [];
1992
- const type = typeof raw["type"] === "string" ? raw["type"] : "";
2428
+ const name = raw["name"];
1993
2429
  const variable = {
1994
- name: raw["name"],
1995
- type
1996
- };
1997
- if (typeof raw["ref"] === "string") variable.ref = raw["ref"];
1998
- else if (typeof raw["value"] === "string") variable.value = raw["value"];
1999
- 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);
2000
2444
  if (modes) variable.modes = modes;
2001
2445
  return [variable];
2002
2446
  };
2003
- const readStringMap = (raw) => {
2004
- 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}`);
2005
2450
  const out = {};
2006
- 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
+ }
2007
2456
  return Object.keys(out).length > 0 ? out : void 0;
2008
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
+ };
2009
2472
  const readJsonObject = (raw) => {
2010
2473
  let parsed;
2011
2474
  try {
@@ -2014,6 +2477,7 @@ const readJsonObject = (raw) => {
2014
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 });
2015
2478
  }
2016
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.`);
2017
2481
  return parsed;
2018
2482
  };
2019
2483
  const writeJsonFile = (value) => `${JSON.stringify(value, null, 2)}\n`;
@@ -2025,17 +2489,16 @@ const runImport = async (inputs) => {
2025
2489
  tokensFound: false,
2026
2490
  changes: [],
2027
2491
  applied: false,
2492
+ refused: false,
2028
2493
  written: [],
2029
2494
  skipped: [],
2030
- problems: []
2031
- };
2032
- if (!await pathExists(inputs.neutralPath)) return {
2033
- ...empty,
2034
- problems: [`neutral file not found: ${inputs.neutralPath}`]
2495
+ unsynced: [],
2496
+ problems: [],
2497
+ warnings: []
2035
2498
  };
2036
2499
  let neutral;
2037
2500
  try {
2038
- neutral = parseNeutralFile(await readFile(inputs.neutralPath, "utf8"));
2501
+ neutral = parseNeutralFile(await readRequiredFile(inputs.neutralPath, "neutral"), "neutral");
2039
2502
  } catch (error) {
2040
2503
  return {
2041
2504
  ...empty,
@@ -2046,37 +2509,79 @@ const runImport = async (inputs) => {
2046
2509
  ...empty,
2047
2510
  problems: [`neutral file schemaVersion ${neutral.schemaVersion} is not supported (expected 1).`]
2048
2511
  };
2049
- const tokenPath = join(inputs.repoRoot, DESIGN_TOKENS_FILE);
2050
- const tokensFound = await pathExists(tokenPath);
2512
+ let tokensFound = false;
2513
+ let raw;
2051
2514
  let parsed;
2052
2515
  try {
2053
- 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);
2054
2519
  } catch (error) {
2055
2520
  return {
2056
2521
  ...empty,
2057
- tokensFound,
2522
+ tokensFound: true,
2058
2523
  problems: [asMessage$1(error)]
2059
2524
  };
2060
2525
  }
2061
2526
  const changes = diffNeutralAgainstJson(neutral, parsed);
2062
- 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,
2063
2539
  tokensFound,
2064
2540
  changes,
2065
- applied: false,
2066
- written: [],
2067
- skipped: [],
2068
- 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.`]
2069
2543
  };
2070
- const { merged, written, skipped } = mergeNeutralIntoJson(neutral, parsed, inputs.only);
2071
- await mkdir(dirname(tokenPath), { recursive: true });
2072
- 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
+ }
2073
2575
  return {
2074
2576
  tokensFound,
2075
2577
  changes,
2076
2578
  applied: true,
2579
+ refused: false,
2077
2580
  written,
2078
2581
  skipped,
2079
- problems: []
2582
+ unsynced,
2583
+ problems: [],
2584
+ warnings: []
2080
2585
  };
2081
2586
  };
2082
2587
  const asMessage$1 = (error) => error instanceof Error ? error.message : String(error);
@@ -2130,15 +2635,22 @@ const runExport = async (inputs) => {
2130
2635
  const empty = {
2131
2636
  tokensFound: false,
2132
2637
  plan: [],
2638
+ unprojected: [],
2133
2639
  hash: "",
2134
2640
  recorded: false,
2135
2641
  problems: []
2136
2642
  };
2137
- const tokenPath = join(inputs.repoRoot, DESIGN_TOKENS_FILE);
2138
- if (!await pathExists(tokenPath)) return empty;
2139
2643
  let parsed;
2140
2644
  try {
2141
- 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
+ };
2142
2654
  } catch (error) {
2143
2655
  return {
2144
2656
  ...empty,
@@ -2154,35 +2666,46 @@ const runExport = async (inputs) => {
2154
2666
  };
2155
2667
  const hash = projectionHash(parsed);
2156
2668
  let toolState;
2157
- if (inputs.toolStatePath !== void 0) {
2158
- 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 {
2159
2673
  ...empty,
2160
2674
  tokensFound: true,
2161
2675
  hash,
2162
- problems: [`tool-state file not found: ${inputs.toolStatePath}`]
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 {
2685
+ ...empty,
2686
+ tokensFound: true,
2687
+ hash,
2688
+ problems: [`Cannot write the projection to ${inputs.outPath}: it is ${DESIGN_TOKENS_FILE} itself, which the projection would replace. Name another path.`]
2163
2689
  };
2164
2690
  try {
2165
- toolState = parseNeutralFile(await readFile(inputs.toolStatePath, "utf8"));
2691
+ await writeRegularFile(inputs.outPath, writeNeutralFile(projection));
2166
2692
  } catch (error) {
2167
2693
  return {
2168
2694
  ...empty,
2169
2695
  tokensFound: true,
2170
2696
  hash,
2171
- problems: [asMessage(error)]
2697
+ problems: [`Cannot write the projection to ${inputs.outPath}: ${asMessage(error)}`]
2172
2698
  };
2173
2699
  }
2174
2700
  }
2175
- const plan = upsertPlan(parsed, toolState);
2176
- if (inputs.outPath !== void 0) {
2177
- const projection = dtcgToNeutral(parsed);
2178
- projection.provider = binding.provider;
2179
- await writeFile(inputs.outPath, writeNeutralFile(projection));
2180
- }
2181
2701
  if (inputs.record) {
2182
- 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);
2183
2705
  return {
2184
2706
  tokensFound: true,
2185
2707
  plan,
2708
+ unprojected,
2186
2709
  hash,
2187
2710
  recorded: true,
2188
2711
  problems: []
@@ -2191,13 +2714,14 @@ const runExport = async (inputs) => {
2191
2714
  return {
2192
2715
  tokensFound: true,
2193
2716
  plan,
2717
+ unprojected,
2194
2718
  hash,
2195
2719
  recorded: false,
2196
2720
  problems: []
2197
2721
  };
2198
2722
  };
2199
2723
  const stampBaseline = (parsed, hash) => {
2200
- const clone = structuredClone(parsed);
2724
+ const clone = { ...parsed };
2201
2725
  const extensions = isRecord(clone["$extensions"]) ? { ...clone["$extensions"] } : {};
2202
2726
  const binding = isRecord(extensions["com.lemony.design-tool"]) ? { ...extensions[DESIGN_TOOL_EXTENSION] } : {};
2203
2727
  binding["lastProjected"] = hash;
@@ -2205,6 +2729,10 @@ const stampBaseline = (parsed, hash) => {
2205
2729
  clone["$extensions"] = extensions;
2206
2730
  return clone;
2207
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
+ };
2208
2736
  const asMessage = (error) => error instanceof Error ? error.message : String(error);
2209
2737
  //#endregion
2210
2738
  //#region src/review-ledger/review-ledger.constant.ts
@@ -2710,19 +3238,7 @@ const checkMutantsFloor = (ledger, owedFiles, anchor, problems) => {
2710
3238
  });
2711
3239
  }
2712
3240
  };
2713
- const collectDeclaredGates = async (repoRoot) => {
2714
- if (!await configEntryExists(join(repoRoot, "harness.config.yml"))) return;
2715
- return (await readHarnessConfig(repoRoot)).gates;
2716
- };
2717
- const configEntryExists = async (path) => {
2718
- try {
2719
- await lstat(path);
2720
- return true;
2721
- } catch (error) {
2722
- if (error.code === "ENOENT") return false;
2723
- throw error;
2724
- }
2725
- };
3241
+ const collectDeclaredGates = async (repoRoot) => (await readOptionalHarnessConfig(repoRoot))?.gates;
2726
3242
  const owedGates = (declared) => ({
2727
3243
  basis: declared === void 0 ? "undeclared" : "config",
2728
3244
  required: [...new Set(declared ?? [])],
@@ -3549,7 +4065,7 @@ const inspectDevDependency = async (repoRoot) => {
3549
4065
  const pkgPath = join(repoRoot, "package.json");
3550
4066
  if (!await pathExists(pkgPath)) return "no-package-json";
3551
4067
  try {
3552
- const pkg = JSON.parse(await readFile(pkgPath, "utf8"));
4068
+ const pkg = JSON.parse(await readRegularFile(pkgPath));
3553
4069
  return Object.hasOwn(pkg.dependencies ?? {}, "@lemoncode/lemony") || Object.hasOwn(pkg.devDependencies ?? {}, "@lemoncode/lemony") ? "declared" : "missing";
3554
4070
  } catch {
3555
4071
  return "no-package-json";
@@ -3820,14 +4336,27 @@ const checkHooks = async (repoRoot) => {
3820
4336
  const settingsPath = join(repoRoot, SETTINGS_FILE);
3821
4337
  let raw;
3822
4338
  try {
3823
- raw = await readFile(settingsPath, "utf8");
3824
- } catch {
3825
- 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 {
3826
4349
  name,
3827
4350
  status: "fail",
3828
4351
  detail: ".claude/settings.json not found.",
3829
4352
  remediation: "Run `lemony repair`."
3830
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
+ };
3831
4360
  }
3832
4361
  let commands;
3833
4362
  try {
@@ -3984,29 +4513,46 @@ const checkCapabilities = async (deps, config) => {
3984
4513
  };
3985
4514
  const checkDesignToolDrift = async (deps) => {
3986
4515
  const name = "design-tool-drift";
3987
- const tokenPath = join(deps.repoRoot, DESIGN_TOKENS_FILE);
3988
- 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 {
3989
4528
  name,
3990
4529
  status: "info",
3991
4530
  detail: `No ${DESIGN_TOKENS_FILE} — token sync not in use.`
3992
4531
  };
3993
4532
  let parsed;
3994
4533
  try {
3995
- parsed = JSON.parse(await readFile(tokenPath, "utf8"));
4534
+ parsed = JSON.parse(raw);
3996
4535
  } catch (error) {
3997
4536
  return {
3998
4537
  name,
3999
- status: "warn",
4000
- detail: `Could not read ${DESIGN_TOKENS_FILE}: ${error.message}`,
4538
+ status: "fail",
4539
+ detail: `${DESIGN_TOKENS_FILE} is not valid JSON: ${error.message}`,
4001
4540
  remediation: "Run `lemony design-tokens validate` to find the problem."
4002
4541
  };
4003
4542
  }
4004
4543
  if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return {
4005
4544
  name,
4006
- status: "warn",
4545
+ status: "fail",
4007
4546
  detail: `${DESIGN_TOKENS_FILE} is not a JSON object.`,
4008
4547
  remediation: "Run `lemony design-tokens validate` to find the problem."
4009
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
+ };
4010
4556
  const report = driftReport(parsed);
4011
4557
  switch (report.state) {
4012
4558
  case "n/a": return {
@@ -4144,11 +4690,12 @@ const runStatus = async (deps) => {
4144
4690
  };
4145
4691
  };
4146
4692
  const readDriftState = async (repoRoot) => {
4147
- const tokenPath = join(repoRoot, DESIGN_TOKENS_FILE);
4148
- if (!await pathExists(tokenPath)) return null;
4149
4693
  try {
4150
- 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);
4151
4697
  if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
4698
+ if (summarizeTokenFileViolations(raw) !== void 0) return null;
4152
4699
  return driftReport(parsed).state;
4153
4700
  } catch {
4154
4701
  return null;
@@ -4163,7 +4710,7 @@ const readPointer = async (repoRoot, readGitUserEmail) => {
4163
4710
  const pointerPath = join(repoRoot, STATE_DIR, `current-${slug}.md`);
4164
4711
  let raw;
4165
4712
  try {
4166
- raw = await readFile(pointerPath, "utf8");
4713
+ raw = await readRegularFile(pointerPath);
4167
4714
  } catch {
4168
4715
  return empty;
4169
4716
  }
@@ -4238,7 +4785,7 @@ const COMMANDS = [
4238
4785
  },
4239
4786
  {
4240
4787
  name: "design-tokens",
4241
- 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).",
4242
4789
  usage: "lemony design-tokens <validate [--scan=<dir>] | contrast | import --from=<file> [--apply] [--only=<paths>] | export [--tool-state=<file>] [--out=<file>] [--record]>"
4243
4790
  },
4244
4791
  {
@@ -4808,54 +5355,6 @@ const materializeVendorFiles = async (ctx) => {
4808
5355
  ];
4809
5356
  };
4810
5357
  //#endregion
4811
- //#region src/install/gitignore.ts
4812
- const GITIGNORE_BEGIN = "# BEGIN lemony";
4813
- const GITIGNORE_END = "# END lemony";
4814
- const GITIGNORE_BLOCK = [
4815
- GITIGNORE_BEGIN,
4816
- ".claude/state/current-*.md",
4817
- ".claude/state/.ptr.*",
4818
- ".claude/state/sessions/",
4819
- ".claude/state/events.jsonl",
4820
- ".claude/state/telemetry-cursor.json",
4821
- ".claude/state/telemetry-consent.json",
4822
- ".claude/state/.telemetry-notice-shown",
4823
- ".claude/state/telemetry-rejected.jsonl",
4824
- ".claude/.harness/snapshots/",
4825
- GITIGNORE_END
4826
- ].join("\n");
4827
- const appendGitignoreBlock = async (repoRoot) => {
4828
- const gitignorePath = join(repoRoot, ".gitignore");
4829
- const current = await pathExists(gitignorePath) ? await readFile(gitignorePath, "utf8") : "";
4830
- const beginIdx = current.indexOf(GITIGNORE_BEGIN);
4831
- const endIdx = current.indexOf(GITIGNORE_END, beginIdx);
4832
- if (beginIdx !== -1 && endIdx !== -1) {
4833
- const next = current.slice(0, beginIdx) + GITIGNORE_BLOCK + current.slice(endIdx + 12);
4834
- if (next === current) return null;
4835
- await writeFile(gitignorePath, next);
4836
- return ".gitignore";
4837
- }
4838
- const prefix = current.length === 0 ? "" : current.endsWith("\n") ? "\n" : "\n\n";
4839
- await writeFile(gitignorePath, `${current}${prefix}${GITIGNORE_BLOCK}\n`);
4840
- return ".gitignore";
4841
- };
4842
- const removeGitignoreBlock = async (repoRoot) => {
4843
- const relPath = ".gitignore";
4844
- const gitignorePath = join(repoRoot, relPath);
4845
- if (!await pathExists(gitignorePath)) return null;
4846
- const lines = (await readFile(gitignorePath, "utf8")).split("\n");
4847
- const startLine = lines.findIndex((line) => line.trim() === GITIGNORE_BEGIN);
4848
- const endLine = startLine === -1 ? -1 : lines.findIndex((line, index) => index > startLine && line.trim() === GITIGNORE_END);
4849
- if (startLine === -1 || endLine === -1) return null;
4850
- const result = [...lines.slice(0, startLine), ...lines.slice(endLine + 1)].join("\n").replace(/\n+$/, "\n");
4851
- if (result.trim() === "") {
4852
- await rm(gitignorePath, { force: true });
4853
- return relPath;
4854
- }
4855
- await writeFile(gitignorePath, result);
4856
- return relPath;
4857
- };
4858
- //#endregion
4859
5358
  //#region src/install/merge-settings.ts
4860
5359
  const mergeSettingsHooks = async (templatePath, destPath) => {
4861
5360
  const templateRaw = await readFile(templatePath, "utf8");
@@ -4863,7 +5362,7 @@ const mergeSettingsHooks = async (templatePath, destPath) => {
4863
5362
  if (template.hooks === void 0) throw new Error(`settings template at ${templatePath} has no "hooks" key — vendor authority is the hooks block.`);
4864
5363
  let existing = {};
4865
5364
  if (await pathExists(destPath)) {
4866
- const existingRaw = await readFile(destPath, "utf8");
5365
+ const existingRaw = await readSettings(destPath, mergeContext(destPath));
4867
5366
  if (existingRaw.trim().length > 0) try {
4868
5367
  existing = JSON.parse(existingRaw);
4869
5368
  } catch (cause) {
@@ -4881,7 +5380,7 @@ const mergeSettingsHooks = async (templatePath, destPath) => {
4881
5380
  };
4882
5381
  const removeSettingsHooks = async (settingsPath) => {
4883
5382
  if (!await pathExists(settingsPath)) return "absent";
4884
- const raw = await readFile(settingsPath, "utf8");
5383
+ const raw = await readSettings(settingsPath, removeContext(settingsPath));
4885
5384
  if (raw.trim().length === 0) return "absent";
4886
5385
  let parsed;
4887
5386
  try {
@@ -4898,6 +5397,24 @@ const removeSettingsHooks = async (settingsPath) => {
4898
5397
  await writeFile(settingsPath, `${JSON.stringify(rest, null, 2)}\n`);
4899
5398
  return "stripped";
4900
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
+ };
4901
5418
  const collectCommands = (hooks) => {
4902
5419
  const result = /* @__PURE__ */ new Set();
4903
5420
  if (!hooks || typeof hooks !== "object") return result;
@@ -4922,6 +5439,54 @@ const diffLostCommands = (existingHooks, templateHooks) => {
4922
5439
  return lost.toSorted();
4923
5440
  };
4924
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
4925
5490
  //#region src/install/resync.ts
4926
5491
  const resyncSpecialCased = async (repoRoot, templateRoot) => {
4927
5492
  const { lostCommands } = await mergeSettingsHooks(join(templateRoot, CLAUDE_DIR, "settings.json.tpl"), join(repoRoot, SETTINGS_FILE));
@@ -4978,6 +5543,7 @@ const runInstall = async (options) => {
4978
5543
  ...COMMITTED_STATE_DIRS.map((dir) => join(STATE_DIR, dir, ".gitkeep")),
4979
5544
  HARNESS_CONFIG_FILENAME
4980
5545
  ]);
5546
+ await assertSettingsNotSpecial(join(repoRoot, SETTINGS_FILE), "merge");
4981
5547
  const readClient = async (relPath) => {
4982
5548
  const filePath = join(repoRoot, relPath);
4983
5549
  if (!await pathExists(filePath)) return null;
@@ -5126,6 +5692,7 @@ const runReconcile = async (inputs) => {
5126
5692
  SETTINGS_FILE,
5127
5693
  ".gitignore"
5128
5694
  ]);
5695
+ await assertSettingsNotSpecial(join(repoRoot, SETTINGS_FILE), "merge");
5129
5696
  const actions = await runEngine({
5130
5697
  baseline,
5131
5698
  vendorNew,
@@ -5282,6 +5849,7 @@ const runUninstall = async (options) => {
5282
5849
  HARNESS_DIR,
5283
5850
  HARNESS_CONFIG_FILENAME
5284
5851
  ]);
5852
+ await assertSettingsNotSpecial(join(repoRoot, SETTINGS_FILE), "remove");
5285
5853
  await Promise.all(removedFiles.map((relPath) => rm(join(repoRoot, relPath), { force: true })));
5286
5854
  await pruneEmptyDirs(repoRoot, removedFiles);
5287
5855
  const settings = await removeSettingsHooks(join(repoRoot, SETTINGS_FILE));
@@ -5493,8 +6061,14 @@ const PACKAGE_JSON_PATH = join(PACKAGE_ROOT, "package.json");
5493
6061
  const execFileAsync = promisify(execFile);
5494
6062
  const parseFlag = (args, name) => {
5495
6063
  const prefix = `--${name}=`;
5496
- const value = args.find((arg) => arg.startsWith(prefix))?.slice(prefix.length);
5497
- 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.`);
5498
6072
  };
5499
6073
  const readHarnessVersion = async () => {
5500
6074
  const raw = await readFile(PACKAGE_JSON_PATH, "utf8");
@@ -5545,7 +6119,8 @@ const makeVerifierRunCommand = () => (cmd, args) => new Promise((resolveResult)
5545
6119
  resolveResult({
5546
6120
  code: code ?? 1,
5547
6121
  stdout: Buffer.concat(out).toString("utf8"),
5548
- stderr: `${Buffer.concat(err).toString("utf8")}${signal ? `killed by ${signal}\n` : ""}`
6122
+ stderr: Buffer.concat(err).toString("utf8"),
6123
+ ...signal === null ? {} : { signal }
5549
6124
  });
5550
6125
  });
5551
6126
  });
@@ -5938,18 +6513,18 @@ const designTokens = async (args) => {
5938
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}".`);
5939
6514
  };
5940
6515
  const designTokensValidate = async (args) => {
5941
- const config = await readHarnessConfig(cwd()).catch(() => void 0);
6516
+ const config = await readOptionalHarnessConfig(cwd());
5942
6517
  const result = await runValidate({
5943
6518
  repoRoot: cwd(),
5944
6519
  scanDir: parseFlag(args, "scan"),
5945
6520
  extraExtensions: config?.design_tokens.scan_extensions
5946
6521
  });
5947
6522
  if (!result.tokensFound) {
5948
- 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).`);
5949
6524
  return;
5950
6525
  }
5951
6526
  if (result.ok) {
5952
- 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.`);
5953
6528
  return;
5954
6529
  }
5955
6530
  console.error(`design-tokens validate found ${result.violations.length} violation(s):`);
@@ -5959,8 +6534,22 @@ const designTokensValidate = async (args) => {
5959
6534
  }
5960
6535
  exit(1);
5961
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
+ };
5962
6551
  const designTokensContrast = async () => {
5963
- const verify = (await pathExists(join(cwd(), "harness.config.yml")) ? await readHarnessConfig(cwd()) : void 0)?.design_tokens.verify;
6552
+ const verify = (await readOptionalHarnessConfig(cwd()))?.design_tokens.verify;
5964
6553
  const result = await runContrast({
5965
6554
  repoRoot: cwd(),
5966
6555
  ...verify === void 0 ? {} : { verify: {
@@ -5968,10 +6557,6 @@ const designTokensContrast = async () => {
5968
6557
  run: makeVerifierRunCommand()
5969
6558
  } }
5970
6559
  });
5971
- if (!result.tokensFound) {
5972
- console.log(`No docs/design-tokens.json — design-tokens contrast skipped (consume-if-exists; the harness never creates it).`);
5973
- return;
5974
- }
5975
6560
  const failures = result.pairs.filter((pair) => !pair.passes);
5976
6561
  const forwardVerifier = () => {
5977
6562
  if (result.verify === void 0) return;
@@ -5979,13 +6564,28 @@ const designTokensContrast = async () => {
5979
6564
  if (out.length > 0) stdout.write(out.endsWith("\n") ? out : `${out}\n`);
5980
6565
  if (err.length > 0) stderr.write(err.endsWith("\n") ? err : `${err}\n`);
5981
6566
  };
6567
+ const failedVerifier = result.verify !== void 0 && result.verify.code !== 0 ? result.verify : void 0;
6568
+ if (!result.tokensFound) {
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.`);
6581
+ return;
6582
+ }
5982
6583
  if (result.ok) {
5983
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.`);
5984
6585
  forwardVerifier();
5985
- if (result.verify !== void 0) console.log(`design-tokens verify OK: \`${result.verify.command}\` exited 0.`);
6586
+ if (result.verify !== void 0) console.log(`design-tokens verify OK: ${quotedCommand(result.verify.command)} exited 0.`);
5986
6587
  return;
5987
6588
  }
5988
- const failedVerifier = result.verify !== void 0 && result.verify.code !== 0 ? result.verify : void 0;
5989
6589
  console.error(`design-tokens contrast found ${failures.length + result.problems.length + (failedVerifier ? 1 : 0)} issue(s):`);
5990
6590
  for (const pair of failures) {
5991
6591
  const over = pair.over === void 0 ? "" : ` over ${pair.over}`;
@@ -5993,13 +6593,18 @@ const designTokensContrast = async () => {
5993
6593
  }
5994
6594
  for (const problem of result.problems) console.error(` ${problem}`);
5995
6595
  forwardVerifier();
5996
- if (failedVerifier) console.error(` design_tokens.verify \`${failedVerifier.command}\` exited ${failedVerifier.code}.`);
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));
5997
6600
  process.exitCode = 1;
5998
6601
  };
5999
6602
  const designTokensImport = async (args) => {
6000
6603
  const from = parseFlag(args, "from");
6001
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"]);
6002
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.");
6003
6608
  const result = await runImport({
6004
6609
  repoRoot: cwd(),
6005
6610
  neutralPath: resolve(cwd(), from),
@@ -6007,26 +6612,43 @@ const designTokensImport = async (args) => {
6007
6612
  only
6008
6613
  });
6009
6614
  if (result.problems.length > 0) {
6010
- console.error("design-tokens import could not run:");
6615
+ console.error(result.refused ? "design-tokens import --apply refused:" : "design-tokens import could not run:");
6011
6616
  for (const problem of result.problems) console.error(` ${problem}`);
6012
- exit(1);
6617
+ if (result.changes.length > 0) printImportPreview(result);
6618
+ process.exitCode = 1;
6619
+ return;
6013
6620
  }
6014
6621
  if (result.applied) {
6015
6622
  console.log(`design-tokens import applied: wrote ${result.written.length} token(s) to ${DESIGN_TOKENS_FILE}.`);
6016
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(", ")}`);
6017
6625
  return;
6018
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) => {
6019
6632
  const landing = result.changes.filter((change) => change.kind !== "unchanged");
6020
6633
  const news = landing.filter((change) => change.kind === "new").length;
6021
6634
  const changed = landing.length - news;
6022
6635
  console.log(`design-tokens import preview: ${news} new, ${changed} changed, ${result.changes.length - landing.length} unchanged.`);
6023
6636
  for (const change of landing) {
6024
- const detail = change.kind === "changed" ? `${change.before} → ${change.after}` : change.after;
6637
+ const detail = change.kind === "changed" ? changedDetail(change) : change.after;
6025
6638
  console.log(` [${change.kind}] ${change.path} (${change.tier}): ${detail}`);
6026
6639
  }
6027
- 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("; ");
6028
6648
  };
6649
+ const shown = (value) => value === void 0 || value === "" ? "(none)" : value;
6029
6650
  const designTokensExport = async (args) => {
6651
+ rejectValuelessFlags("design-tokens export", args, ["tool-state", "out"]);
6030
6652
  const toolState = parseFlag(args, "tool-state");
6031
6653
  const out = parseFlag(args, "out");
6032
6654
  if (out !== void 0 && args.includes("--record")) {
@@ -6040,7 +6662,7 @@ const designTokensExport = async (args) => {
6040
6662
  record: args.includes("--record")
6041
6663
  });
6042
6664
  if (!result.tokensFound) {
6043
- 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).`);
6044
6666
  return;
6045
6667
  }
6046
6668
  if (result.problems.length > 0) {
@@ -6055,6 +6677,7 @@ const designTokensExport = async (args) => {
6055
6677
  const creates = result.plan.filter((entry) => entry.kind === "create").length;
6056
6678
  const updates = result.plan.filter((entry) => entry.kind === "update").length;
6057
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(", ")}`);
6058
6681
  if (out !== void 0) console.log(` projection written to ${out}`);
6059
6682
  };
6060
6683
  const REVIEW_LEDGER_USAGE = "Usage: lemony review-ledger validate --task-id=<id> --anchor=<oid> (--step=<N> | --full-pass).";