@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/catalog/VERSION +1 -1
- package/catalog/commands/sync-design-tokens.md +3 -1
- package/catalog/hooks/init.sh +23 -1
- package/catalog/hooks/lib/merge-pr.sh +3 -2
- package/catalog/hooks/lib/playbook-scan.sh +4 -2
- package/catalog/skills/design-tool-sync/SKILL.md +40 -7
- package/catalog/templates/claude-code/harness.config.yml.tpl +5 -2
- package/dist/cli.mjs +920 -297
- package/package.json +1 -1
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
|
|
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
|
-
|
|
1123
|
-
|
|
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
|
|
1277
|
-
const
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
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
|
-
|
|
1515
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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 (
|
|
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
|
|
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
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
const
|
|
1869
|
-
return
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
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
|
-
}
|
|
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
|
|
1896
|
-
const
|
|
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
|
|
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
|
|
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,
|
|
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
|
-
|
|
1921
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
1959
|
-
|
|
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
|
|
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,
|
|
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(
|
|
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(
|
|
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
|
|
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
|
|
2428
|
+
const name = raw["name"];
|
|
1993
2429
|
const variable = {
|
|
1994
|
-
name
|
|
1995
|
-
type
|
|
1996
|
-
};
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
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
|
|
2004
|
-
if (
|
|
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 [
|
|
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
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
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
|
|
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
|
-
|
|
2050
|
-
|
|
2512
|
+
let tokensFound = false;
|
|
2513
|
+
let raw;
|
|
2051
2514
|
let parsed;
|
|
2052
2515
|
try {
|
|
2053
|
-
|
|
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)
|
|
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
|
-
|
|
2066
|
-
|
|
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
|
-
|
|
2072
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: [
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
|
3824
|
-
} catch {
|
|
3825
|
-
|
|
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
|
-
|
|
3988
|
-
|
|
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(
|
|
4534
|
+
parsed = JSON.parse(raw);
|
|
3996
4535
|
} catch (error) {
|
|
3997
4536
|
return {
|
|
3998
4537
|
name,
|
|
3999
|
-
status: "
|
|
4000
|
-
detail:
|
|
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: "
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
5497
|
-
return
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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" ?
|
|
6637
|
+
const detail = change.kind === "changed" ? changedDetail(change) : change.after;
|
|
6025
6638
|
console.log(` [${change.kind}] ${change.path} (${change.tier}): ${detail}`);
|
|
6026
6639
|
}
|
|
6027
|
-
|
|
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;
|
|
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).";
|