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