@lemoncode/lemony 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -16
- package/catalog/VERSION +1 -1
- package/catalog/agents/implementer.md +36 -1
- package/catalog/agents/orchestrator.md +206 -83
- package/catalog/agents/reviewer.md +8 -3
- package/catalog/agents/spinoff.md +3 -2
- package/catalog/agents/ui-design.md +3 -1
- package/catalog/agents/ui-designer.md +6 -3
- package/catalog/commands/pause.md +50 -5
- package/catalog/commands/resume.md +13 -2
- package/catalog/commands/spinoff.md +5 -3
- package/catalog/commands/sync-design-tokens.md +6 -2
- package/catalog/harness.config.schema.json +4 -0
- package/catalog/hooks/init.sh +80 -7
- package/catalog/hooks/lib/live-branch.sh +37 -0
- package/catalog/hooks/lib/merge-pr.sh +17 -6
- package/catalog/hooks/lib/playbook-scan.sh +4 -2
- package/catalog/hooks/session-close.sh +26 -7
- package/catalog/schemas/tier2-events-history.md +33 -2
- package/catalog/schemas/tier2-events.md +30 -22
- package/catalog/skills/build-ui/SKILL.md +5 -2
- package/catalog/skills/design-tool-sync/SKILL.md +48 -7
- package/catalog/skills/grill-ui/SKILL.md +4 -1
- package/catalog/skills/grill-ui/ui-handoff-format.md +2 -1
- package/catalog/skills/mutation-testing/SKILL.md +22 -8
- package/catalog/skills/prd-to-spec/SKILL.md +27 -5
- package/catalog/skills/review-pr/SKILL.md +7 -1
- package/catalog/skills/review-pr/reference.md +3 -2
- package/catalog/templates/claude-code/agents.md.tpl +2 -1
- package/catalog/templates/claude-code/harness.config.yml.tpl +12 -3
- package/dist/cli.mjs +2579 -775
- package/package.json +11 -10
package/dist/cli.mjs
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
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
|
-
import { basename, delimiter, dirname, extname, join, relative, resolve, sep } from "node:path";
|
|
4
|
+
import { basename, delimiter, dirname, extname, join, posix, relative, resolve, sep } from "node:path";
|
|
5
5
|
import { argv, cwd, env, exit, stderr, stdin, stdout } from "node:process";
|
|
6
6
|
import { createInterface } from "node:readline/promises";
|
|
7
7
|
import { promisify } from "node:util";
|
|
8
|
-
import { parse, parseDocument } from "yaml";
|
|
8
|
+
import { isMap, isPair, isScalar, parse, parseDocument, visit } from "yaml";
|
|
9
|
+
import { constants } from "node:fs";
|
|
9
10
|
import { z } from "zod";
|
|
10
11
|
import { createHash, randomBytes } from "node:crypto";
|
|
11
|
-
import
|
|
12
|
+
import MarkdownIt from "markdown-it";
|
|
12
13
|
//#region src/events/append.ts
|
|
13
14
|
const appendEvent = async (eventsPath, event) => {
|
|
14
15
|
await mkdir(dirname(eventsPath), { recursive: true });
|
|
@@ -17,6 +18,8 @@ const appendEvent = async (eventsPath, event) => {
|
|
|
17
18
|
//#endregion
|
|
18
19
|
//#region src/config/config.constant.ts
|
|
19
20
|
const HARNESS_CONFIG_FILENAME = "harness.config.yml";
|
|
21
|
+
const CONFIG_MALFORMED_MESSAGE = `${HARNESS_CONFIG_FILENAME} is empty or malformed.`;
|
|
22
|
+
const NULL_BLOCK_SENTINEL = "lemony-null-block-comment-";
|
|
20
23
|
const DEPRECATED_CONFIG_KEYS = ["profile", "agents"];
|
|
21
24
|
const DEPRECATED_PATHS_KEYS = [
|
|
22
25
|
"state",
|
|
@@ -28,7 +31,8 @@ const HARNESS_CONFIG_SCHEMA_FILENAME = "harness.config.schema.json";
|
|
|
28
31
|
const CONFIG_KEY_SINCE = {
|
|
29
32
|
gates: "0.3.0",
|
|
30
33
|
"implementation.auto_commit": "0.4.0",
|
|
31
|
-
"design_tokens.verify": "0.5.0"
|
|
34
|
+
"design_tokens.verify": "0.5.0",
|
|
35
|
+
"design_tokens.file": "0.6.0"
|
|
32
36
|
};
|
|
33
37
|
const TASK_STORAGE_REPO_PLACEHOLDER = "OWNER/REPO";
|
|
34
38
|
const TARGETS = ["claude-code"];
|
|
@@ -47,6 +51,236 @@ const PATHS_DEFAULTS = {
|
|
|
47
51
|
playbooks_global: "~/.claude/playbooks"
|
|
48
52
|
};
|
|
49
53
|
//#endregion
|
|
54
|
+
//#region src/fs/exists.ts
|
|
55
|
+
const pathExists = async (path) => {
|
|
56
|
+
try {
|
|
57
|
+
await stat(path);
|
|
58
|
+
return true;
|
|
59
|
+
} catch {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
const entryExists = async (path) => {
|
|
64
|
+
try {
|
|
65
|
+
await lstat(path);
|
|
66
|
+
return true;
|
|
67
|
+
} catch (error) {
|
|
68
|
+
if (error.code === "ENOENT") return false;
|
|
69
|
+
throw error;
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
//#endregion
|
|
73
|
+
//#region src/fs/list-files.ts
|
|
74
|
+
const listFiles = async (dir) => {
|
|
75
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
76
|
+
return (await Promise.all(entries.map(async (entry) => {
|
|
77
|
+
if (entry.isDirectory()) return (await listFiles(join(dir, entry.name))).map((rel) => join(entry.name, rel));
|
|
78
|
+
return [entry.name];
|
|
79
|
+
}))).flat();
|
|
80
|
+
};
|
|
81
|
+
//#endregion
|
|
82
|
+
//#region src/fs/prune-empty-dirs.ts
|
|
83
|
+
const pruneEmptyDirs = async (repoRoot, removedFiles) => {
|
|
84
|
+
const dirs = /* @__PURE__ */ new Set();
|
|
85
|
+
for (const relPath of removedFiles) {
|
|
86
|
+
let dir = dirname(relPath);
|
|
87
|
+
while (dir !== "." && dir !== sep) {
|
|
88
|
+
dirs.add(dir);
|
|
89
|
+
dir = dirname(dir);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
const ordered = [...dirs].toSorted((a, b) => b.length - a.length);
|
|
93
|
+
for (const dir of ordered) try {
|
|
94
|
+
await rmdir(join(repoRoot, dir));
|
|
95
|
+
} catch {}
|
|
96
|
+
};
|
|
97
|
+
//#endregion
|
|
98
|
+
//#region src/fs/fs.constant.ts
|
|
99
|
+
const SPECIAL_FILE_REFUSAL = "the entry is not a regular file (a FIFO, a socket or a device), so it was not read.";
|
|
100
|
+
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).";
|
|
101
|
+
const SPECIAL_FILE_WRITE_REFUSAL = "the entry is not a regular file (a FIFO, a socket or a device), so it was not written.";
|
|
102
|
+
//#endregion
|
|
103
|
+
//#region src/fs/read-regular-file.ts
|
|
104
|
+
const readRegularFile = async (path) => {
|
|
105
|
+
let handle;
|
|
106
|
+
try {
|
|
107
|
+
handle = await open(path, constants.O_RDONLY | constants.O_NONBLOCK);
|
|
108
|
+
} catch (error) {
|
|
109
|
+
const target = await stat(path).catch(() => void 0);
|
|
110
|
+
if (target && !target.isFile() && !target.isDirectory()) throw new Error(SPECIAL_FILE_REFUSAL, { cause: error });
|
|
111
|
+
throw error;
|
|
112
|
+
}
|
|
113
|
+
try {
|
|
114
|
+
const target = await handle.stat();
|
|
115
|
+
if (!target.isFile() && !target.isDirectory()) throw new Error(SPECIAL_FILE_REFUSAL);
|
|
116
|
+
return await handle.readFile("utf8");
|
|
117
|
+
} finally {
|
|
118
|
+
await handle.close();
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
//#endregion
|
|
122
|
+
//#region src/fs/write-managed.ts
|
|
123
|
+
const writeManaged = async (root, relPath, content, executable) => {
|
|
124
|
+
const dest = join(root, relPath);
|
|
125
|
+
await mkdir(dirname(dest), { recursive: true });
|
|
126
|
+
await writeFile(dest, content);
|
|
127
|
+
await chmod(dest, executable ? 493 : 420);
|
|
128
|
+
};
|
|
129
|
+
//#endregion
|
|
130
|
+
//#region src/fs/write-regular-file.ts
|
|
131
|
+
const writeRegularFile = async (path, text) => {
|
|
132
|
+
let handle;
|
|
133
|
+
try {
|
|
134
|
+
handle = await openForWrite(path);
|
|
135
|
+
} catch (error) {
|
|
136
|
+
const target = await stat(path).catch(() => void 0);
|
|
137
|
+
if (target && !target.isFile() && !target.isDirectory()) throw new Error(SPECIAL_FILE_WRITE_REFUSAL, { cause: error });
|
|
138
|
+
throw error;
|
|
139
|
+
}
|
|
140
|
+
try {
|
|
141
|
+
if (!(await handle.stat()).isFile()) throw new Error(SPECIAL_FILE_WRITE_REFUSAL);
|
|
142
|
+
await handle.writeFile(text, "utf8");
|
|
143
|
+
} finally {
|
|
144
|
+
await handle.close();
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
const WRITE = constants.O_WRONLY | constants.O_TRUNC | constants.O_NONBLOCK;
|
|
148
|
+
const openForWrite = async (path) => {
|
|
149
|
+
try {
|
|
150
|
+
return await open(path, WRITE);
|
|
151
|
+
} catch (error) {
|
|
152
|
+
if (error.code !== "ENOENT") throw error;
|
|
153
|
+
}
|
|
154
|
+
try {
|
|
155
|
+
return await open(path, WRITE | constants.O_CREAT | constants.O_EXCL);
|
|
156
|
+
} catch (error) {
|
|
157
|
+
if (error.code !== "EEXIST") throw error;
|
|
158
|
+
if ((await lstat(path).catch(() => void 0))?.isSymbolicLink()) throw new Error(DANGLING_WRITE_REFUSAL, { cause: error });
|
|
159
|
+
}
|
|
160
|
+
return await open(path, WRITE);
|
|
161
|
+
};
|
|
162
|
+
//#endregion
|
|
163
|
+
//#region src/fs/symlink-guard.ts
|
|
164
|
+
const assertNoSymlinkTraversal = async (root, relPaths) => {
|
|
165
|
+
const offenders = await findSymlinkTraversals(root, relPaths);
|
|
166
|
+
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"));
|
|
167
|
+
};
|
|
168
|
+
const findSymlinkTraversals = async (root, relPaths) => {
|
|
169
|
+
const components = /* @__PURE__ */ new Set();
|
|
170
|
+
for (const relPath of relPaths) {
|
|
171
|
+
let current = "";
|
|
172
|
+
for (const part of relPath.split(sep)) {
|
|
173
|
+
if (part.length === 0) continue;
|
|
174
|
+
current = current ? join(current, part) : part;
|
|
175
|
+
components.add(current);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return (await Promise.all([...components].map(async (rel) => {
|
|
179
|
+
try {
|
|
180
|
+
return (await lstat(join(root, rel))).isSymbolicLink() ? rel : null;
|
|
181
|
+
} catch {
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
}))).filter((rel) => rel !== null);
|
|
185
|
+
};
|
|
186
|
+
//#endregion
|
|
187
|
+
//#region src/paths/claude-paths.constant.ts
|
|
188
|
+
const CLAUDE_DIR = ".claude";
|
|
189
|
+
const STATE_DIR = join(CLAUDE_DIR, "state");
|
|
190
|
+
const TASKS_DIR = join(STATE_DIR, "tasks");
|
|
191
|
+
const SKILLS_DIR = join(CLAUDE_DIR, "skills");
|
|
192
|
+
const AGENTS_DIR = join(CLAUDE_DIR, "agents");
|
|
193
|
+
const HOOKS_DIR = join(CLAUDE_DIR, "hooks");
|
|
194
|
+
const COMMANDS_DIR = join(CLAUDE_DIR, "commands");
|
|
195
|
+
const SETTINGS_FILE = join(CLAUDE_DIR, "settings.json");
|
|
196
|
+
const HARNESS_DIR = join(CLAUDE_DIR, ".harness");
|
|
197
|
+
const BASELINE_ROOT_REL = join(HARNESS_DIR, "baseline");
|
|
198
|
+
const SNAPSHOTS_ROOT_REL = join(HARNESS_DIR, "snapshots");
|
|
199
|
+
const EVENTS_RELPATH = join(STATE_DIR, "events.jsonl");
|
|
200
|
+
//#endregion
|
|
201
|
+
//#region src/config/classify-config-link.ts
|
|
202
|
+
const LINK_FAULTS = /* @__PURE__ */ new Map([
|
|
203
|
+
["ENOENT", "target-missing"],
|
|
204
|
+
["ENOTDIR", "target-missing"],
|
|
205
|
+
["ENAMETOOLONG", "target-missing"],
|
|
206
|
+
["ELOOP", "loop"],
|
|
207
|
+
["EACCES", "target-unreadable"]
|
|
208
|
+
]);
|
|
209
|
+
const classifyConfigLinkFault = async (configPath, readError) => {
|
|
210
|
+
const code = readError?.code;
|
|
211
|
+
const fault = code === void 0 ? void 0 : LINK_FAULTS.get(code);
|
|
212
|
+
if (fault === void 0) return void 0;
|
|
213
|
+
return await lstat(configPath).then((entry) => entry.isSymbolicLink(), () => false) ? fault : void 0;
|
|
214
|
+
};
|
|
215
|
+
//#endregion
|
|
216
|
+
//#region src/baseline/baseline.ts
|
|
217
|
+
const STAGING_DIR$1 = ".staging";
|
|
218
|
+
const isENOENT$1 = (error) => typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
|
|
219
|
+
const baselineRootDir = (repoRoot) => join(repoRoot, BASELINE_ROOT_REL);
|
|
220
|
+
const baselineVersionDir = (repoRoot, version) => join(baselineRootDir(repoRoot), version);
|
|
221
|
+
const writeBaseline = async (repoRoot, version, files) => {
|
|
222
|
+
const root = baselineRootDir(repoRoot);
|
|
223
|
+
const staging = join(root, STAGING_DIR$1);
|
|
224
|
+
await rm(staging, {
|
|
225
|
+
recursive: true,
|
|
226
|
+
force: true
|
|
227
|
+
});
|
|
228
|
+
await mkdir(staging, { recursive: true });
|
|
229
|
+
await Promise.all(files.map(async ({ relPath, content }) => {
|
|
230
|
+
const dest = join(staging, relPath);
|
|
231
|
+
await mkdir(dirname(dest), { recursive: true });
|
|
232
|
+
await writeFile(dest, content);
|
|
233
|
+
}));
|
|
234
|
+
const versionDir = baselineVersionDir(repoRoot, version);
|
|
235
|
+
await rm(versionDir, {
|
|
236
|
+
recursive: true,
|
|
237
|
+
force: true
|
|
238
|
+
});
|
|
239
|
+
await rename(staging, versionDir);
|
|
240
|
+
await pruneOtherVersions(root, version);
|
|
241
|
+
};
|
|
242
|
+
const pruneOtherVersions = async (root, keep) => {
|
|
243
|
+
const entries = await readdir(root, { withFileTypes: true });
|
|
244
|
+
await Promise.all(entries.filter((entry) => entry.isDirectory() && entry.name !== keep).map((entry) => rm(join(root, entry.name), {
|
|
245
|
+
recursive: true,
|
|
246
|
+
force: true
|
|
247
|
+
})));
|
|
248
|
+
};
|
|
249
|
+
const readBaseline = async (repoRoot, version) => {
|
|
250
|
+
const versionDir = baselineVersionDir(repoRoot, version);
|
|
251
|
+
let relPaths;
|
|
252
|
+
try {
|
|
253
|
+
relPaths = await listFiles(versionDir);
|
|
254
|
+
} catch (error) {
|
|
255
|
+
if (isENOENT$1(error)) return /* @__PURE__ */ new Map();
|
|
256
|
+
throw error;
|
|
257
|
+
}
|
|
258
|
+
const entries = await Promise.all(relPaths.map(async (relPath) => [relPath, await readFile(join(versionDir, relPath), "utf8")]));
|
|
259
|
+
return new Map(entries);
|
|
260
|
+
};
|
|
261
|
+
const findBaselineVersion = async (repoRoot) => {
|
|
262
|
+
const root = baselineRootDir(repoRoot);
|
|
263
|
+
let entries;
|
|
264
|
+
try {
|
|
265
|
+
entries = await readdir(root, { withFileTypes: true });
|
|
266
|
+
} catch (error) {
|
|
267
|
+
if (isENOENT$1(error)) return null;
|
|
268
|
+
throw error;
|
|
269
|
+
}
|
|
270
|
+
const versions = entries.filter((entry) => entry.isDirectory() && !entry.name.startsWith(".")).map((entry) => entry.name);
|
|
271
|
+
if (versions.length <= 1) return versions[0] ?? null;
|
|
272
|
+
const withMtime = await Promise.all(versions.map(async (version) => ({
|
|
273
|
+
version,
|
|
274
|
+
mtimeMs: (await stat(join(root, version))).mtimeMs
|
|
275
|
+
})));
|
|
276
|
+
withMtime.sort((a, b) => a.mtimeMs - b.mtimeMs || a.version.localeCompare(b.version, void 0, { numeric: true }));
|
|
277
|
+
return withMtime.at(-1)?.version ?? null;
|
|
278
|
+
};
|
|
279
|
+
//#endregion
|
|
280
|
+
//#region src/config/has-install-baseline.ts
|
|
281
|
+
const hasInstallBaseline = async (repoRoot) => findBaselineVersion(repoRoot).then((version) => version !== null, (error) => PERMISSION_CODES.has(error.code ?? ""));
|
|
282
|
+
const PERMISSION_CODES = /* @__PURE__ */ new Set(["EACCES", "EPERM"]);
|
|
283
|
+
//#endregion
|
|
50
284
|
//#region src/config/config-error.ts
|
|
51
285
|
const formatConfigError = (schema, error) => {
|
|
52
286
|
return error.issues.flatMap((issue) => formatIssue(schema, issue)).join("\n");
|
|
@@ -110,6 +344,16 @@ const levenshtein = (a, b) => {
|
|
|
110
344
|
return prev[b.length] ?? 0;
|
|
111
345
|
};
|
|
112
346
|
//#endregion
|
|
347
|
+
//#region src/config/repo-relative-path.ts
|
|
348
|
+
const repoRelativePathIssue = (value) => {
|
|
349
|
+
if (value.includes("\\")) return `must use "/" as the separator, got "${value}".`;
|
|
350
|
+
if (posix.isAbsolute(value) || /^[A-Za-z]:/.test(value)) return `must be relative to the repository root, got the absolute path "${value}".`;
|
|
351
|
+
const normalized = posix.normalize(value);
|
|
352
|
+
if (normalized === ".." || normalized.startsWith("../")) return `must stay inside the repository, got "${value}", which leads out of it.`;
|
|
353
|
+
if (normalized === "." || normalized === "./") return `must name a file inside the repository, got "${value}", which is the repository root.`;
|
|
354
|
+
if (value.endsWith("/") || posix.basename(value) === ".") return `must name a file, got "${value}", which names a directory.`;
|
|
355
|
+
};
|
|
356
|
+
//#endregion
|
|
113
357
|
//#region src/config/config.schema.ts
|
|
114
358
|
const pathsSchema = z.object({
|
|
115
359
|
playbooks: z.string().default(PATHS_DEFAULTS.playbooks),
|
|
@@ -123,7 +367,15 @@ const rollbackSchema = z.object({ keep_snapshots: z.union([z.int().positive(), z
|
|
|
123
367
|
const telemetrySchema = z.object({ enabled: z.boolean().default(true) }).strict().prefault({});
|
|
124
368
|
const designTokensSchema = z.object({
|
|
125
369
|
scan_extensions: z.array(z.string()).default([]),
|
|
126
|
-
verify: z.string().trim().min(1).optional()
|
|
370
|
+
verify: z.string().trim().min(1).optional(),
|
|
371
|
+
file: z.string().trim().min(1, "must name the token file, relative to the repository root.").superRefine((value, ctx) => {
|
|
372
|
+
if (value.length === 0) return;
|
|
373
|
+
const issue = repoRelativePathIssue(value);
|
|
374
|
+
if (issue !== void 0) ctx.addIssue({
|
|
375
|
+
code: "custom",
|
|
376
|
+
message: issue
|
|
377
|
+
});
|
|
378
|
+
}).optional()
|
|
127
379
|
}).strict().prefault({});
|
|
128
380
|
const mergeSchema = z.object({
|
|
129
381
|
checks_timeout_secs: z.int().positive().default(600),
|
|
@@ -145,24 +397,31 @@ const harnessConfigSchema = z.object({
|
|
|
145
397
|
}).strict();
|
|
146
398
|
//#endregion
|
|
147
399
|
//#region src/config/config.ts
|
|
400
|
+
const OPTIONAL_CONFIG_KEYS = Object.entries(harnessConfigSchema.shape).filter(([, schema]) => schema.safeParse(void 0).success).map(([key]) => key);
|
|
148
401
|
const readHarnessConfig = async (repoRoot) => {
|
|
149
402
|
const configPath = join(repoRoot, HARNESS_CONFIG_FILENAME);
|
|
150
403
|
let raw;
|
|
151
404
|
try {
|
|
152
|
-
raw = await
|
|
405
|
+
raw = await readRegularFile(configPath);
|
|
153
406
|
} catch (cause) {
|
|
154
|
-
|
|
407
|
+
const linkFault = await classifyConfigLinkFault(configPath, cause);
|
|
408
|
+
if (linkFault !== void 0) throw new Error(configLinkMessage(repoRoot, linkFault), { cause });
|
|
409
|
+
if (cause.code === "ENOENT") {
|
|
410
|
+
if (await hasInstallBaseline(repoRoot)) throw new Error(missingBesideBaselineMessage(repoRoot), { cause });
|
|
411
|
+
throw new Error(`${HARNESS_CONFIG_FILENAME} not found at ${repoRoot}. Run \`lemony install\` first.`, { cause });
|
|
412
|
+
}
|
|
155
413
|
throw new Error(`Cannot read ${HARNESS_CONFIG_FILENAME} at ${repoRoot}: ${cause instanceof Error ? cause.message : String(cause)}`, { cause });
|
|
156
414
|
}
|
|
157
415
|
let parsed;
|
|
158
416
|
try {
|
|
159
417
|
parsed = parse(raw);
|
|
160
418
|
} catch (cause) {
|
|
161
|
-
throw new Error(
|
|
419
|
+
throw new Error(CONFIG_MALFORMED_MESSAGE, { cause });
|
|
162
420
|
}
|
|
163
|
-
if (
|
|
421
|
+
if (typeof parsed !== "object" || parsed === null || Object.getPrototypeOf(parsed) !== Object.prototype) throw new Error(CONFIG_MALFORMED_MESSAGE);
|
|
164
422
|
const candidate = { ...parsed };
|
|
165
423
|
for (const key of DEPRECATED_CONFIG_KEYS) delete candidate[key];
|
|
424
|
+
for (const key of OPTIONAL_CONFIG_KEYS) if (candidate[key] === null) delete candidate[key];
|
|
166
425
|
const paths = candidate.paths;
|
|
167
426
|
if (paths && typeof paths === "object" && !Array.isArray(paths)) {
|
|
168
427
|
const pathsCandidate = { ...paths };
|
|
@@ -179,6 +438,26 @@ const readHarnessConfig = async (repoRoot) => {
|
|
|
179
438
|
if (!result.success) throw new Error(`${HARNESS_CONFIG_FILENAME} is invalid:\n${formatConfigError(harnessConfigSchema, result.error)}`);
|
|
180
439
|
return result.data;
|
|
181
440
|
};
|
|
441
|
+
const missingBesideBaselineMessage = (repoRoot) => `${HARNESS_CONFIG_FILENAME} not found at ${repoRoot}, but ${BASELINE_ROOT_REL} exists: the harness was installed here, or an install did not finish. If a \`lemony install\` did not finish here, re-run it; otherwise restore ${HARNESS_CONFIG_FILENAME} from git history or a backup (\`lemony install\` would, with no TTY, replace the files you edited with the vendor copy).`;
|
|
442
|
+
const REPOINT_CONFIG_LINK = `Repoint it at the config file, or replace it with a copy of the config (from git history or a backup); remove it and run \`lemony install\` only in a repo that was never installed.`;
|
|
443
|
+
const configLinkMessage = (repoRoot, fault) => {
|
|
444
|
+
const prefix = `Cannot read ${HARNESS_CONFIG_FILENAME} at ${repoRoot}:`;
|
|
445
|
+
switch (fault) {
|
|
446
|
+
case "target-missing": return `${prefix} the entry is there but its target is missing (a broken symlink?). ${REPOINT_CONFIG_LINK}`;
|
|
447
|
+
case "loop": return `${prefix} it is a symlink that loops or runs through too many links (following it never reaches a file). ${REPOINT_CONFIG_LINK}`;
|
|
448
|
+
case "target-unreadable": return `${prefix} it is a symlink whose target cannot be read (permission denied). Give your user read access to the target and search access to each directory on its path, or repoint the link at a config file you can read.`;
|
|
449
|
+
}
|
|
450
|
+
};
|
|
451
|
+
const readOptionalHarnessConfig = async (repoRoot) => {
|
|
452
|
+
let present;
|
|
453
|
+
try {
|
|
454
|
+
present = await entryExists(join(repoRoot, HARNESS_CONFIG_FILENAME));
|
|
455
|
+
} catch (error) {
|
|
456
|
+
throw new Error(`Cannot read ${HARNESS_CONFIG_FILENAME} at ${repoRoot}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
457
|
+
}
|
|
458
|
+
if (!present && !await hasInstallBaseline(repoRoot)) return void 0;
|
|
459
|
+
return readHarnessConfig(repoRoot);
|
|
460
|
+
};
|
|
182
461
|
//#endregion
|
|
183
462
|
//#region src/config/compare-version.ts
|
|
184
463
|
const PRERELEASE_RANK = {
|
|
@@ -235,6 +514,34 @@ const setConfigValues = (rawYaml, updates) => {
|
|
|
235
514
|
for (const key of DEPRECATED_CONFIG_KEYS) if (doc.has(key)) doc.delete(key);
|
|
236
515
|
for (const key of DEPRECATED_PATHS_KEYS) if (doc.hasIn(["paths", key])) doc.deleteIn(["paths", key]);
|
|
237
516
|
for (const key of DEPRECATED_IMPLEMENTATION_KEYS) if (doc.hasIn(["implementation", key])) doc.deleteIn(["implementation", key]);
|
|
517
|
+
return restoreNullBlockComments(rawYaml, doc);
|
|
518
|
+
};
|
|
519
|
+
const restoreNullBlockComments = (rawYaml, doc) => {
|
|
520
|
+
const restored = [];
|
|
521
|
+
visit(doc, { Pair(index, pair, path) {
|
|
522
|
+
const { key, value } = pair;
|
|
523
|
+
const parent = path.at(-1);
|
|
524
|
+
if (!isMap(parent) || parent.flow || !isScalar(key) || !key.range) return;
|
|
525
|
+
if (!isScalar(value) || value.value !== null || !value.range) return;
|
|
526
|
+
if (value.range[0] !== value.range[1] || value.anchor || value.tag) return;
|
|
527
|
+
if (value.comment === void 0 || value.comment === null) return;
|
|
528
|
+
const colon = rawYaml.indexOf(":", key.range[1]) + 1;
|
|
529
|
+
if (!/^[ \t]*$/.test(rawYaml.slice(key.range[1], colon - 1))) return;
|
|
530
|
+
const source = rawYaml.slice(colon, value.range[2]).replace(/[ \t]+$/, "");
|
|
531
|
+
restored.push({
|
|
532
|
+
value,
|
|
533
|
+
comment: value.comment,
|
|
534
|
+
source: source.replaceAll("\r\n", "\n")
|
|
535
|
+
});
|
|
536
|
+
value.comment = `${NULL_BLOCK_SENTINEL}${restored.length - 1}`;
|
|
537
|
+
const commentEnd = colon + source.trimEnd().length;
|
|
538
|
+
const gap = /^\s*/.exec(rawYaml.slice(commentEnd))?.[0] ?? "";
|
|
539
|
+
const next = parent.items[index + 1];
|
|
540
|
+
if (isPair(next) && isScalar(next.key) && next.key.range?.[0] === commentEnd + gap.length && !/\n[ \t\r]*\n/.test(gap)) next.key.spaceBefore = false;
|
|
541
|
+
} });
|
|
542
|
+
const text = restored.reduce((current, { source }, index) => current.replace(`: #${NULL_BLOCK_SENTINEL}${index}\n`, `:${source.endsWith("\n") ? source : `${source}\n`}`), doc.toString());
|
|
543
|
+
if (!text.includes("lemony-null-block-comment-")) return text;
|
|
544
|
+
for (const { value, comment } of restored) value.comment = comment;
|
|
238
545
|
return doc.toString();
|
|
239
546
|
};
|
|
240
547
|
const writeConfigValues = async (repoRoot, updates) => {
|
|
@@ -266,20 +573,6 @@ const pointerFrontmatterSchema = z.object({
|
|
|
266
573
|
});
|
|
267
574
|
Object.keys(pointerFrontmatterSchema.shape);
|
|
268
575
|
//#endregion
|
|
269
|
-
//#region src/paths/claude-paths.constant.ts
|
|
270
|
-
const CLAUDE_DIR = ".claude";
|
|
271
|
-
const STATE_DIR = join(CLAUDE_DIR, "state");
|
|
272
|
-
const TASKS_DIR = join(STATE_DIR, "tasks");
|
|
273
|
-
const SKILLS_DIR = join(CLAUDE_DIR, "skills");
|
|
274
|
-
const AGENTS_DIR = join(CLAUDE_DIR, "agents");
|
|
275
|
-
const HOOKS_DIR = join(CLAUDE_DIR, "hooks");
|
|
276
|
-
const COMMANDS_DIR = join(CLAUDE_DIR, "commands");
|
|
277
|
-
const SETTINGS_FILE = join(CLAUDE_DIR, "settings.json");
|
|
278
|
-
const HARNESS_DIR = join(CLAUDE_DIR, ".harness");
|
|
279
|
-
const BASELINE_ROOT_REL = join(HARNESS_DIR, "baseline");
|
|
280
|
-
const SNAPSHOTS_ROOT_REL = join(HARNESS_DIR, "snapshots");
|
|
281
|
-
const EVENTS_RELPATH = join(STATE_DIR, "events.jsonl");
|
|
282
|
-
//#endregion
|
|
283
576
|
//#region src/events/envelope.ts
|
|
284
577
|
const buildEnvelope = (input) => {
|
|
285
578
|
const ts = (input.now ?? /* @__PURE__ */ new Date()).toISOString();
|
|
@@ -392,7 +685,8 @@ const taskDoneSchema = envelopeBase.extend({
|
|
|
392
685
|
cycle_time_h: z.number().nonnegative().finite(),
|
|
393
686
|
review_rejections: z.number().int().nonnegative(),
|
|
394
687
|
mode: z.enum(IMPLEMENTATION_MODES).optional(),
|
|
395
|
-
steps: z.number().int().positive().optional()
|
|
688
|
+
steps: z.number().int().positive().optional(),
|
|
689
|
+
checkpoints: z.number().int().positive().optional()
|
|
396
690
|
}).strict();
|
|
397
691
|
const reviewRejectedSchema = envelopeBase.extend({
|
|
398
692
|
type: z.literal("review_rejected"),
|
|
@@ -439,6 +733,16 @@ const eventSchema = z.discriminatedUnion("type", [
|
|
|
439
733
|
stepCompletedSchema
|
|
440
734
|
]);
|
|
441
735
|
//#endregion
|
|
736
|
+
//#region src/events/task-done-count-issues.ts
|
|
737
|
+
const taskDoneCountIssues = (event) => {
|
|
738
|
+
const { mode, steps, checkpoints } = event;
|
|
739
|
+
const issues = [];
|
|
740
|
+
if ((steps !== void 0 || checkpoints !== void 0) && mode !== "step_by_step") issues.push(`steps/checkpoints: only sent when mode is step_by_step (got mode ${mode ?? "absent"})`);
|
|
741
|
+
if (checkpoints !== void 0 && steps === void 0) issues.push("checkpoints: sent without steps — pass both");
|
|
742
|
+
if (steps !== void 0 && checkpoints !== void 0 && checkpoints < steps) issues.push(`checkpoints: ${checkpoints} is fewer than steps ${steps} — every step resolves at least one checkpoint`);
|
|
743
|
+
return issues;
|
|
744
|
+
};
|
|
745
|
+
//#endregion
|
|
442
746
|
//#region src/events/emit.ts
|
|
443
747
|
const NUMERIC_FIELDS = /* @__PURE__ */ new Set([
|
|
444
748
|
"session_active_h",
|
|
@@ -450,6 +754,7 @@ const NUMERIC_FIELDS = /* @__PURE__ */ new Set([
|
|
|
450
754
|
"discovered_h",
|
|
451
755
|
"step",
|
|
452
756
|
"steps",
|
|
757
|
+
"checkpoints",
|
|
453
758
|
"review_iterations"
|
|
454
759
|
]);
|
|
455
760
|
const BOOLEAN_FIELDS = /* @__PURE__ */ new Set(["auto_close"]);
|
|
@@ -480,7 +785,7 @@ const parseFlags = (args) => {
|
|
|
480
785
|
}
|
|
481
786
|
return result;
|
|
482
787
|
};
|
|
483
|
-
const
|
|
788
|
+
const prepareEmit = async (options) => {
|
|
484
789
|
const { repoRoot, harnessVersion, args } = options;
|
|
485
790
|
const [rawType, ...rest] = args;
|
|
486
791
|
if (!rawType) throw new Error(`Usage: lemony emit <type> [--field=value …]. Types: ${EVENT_TYPES.join(", ")}.`);
|
|
@@ -507,11 +812,217 @@ const runEmit = async (options) => {
|
|
|
507
812
|
const issues = parsed.error.issues.map((issue) => ` - ${issue.path.join(".") || "(root)"}: ${issue.message}`).join("\n");
|
|
508
813
|
throw new Error(`Event validation failed for type "${rawType}":\n${issues}`);
|
|
509
814
|
}
|
|
510
|
-
const
|
|
511
|
-
|
|
815
|
+
const countIssues = parsed.data.type === "task_done" ? taskDoneCountIssues(parsed.data) : [];
|
|
816
|
+
if (countIssues.length > 0) throw new Error(`Event validation failed for type "${rawType}":\n${countIssues.map((issue) => ` - ${issue}`).join("\n")}`);
|
|
512
817
|
return {
|
|
513
818
|
event: parsed.data,
|
|
514
|
-
eventsPath
|
|
819
|
+
eventsPath: join(repoRoot, EVENTS_RELPATH)
|
|
820
|
+
};
|
|
821
|
+
};
|
|
822
|
+
const runEmit = async (options) => {
|
|
823
|
+
const prepared = await prepareEmit(options);
|
|
824
|
+
await appendEvent(prepared.eventsPath, prepared.event);
|
|
825
|
+
return prepared;
|
|
826
|
+
};
|
|
827
|
+
//#endregion
|
|
828
|
+
//#region src/checkpoint/checkpoint.constant.ts
|
|
829
|
+
const CHECKPOINT_TRAILER = "Lemony-Checkpoint";
|
|
830
|
+
const CHECKPOINT_LAUNCHER = ".claude/hooks/lib/lemony.sh";
|
|
831
|
+
const TASK_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
832
|
+
const POSITIVE_INTEGER_PATTERN = /^[1-9]\d*$/;
|
|
833
|
+
//#endregion
|
|
834
|
+
//#region src/checkpoint/checkpoint.model.ts
|
|
835
|
+
const CHECKPOINT_AUTO_COMMIT = ["on", "off"];
|
|
836
|
+
//#endregion
|
|
837
|
+
//#region src/checkpoint/checkpoint.ts
|
|
838
|
+
const USAGE = "lemony checkpoint <ok|changes|ok_downgrade> --task-id=<id> --auto-commit=<on|off> [--step=<N> --review-iterations=<K>]";
|
|
839
|
+
const isAnswer = (value) => CHECKPOINT_RESULTS.includes(value);
|
|
840
|
+
const isAutoCommit = (value) => CHECKPOINT_AUTO_COMMIT.includes(value);
|
|
841
|
+
const parseCall = (inputs) => {
|
|
842
|
+
const fail = (message) => {
|
|
843
|
+
throw new Error(`checkpoint: ${message}\nUsage: ${USAGE}`);
|
|
844
|
+
};
|
|
845
|
+
const { answer, taskId, autoCommit, step, reviewIterations } = inputs;
|
|
846
|
+
if (answer === void 0 || !isAnswer(answer)) return fail(`the answer must be one of ${CHECKPOINT_RESULTS.join(", ")}${answer === void 0 ? "." : `, got "${answer}".`}`);
|
|
847
|
+
if (taskId === void 0) return fail("--task-id is required.");
|
|
848
|
+
if (!TASK_ID_PATTERN.test(taskId)) return fail(`--task-id "${taskId}" is not a task id.`);
|
|
849
|
+
if (autoCommit === void 0 || !isAutoCommit(autoCommit)) return fail(`--auto-commit must be on or off${autoCommit === void 0 ? " (the value progress.md records)." : `, got "${autoCommit}".`}`);
|
|
850
|
+
if (step === void 0) {
|
|
851
|
+
if (answer === "ok_downgrade") return fail("ok_downgrade needs --step: the downgrade is offered only at a step-by-step checkpoint.");
|
|
852
|
+
if (autoCommit === "on") return fail("without --step this is the all-at-once gate, which only auto-commit OFF has.");
|
|
853
|
+
const stepOnly = [
|
|
854
|
+
["review-iterations", reviewIterations],
|
|
855
|
+
["attributed-kind", inputs.attributedKind],
|
|
856
|
+
["attributed-name", inputs.attributedName]
|
|
857
|
+
].find(([, value]) => value !== void 0);
|
|
858
|
+
if (stepOnly) return fail(`--${stepOnly[0]} goes with --step: the all-at-once gate emits no step_completed.`);
|
|
859
|
+
return {
|
|
860
|
+
answer,
|
|
861
|
+
taskId,
|
|
862
|
+
autoCommit,
|
|
863
|
+
step: void 0
|
|
864
|
+
};
|
|
865
|
+
}
|
|
866
|
+
if (!POSITIVE_INTEGER_PATTERN.test(step)) return fail(`--step must be a positive integer, got "${step}".`);
|
|
867
|
+
if (reviewIterations === void 0) return fail("--review-iterations is required with --step.");
|
|
868
|
+
if (!POSITIVE_INTEGER_PATTERN.test(reviewIterations)) return fail(`--review-iterations must be a positive integer, got "${reviewIterations}".`);
|
|
869
|
+
return {
|
|
870
|
+
answer,
|
|
871
|
+
taskId,
|
|
872
|
+
autoCommit,
|
|
873
|
+
step: {
|
|
874
|
+
number: step,
|
|
875
|
+
reviewIterations
|
|
876
|
+
}
|
|
877
|
+
};
|
|
878
|
+
};
|
|
879
|
+
const buildCheckpointCall = (call) => [
|
|
880
|
+
CHECKPOINT_LAUNCHER,
|
|
881
|
+
"checkpoint",
|
|
882
|
+
call.answer,
|
|
883
|
+
`--task-id=${call.taskId}`,
|
|
884
|
+
`--auto-commit=${call.autoCommit}`,
|
|
885
|
+
...call.step ? [`--step=${call.step.number}`, `--review-iterations=${call.step.reviewIterations}`] : []
|
|
886
|
+
].join(" ");
|
|
887
|
+
const commitSubjects = (call) => call.step ? {
|
|
888
|
+
code: `step(${call.taskId}): step ${call.step.number}`,
|
|
889
|
+
state: `step(${call.taskId}): step ${call.step.number} checkpoint OK`
|
|
890
|
+
} : {
|
|
891
|
+
code: `task(${call.taskId}): implementation`,
|
|
892
|
+
state: `task(${call.taskId}): implementation checkpoint OK`
|
|
893
|
+
};
|
|
894
|
+
const firstLine = (text) => text.trim().split("\n")[0] ?? "";
|
|
895
|
+
const bindGit = (run, top) => (...args) => run("git", [
|
|
896
|
+
"--no-literal-pathspecs",
|
|
897
|
+
"-C",
|
|
898
|
+
top,
|
|
899
|
+
...args
|
|
900
|
+
]);
|
|
901
|
+
const resolveTopLevel = async (run, repoRoot) => {
|
|
902
|
+
const result = await run("git", [
|
|
903
|
+
"-C",
|
|
904
|
+
repoRoot,
|
|
905
|
+
"rev-parse",
|
|
906
|
+
"--show-toplevel"
|
|
907
|
+
]);
|
|
908
|
+
if (result.code !== 0) throw new Error(`checkpoint: not inside a git work tree (${firstLine(result.stderr)}).`);
|
|
909
|
+
return result.stdout.trim();
|
|
910
|
+
};
|
|
911
|
+
const IN_PROGRESS = [
|
|
912
|
+
"MERGE_HEAD",
|
|
913
|
+
"CHERRY_PICK_HEAD",
|
|
914
|
+
"REVERT_HEAD",
|
|
915
|
+
"rebase-merge",
|
|
916
|
+
"rebase-apply"
|
|
917
|
+
];
|
|
918
|
+
const checkRepoState = async (git, top, call) => {
|
|
919
|
+
const taskState = join(TASKS_DIR, call.taskId);
|
|
920
|
+
if (!await pathExists(join(top, taskState))) throw new Error(`checkpoint: no task state at ${taskState} — check --task-id.`);
|
|
921
|
+
if ((await git("symbolic-ref", "--quiet", "HEAD")).code !== 0) throw new Error("checkpoint: HEAD is detached — check out the task branch first.");
|
|
922
|
+
for (const entry of IN_PROGRESS) {
|
|
923
|
+
const path = (await git("rev-parse", "--git-path", entry)).stdout.trim();
|
|
924
|
+
if (path && await pathExists(resolve(top, path))) throw new Error(`checkpoint: ${entry} is present — finish or abort that merge, rebase, cherry-pick or revert first.`);
|
|
925
|
+
}
|
|
926
|
+
if ((await git("ls-files", "--unmerged")).stdout.trim() !== "") throw new Error("checkpoint: the index has unmerged paths — resolve them first.");
|
|
927
|
+
if (call.answer === "changes") return;
|
|
928
|
+
const changed = await git("status", "--porcelain", "--untracked-files=all", "--", join(taskState, "progress.md"));
|
|
929
|
+
if (changed.code !== 0) throw new Error(`checkpoint: git status failed (${firstLine(changed.stderr)}).`);
|
|
930
|
+
if (changed.stdout.trim() !== "") return;
|
|
931
|
+
const ownCall = (await git("log", "-1", "--format=%B")).stdout.split("\n").some((line) => line.trim() === `${CHECKPOINT_TRAILER}: ${buildCheckpointCall(call)}`);
|
|
932
|
+
const touchesState = (await git("diff-tree", "--quiet", "--root", "--no-commit-id", "-r", "HEAD", "--", taskState)).code === 1;
|
|
933
|
+
if (ownCall && touchesState) return;
|
|
934
|
+
throw new Error(`checkpoint: nothing changed in ${join(taskState, "progress.md")} — write the resolved progress.md line first (the state commit carries it), then run this call. If this answer's commits were already made without it, emit step_completed by hand instead.`);
|
|
935
|
+
};
|
|
936
|
+
const guardedCommit = async (git, subject, trailer, pathspec) => {
|
|
937
|
+
const staged = await git("diff", "--cached", "--quiet", "--", ...pathspec);
|
|
938
|
+
if (staged.code === 0) return "empty";
|
|
939
|
+
if (staged.code !== 1) return {
|
|
940
|
+
failedStep: `commit "${subject}"`,
|
|
941
|
+
reason: firstLine(staged.stderr) || `git diff exited ${staged.code}`
|
|
942
|
+
};
|
|
943
|
+
const commit = await git("commit", "-m", subject, "-m", `${CHECKPOINT_TRAILER}: ${trailer}`, "--", ...pathspec);
|
|
944
|
+
if (commit.code !== 0) return {
|
|
945
|
+
failedStep: `commit "${subject}"`,
|
|
946
|
+
reason: commit.stderr.trim() || commit.stdout.trim() || `git commit exited ${commit.code}`
|
|
947
|
+
};
|
|
948
|
+
return { oid: (await git("rev-parse", "--short", "HEAD")).stdout.trim() };
|
|
949
|
+
};
|
|
950
|
+
const isFailure = (value) => typeof value === "object" && value !== null && "failedStep" in value;
|
|
951
|
+
const runCheckpoint = async (inputs) => {
|
|
952
|
+
const call = parseCall(inputs);
|
|
953
|
+
const top = await resolveTopLevel(inputs.runCommand, inputs.repoRoot);
|
|
954
|
+
const git = bindGit(inputs.runCommand, top);
|
|
955
|
+
await checkRepoState(git, top, call);
|
|
956
|
+
const prepared = call.step ? await prepareEmit({
|
|
957
|
+
repoRoot: top,
|
|
958
|
+
harnessVersion: inputs.harnessVersion,
|
|
959
|
+
args: [
|
|
960
|
+
"step_completed",
|
|
961
|
+
`--task-id=${call.taskId}`,
|
|
962
|
+
`--step=${call.step.number}`,
|
|
963
|
+
`--review-iterations=${call.step.reviewIterations}`,
|
|
964
|
+
`--checkpoint-result=${call.answer}`,
|
|
965
|
+
...inputs.attributedKind === void 0 ? [] : [`--attributed-kind=${inputs.attributedKind}`],
|
|
966
|
+
...inputs.attributedName === void 0 ? [] : [`--attributed-name=${inputs.attributedName}`]
|
|
967
|
+
]
|
|
968
|
+
}) : void 0;
|
|
969
|
+
const done = [];
|
|
970
|
+
const warnings = [];
|
|
971
|
+
const stop = (failure) => ({
|
|
972
|
+
ok: false,
|
|
973
|
+
done,
|
|
974
|
+
warnings,
|
|
975
|
+
...failure
|
|
976
|
+
});
|
|
977
|
+
if (call.autoCommit === "off") {
|
|
978
|
+
const add = await git("add", "-A");
|
|
979
|
+
if (add.code !== 0) return stop({
|
|
980
|
+
failedStep: "git add -A",
|
|
981
|
+
reason: firstLine(add.stderr) || `git add exited ${add.code}`
|
|
982
|
+
});
|
|
983
|
+
done.push(call.answer === "changes" ? "staged the worktree (the human delta)" : "staged the worktree");
|
|
984
|
+
}
|
|
985
|
+
if (call.answer !== "changes") {
|
|
986
|
+
const trailer = buildCheckpointCall(call);
|
|
987
|
+
const subjects = commitSubjects(call);
|
|
988
|
+
const taskState = join(TASKS_DIR, call.taskId);
|
|
989
|
+
if (call.autoCommit === "off") {
|
|
990
|
+
const code = await guardedCommit(git, subjects.code, trailer, [".", `:(exclude)${STATE_DIR}`]);
|
|
991
|
+
if (isFailure(code)) return stop(code);
|
|
992
|
+
done.push(code === "empty" ? "no code change to commit" : `committed ${code.oid} ${subjects.code}`);
|
|
993
|
+
} else {
|
|
994
|
+
const add = await git("add", "--", taskState);
|
|
995
|
+
if (add.code !== 0) return stop({
|
|
996
|
+
failedStep: `git add ${taskState}`,
|
|
997
|
+
reason: firstLine(add.stderr) || `git add exited ${add.code}`
|
|
998
|
+
});
|
|
999
|
+
}
|
|
1000
|
+
const state = await guardedCommit(git, subjects.state, trailer, [taskState]);
|
|
1001
|
+
if (isFailure(state)) return stop(state);
|
|
1002
|
+
done.push(state === "empty" ? `"${subjects.state}" already on the branch` : `committed ${state.oid} ${subjects.state}`);
|
|
1003
|
+
if (call.autoCommit === "off") {
|
|
1004
|
+
const paths = (await git("diff", "--cached", "--name-only", "--", STATE_DIR)).stdout.trim();
|
|
1005
|
+
if (paths !== "") warnings.push(`left staged, not committed (task state outside ${taskState}): ${paths.split("\n").join(", ")}.`);
|
|
1006
|
+
}
|
|
1007
|
+
const push = await git("push");
|
|
1008
|
+
if (push.code === 0) done.push("pushed");
|
|
1009
|
+
else warnings.push(`push failed (${firstLine(push.stderr) || `exit ${push.code}`}); the next push carries it.`);
|
|
1010
|
+
}
|
|
1011
|
+
if (prepared) {
|
|
1012
|
+
try {
|
|
1013
|
+
await appendEvent(prepared.eventsPath, prepared.event);
|
|
1014
|
+
} catch (error) {
|
|
1015
|
+
return stop({
|
|
1016
|
+
failedStep: "emit step_completed",
|
|
1017
|
+
reason: error instanceof Error ? error.message : String(error)
|
|
1018
|
+
});
|
|
1019
|
+
}
|
|
1020
|
+
done.push(`emitted step_completed → ${EVENTS_RELPATH}`);
|
|
1021
|
+
}
|
|
1022
|
+
return {
|
|
1023
|
+
ok: true,
|
|
1024
|
+
done,
|
|
1025
|
+
warnings
|
|
515
1026
|
};
|
|
516
1027
|
};
|
|
517
1028
|
//#endregion
|
|
@@ -995,72 +1506,17 @@ const failureReason$1 = (result) => {
|
|
|
995
1506
|
return `\`gh\` failed (exit ${result.code})${suffix}${detail ? `: ${detail}` : ""}`;
|
|
996
1507
|
};
|
|
997
1508
|
//#endregion
|
|
998
|
-
//#region src/
|
|
999
|
-
const
|
|
1509
|
+
//#region src/design-tokens/read-token-file.ts
|
|
1510
|
+
const readOptionalTokenFile = async (repoRoot, tokenFile) => {
|
|
1511
|
+
const tokenPath = join(repoRoot, tokenFile);
|
|
1000
1512
|
try {
|
|
1001
|
-
await
|
|
1002
|
-
return
|
|
1003
|
-
} catch {
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
};
|
|
1007
|
-
//#endregion
|
|
1008
|
-
//#region src/fs/list-files.ts
|
|
1009
|
-
const listFiles = async (dir) => {
|
|
1010
|
-
const entries = await readdir(dir, { withFileTypes: true });
|
|
1011
|
-
return (await Promise.all(entries.map(async (entry) => {
|
|
1012
|
-
if (entry.isDirectory()) return (await listFiles(join(dir, entry.name))).map((rel) => join(entry.name, rel));
|
|
1013
|
-
return [entry.name];
|
|
1014
|
-
}))).flat();
|
|
1015
|
-
};
|
|
1016
|
-
//#endregion
|
|
1017
|
-
//#region src/fs/prune-empty-dirs.ts
|
|
1018
|
-
const pruneEmptyDirs = async (repoRoot, removedFiles) => {
|
|
1019
|
-
const dirs = /* @__PURE__ */ new Set();
|
|
1020
|
-
for (const relPath of removedFiles) {
|
|
1021
|
-
let dir = dirname(relPath);
|
|
1022
|
-
while (dir !== "." && dir !== sep) {
|
|
1023
|
-
dirs.add(dir);
|
|
1024
|
-
dir = dirname(dir);
|
|
1025
|
-
}
|
|
1026
|
-
}
|
|
1027
|
-
const ordered = [...dirs].toSorted((a, b) => b.length - a.length);
|
|
1028
|
-
for (const dir of ordered) try {
|
|
1029
|
-
await rmdir(join(repoRoot, dir));
|
|
1030
|
-
} catch {}
|
|
1031
|
-
};
|
|
1032
|
-
//#endregion
|
|
1033
|
-
//#region src/fs/write-managed.ts
|
|
1034
|
-
const writeManaged = async (root, relPath, content, executable) => {
|
|
1035
|
-
const dest = join(root, relPath);
|
|
1036
|
-
await mkdir(dirname(dest), { recursive: true });
|
|
1037
|
-
await writeFile(dest, content);
|
|
1038
|
-
await chmod(dest, executable ? 493 : 420);
|
|
1039
|
-
};
|
|
1040
|
-
//#endregion
|
|
1041
|
-
//#region src/fs/symlink-guard.ts
|
|
1042
|
-
const assertNoSymlinkTraversal = async (root, relPaths) => {
|
|
1043
|
-
const components = /* @__PURE__ */ new Set();
|
|
1044
|
-
for (const relPath of relPaths) {
|
|
1045
|
-
let current = "";
|
|
1046
|
-
for (const part of relPath.split(sep)) {
|
|
1047
|
-
if (part.length === 0) continue;
|
|
1048
|
-
current = current ? join(current, part) : part;
|
|
1049
|
-
components.add(current);
|
|
1050
|
-
}
|
|
1513
|
+
if (!await entryExists(tokenPath)) return void 0;
|
|
1514
|
+
return await readRegularFile(tokenPath);
|
|
1515
|
+
} catch (error) {
|
|
1516
|
+
if (error.code === "ENOENT") throw new Error(`Cannot read ${tokenFile} at ${repoRoot}: the entry is there but its target is missing (a broken symlink?).`, { cause: error });
|
|
1517
|
+
throw new Error(`Cannot read ${tokenFile} at ${repoRoot}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
1051
1518
|
}
|
|
1052
|
-
const offenders = (await Promise.all([...components].map(async (rel) => {
|
|
1053
|
-
try {
|
|
1054
|
-
return (await lstat(join(root, rel))).isSymbolicLink() ? rel : null;
|
|
1055
|
-
} catch {
|
|
1056
|
-
return null;
|
|
1057
|
-
}
|
|
1058
|
-
}))).filter((rel) => rel !== null);
|
|
1059
|
-
if (offenders.length > 0) throw new Error("Refusing to write — a managed path traverses a symlink (a redirect could land outside the repo):\n" + offenders.map((rel) => ` ${rel}`).join("\n"));
|
|
1060
1519
|
};
|
|
1061
|
-
//#endregion
|
|
1062
|
-
//#region src/design-tokens/design-tokens.constant.ts
|
|
1063
|
-
const DESIGN_TOKENS_FILE = "docs/design-tokens.json";
|
|
1064
1520
|
const SCANNED_EXTENSIONS = [
|
|
1065
1521
|
".css",
|
|
1066
1522
|
".scss",
|
|
@@ -1109,6 +1565,47 @@ const TOKEN_TIERS = [
|
|
|
1109
1565
|
"component"
|
|
1110
1566
|
];
|
|
1111
1567
|
//#endregion
|
|
1568
|
+
//#region src/design-tokens/contrast.constant.ts
|
|
1569
|
+
const CONTRAST_EXTENSION = "com.lemony.contrast";
|
|
1570
|
+
const MODES_EXTENSION = "com.lemony.modes";
|
|
1571
|
+
const BASE_MODE = "base";
|
|
1572
|
+
const DEFAULT_LEVEL = "text";
|
|
1573
|
+
const WCAG_FLOORS = {
|
|
1574
|
+
text: 4.5,
|
|
1575
|
+
large: 3,
|
|
1576
|
+
"non-text": 3
|
|
1577
|
+
};
|
|
1578
|
+
//#endregion
|
|
1579
|
+
//#region src/design-tokens/nesting-depth.ts
|
|
1580
|
+
const nestedTooDeeply = (tokenFile) => `${tokenFile} nests deeper than 64 levels, the most Lemony reads.`;
|
|
1581
|
+
const exceedsNestingLimit = (value, limit = 64) => {
|
|
1582
|
+
const pending = [{
|
|
1583
|
+
node: value,
|
|
1584
|
+
depth: 1
|
|
1585
|
+
}];
|
|
1586
|
+
for (let next = pending.pop(); next !== void 0; next = pending.pop()) {
|
|
1587
|
+
const { node, depth } = next;
|
|
1588
|
+
if (typeof node !== "object" || node === null) continue;
|
|
1589
|
+
if (depth > limit) return true;
|
|
1590
|
+
for (const child of Object.values(node)) pending.push({
|
|
1591
|
+
node: child,
|
|
1592
|
+
depth: depth + 1
|
|
1593
|
+
});
|
|
1594
|
+
}
|
|
1595
|
+
return false;
|
|
1596
|
+
};
|
|
1597
|
+
//#endregion
|
|
1598
|
+
//#region src/design-tokens/non-finite-number.ts
|
|
1599
|
+
const nonFiniteNumberFaults = (value, tokenFile) => nonFiniteNumbers(value, []).map(({ path, number }) => `${tokenFile} has a number at "${path}" too large for JSON to carry: it reads as ${number}, and rewriting the file would turn it into null.`);
|
|
1600
|
+
const nonFiniteNumbers = (value, path) => {
|
|
1601
|
+
if (typeof value === "number") return Number.isFinite(value) ? [] : [{
|
|
1602
|
+
path: path.join("."),
|
|
1603
|
+
number: value
|
|
1604
|
+
}];
|
|
1605
|
+
if (typeof value !== "object" || value === null) return [];
|
|
1606
|
+
return Object.entries(value).flatMap(([key, child]) => nonFiniteNumbers(child, [...path, key]));
|
|
1607
|
+
};
|
|
1608
|
+
//#endregion
|
|
1112
1609
|
//#region src/design-tokens/token-parse.ts
|
|
1113
1610
|
const aliasTarget = (value) => {
|
|
1114
1611
|
if (typeof value !== "string") return void 0;
|
|
@@ -1117,18 +1614,35 @@ const aliasTarget = (value) => {
|
|
|
1117
1614
|
};
|
|
1118
1615
|
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1119
1616
|
//#endregion
|
|
1617
|
+
//#region src/design-tokens/resolve-token-file.ts
|
|
1618
|
+
const resolveTokenFile = (declared) => declared ?? "docs/design-tokens.json";
|
|
1619
|
+
//#endregion
|
|
1120
1620
|
//#region src/design-tokens/design-tokens.ts
|
|
1121
1621
|
const runValidate = async (inputs) => {
|
|
1122
|
-
const
|
|
1123
|
-
|
|
1622
|
+
const tokenFile = resolveTokenFile(inputs.tokenFile);
|
|
1623
|
+
let raw;
|
|
1624
|
+
try {
|
|
1625
|
+
raw = await readOptionalTokenFile(inputs.repoRoot, tokenFile);
|
|
1626
|
+
} catch (error) {
|
|
1627
|
+
return {
|
|
1628
|
+
ok: false,
|
|
1629
|
+
tokensFound: true,
|
|
1630
|
+
tokenCount: 0,
|
|
1631
|
+
filesScanned: 0,
|
|
1632
|
+
violations: [{
|
|
1633
|
+
kind: "unreadable-token-file",
|
|
1634
|
+
message: error instanceof Error ? error.message : String(error)
|
|
1635
|
+
}]
|
|
1636
|
+
};
|
|
1637
|
+
}
|
|
1638
|
+
if (raw === void 0) return {
|
|
1124
1639
|
ok: true,
|
|
1125
1640
|
tokensFound: false,
|
|
1126
1641
|
tokenCount: 0,
|
|
1127
1642
|
filesScanned: 0,
|
|
1128
1643
|
violations: []
|
|
1129
1644
|
};
|
|
1130
|
-
const
|
|
1131
|
-
const fileCheck = validateTokenFile(raw);
|
|
1645
|
+
const fileCheck = validateTokenFile(raw, tokenFile);
|
|
1132
1646
|
if (!fileCheck.ok) return {
|
|
1133
1647
|
ok: false,
|
|
1134
1648
|
tokensFound: true,
|
|
@@ -1137,8 +1651,21 @@ const runValidate = async (inputs) => {
|
|
|
1137
1651
|
violations: fileCheck.violations
|
|
1138
1652
|
};
|
|
1139
1653
|
const scanRoot = inputs.scanDir ? join(inputs.repoRoot, inputs.scanDir) : inputs.repoRoot;
|
|
1654
|
+
if (inputs.scanDir) {
|
|
1655
|
+
const unreadable = await describeUnreadableDir(scanRoot);
|
|
1656
|
+
if (unreadable !== void 0) return {
|
|
1657
|
+
ok: false,
|
|
1658
|
+
tokensFound: true,
|
|
1659
|
+
tokenCount: fileCheck.tokenCount,
|
|
1660
|
+
filesScanned: 0,
|
|
1661
|
+
violations: [{
|
|
1662
|
+
kind: "unreadable-scan-root",
|
|
1663
|
+
message: `--scan=${inputs.scanDir} ${unreadable} — nothing was scanned.`
|
|
1664
|
+
}]
|
|
1665
|
+
};
|
|
1666
|
+
}
|
|
1140
1667
|
const extensions = mergeExtensions(inputs.extraExtensions);
|
|
1141
|
-
const sourceViolations = await scanSource(inputs.repoRoot, scanRoot, extensions);
|
|
1668
|
+
const sourceViolations = await scanSource(inputs.repoRoot, scanRoot, extensions, tokenFile);
|
|
1142
1669
|
return {
|
|
1143
1670
|
ok: sourceViolations.violations.length === 0,
|
|
1144
1671
|
tokensFound: true,
|
|
@@ -1147,7 +1674,15 @@ const runValidate = async (inputs) => {
|
|
|
1147
1674
|
violations: sourceViolations.violations
|
|
1148
1675
|
};
|
|
1149
1676
|
};
|
|
1150
|
-
const
|
|
1677
|
+
const summarizeTokenFileViolations = (raw, tokenFile) => summarizeMessages(tokenFileViolationMessages(raw, tokenFile));
|
|
1678
|
+
const tokenFileViolations = (raw, tokenFile) => validateTokenFile(raw, tokenFile).violations;
|
|
1679
|
+
const tokenFileViolationMessages = (raw, tokenFile) => tokenFileViolations(raw, tokenFile).map((violation) => violation.message);
|
|
1680
|
+
const summarizeMessages = (messages) => {
|
|
1681
|
+
const [first, ...more] = messages;
|
|
1682
|
+
if (first === void 0) return void 0;
|
|
1683
|
+
return `${first}${more.length > 0 ? ` (and ${more.length} more)` : ""}`;
|
|
1684
|
+
};
|
|
1685
|
+
const validateTokenFile = (raw, tokenFile) => {
|
|
1151
1686
|
let parsed;
|
|
1152
1687
|
try {
|
|
1153
1688
|
parsed = JSON.parse(raw);
|
|
@@ -1157,7 +1692,7 @@ const validateTokenFile = (raw) => {
|
|
|
1157
1692
|
tokenCount: 0,
|
|
1158
1693
|
violations: [{
|
|
1159
1694
|
kind: "invalid-json",
|
|
1160
|
-
message: `${
|
|
1695
|
+
message: `${tokenFile} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`
|
|
1161
1696
|
}]
|
|
1162
1697
|
};
|
|
1163
1698
|
}
|
|
@@ -1166,10 +1701,22 @@ const validateTokenFile = (raw) => {
|
|
|
1166
1701
|
tokenCount: 0,
|
|
1167
1702
|
violations: [{
|
|
1168
1703
|
kind: "malformed-token-file",
|
|
1169
|
-
message: `${
|
|
1704
|
+
message: `${tokenFile} must be a JSON object with primitive/semantic/component tiers.`
|
|
1705
|
+
}]
|
|
1706
|
+
};
|
|
1707
|
+
if (exceedsNestingLimit(parsed)) return {
|
|
1708
|
+
ok: false,
|
|
1709
|
+
tokenCount: 0,
|
|
1710
|
+
violations: [{
|
|
1711
|
+
kind: "malformed-token-file",
|
|
1712
|
+
message: nestedTooDeeply(tokenFile)
|
|
1170
1713
|
}]
|
|
1171
1714
|
};
|
|
1172
1715
|
const violations = [];
|
|
1716
|
+
for (const message of nonFiniteNumberFaults(parsed, tokenFile)) violations.push({
|
|
1717
|
+
kind: "malformed-token-file",
|
|
1718
|
+
message
|
|
1719
|
+
});
|
|
1173
1720
|
for (const key of Object.keys(parsed)) {
|
|
1174
1721
|
if (key.startsWith("$")) continue;
|
|
1175
1722
|
if (!TOKEN_TIERS.includes(key)) violations.push({
|
|
@@ -1197,6 +1744,10 @@ const validateTokenFile = (raw) => {
|
|
|
1197
1744
|
violations
|
|
1198
1745
|
};
|
|
1199
1746
|
};
|
|
1747
|
+
const modeValues = (extensions) => {
|
|
1748
|
+
const modes = isRecord(extensions) ? extensions[MODES_EXTENSION] : void 0;
|
|
1749
|
+
return isRecord(modes) ? modes : {};
|
|
1750
|
+
};
|
|
1200
1751
|
const collectTokens$1 = (node, tier, path, out, violations) => {
|
|
1201
1752
|
for (const [key, child] of Object.entries(node)) {
|
|
1202
1753
|
if (key.startsWith("$")) continue;
|
|
@@ -1211,7 +1762,8 @@ const collectTokens$1 = (node, tier, path, out, violations) => {
|
|
|
1211
1762
|
if ("$value" in child) {
|
|
1212
1763
|
out.set(childPath, {
|
|
1213
1764
|
tier,
|
|
1214
|
-
value: child.$value
|
|
1765
|
+
value: child.$value,
|
|
1766
|
+
modes: modeValues(child["$extensions"])
|
|
1215
1767
|
});
|
|
1216
1768
|
const stray = Object.keys(child).filter((k) => !k.startsWith("$"));
|
|
1217
1769
|
if (stray.length > 0) violations.push({
|
|
@@ -1223,41 +1775,162 @@ const collectTokens$1 = (node, tier, path, out, violations) => {
|
|
|
1223
1775
|
};
|
|
1224
1776
|
const collectAliasViolations = (tokens) => {
|
|
1225
1777
|
const violations = [];
|
|
1778
|
+
for (const [path, token] of tokens) for (const [mode, value] of Object.entries(token.modes)) {
|
|
1779
|
+
const alias = aliasTarget(value);
|
|
1780
|
+
if (alias !== void 0 && token.tier === "primitive") {
|
|
1781
|
+
violations.push({
|
|
1782
|
+
kind: "primitive-not-literal",
|
|
1783
|
+
message: `primitive token "${path}" must hold a literal value in mode "${mode}", not the alias "{${alias}}".`
|
|
1784
|
+
});
|
|
1785
|
+
continue;
|
|
1786
|
+
}
|
|
1787
|
+
if (alias !== void 0 && !tokens.has(alias)) violations.push({
|
|
1788
|
+
kind: "unresolved-alias",
|
|
1789
|
+
message: `token "${path}" references "{${alias}}" in mode "${mode}", which does not exist.`
|
|
1790
|
+
});
|
|
1791
|
+
}
|
|
1792
|
+
for (const [path, token] of tokens) {
|
|
1793
|
+
const values = [[void 0, token.value], ...Object.entries(token.modes)];
|
|
1794
|
+
for (const [mode, value] of values) {
|
|
1795
|
+
const where = mode === void 0 ? "" : ` in mode "${mode}"`;
|
|
1796
|
+
for (const { at, alias } of compositeAliases(value)) if (token.tier === "primitive") violations.push({
|
|
1797
|
+
kind: "primitive-not-literal",
|
|
1798
|
+
message: `primitive token "${path}" must hold a literal value${where}, not the alias "{${alias}}" at "${at}".`
|
|
1799
|
+
});
|
|
1800
|
+
else if (!tokens.has(alias)) violations.push({
|
|
1801
|
+
kind: "unresolved-alias",
|
|
1802
|
+
message: `token "${path}" references "{${alias}}" at "${at}" in its value${where}, which does not exist.`
|
|
1803
|
+
});
|
|
1804
|
+
}
|
|
1805
|
+
}
|
|
1806
|
+
const loopsInBase = tokensReachingACycle(tokens, [...tokens.keys()]);
|
|
1226
1807
|
for (const [path, token] of tokens) {
|
|
1227
1808
|
const alias = aliasTarget(token.value);
|
|
1228
|
-
if (alias
|
|
1229
|
-
if (token.tier === "primitive") {
|
|
1809
|
+
if (alias !== void 0 && token.tier === "primitive") {
|
|
1230
1810
|
violations.push({
|
|
1231
1811
|
kind: "primitive-not-literal",
|
|
1232
1812
|
message: `primitive token "${path}" must hold a literal value, not the alias "{${alias}}".`
|
|
1233
1813
|
});
|
|
1234
1814
|
continue;
|
|
1235
1815
|
}
|
|
1236
|
-
if (!tokens.has(alias)) {
|
|
1816
|
+
if (alias !== void 0 && !tokens.has(alias)) {
|
|
1237
1817
|
violations.push({
|
|
1238
1818
|
kind: "unresolved-alias",
|
|
1239
1819
|
message: `token "${path}" references "{${alias}}", which does not exist.`
|
|
1240
1820
|
});
|
|
1241
1821
|
continue;
|
|
1242
1822
|
}
|
|
1243
|
-
if (
|
|
1823
|
+
if (token.tier === "primitive") continue;
|
|
1824
|
+
if (loopsInBase.has(path)) violations.push({
|
|
1244
1825
|
kind: "cyclic-alias",
|
|
1245
1826
|
message: `token "${path}" has an alias chain that never resolves to a literal value (alias cycle).`
|
|
1246
1827
|
});
|
|
1247
1828
|
}
|
|
1829
|
+
const owners = /* @__PURE__ */ new Map();
|
|
1830
|
+
for (const [path, token] of tokens) for (const mode of Object.keys(token.modes)) {
|
|
1831
|
+
const owning = owners.get(mode);
|
|
1832
|
+
if (owning === void 0) owners.set(mode, [path]);
|
|
1833
|
+
else owning.push(path);
|
|
1834
|
+
}
|
|
1835
|
+
const baseReferrers = /* @__PURE__ */ new Map();
|
|
1836
|
+
for (const [path, token] of tokens) {
|
|
1837
|
+
if (loopsInBase.has(path)) continue;
|
|
1838
|
+
for (const target of aliasReferences(token.value)) {
|
|
1839
|
+
const referring = baseReferrers.get(target);
|
|
1840
|
+
if (referring === void 0) baseReferrers.set(target, [path]);
|
|
1841
|
+
else referring.push(path);
|
|
1842
|
+
}
|
|
1843
|
+
}
|
|
1844
|
+
const loopsInMode = /* @__PURE__ */ new Map();
|
|
1845
|
+
const loopsWithoutOverride = /* @__PURE__ */ new Map();
|
|
1846
|
+
for (const [mode, owning] of owners) {
|
|
1847
|
+
const loops = tokensReachingACycle(tokens, owning, mode);
|
|
1848
|
+
loopsInMode.set(mode, loops);
|
|
1849
|
+
const feeding = tokensFeedingInto(tokens, baseReferrers, loops, mode);
|
|
1850
|
+
for (const path of [...loops, ...feeding]) {
|
|
1851
|
+
const token = tokens.get(path);
|
|
1852
|
+
if (token === void 0 || Object.hasOwn(token.modes, mode)) continue;
|
|
1853
|
+
if (loopsInBase.has(path)) continue;
|
|
1854
|
+
const looping = loopsWithoutOverride.get(path);
|
|
1855
|
+
if (looping === void 0) loopsWithoutOverride.set(path, [mode]);
|
|
1856
|
+
else looping.push(mode);
|
|
1857
|
+
}
|
|
1858
|
+
}
|
|
1859
|
+
for (const [path, token] of tokens) {
|
|
1860
|
+
if (token.tier === "primitive") continue;
|
|
1861
|
+
const looping = [...Object.keys(token.modes).filter((mode) => loopsInMode.get(mode)?.has(path)), ...loopsWithoutOverride.get(path) ?? []];
|
|
1862
|
+
for (const mode of looping) violations.push({
|
|
1863
|
+
kind: "cyclic-alias",
|
|
1864
|
+
message: `token "${path}" has an alias chain in mode "${mode}" that never resolves to a literal value (alias cycle).`
|
|
1865
|
+
});
|
|
1866
|
+
}
|
|
1248
1867
|
return violations;
|
|
1249
1868
|
};
|
|
1250
|
-
const
|
|
1251
|
-
const
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
if (
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
current = aliasTarget(token.value);
|
|
1869
|
+
const tokensFeedingInto = (tokens, baseReferrers, loops, mode) => {
|
|
1870
|
+
const found = /* @__PURE__ */ new Set();
|
|
1871
|
+
const queue = [...loops];
|
|
1872
|
+
for (let path = queue.pop(); path !== void 0; path = queue.pop()) for (const referrer of baseReferrers.get(path) ?? []) {
|
|
1873
|
+
if (Object.hasOwn(tokens.get(referrer)?.modes ?? {}, mode)) continue;
|
|
1874
|
+
if (loops.has(referrer) || found.has(referrer)) continue;
|
|
1875
|
+
found.add(referrer);
|
|
1876
|
+
queue.push(referrer);
|
|
1259
1877
|
}
|
|
1260
|
-
return
|
|
1878
|
+
return found;
|
|
1879
|
+
};
|
|
1880
|
+
const compositeAliases = (value, at = []) => {
|
|
1881
|
+
if (typeof value !== "object" || value === null) {
|
|
1882
|
+
const alias = at.length === 0 ? void 0 : aliasTarget(value);
|
|
1883
|
+
return alias === void 0 ? [] : [{
|
|
1884
|
+
at: at.join("."),
|
|
1885
|
+
alias
|
|
1886
|
+
}];
|
|
1887
|
+
}
|
|
1888
|
+
return Object.entries(value).flatMap(([key, child]) => compositeAliases(child, [...at, key]));
|
|
1889
|
+
};
|
|
1890
|
+
const aliasReferences = (value) => {
|
|
1891
|
+
const alias = aliasTarget(value);
|
|
1892
|
+
return alias === void 0 ? compositeAliases(value).map((leaf) => leaf.alias) : [alias];
|
|
1893
|
+
};
|
|
1894
|
+
const tokensReachingACycle = (tokens, starts, mode) => {
|
|
1895
|
+
const references = (path) => {
|
|
1896
|
+
const token = tokens.get(path);
|
|
1897
|
+
if (token === void 0) return [];
|
|
1898
|
+
return aliasReferences(mode !== void 0 && Object.hasOwn(token.modes, mode) ? token.modes[mode] : token.value);
|
|
1899
|
+
};
|
|
1900
|
+
const onPath = /* @__PURE__ */ new Set();
|
|
1901
|
+
const settled = /* @__PURE__ */ new Map();
|
|
1902
|
+
for (const start of starts) {
|
|
1903
|
+
if (settled.has(start)) continue;
|
|
1904
|
+
const stack = [{
|
|
1905
|
+
path: start,
|
|
1906
|
+
next: references(start),
|
|
1907
|
+
loops: false
|
|
1908
|
+
}];
|
|
1909
|
+
onPath.add(start);
|
|
1910
|
+
for (let frame = stack.at(-1); frame !== void 0; frame = stack.at(-1)) {
|
|
1911
|
+
const target = frame.next.pop();
|
|
1912
|
+
if (target === void 0) {
|
|
1913
|
+
stack.pop();
|
|
1914
|
+
onPath.delete(frame.path);
|
|
1915
|
+
settled.set(frame.path, frame.loops);
|
|
1916
|
+
const parent = stack.at(-1);
|
|
1917
|
+
if (parent !== void 0 && frame.loops) parent.loops = true;
|
|
1918
|
+
continue;
|
|
1919
|
+
}
|
|
1920
|
+
if (onPath.has(target)) frame.loops = true;
|
|
1921
|
+
else if (settled.has(target)) {
|
|
1922
|
+
if (settled.get(target)) frame.loops = true;
|
|
1923
|
+
} else {
|
|
1924
|
+
stack.push({
|
|
1925
|
+
path: target,
|
|
1926
|
+
next: references(target),
|
|
1927
|
+
loops: false
|
|
1928
|
+
});
|
|
1929
|
+
onPath.add(target);
|
|
1930
|
+
}
|
|
1931
|
+
}
|
|
1932
|
+
}
|
|
1933
|
+
return new Set([...settled].filter(([, loops]) => loops).map(([path]) => path));
|
|
1261
1934
|
};
|
|
1262
1935
|
const mergeExtensions = (extra) => {
|
|
1263
1936
|
const set = new Set(SCANNED_EXTENSIONS);
|
|
@@ -1268,33 +1941,64 @@ const mergeExtensions = (extra) => {
|
|
|
1268
1941
|
}
|
|
1269
1942
|
return set;
|
|
1270
1943
|
};
|
|
1271
|
-
const
|
|
1944
|
+
const codeOf = (error) => error.code ?? String(error);
|
|
1945
|
+
const describeUnreadableDir = async (dir) => {
|
|
1946
|
+
try {
|
|
1947
|
+
await readdir(dir);
|
|
1948
|
+
} catch (error) {
|
|
1949
|
+
const code = codeOf(error);
|
|
1950
|
+
if (code === "ENOENT") return "does not exist";
|
|
1951
|
+
if (code === "ENOTDIR") return "is not a directory";
|
|
1952
|
+
return `cannot be listed (${code})`;
|
|
1953
|
+
}
|
|
1954
|
+
try {
|
|
1955
|
+
await access(dir, constants.R_OK | constants.X_OK);
|
|
1956
|
+
return;
|
|
1957
|
+
} catch (error) {
|
|
1958
|
+
return `can be listed but not searched (${codeOf(error)})`;
|
|
1959
|
+
}
|
|
1960
|
+
};
|
|
1961
|
+
const scanSource = async (repoRoot, scanRoot, extensions, tokenFile) => {
|
|
1272
1962
|
if (!await pathExists(scanRoot)) return {
|
|
1273
1963
|
filesScanned: 0,
|
|
1274
1964
|
violations: []
|
|
1275
1965
|
};
|
|
1276
|
-
const
|
|
1277
|
-
const
|
|
1966
|
+
const unlistable = [];
|
|
1967
|
+
const files = (await collectSourceFiles(scanRoot, extensions, unlistable)).toSorted();
|
|
1968
|
+
const violations = unlistable.map(({ dir, code }) => ({
|
|
1969
|
+
kind: "unreadable-source-dir",
|
|
1970
|
+
message: `directory cannot be listed (${code}) — nothing under it was scanned.`,
|
|
1971
|
+
file: relative(repoRoot, dir).split(sep).join("/") || "."
|
|
1972
|
+
})).toSorted((a, b) => a.file.localeCompare(b.file));
|
|
1278
1973
|
let filesScanned = 0;
|
|
1279
1974
|
for (const file of files) {
|
|
1280
|
-
const content = await
|
|
1975
|
+
const content = await readRegularFile(file).catch(() => void 0);
|
|
1281
1976
|
if (content === void 0) continue;
|
|
1282
1977
|
filesScanned += 1;
|
|
1283
1978
|
const relPath = relative(repoRoot, file).split(sep).join("/");
|
|
1284
|
-
violations.push(...scanFile(relPath, extname(file).toLowerCase(), content));
|
|
1979
|
+
violations.push(...scanFile(relPath, extname(file).toLowerCase(), content, tokenFile));
|
|
1285
1980
|
}
|
|
1286
1981
|
return {
|
|
1287
1982
|
filesScanned,
|
|
1288
1983
|
violations
|
|
1289
1984
|
};
|
|
1290
1985
|
};
|
|
1291
|
-
const collectSourceFiles = async (dir, extensions) => {
|
|
1292
|
-
|
|
1986
|
+
const collectSourceFiles = async (dir, extensions, unlistable) => {
|
|
1987
|
+
let entries;
|
|
1988
|
+
try {
|
|
1989
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
1990
|
+
} catch (error) {
|
|
1991
|
+
unlistable.push({
|
|
1992
|
+
dir,
|
|
1993
|
+
code: codeOf(error)
|
|
1994
|
+
});
|
|
1995
|
+
return [];
|
|
1996
|
+
}
|
|
1293
1997
|
return (await Promise.all(entries.map(async (entry) => {
|
|
1294
1998
|
const full = join(dir, entry.name);
|
|
1295
1999
|
if (entry.isDirectory()) {
|
|
1296
2000
|
if (IGNORED_DIRS.has(entry.name)) return [];
|
|
1297
|
-
return collectSourceFiles(full, extensions);
|
|
2001
|
+
return collectSourceFiles(full, extensions, unlistable);
|
|
1298
2002
|
}
|
|
1299
2003
|
return extensions.has(extname(entry.name).toLowerCase()) ? [full] : [];
|
|
1300
2004
|
}))).flat();
|
|
@@ -1303,7 +2007,7 @@ const HEX_COLOR = /#(?:[0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{4}|[0-9a-fA-F]{
|
|
|
1303
2007
|
const FUNCTIONAL_COLOR = /\b(?:rgb|rgba|hsl|hsla)\s*\(/i;
|
|
1304
2008
|
const PX_DIMENSION = /\b(\d*\.?\d+)px\b/g;
|
|
1305
2009
|
const EXEMPT_DIMENSIONS = /* @__PURE__ */ new Set(["0", "1"]);
|
|
1306
|
-
const scanFile = (relPath, ext, content) => {
|
|
2010
|
+
const scanFile = (relPath, ext, content, tokenFile) => {
|
|
1307
2011
|
const isStylesheet = STYLESHEET_EXTENSIONS.includes(ext);
|
|
1308
2012
|
const violations = [];
|
|
1309
2013
|
content.split("\n").forEach((line, index) => {
|
|
@@ -1311,13 +2015,13 @@ const scanFile = (relPath, ext, content) => {
|
|
|
1311
2015
|
const lineNo = index + 1;
|
|
1312
2016
|
if (HEX_COLOR.test(line) || FUNCTIONAL_COLOR.test(line)) violations.push({
|
|
1313
2017
|
kind: "hardcoded-color",
|
|
1314
|
-
message: `raw color literal — reference a token from ${
|
|
2018
|
+
message: `raw color literal — reference a token from ${tokenFile} instead.`,
|
|
1315
2019
|
file: relPath,
|
|
1316
2020
|
line: lineNo
|
|
1317
2021
|
});
|
|
1318
2022
|
if (isStylesheet && hasRawDimension(line)) violations.push({
|
|
1319
2023
|
kind: "hardcoded-dimension",
|
|
1320
|
-
message: `raw dimension literal — reference a token from ${
|
|
2024
|
+
message: `raw dimension literal — reference a token from ${tokenFile} instead.`,
|
|
1321
2025
|
file: relPath,
|
|
1322
2026
|
line: lineNo
|
|
1323
2027
|
});
|
|
@@ -1465,18 +2169,11 @@ const contrastRatio = (foreground, background) => {
|
|
|
1465
2169
|
const l2 = relativeLuminance(bg);
|
|
1466
2170
|
const light = Math.max(l1, l2);
|
|
1467
2171
|
const dark = Math.min(l1, l2);
|
|
1468
|
-
return
|
|
2172
|
+
return (light + .05) / (dark + .05);
|
|
1469
2173
|
};
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
const MODES_EXTENSION = "com.lemony.modes";
|
|
1474
|
-
const BASE_MODE = "base";
|
|
1475
|
-
const DEFAULT_LEVEL = "text";
|
|
1476
|
-
const WCAG_FLOORS = {
|
|
1477
|
-
text: 4.5,
|
|
1478
|
-
large: 3,
|
|
1479
|
-
"non-text": 3
|
|
2174
|
+
const truncateRatio = (ratio) => {
|
|
2175
|
+
const hundredths = Math.floor(ratio * 100);
|
|
2176
|
+
return hundredths / 100 > ratio ? (hundredths - 1) / 100 : hundredths / 100;
|
|
1480
2177
|
};
|
|
1481
2178
|
//#endregion
|
|
1482
2179
|
//#region src/design-tokens/contrast.model.ts
|
|
@@ -1488,38 +2185,58 @@ const CONTRAST_LEVELS = [
|
|
|
1488
2185
|
//#endregion
|
|
1489
2186
|
//#region src/design-tokens/contrast.ts
|
|
1490
2187
|
const runContrast = async (inputs) => {
|
|
1491
|
-
const
|
|
1492
|
-
|
|
1493
|
-
|
|
2188
|
+
const tokenFile = resolveTokenFile(inputs.tokenFile);
|
|
2189
|
+
let raw;
|
|
2190
|
+
let unreadable;
|
|
2191
|
+
try {
|
|
2192
|
+
raw = await readOptionalTokenFile(inputs.repoRoot, tokenFile);
|
|
2193
|
+
} catch (error) {
|
|
2194
|
+
unreadable = error instanceof Error ? error.message : String(error);
|
|
2195
|
+
}
|
|
2196
|
+
const verify = inputs.verify ? await runVerifier(inputs.verify) : void 0;
|
|
2197
|
+
const withVerify = (result) => ({
|
|
2198
|
+
...result,
|
|
2199
|
+
ok: result.ok && (verify === void 0 || verify.code === 0),
|
|
2200
|
+
...verify === void 0 ? {} : { verify }
|
|
2201
|
+
});
|
|
2202
|
+
if (unreadable !== void 0) return withVerify({
|
|
2203
|
+
...emptyResult$1(true),
|
|
2204
|
+
ok: false,
|
|
2205
|
+
problems: [unreadable]
|
|
2206
|
+
});
|
|
2207
|
+
if (raw === void 0) return withVerify(emptyResult$1(false));
|
|
1494
2208
|
let parsed;
|
|
1495
2209
|
try {
|
|
1496
2210
|
parsed = JSON.parse(raw);
|
|
1497
2211
|
} catch (error) {
|
|
1498
|
-
return {
|
|
2212
|
+
return withVerify({
|
|
1499
2213
|
...emptyResult$1(true),
|
|
1500
2214
|
ok: false,
|
|
1501
|
-
problems: [`${
|
|
1502
|
-
};
|
|
2215
|
+
problems: [`${tokenFile} is not valid JSON (${error instanceof Error ? error.message : String(error)}) — run \`design-tokens validate\` first.`]
|
|
2216
|
+
});
|
|
1503
2217
|
}
|
|
1504
|
-
if (!isRecord(parsed)) return {
|
|
2218
|
+
if (!isRecord(parsed)) return withVerify({
|
|
1505
2219
|
...emptyResult$1(true),
|
|
1506
2220
|
ok: false,
|
|
1507
|
-
problems: [`${
|
|
1508
|
-
};
|
|
2221
|
+
problems: [`${tokenFile} must be a JSON object — run \`design-tokens validate\` first.`]
|
|
2222
|
+
});
|
|
2223
|
+
if (exceedsNestingLimit(parsed)) return withVerify({
|
|
2224
|
+
...emptyResult$1(true),
|
|
2225
|
+
ok: false,
|
|
2226
|
+
problems: [`${nestedTooDeeply(tokenFile)} Run \`design-tokens validate\` first.`]
|
|
2227
|
+
});
|
|
1509
2228
|
const tokens = collectTokens(parsed);
|
|
1510
2229
|
const problems = [];
|
|
1511
2230
|
const modes = declaredModes(tokens);
|
|
1512
2231
|
const specs = discoverPairs(tokens, modes, problems);
|
|
1513
2232
|
const pairs = measurePairs(specs, modes, tokens);
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
ok: problems.length === 0 && pairs.every((pair) => pair.passes) && (verify === void 0 || verify.code === 0),
|
|
2233
|
+
return withVerify({
|
|
2234
|
+
ok: problems.length === 0 && pairs.every((pair) => pair.passes),
|
|
1517
2235
|
tokensFound: true,
|
|
1518
2236
|
pairsChecked: pairs.length,
|
|
1519
2237
|
pairs,
|
|
1520
|
-
problems
|
|
1521
|
-
|
|
1522
|
-
};
|
|
2238
|
+
problems
|
|
2239
|
+
});
|
|
1523
2240
|
};
|
|
1524
2241
|
const emptyResult$1 = (tokensFound) => ({
|
|
1525
2242
|
ok: true,
|
|
@@ -1564,12 +2281,12 @@ const declaredModes = (tokens) => {
|
|
|
1564
2281
|
};
|
|
1565
2282
|
const pairKey = (spec) => `${spec.foreground}|${spec.background}`;
|
|
1566
2283
|
const discoverPairs = (tokens, modes, problems) => {
|
|
1567
|
-
const convention = new Map(conventionPairs(tokens).map((spec) => [pairKey(spec), spec]));
|
|
2284
|
+
const convention = new Map(conventionPairs(tokens, modes, problems).map((spec) => [pairKey(spec), spec]));
|
|
1568
2285
|
const explicit = extensionPairs(tokens, modes, problems);
|
|
1569
2286
|
for (const spec of explicit) convention.delete(pairKey(spec));
|
|
1570
2287
|
return [...convention.values(), ...explicit];
|
|
1571
2288
|
};
|
|
1572
|
-
const conventionPairs = (tokens) => {
|
|
2289
|
+
const conventionPairs = (tokens, modes, problems) => {
|
|
1573
2290
|
const pairs = [];
|
|
1574
2291
|
for (const [path, token] of tokens) {
|
|
1575
2292
|
if (token.tier !== "semantic") continue;
|
|
@@ -1580,6 +2297,21 @@ const conventionPairs = (tokens) => {
|
|
|
1580
2297
|
if (!tokens.has(basePath)) continue;
|
|
1581
2298
|
if (!resolveColor(path, "base", tokens)) continue;
|
|
1582
2299
|
if (!resolveColor(basePath, "base", tokens)) continue;
|
|
2300
|
+
const broken = [{
|
|
2301
|
+
end: path,
|
|
2302
|
+
other: basePath
|
|
2303
|
+
}, {
|
|
2304
|
+
end: basePath,
|
|
2305
|
+
other: path
|
|
2306
|
+
}].flatMap(({ end, other }) => modes.flatMap((mode) => {
|
|
2307
|
+
const fault = modeAliasBreak(end, mode, tokens);
|
|
2308
|
+
if (fault === void 0) return [];
|
|
2309
|
+
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\`.`];
|
|
2310
|
+
}));
|
|
2311
|
+
if (broken.length > 0) {
|
|
2312
|
+
problems.push(...broken);
|
|
2313
|
+
continue;
|
|
2314
|
+
}
|
|
1583
2315
|
pairs.push({
|
|
1584
2316
|
foreground: path,
|
|
1585
2317
|
background: basePath,
|
|
@@ -1773,7 +2505,7 @@ const makeRow = (spec, mode, foreground, background) => {
|
|
|
1773
2505
|
...spec.over === void 0 ? {} : { over: spec.over },
|
|
1774
2506
|
mode,
|
|
1775
2507
|
level: spec.level,
|
|
1776
|
-
ratio,
|
|
2508
|
+
ratio: truncateRatio(ratio),
|
|
1777
2509
|
floor,
|
|
1778
2510
|
passes: ratio >= floor,
|
|
1779
2511
|
source: spec.source
|
|
@@ -1788,12 +2520,26 @@ const resolveColor = (path, mode, tokens, seen = /* @__PURE__ */ new Set()) => {
|
|
|
1788
2520
|
let value = token.value;
|
|
1789
2521
|
if (mode !== "base") {
|
|
1790
2522
|
const modes = readExtension(token.node, MODES_EXTENSION);
|
|
1791
|
-
if (isRecord(modes) && modes
|
|
2523
|
+
if (isRecord(modes) && Object.hasOwn(modes, mode)) value = modes[mode];
|
|
1792
2524
|
}
|
|
1793
2525
|
const alias = aliasTarget(value);
|
|
1794
2526
|
if (alias !== void 0) return resolveColor(alias, mode, tokens, seen);
|
|
1795
2527
|
return typeof value === "string" ? parseColor(value) : void 0;
|
|
1796
2528
|
};
|
|
2529
|
+
const modeAliasBreak = (path, mode, tokens) => {
|
|
2530
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2531
|
+
let current = path;
|
|
2532
|
+
while (current !== void 0) {
|
|
2533
|
+
if (seen.has(current)) return "unresolved";
|
|
2534
|
+
seen.add(current);
|
|
2535
|
+
const token = tokens.get(current);
|
|
2536
|
+
if (!token) return "unresolved";
|
|
2537
|
+
const modes = readExtension(token.node, MODES_EXTENSION);
|
|
2538
|
+
const value = isRecord(modes) && Object.hasOwn(modes, mode) ? modes[mode] : token.value;
|
|
2539
|
+
current = aliasTarget(value);
|
|
2540
|
+
}
|
|
2541
|
+
return seen.size > 64 ? "too-deep" : void 0;
|
|
2542
|
+
};
|
|
1797
2543
|
const runVerifier = async (hook) => {
|
|
1798
2544
|
const result = await hook.run("sh", ["-c", hook.command]);
|
|
1799
2545
|
return {
|
|
@@ -1804,32 +2550,105 @@ const runVerifier = async (hook) => {
|
|
|
1804
2550
|
//#endregion
|
|
1805
2551
|
//#region src/design-tokens/design-sync.constant.ts
|
|
1806
2552
|
const DESIGN_TOOL_EXTENSION = "com.lemony.design-tool";
|
|
2553
|
+
const NEUTRAL_STRING_VALUES = "The neutral format carries every value as a string (e.g. \"1024\", \"#3b82f6\") — fix the adapter that wrote the file.";
|
|
2554
|
+
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.";
|
|
2555
|
+
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.";
|
|
2556
|
+
const NEUTRAL_NAME_SHAPE = "A name is dot-separated segments, like \"primitive.color.brand\" — fix the adapter that wrote the file.";
|
|
2557
|
+
const VALIDATE_HINT = "Run `lemony design-tokens validate` for the full list.";
|
|
2558
|
+
const IMPORT_INVALID_HINT = "Change the `--only` slice (add what it references), or fix the file first; nothing was written.";
|
|
2559
|
+
const IMPORT_FAULT_CLAUSE = "Fix the variables named above in the design tool, or leave them out with `--only`";
|
|
2560
|
+
const IMPORT_PREVIEW_INVALID_HINT = "`--apply` refuses any slice that leaves it failing: include what repairs it, or fix the file first.";
|
|
2561
|
+
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.";
|
|
2562
|
+
//#endregion
|
|
2563
|
+
//#region src/design-tokens/read-required-file.ts
|
|
2564
|
+
const readRequiredFile = async (path, label) => {
|
|
2565
|
+
try {
|
|
2566
|
+
return await readRegularFile(path);
|
|
2567
|
+
} catch (error) {
|
|
2568
|
+
if (error.code === "ENOENT") {
|
|
2569
|
+
if (!await entryExists(path)) throw new Error(`${label} file not found: ${path}`, { cause: error });
|
|
2570
|
+
throw new Error(`Cannot read ${label} file at ${path}: the entry is there but its target is missing (a broken symlink?).`, { cause: error });
|
|
2571
|
+
}
|
|
2572
|
+
throw new Error(`Cannot read ${label} file at ${path}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
2573
|
+
}
|
|
2574
|
+
};
|
|
1807
2575
|
//#endregion
|
|
1808
2576
|
//#region src/design-tokens/neutral-mapping.ts
|
|
1809
2577
|
const KNOWN_TIERS = new Set(TOKEN_TIERS);
|
|
1810
2578
|
const dtcgToNeutral = (parsed) => {
|
|
2579
|
+
const nodes = tokenNodes(parsed);
|
|
2580
|
+
const unsynced = unsyncedReasons(nodes);
|
|
1811
2581
|
const variables = [];
|
|
1812
|
-
for (const
|
|
1813
|
-
const group = parsed[tier];
|
|
1814
|
-
if (isRecord(group)) walk(group, tier, variables);
|
|
1815
|
-
}
|
|
2582
|
+
for (const [path, node] of nodes) if (!unsynced.has(path)) variables.push(toVariable$1(path, node));
|
|
1816
2583
|
return {
|
|
1817
2584
|
schemaVersion: 1,
|
|
1818
2585
|
variables: variables.toSorted(byName)
|
|
1819
2586
|
};
|
|
1820
2587
|
};
|
|
1821
|
-
const
|
|
2588
|
+
const unsyncedTokens = (parsed) => [...unsyncedReasons(tokenNodes(parsed))].map(([path, reason]) => ({
|
|
2589
|
+
path,
|
|
2590
|
+
reason
|
|
2591
|
+
})).toSorted((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
|
|
2592
|
+
const tokenNodes = (parsed) => {
|
|
2593
|
+
const nodes = /* @__PURE__ */ new Map();
|
|
2594
|
+
for (const tier of TOKEN_TIERS) {
|
|
2595
|
+
const group = parsed[tier];
|
|
2596
|
+
if (isRecord(group)) walk(group, tier, nodes);
|
|
2597
|
+
}
|
|
2598
|
+
return nodes;
|
|
2599
|
+
};
|
|
1822
2600
|
const walk = (node, path, out) => {
|
|
1823
2601
|
for (const [key, child] of Object.entries(node)) {
|
|
1824
2602
|
if (key.startsWith("$") || !isRecord(child)) continue;
|
|
1825
2603
|
const childPath = `${path}.${key}`;
|
|
1826
|
-
if ("$value" in child) out.
|
|
2604
|
+
if ("$value" in child) out.set(childPath, child);
|
|
1827
2605
|
else walk(child, childPath, out);
|
|
1828
2606
|
}
|
|
1829
2607
|
};
|
|
2608
|
+
const unsyncedReasons = (nodes) => {
|
|
2609
|
+
const reasons = /* @__PURE__ */ new Map();
|
|
2610
|
+
for (const [path, node] of nodes) {
|
|
2611
|
+
const reason = ownUnsyncedReason(node);
|
|
2612
|
+
if (reason !== void 0) reasons.set(path, reason);
|
|
2613
|
+
}
|
|
2614
|
+
let grew = true;
|
|
2615
|
+
while (grew) {
|
|
2616
|
+
grew = false;
|
|
2617
|
+
for (const [path, node] of nodes) {
|
|
2618
|
+
if (reasons.has(path)) continue;
|
|
2619
|
+
const target = aliasTargetsOf(node).find((name) => reasons.has(name));
|
|
2620
|
+
if (target === void 0) continue;
|
|
2621
|
+
reasons.set(path, `an alias to ${target}`);
|
|
2622
|
+
grew = true;
|
|
2623
|
+
}
|
|
2624
|
+
}
|
|
2625
|
+
return reasons;
|
|
2626
|
+
};
|
|
2627
|
+
const ownUnsyncedReason = (node) => {
|
|
2628
|
+
if (isCompositeValue(node["$value"])) return "a composite value";
|
|
2629
|
+
const modes = modesExtension(node);
|
|
2630
|
+
if (modes === void 0) return void 0;
|
|
2631
|
+
if (!isRecord(modes)) return "a modes extension that is not an object";
|
|
2632
|
+
for (const [name, value] of Object.entries(modes)) {
|
|
2633
|
+
if (name.trim().length === 0) return "a mode with a blank name";
|
|
2634
|
+
if (name === "__proto__") return "a mode named \"__proto__\"";
|
|
2635
|
+
if (!isScalar$1(value)) return `mode "${name}" holding neither text, a number nor a boolean`;
|
|
2636
|
+
}
|
|
2637
|
+
};
|
|
2638
|
+
const aliasTargetsOf = (node) => {
|
|
2639
|
+
const modes = modesExtension(node);
|
|
2640
|
+
return [node["$value"], ...isRecord(modes) ? Object.values(modes) : []].map(aliasTarget).filter((target) => target !== void 0);
|
|
2641
|
+
};
|
|
2642
|
+
const modesExtension = (node) => {
|
|
2643
|
+
const extensions = node["$extensions"];
|
|
2644
|
+
return isRecord(extensions) ? extensions[MODES_EXTENSION] : void 0;
|
|
2645
|
+
};
|
|
2646
|
+
const isCompositeValue = (value) => typeof value === "object" && value !== null;
|
|
2647
|
+
const isScalar$1 = (value) => typeof value === "string" || typeof value === "number" || typeof value === "boolean";
|
|
2648
|
+
const byName = (a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
|
|
1830
2649
|
const toVariable$1 = (name, node) => {
|
|
1831
2650
|
const type = typeof node["$type"] === "string" ? node["$type"] : "";
|
|
1832
|
-
const modes = readModes(node);
|
|
2651
|
+
const modes = readModes$1(node);
|
|
1833
2652
|
const alias = aliasTarget(node["$value"]);
|
|
1834
2653
|
const base = {
|
|
1835
2654
|
name,
|
|
@@ -1846,37 +2665,64 @@ const toVariable$1 = (name, node) => {
|
|
|
1846
2665
|
};
|
|
1847
2666
|
};
|
|
1848
2667
|
const stringifyValue = (value) => value === void 0 || value === null ? "" : String(value);
|
|
1849
|
-
const readModes = (node) => {
|
|
1850
|
-
const
|
|
1851
|
-
const modes = isRecord(extensions) ? extensions[MODES_EXTENSION] : void 0;
|
|
2668
|
+
const readModes$1 = (node) => {
|
|
2669
|
+
const modes = modesExtension(node);
|
|
1852
2670
|
if (!isRecord(modes)) return void 0;
|
|
1853
2671
|
const out = {};
|
|
1854
|
-
for (const [name, value] of Object.entries(modes)) if (
|
|
2672
|
+
for (const [name, value] of Object.entries(modes)) if (isScalar$1(value)) out[name] = String(value);
|
|
1855
2673
|
return Object.keys(out).length > 0 ? out : void 0;
|
|
1856
2674
|
};
|
|
1857
|
-
const
|
|
1858
|
-
const isAlias = variable.ref !== void 0;
|
|
1859
|
-
const value = isAlias ? `{${variable.ref}}` : variable.value ?? "";
|
|
1860
|
-
const type = variable.type;
|
|
2675
|
+
const landingPath = (variable, known) => {
|
|
1861
2676
|
const first = variable.name.split(".")[0] ?? "";
|
|
1862
|
-
if (KNOWN_TIERS.has(first)) return
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
const
|
|
1869
|
-
return
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
2677
|
+
if (KNOWN_TIERS.has(first)) return variable.name;
|
|
2678
|
+
const existing = existingTierPath(variable.name, known);
|
|
2679
|
+
if (existing !== void 0) return existing;
|
|
2680
|
+
return `${variable.ref !== void 0 || Object.values(variable.modes ?? {}).some((modeValue) => aliasTarget(modeValue) !== void 0) ? "semantic" : "primitive"}.${variable.name}`;
|
|
2681
|
+
};
|
|
2682
|
+
const existingTierPath = (bare, known) => {
|
|
2683
|
+
const matches = TOKEN_TIERS.map((tier) => `${tier}.${bare}`).filter((path) => known.has(path));
|
|
2684
|
+
return matches.length === 1 ? matches[0] : void 0;
|
|
2685
|
+
};
|
|
2686
|
+
const refResolver = (neutral, known) => {
|
|
2687
|
+
const landings = /* @__PURE__ */ new Map();
|
|
2688
|
+
for (const variable of neutral.variables) landings.set(variable.name, landingPath(variable, known));
|
|
2689
|
+
return (ref) => {
|
|
2690
|
+
const first = ref.split(".")[0] ?? "";
|
|
2691
|
+
if (KNOWN_TIERS.has(first)) return ref;
|
|
2692
|
+
return landings.get(ref) ?? existingTierPath(ref, known) ?? ref;
|
|
2693
|
+
};
|
|
2694
|
+
};
|
|
2695
|
+
const toIncoming = (variable, resolveRef, known) => {
|
|
2696
|
+
const value = variable.ref === void 0 ? variable.value ?? "" : `{${resolveRef(variable.ref)}}`;
|
|
2697
|
+
const path = landingPath(variable, known);
|
|
2698
|
+
const token = {
|
|
2699
|
+
path,
|
|
2700
|
+
tier: path.split(".")[0],
|
|
2701
|
+
type: variable.type,
|
|
1873
2702
|
value
|
|
1874
|
-
}
|
|
2703
|
+
};
|
|
2704
|
+
if (!variable.modes) return token;
|
|
2705
|
+
const modes = {};
|
|
2706
|
+
for (const [mode, modeValue] of Object.entries(variable.modes)) {
|
|
2707
|
+
const alias = aliasTarget(modeValue);
|
|
2708
|
+
modes[mode] = alias === void 0 ? modeValue : `{${resolveRef(alias)}}`;
|
|
2709
|
+
}
|
|
2710
|
+
return {
|
|
2711
|
+
...token,
|
|
2712
|
+
modes
|
|
2713
|
+
};
|
|
2714
|
+
};
|
|
2715
|
+
const incomingByPath = (neutral, known) => {
|
|
2716
|
+
const resolveRef = refResolver(neutral, known);
|
|
2717
|
+
const tokens = neutral.variables.map((variable) => ({
|
|
2718
|
+
exact: KNOWN_TIERS.has(variable.name.split(".")[0] ?? ""),
|
|
2719
|
+
token: toIncoming(variable, resolveRef, known)
|
|
2720
|
+
}));
|
|
2721
|
+
const byPath = /* @__PURE__ */ new Map();
|
|
2722
|
+
for (const { exact, token } of tokens) if (exact) byPath.set(token.path, token);
|
|
2723
|
+
for (const { exact, token } of tokens) if (!exact && !byPath.has(token.path)) byPath.set(token.path, token);
|
|
2724
|
+
return byPath;
|
|
1875
2725
|
};
|
|
1876
|
-
const withModes = (token, variable) => variable.modes ? {
|
|
1877
|
-
...token,
|
|
1878
|
-
modes: variable.modes
|
|
1879
|
-
} : token;
|
|
1880
2726
|
const comparisonKey = (type, value, modes) => JSON.stringify({
|
|
1881
2727
|
type,
|
|
1882
2728
|
value,
|
|
@@ -1891,41 +2737,83 @@ const sortedModes = (modes) => {
|
|
|
1891
2737
|
const diffNeutralAgainstJson = (neutral, parsed) => {
|
|
1892
2738
|
const current = /* @__PURE__ */ new Map();
|
|
1893
2739
|
for (const variable of dtcgToNeutral(parsed).variables) current.set(variable.name, variable);
|
|
2740
|
+
const unsynced = new Map(unsyncedTokens(parsed).map(({ path, reason }) => [path, reason]));
|
|
1894
2741
|
const changes = [];
|
|
1895
|
-
for (const
|
|
1896
|
-
const
|
|
2742
|
+
for (const incoming of incomingByPath(neutral, /* @__PURE__ */ new Set([...current.keys(), ...unsynced.keys()])).values()) {
|
|
2743
|
+
const reason = unsynced.get(incoming.path);
|
|
2744
|
+
if (reason !== void 0) {
|
|
2745
|
+
changes.push({
|
|
2746
|
+
path: incoming.path,
|
|
2747
|
+
tier: incoming.tier,
|
|
2748
|
+
kind: "changed",
|
|
2749
|
+
before: `(outside the sync: ${reason})`,
|
|
2750
|
+
after: incoming.value
|
|
2751
|
+
});
|
|
2752
|
+
continue;
|
|
2753
|
+
}
|
|
1897
2754
|
const existing = current.get(incoming.path);
|
|
1898
2755
|
const after = incoming.value;
|
|
1899
2756
|
const before = existing ? neutralValue(existing) : void 0;
|
|
2757
|
+
const kind = classifyImport(existing, incoming);
|
|
1900
2758
|
changes.push({
|
|
1901
2759
|
path: incoming.path,
|
|
1902
2760
|
tier: incoming.tier,
|
|
1903
|
-
kind
|
|
2761
|
+
kind,
|
|
1904
2762
|
...before !== void 0 ? { before } : {},
|
|
1905
|
-
after
|
|
2763
|
+
after,
|
|
2764
|
+
...existing && kind === "changed" ? changeDetail(existing, incoming) : {}
|
|
1906
2765
|
});
|
|
1907
2766
|
}
|
|
1908
2767
|
return changes.toSorted((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
|
|
1909
2768
|
};
|
|
2769
|
+
const ownMode = (modes, mode) => Object.hasOwn(modes, mode) ? modes[mode] : void 0;
|
|
2770
|
+
const changeDetail = (existing, incoming) => {
|
|
2771
|
+
const detail = {};
|
|
2772
|
+
if (existing.type !== incoming.type) detail.typeChange = {
|
|
2773
|
+
before: existing.type,
|
|
2774
|
+
after: incoming.type
|
|
2775
|
+
};
|
|
2776
|
+
const before = existing.modes ?? {};
|
|
2777
|
+
const after = incoming.modes ?? {};
|
|
2778
|
+
const modeChanges = [.../* @__PURE__ */ new Set([...Object.keys(before), ...Object.keys(after)])].toSorted().filter((mode) => ownMode(before, mode) !== ownMode(after, mode)).map((mode) => {
|
|
2779
|
+
const was = ownMode(before, mode);
|
|
2780
|
+
const is = ownMode(after, mode);
|
|
2781
|
+
return {
|
|
2782
|
+
mode,
|
|
2783
|
+
...was !== void 0 ? { before: was } : {},
|
|
2784
|
+
...is !== void 0 ? { after: is } : {}
|
|
2785
|
+
};
|
|
2786
|
+
});
|
|
2787
|
+
if (modeChanges.length > 0) detail.modeChanges = modeChanges;
|
|
2788
|
+
return detail;
|
|
2789
|
+
};
|
|
1910
2790
|
const neutralValue = (variable) => variable.ref !== void 0 ? `{${variable.ref}}` : variable.value ?? "";
|
|
1911
|
-
const classifyImport = (existing, incoming
|
|
2791
|
+
const classifyImport = (existing, incoming) => {
|
|
1912
2792
|
if (!existing) return "new";
|
|
1913
|
-
return comparisonKey(existing.type, neutralValue(existing), existing.modes) === comparisonKey(incoming.type, incoming.value,
|
|
2793
|
+
return comparisonKey(existing.type, neutralValue(existing), existing.modes) === comparisonKey(incoming.type, incoming.value, incoming.modes) ? "unchanged" : "changed";
|
|
1914
2794
|
};
|
|
1915
2795
|
const mergeNeutralIntoJson = (neutral, parsed, only) => {
|
|
1916
2796
|
const merged = structuredClone(parsed);
|
|
1917
2797
|
const onlySet = only ? new Set(only) : void 0;
|
|
1918
2798
|
const written = [];
|
|
1919
2799
|
const skipped = [];
|
|
1920
|
-
|
|
1921
|
-
|
|
2800
|
+
const unsyncedLanded = [];
|
|
2801
|
+
const current = new Map(dtcgToNeutral(parsed).variables.map((variable) => [variable.name, variable]));
|
|
2802
|
+
const unsynced = new Set(unsyncedTokens(parsed).map(({ path }) => path));
|
|
2803
|
+
for (const incoming of incomingByPath(neutral, /* @__PURE__ */ new Set([...current.keys(), ...unsynced])).values()) {
|
|
1922
2804
|
if (onlySet && !onlySet.has(incoming.path)) continue;
|
|
2805
|
+
if (unsynced.has(incoming.path)) {
|
|
2806
|
+
unsyncedLanded.push(incoming.path);
|
|
2807
|
+
continue;
|
|
2808
|
+
}
|
|
2809
|
+
if (classifyImport(current.get(incoming.path), incoming) === "unchanged") continue;
|
|
1923
2810
|
(setToken(merged, incoming) ? written : skipped).push(incoming.path);
|
|
1924
2811
|
}
|
|
1925
2812
|
return {
|
|
1926
2813
|
merged,
|
|
1927
2814
|
written: written.toSorted(),
|
|
1928
|
-
skipped: skipped.toSorted()
|
|
2815
|
+
skipped: skipped.toSorted(),
|
|
2816
|
+
unsynced: unsyncedLanded.toSorted()
|
|
1929
2817
|
};
|
|
1930
2818
|
};
|
|
1931
2819
|
const isTokenNode = (value) => isRecord(value) && "$value" in value;
|
|
@@ -1948,17 +2836,44 @@ const setToken = (root, incoming) => {
|
|
|
1948
2836
|
if (!isRecord(next)) node[segment] = {};
|
|
1949
2837
|
node = node[segment];
|
|
1950
2838
|
}
|
|
1951
|
-
const
|
|
2839
|
+
const existing = node[leafKey];
|
|
2840
|
+
const leaf = isRecord(existing) ? { ...existing } : {};
|
|
2841
|
+
const typeBefore = typeof leaf["$type"] === "string" ? leaf["$type"] : "";
|
|
2842
|
+
const sameType = incoming.type === typeBefore;
|
|
2843
|
+
const keep = (before, text) => sameType ? keptScalar(before, text) : text;
|
|
2844
|
+
leaf["$value"] = keep(leaf["$value"], incoming.value);
|
|
1952
2845
|
if (incoming.type.length > 0) leaf["$type"] = incoming.type;
|
|
1953
|
-
|
|
2846
|
+
else delete leaf["$type"];
|
|
2847
|
+
const extensions = isRecord(leaf["$extensions"]) ? { ...leaf["$extensions"] } : {};
|
|
2848
|
+
if (incoming.modes) {
|
|
2849
|
+
const before = extensions[MODES_EXTENSION];
|
|
2850
|
+
const modes = {};
|
|
2851
|
+
for (const [mode, value] of Object.entries(incoming.modes)) modes[mode] = keep(isRecord(before) && Object.hasOwn(before, mode) ? before[mode] : void 0, value);
|
|
2852
|
+
extensions[MODES_EXTENSION] = modes;
|
|
2853
|
+
} else delete extensions[MODES_EXTENSION];
|
|
2854
|
+
if (Object.keys(extensions).length > 0) leaf["$extensions"] = extensions;
|
|
2855
|
+
else delete leaf["$extensions"];
|
|
1954
2856
|
node[leafKey] = leaf;
|
|
1955
2857
|
return true;
|
|
1956
2858
|
};
|
|
2859
|
+
const keptScalar = (before, incoming) => (typeof before === "number" || typeof before === "boolean") && String(before) === incoming ? before : incoming;
|
|
2860
|
+
const danglingAliasTargets = (parsed, paths) => {
|
|
2861
|
+
const { variables } = dtcgToNeutral(parsed);
|
|
2862
|
+
const names = new Set(variables.map((variable) => variable.name));
|
|
2863
|
+
const wanted = new Set(paths);
|
|
2864
|
+
const dangling = /* @__PURE__ */ new Set();
|
|
2865
|
+
for (const variable of variables) {
|
|
2866
|
+
if (!wanted.has(variable.name)) continue;
|
|
2867
|
+
const targets = [variable.ref, ...Object.values(variable.modes ?? {}).map(aliasTarget)];
|
|
2868
|
+
for (const target of targets) if (target !== void 0 && !names.has(target)) dangling.add(target);
|
|
2869
|
+
}
|
|
2870
|
+
return [...dangling].toSorted();
|
|
2871
|
+
};
|
|
1957
2872
|
const upsertPlan = (parsed, toolState) => {
|
|
1958
|
-
const
|
|
1959
|
-
|
|
2873
|
+
const { variables } = dtcgToNeutral(parsed);
|
|
2874
|
+
const current = toolState ? incomingByPath(toolState, /* @__PURE__ */ new Set([...variables.map((variable) => variable.name), ...unsyncedTokens(parsed).map(({ path }) => path)])) : /* @__PURE__ */ new Map();
|
|
1960
2875
|
const plan = [];
|
|
1961
|
-
for (const variable of
|
|
2876
|
+
for (const variable of variables) plan.push({
|
|
1962
2877
|
name: variable.name,
|
|
1963
2878
|
kind: classifyExport(variable, current)
|
|
1964
2879
|
});
|
|
@@ -1967,53 +2882,87 @@ const upsertPlan = (parsed, toolState) => {
|
|
|
1967
2882
|
const classifyExport = (projected, current) => {
|
|
1968
2883
|
const existing = current.get(projected.name);
|
|
1969
2884
|
if (!existing) return "create";
|
|
1970
|
-
return comparisonKey(projected.type, neutralValue(projected), projected.modes) === comparisonKey(existing.type,
|
|
2885
|
+
return comparisonKey(projected.type, neutralValue(projected), projected.modes) === comparisonKey(existing.type, existing.value, existing.modes) ? "unchanged" : "update";
|
|
1971
2886
|
};
|
|
1972
2887
|
//#endregion
|
|
1973
2888
|
//#region src/design-tokens/sync-io.ts
|
|
1974
|
-
const parseNeutralFile = (raw) => {
|
|
2889
|
+
const parseNeutralFile = (raw, label) => {
|
|
1975
2890
|
let parsed;
|
|
1976
2891
|
try {
|
|
1977
2892
|
parsed = JSON.parse(raw);
|
|
1978
2893
|
} catch (error) {
|
|
1979
|
-
throw new Error(
|
|
2894
|
+
throw new Error(`${label} file is not valid JSON: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
1980
2895
|
}
|
|
1981
|
-
if (!isRecord(parsed) || !Array.isArray(parsed["variables"])) throw new Error(
|
|
2896
|
+
if (!isRecord(parsed) || !Array.isArray(parsed["variables"])) throw new Error(`${label} file must be a JSON object with a "variables" array.`);
|
|
1982
2897
|
const schemaVersion = typeof parsed["schemaVersion"] === "number" ? parsed["schemaVersion"] : 0;
|
|
1983
2898
|
const provider = typeof parsed["provider"] === "string" ? parsed["provider"] : void 0;
|
|
2899
|
+
const variables = parsed["variables"].flatMap((entry) => toVariable(entry, label));
|
|
2900
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2901
|
+
for (const { name } of variables) {
|
|
2902
|
+
if (seen.has(name)) throw new Error(`${label} file: variable "${name}" appears more than once — fix the adapter that wrote the file.`);
|
|
2903
|
+
seen.add(name);
|
|
2904
|
+
}
|
|
1984
2905
|
return {
|
|
1985
2906
|
schemaVersion,
|
|
1986
2907
|
...provider ? { provider } : {},
|
|
1987
|
-
variables
|
|
2908
|
+
variables
|
|
1988
2909
|
};
|
|
1989
2910
|
};
|
|
1990
|
-
const toVariable = (raw) => {
|
|
2911
|
+
const toVariable = (raw, label) => {
|
|
1991
2912
|
if (!isRecord(raw) || typeof raw["name"] !== "string" || raw["name"].trim().length === 0) return [];
|
|
1992
|
-
const
|
|
2913
|
+
const name = raw["name"];
|
|
1993
2914
|
const variable = {
|
|
1994
|
-
name
|
|
1995
|
-
type
|
|
1996
|
-
};
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2915
|
+
name,
|
|
2916
|
+
type: typeof raw["type"] === "string" ? raw["type"] : ""
|
|
2917
|
+
};
|
|
2918
|
+
const refuse = (fault, hint) => /* @__PURE__ */ new Error(`${label} file: variable "${name}" ${fault}. ${hint}`);
|
|
2919
|
+
const nameFault = tokenNameFault(name);
|
|
2920
|
+
if (nameFault !== void 0) throw refuse(nameFault, NEUTRAL_NAME_SHAPE);
|
|
2921
|
+
if (raw["ref"] !== void 0) {
|
|
2922
|
+
if (typeof raw["ref"] !== "string") throw refuse(`has ${jsonKind(raw["ref"])} ref`, NEUTRAL_VALUE_OR_REF);
|
|
2923
|
+
if (raw["ref"].trim().length === 0) throw refuse("has an empty ref", NEUTRAL_VALUE_OR_REF);
|
|
2924
|
+
variable.ref = raw["ref"];
|
|
2925
|
+
} else if (typeof raw["value"] === "string") variable.value = raw["value"];
|
|
2926
|
+
else if (raw["value"] !== void 0) throw refuse(`has ${jsonKind(raw["value"])} value`, NEUTRAL_STRING_VALUES);
|
|
2927
|
+
else throw refuse("has neither a value nor a ref", NEUTRAL_VALUE_OR_REF);
|
|
2928
|
+
const modes = readModes(raw["modes"], name, label);
|
|
2000
2929
|
if (modes) variable.modes = modes;
|
|
2001
2930
|
return [variable];
|
|
2002
2931
|
};
|
|
2003
|
-
const
|
|
2004
|
-
if (
|
|
2932
|
+
const readModes = (raw, name, label) => {
|
|
2933
|
+
if (raw === void 0) return void 0;
|
|
2934
|
+
if (!isRecord(raw)) throw new Error(`${label} file: variable "${name}" has ${jsonKind(raw)} "modes". ${NEUTRAL_MODES_OBJECT}`);
|
|
2005
2935
|
const out = {};
|
|
2006
|
-
for (const [
|
|
2936
|
+
for (const [mode, value] of Object.entries(raw)) {
|
|
2937
|
+
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}`);
|
|
2938
|
+
if (typeof value !== "string") throw new Error(`${label} file: variable "${name}" has ${jsonKind(value)} value in mode "${mode}". ${NEUTRAL_STRING_VALUES}`);
|
|
2939
|
+
out[mode] = value;
|
|
2940
|
+
}
|
|
2007
2941
|
return Object.keys(out).length > 0 ? out : void 0;
|
|
2008
2942
|
};
|
|
2009
|
-
const
|
|
2943
|
+
const tokenNameFault = (name) => {
|
|
2944
|
+
const segments = name.split(".");
|
|
2945
|
+
if (segments.some((segment) => segment.trim().length === 0)) return "has an empty segment in its name";
|
|
2946
|
+
if (segments.some((segment) => segment.startsWith("$"))) return "has a segment starting with \"$\", which DTCG keeps for its own keys";
|
|
2947
|
+
if (segments.includes("__proto__")) return "has a \"__proto__\" segment, which no token can be named";
|
|
2948
|
+
if (segments.length === 1 && TIERS.has(name)) return `names only the tier "${name}", with no token under it`;
|
|
2949
|
+
};
|
|
2950
|
+
const TIERS = new Set(TOKEN_TIERS);
|
|
2951
|
+
const jsonKind = (value) => {
|
|
2952
|
+
if (value === null) return "null";
|
|
2953
|
+
if (Array.isArray(value)) return "an array";
|
|
2954
|
+
const kind = typeof value;
|
|
2955
|
+
return kind === "object" ? "an object" : `a ${kind}`;
|
|
2956
|
+
};
|
|
2957
|
+
const readJsonObject = (raw, tokenFile) => {
|
|
2010
2958
|
let parsed;
|
|
2011
2959
|
try {
|
|
2012
2960
|
parsed = JSON.parse(raw);
|
|
2013
2961
|
} catch (error) {
|
|
2014
|
-
throw new Error(`${
|
|
2962
|
+
throw new Error(`${tokenFile} is not valid JSON (${error instanceof Error ? error.message : String(error)}) — run \`design-tokens validate\` first.`, { cause: error });
|
|
2015
2963
|
}
|
|
2016
|
-
if (!isRecord(parsed)) throw new Error(`${
|
|
2964
|
+
if (!isRecord(parsed)) throw new Error(`${tokenFile} must be a JSON object — run \`design-tokens validate\` first.`);
|
|
2965
|
+
if (exceedsNestingLimit(parsed)) throw new Error(`${nestedTooDeeply(tokenFile)} Run \`design-tokens validate\` first.`);
|
|
2017
2966
|
return parsed;
|
|
2018
2967
|
};
|
|
2019
2968
|
const writeJsonFile = (value) => `${JSON.stringify(value, null, 2)}\n`;
|
|
@@ -2025,17 +2974,16 @@ const runImport = async (inputs) => {
|
|
|
2025
2974
|
tokensFound: false,
|
|
2026
2975
|
changes: [],
|
|
2027
2976
|
applied: false,
|
|
2977
|
+
refused: false,
|
|
2028
2978
|
written: [],
|
|
2029
2979
|
skipped: [],
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
...empty,
|
|
2034
|
-
problems: [`neutral file not found: ${inputs.neutralPath}`]
|
|
2980
|
+
unsynced: [],
|
|
2981
|
+
problems: [],
|
|
2982
|
+
warnings: []
|
|
2035
2983
|
};
|
|
2036
2984
|
let neutral;
|
|
2037
2985
|
try {
|
|
2038
|
-
neutral = parseNeutralFile(await
|
|
2986
|
+
neutral = parseNeutralFile(await readRequiredFile(inputs.neutralPath, "neutral"), "neutral");
|
|
2039
2987
|
} catch (error) {
|
|
2040
2988
|
return {
|
|
2041
2989
|
...empty,
|
|
@@ -2046,39 +2994,92 @@ const runImport = async (inputs) => {
|
|
|
2046
2994
|
...empty,
|
|
2047
2995
|
problems: [`neutral file schemaVersion ${neutral.schemaVersion} is not supported (expected 1).`]
|
|
2048
2996
|
};
|
|
2049
|
-
const
|
|
2050
|
-
|
|
2997
|
+
const tokenFile = resolveTokenFile(inputs.tokenFile);
|
|
2998
|
+
let tokensFound = false;
|
|
2999
|
+
let raw;
|
|
2051
3000
|
let parsed;
|
|
2052
3001
|
try {
|
|
2053
|
-
|
|
3002
|
+
raw = await readOptionalTokenFile(inputs.repoRoot, tokenFile);
|
|
3003
|
+
tokensFound = raw !== void 0;
|
|
3004
|
+
parsed = raw === void 0 ? {} : readJsonObject(raw, tokenFile);
|
|
2054
3005
|
} catch (error) {
|
|
2055
3006
|
return {
|
|
2056
3007
|
...empty,
|
|
2057
|
-
tokensFound,
|
|
3008
|
+
tokensFound: true,
|
|
2058
3009
|
problems: [asMessage$1(error)]
|
|
2059
3010
|
};
|
|
2060
3011
|
}
|
|
2061
3012
|
const changes = diffNeutralAgainstJson(neutral, parsed);
|
|
2062
|
-
if (!inputs.apply)
|
|
3013
|
+
if (!inputs.apply) {
|
|
3014
|
+
const faults = summarizeMessages(raw === void 0 ? [] : tokenFileViolationMessages(raw, tokenFile));
|
|
3015
|
+
return {
|
|
3016
|
+
...empty,
|
|
3017
|
+
tokensFound,
|
|
3018
|
+
changes,
|
|
3019
|
+
warnings: faults === void 0 ? [] : [`${tokenFile} already fails validation: ${faults} ${IMPORT_PREVIEW_INVALID_HINT} ${VALIDATE_HINT}`]
|
|
3020
|
+
};
|
|
3021
|
+
}
|
|
3022
|
+
const unmatched = (inputs.only ?? []).filter((path) => !changes.some((change) => change.path === path));
|
|
3023
|
+
if (unmatched.length > 0) return {
|
|
3024
|
+
...empty,
|
|
2063
3025
|
tokensFound,
|
|
2064
3026
|
changes,
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
skipped: [],
|
|
2068
|
-
problems: []
|
|
3027
|
+
refused: true,
|
|
3028
|
+
problems: [`--only names ${unmatched.length === 1 ? "a path" : "paths"} no variable in the neutral file lands at: ${unmatched.join(", ")}. Use the tiered paths the preview prints; nothing was written.`]
|
|
2069
3029
|
};
|
|
2070
|
-
const { merged, written, skipped } = mergeNeutralIntoJson(neutral, parsed, inputs.only);
|
|
2071
|
-
|
|
2072
|
-
|
|
3030
|
+
const { merged, written, skipped, unsynced } = mergeNeutralIntoJson(neutral, parsed, inputs.only);
|
|
3031
|
+
if (exceedsNestingLimit(merged)) return {
|
|
3032
|
+
...empty,
|
|
3033
|
+
tokensFound,
|
|
3034
|
+
changes,
|
|
3035
|
+
refused: true,
|
|
3036
|
+
problems: [`not applied — with this import, ${tokenFile} would fail validation: ${nestedTooDeeply(tokenFile)} ${IMPORT_TOO_DEEP_HINT}`]
|
|
3037
|
+
};
|
|
3038
|
+
const text = writeJsonFile(merged);
|
|
3039
|
+
const mergedViolations = tokenFileViolations(text, tokenFile);
|
|
3040
|
+
const mergedFaults = [...nonFiniteNumberFaults(merged, tokenFile), ...mergedViolations.map((violation) => violation.message)];
|
|
3041
|
+
if (mergedFaults.length > 0) {
|
|
3042
|
+
const onDisk = new Set(raw === void 0 ? [] : tokenFileViolationMessages(raw, tokenFile));
|
|
3043
|
+
const introduced = mergedFaults.filter((fault) => !onDisk.has(fault));
|
|
3044
|
+
const landings = new Set(changes.map((change) => change.path));
|
|
3045
|
+
const dangling = danglingAliasTargets(merged, written.filter((path) => !path.startsWith("primitive.")));
|
|
3046
|
+
const leftBehind = dangling.filter((target) => landings.has(target));
|
|
3047
|
+
const notCarried = dangling.filter((target) => !landings.has(target));
|
|
3048
|
+
const danglingFaults = new Set(mergedViolations.filter((violation) => violation.kind === "unresolved-alias").map((violation) => violation.message));
|
|
3049
|
+
const hint = importRefusalHint(leftBehind, notCarried, introduced.some((fault) => !danglingFaults.has(fault)), tokenFile);
|
|
3050
|
+
return {
|
|
3051
|
+
...empty,
|
|
3052
|
+
tokensFound,
|
|
3053
|
+
changes,
|
|
3054
|
+
refused: true,
|
|
3055
|
+
problems: [introduced.length > 0 ? `not applied — with this import, ${tokenFile} would fail validation: ${summarizeMessages(introduced)} ${hint}` : `not applied — ${tokenFile} already fails validation and this import does not repair it: ${summarizeMessages(mergedFaults)} ${VALIDATE_HINT}`]
|
|
3056
|
+
};
|
|
3057
|
+
}
|
|
3058
|
+
if (written.length > 0) {
|
|
3059
|
+
const tokenPath = join(inputs.repoRoot, tokenFile);
|
|
3060
|
+
await mkdir(dirname(tokenPath), { recursive: true });
|
|
3061
|
+
await writeFile(tokenPath, text);
|
|
3062
|
+
}
|
|
2073
3063
|
return {
|
|
2074
3064
|
tokensFound,
|
|
2075
3065
|
changes,
|
|
2076
3066
|
applied: true,
|
|
3067
|
+
refused: false,
|
|
2077
3068
|
written,
|
|
2078
3069
|
skipped,
|
|
2079
|
-
|
|
3070
|
+
unsynced,
|
|
3071
|
+
problems: [],
|
|
3072
|
+
warnings: []
|
|
2080
3073
|
};
|
|
2081
3074
|
};
|
|
3075
|
+
const importRefusalHint = (leftBehind, notCarried, otherFault, tokenFile) => {
|
|
3076
|
+
if (notCarried.length === 0 && !otherFault) return IMPORT_INVALID_HINT;
|
|
3077
|
+
return `${[
|
|
3078
|
+
...leftBehind.length > 0 ? [`Add ${leftBehind.join(", ")} to the \`--only\` slice`] : [],
|
|
3079
|
+
...notCarried.length > 0 ? [`The neutral file carries no variable that lands at ${notCarried.join(", ")}, so no \`--only\` slice can supply ${notCarried.length === 1 ? "it" : "them"}: add the token in the design tool (or in ${tokenFile}) first, or leave out what references it`] : [],
|
|
3080
|
+
...otherFault ? [IMPORT_FAULT_CLAUSE] : []
|
|
3081
|
+
].join(". ")}; nothing was written.`;
|
|
3082
|
+
};
|
|
2082
3083
|
const asMessage$1 = (error) => error instanceof Error ? error.message : String(error);
|
|
2083
3084
|
//#endregion
|
|
2084
3085
|
//#region src/design-tokens/drift.ts
|
|
@@ -2130,15 +3131,23 @@ const runExport = async (inputs) => {
|
|
|
2130
3131
|
const empty = {
|
|
2131
3132
|
tokensFound: false,
|
|
2132
3133
|
plan: [],
|
|
3134
|
+
unprojected: [],
|
|
2133
3135
|
hash: "",
|
|
2134
3136
|
recorded: false,
|
|
2135
3137
|
problems: []
|
|
2136
3138
|
};
|
|
2137
|
-
const
|
|
2138
|
-
if (!await pathExists(tokenPath)) return empty;
|
|
3139
|
+
const tokenFile = resolveTokenFile(inputs.tokenFile);
|
|
2139
3140
|
let parsed;
|
|
2140
3141
|
try {
|
|
2141
|
-
|
|
3142
|
+
const raw = await readOptionalTokenFile(inputs.repoRoot, tokenFile);
|
|
3143
|
+
if (raw === void 0) return empty;
|
|
3144
|
+
parsed = readJsonObject(raw, tokenFile);
|
|
3145
|
+
const invalid = summarizeTokenFileViolations(raw, tokenFile);
|
|
3146
|
+
if (invalid !== void 0) return {
|
|
3147
|
+
...empty,
|
|
3148
|
+
tokensFound: true,
|
|
3149
|
+
problems: [`${tokenFile} fails validation: ${invalid} ${VALIDATE_HINT}`]
|
|
3150
|
+
};
|
|
2142
3151
|
} catch (error) {
|
|
2143
3152
|
return {
|
|
2144
3153
|
...empty,
|
|
@@ -2150,39 +3159,50 @@ const runExport = async (inputs) => {
|
|
|
2150
3159
|
if (!binding) return {
|
|
2151
3160
|
...empty,
|
|
2152
3161
|
tokensFound: true,
|
|
2153
|
-
problems: [`no design tool is declared — add a "${DESIGN_TOOL_EXTENSION}" provider to ${
|
|
3162
|
+
problems: [`no design tool is declared — add a "${DESIGN_TOOL_EXTENSION}" provider to ${tokenFile} before exporting.`]
|
|
2154
3163
|
};
|
|
2155
3164
|
const hash = projectionHash(parsed);
|
|
2156
3165
|
let toolState;
|
|
2157
|
-
if (inputs.toolStatePath !== void 0) {
|
|
2158
|
-
|
|
3166
|
+
if (inputs.toolStatePath !== void 0) try {
|
|
3167
|
+
toolState = parseNeutralFile(await readRequiredFile(inputs.toolStatePath, "tool-state"), "tool-state");
|
|
3168
|
+
} catch (error) {
|
|
3169
|
+
return {
|
|
3170
|
+
...empty,
|
|
3171
|
+
tokensFound: true,
|
|
3172
|
+
hash,
|
|
3173
|
+
problems: [asMessage(error)]
|
|
3174
|
+
};
|
|
3175
|
+
}
|
|
3176
|
+
const plan = upsertPlan(parsed, toolState);
|
|
3177
|
+
const unprojected = unsyncedTokens(parsed);
|
|
3178
|
+
if (inputs.outPath !== void 0) {
|
|
3179
|
+
const projection = dtcgToNeutral(parsed);
|
|
3180
|
+
projection.provider = binding.provider;
|
|
3181
|
+
if (await sameEntry(inputs.outPath, join(inputs.repoRoot, tokenFile))) return {
|
|
2159
3182
|
...empty,
|
|
2160
3183
|
tokensFound: true,
|
|
2161
3184
|
hash,
|
|
2162
|
-
problems: [`
|
|
3185
|
+
problems: [`Cannot write the projection to ${inputs.outPath}: it is ${tokenFile} itself, which the projection would replace. Name another path.`]
|
|
2163
3186
|
};
|
|
2164
3187
|
try {
|
|
2165
|
-
|
|
3188
|
+
await writeRegularFile(inputs.outPath, writeNeutralFile(projection));
|
|
2166
3189
|
} catch (error) {
|
|
2167
3190
|
return {
|
|
2168
3191
|
...empty,
|
|
2169
3192
|
tokensFound: true,
|
|
2170
3193
|
hash,
|
|
2171
|
-
problems: [asMessage(error)]
|
|
3194
|
+
problems: [`Cannot write the projection to ${inputs.outPath}: ${asMessage(error)}`]
|
|
2172
3195
|
};
|
|
2173
3196
|
}
|
|
2174
3197
|
}
|
|
2175
|
-
const plan = upsertPlan(parsed, toolState);
|
|
2176
|
-
if (inputs.outPath !== void 0) {
|
|
2177
|
-
const projection = dtcgToNeutral(parsed);
|
|
2178
|
-
projection.provider = binding.provider;
|
|
2179
|
-
await writeFile(inputs.outPath, writeNeutralFile(projection));
|
|
2180
|
-
}
|
|
2181
3198
|
if (inputs.record) {
|
|
2182
|
-
|
|
3199
|
+
const stamped = writeJsonFile(stampBaseline(parsed, hash));
|
|
3200
|
+
const tokenPath = join(inputs.repoRoot, tokenFile);
|
|
3201
|
+
await writeFile(tokenPath, stamped);
|
|
2183
3202
|
return {
|
|
2184
3203
|
tokensFound: true,
|
|
2185
3204
|
plan,
|
|
3205
|
+
unprojected,
|
|
2186
3206
|
hash,
|
|
2187
3207
|
recorded: true,
|
|
2188
3208
|
problems: []
|
|
@@ -2191,13 +3211,14 @@ const runExport = async (inputs) => {
|
|
|
2191
3211
|
return {
|
|
2192
3212
|
tokensFound: true,
|
|
2193
3213
|
plan,
|
|
3214
|
+
unprojected,
|
|
2194
3215
|
hash,
|
|
2195
3216
|
recorded: false,
|
|
2196
3217
|
problems: []
|
|
2197
3218
|
};
|
|
2198
3219
|
};
|
|
2199
3220
|
const stampBaseline = (parsed, hash) => {
|
|
2200
|
-
const clone =
|
|
3221
|
+
const clone = { ...parsed };
|
|
2201
3222
|
const extensions = isRecord(clone["$extensions"]) ? { ...clone["$extensions"] } : {};
|
|
2202
3223
|
const binding = isRecord(extensions["com.lemony.design-tool"]) ? { ...extensions[DESIGN_TOOL_EXTENSION] } : {};
|
|
2203
3224
|
binding["lastProjected"] = hash;
|
|
@@ -2205,8 +3226,57 @@ const stampBaseline = (parsed, hash) => {
|
|
|
2205
3226
|
clone["$extensions"] = extensions;
|
|
2206
3227
|
return clone;
|
|
2207
3228
|
};
|
|
3229
|
+
const sameEntry = async (a, b) => {
|
|
3230
|
+
const [left, right] = await Promise.all([a, b].map((path) => stat(path).catch(() => void 0)));
|
|
3231
|
+
return left !== void 0 && right !== void 0 && left.dev === right.dev && left.ino === right.ino;
|
|
3232
|
+
};
|
|
2208
3233
|
const asMessage = (error) => error instanceof Error ? error.message : String(error);
|
|
2209
3234
|
//#endregion
|
|
3235
|
+
//#region src/review-ledger/parse-markdown-blocks.ts
|
|
3236
|
+
const MAX_NESTING = 200;
|
|
3237
|
+
const MARKDOWN = new MarkdownIt("commonmark", { maxNesting: MAX_NESTING });
|
|
3238
|
+
const OPEN_SUFFIX = /_open$/;
|
|
3239
|
+
const parseMarkdownBlocks = (lines) => {
|
|
3240
|
+
const root = {
|
|
3241
|
+
type: "root",
|
|
3242
|
+
start: 0,
|
|
3243
|
+
end: lines.length,
|
|
3244
|
+
markup: "",
|
|
3245
|
+
text: "",
|
|
3246
|
+
content: "",
|
|
3247
|
+
children: []
|
|
3248
|
+
};
|
|
3249
|
+
const stack = [root];
|
|
3250
|
+
const tokens = MARKDOWN.parse(lines.join("\n"), {});
|
|
3251
|
+
const cut = tokens.find((token) => token.nesting === 1 && token.level >= 199);
|
|
3252
|
+
if (cut) throw new Error(`line ${(cut.map?.[0] ?? 0) + 1}: the markdown nests deeper than ${MAX_NESTING} parser levels (a nested list costs two, a quote one), so what it holds was not read.`);
|
|
3253
|
+
for (const token of tokens) {
|
|
3254
|
+
const parent = stack[stack.length - 1] ?? root;
|
|
3255
|
+
if (token.nesting === -1) {
|
|
3256
|
+
stack.pop();
|
|
3257
|
+
continue;
|
|
3258
|
+
}
|
|
3259
|
+
if (token.type === "inline") {
|
|
3260
|
+
parent.text = plainText(token.children ?? []);
|
|
3261
|
+
continue;
|
|
3262
|
+
}
|
|
3263
|
+
const [start, end] = token.map ?? [parent.start, parent.start];
|
|
3264
|
+
const block = {
|
|
3265
|
+
type: token.type.replace(OPEN_SUFFIX, ""),
|
|
3266
|
+
start,
|
|
3267
|
+
end,
|
|
3268
|
+
markup: token.markup,
|
|
3269
|
+
text: "",
|
|
3270
|
+
content: token.content,
|
|
3271
|
+
children: []
|
|
3272
|
+
};
|
|
3273
|
+
parent.children.push(block);
|
|
3274
|
+
if (token.nesting === 1) stack.push(block);
|
|
3275
|
+
}
|
|
3276
|
+
return root.children;
|
|
3277
|
+
};
|
|
3278
|
+
const plainText = (children) => children.filter((child) => child.type === "text" || child.type === "code_inline").map((child) => child.content).join("");
|
|
3279
|
+
//#endregion
|
|
2210
3280
|
//#region src/review-ledger/review-ledger.constant.ts
|
|
2211
3281
|
const REVIEW_LEDGER_DIRNAME = "review-ledger";
|
|
2212
3282
|
const FULL_PASS_FILENAME = "full-pass.json";
|
|
@@ -2217,18 +3287,29 @@ const SPEC_SIDE_KINDS = [
|
|
|
2217
3287
|
"group-missing",
|
|
2218
3288
|
"group-empty",
|
|
2219
3289
|
"duplicate-group-index",
|
|
3290
|
+
"duplicate-task-id",
|
|
2220
3291
|
"orphan-task",
|
|
2221
3292
|
"malformed-risk-marker",
|
|
2222
3293
|
"malformed-task-refs",
|
|
3294
|
+
"malformed-task-line",
|
|
3295
|
+
"malformed-group-header",
|
|
2223
3296
|
"unknown-risk-class",
|
|
2224
3297
|
"dangling-requirement-ref",
|
|
2225
3298
|
"unticked-completed-task"
|
|
2226
3299
|
];
|
|
2227
3300
|
const GROUP_HEADER = /^##\s+Group\s+(\d+)\b/;
|
|
3301
|
+
const GROUP_HEADER_LOOKALIKE = /^ {0,3}#+\s*group(?:\s*\d|\s+\S+\s*(?:—|$))/i;
|
|
3302
|
+
const GROUP_SHAPED_HEADING = /^ {0,3}##\s+[^\s\d]\S*\s+\d.*(?:[_*]\(.*\)[_*]|\[\s*risks?\s*:)/i;
|
|
3303
|
+
const LINE_CONTAINER_MARKERS = /^(?:\s*(?:>|[-+*](?=\s)|\d{1,9}[.)](?=\s)))+\s*/;
|
|
2228
3304
|
const RISK_MARKER = /\[risk:\s*([^\]]*)\]\s*$/;
|
|
2229
3305
|
const RISK_MARKER_ALL = /\[risk:/gi;
|
|
2230
3306
|
const RISK_MARKER_LOOKALIKE = /\[\s*risks?\s*:/i;
|
|
2231
|
-
const TASK_LINE = /^\s*[-*+]\s*\[([ xX])\]\s*(?:\*\*|__|`)?\s*(T\d+)\b/;
|
|
3307
|
+
const TASK_LINE = /^\s*[-*+]\s*\[([ xX])\]\s*(?:\*\*|__|`)?\s*(T\d+)\b(?![.-]\w)/;
|
|
3308
|
+
const TASK_LINE_LOOKALIKE = /^\s*(?:[-*+]|\d+[.)])\s*\[[^\]]*\]\s*[*_`]*\s*T\d/;
|
|
3309
|
+
const BOXLESS_TASK_LINE = /^\s*(?:[-*+]|\d+[.)])\s+[*_`]*\s*T\d/;
|
|
3310
|
+
const TASK_BOX = /\[([^\]]*)\]/;
|
|
3311
|
+
const TASK_LOOKALIKE_ID = /\]\s*[*_`]*\s*(T\d+(?:[.-]?\w)*)/;
|
|
3312
|
+
const LINE_BOM = /^\uFEFF/;
|
|
2232
3313
|
const TASK_REFS = /^\(((?:R\d+)(?:\s*,\s*R\d+)*)\)/;
|
|
2233
3314
|
const TASK_REFS_LOOKALIKE_ALL = /\(\s*R\d+/g;
|
|
2234
3315
|
const TASK_TITLE_WRAPPER = /^\s*[-*+]\s*\[[ xX]\]\s*(\*\*|__)/;
|
|
@@ -2254,42 +3335,66 @@ const NOT_APPLICABLE_REASONS = [
|
|
|
2254
3335
|
];
|
|
2255
3336
|
//#endregion
|
|
2256
3337
|
//#region src/review-ledger/parse-spec.ts
|
|
3338
|
+
const VERBATIM_BLOCKS = /* @__PURE__ */ new Set([
|
|
3339
|
+
"fence",
|
|
3340
|
+
"code_block",
|
|
3341
|
+
"html_block"
|
|
3342
|
+
]);
|
|
3343
|
+
const CODE_BLOCKS = /* @__PURE__ */ new Set(["fence", "code_block"]);
|
|
3344
|
+
const HIDDEN_HTML_SPAN = /<!--[\s\S]*?(?:-->|$)|<(script|style|textarea)(?=[\s/>]|$)[\s\S]*?(?:<\/\1\s*>|$)/gi;
|
|
3345
|
+
const HANGING_INDENT = /^ {4}/;
|
|
3346
|
+
const LEADING_WHITESPACE = /^[ \t]*/;
|
|
3347
|
+
const LINE_BREAK = /\r\n?|\n/;
|
|
3348
|
+
const LEADING_SPACES = /^ */;
|
|
2257
3349
|
const parseTasksSpec = (text) => {
|
|
2258
|
-
const lines = text.split(
|
|
2259
|
-
const
|
|
3350
|
+
const { lines, blocks } = withHangingIndentsRead(text.split(LINE_BREAK).map((line) => line.replace(LINE_BOM, "")));
|
|
3351
|
+
const malformedTaskLines = [];
|
|
3352
|
+
const malformedGroupHeaders = [];
|
|
3353
|
+
const entries = readSpecEntries(lines, blocks, malformedTaskLines, malformedGroupHeaders);
|
|
3354
|
+
const grouped = entries.some((entry) => entry.kind !== "task");
|
|
2260
3355
|
const groups = [];
|
|
2261
3356
|
const orphanTaskIds = [];
|
|
2262
3357
|
const malformedRiskHeaders = [];
|
|
2263
3358
|
const malformedTaskRefs = [];
|
|
3359
|
+
const taskIdGroups = /* @__PURE__ */ new Map();
|
|
3360
|
+
const record = (id, group) => {
|
|
3361
|
+
taskIdGroups.set(id, [...taskIdGroups.get(id) ?? [], group]);
|
|
3362
|
+
};
|
|
2264
3363
|
let current;
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
if (risk.malformed) malformedRiskHeaders.push(line.trim());
|
|
3364
|
+
let quarantined = false;
|
|
3365
|
+
for (const entry of entries) {
|
|
3366
|
+
if (entry.kind === "group") {
|
|
3367
|
+
const risk = parseRiskMarker(entry.line);
|
|
3368
|
+
if (risk.malformed) malformedRiskHeaders.push(entry.line.trim());
|
|
2271
3369
|
current = {
|
|
2272
|
-
index: Number(
|
|
2273
|
-
header: line.trim(),
|
|
3370
|
+
index: Number(GROUP_HEADER.exec(entry.line)?.[1]),
|
|
3371
|
+
header: entry.line.trim(),
|
|
2274
3372
|
riskClasses: risk.riskClasses,
|
|
2275
3373
|
unknownRiskClasses: risk.unknownRiskClasses,
|
|
2276
3374
|
tasks: []
|
|
2277
3375
|
};
|
|
2278
3376
|
groups.push(current);
|
|
3377
|
+
quarantined = false;
|
|
3378
|
+
continue;
|
|
3379
|
+
}
|
|
3380
|
+
if (entry.kind === "not-a-group") {
|
|
3381
|
+
quarantined = true;
|
|
3382
|
+
continue;
|
|
3383
|
+
}
|
|
3384
|
+
const { id } = entry;
|
|
3385
|
+
if (quarantined) {
|
|
3386
|
+
record(id, null);
|
|
2279
3387
|
continue;
|
|
2280
3388
|
}
|
|
2281
|
-
const
|
|
2282
|
-
if (!match) continue;
|
|
2283
|
-
const blockEnd = taskBlockEnd(lines, index);
|
|
2284
|
-
const parts = lines.slice(index, blockEnd).map((part) => part.trim());
|
|
2285
|
-
index = blockEnd - 1;
|
|
2286
|
-
const task = parseTaskBlock(match[2] ?? "", (match[1] ?? " ") !== " ", parts);
|
|
3389
|
+
const task = parseTaskBlock(id, entry.ticked, entry.parts, entry.refInCode);
|
|
2287
3390
|
if (task.malformedRefs) malformedTaskRefs.push(task.id);
|
|
2288
3391
|
if (current) {
|
|
3392
|
+
record(id, current.index);
|
|
2289
3393
|
current.tasks.push(task.task);
|
|
2290
3394
|
continue;
|
|
2291
3395
|
}
|
|
2292
3396
|
if (grouped) {
|
|
3397
|
+
record(id, null);
|
|
2293
3398
|
orphanTaskIds.push(task.id);
|
|
2294
3399
|
continue;
|
|
2295
3400
|
}
|
|
@@ -2300,6 +3405,7 @@ const parseTasksSpec = (text) => {
|
|
|
2300
3405
|
unknownRiskClasses: [],
|
|
2301
3406
|
tasks: [task.task]
|
|
2302
3407
|
});
|
|
3408
|
+
record(id, groups.length);
|
|
2303
3409
|
}
|
|
2304
3410
|
const seen = /* @__PURE__ */ new Set();
|
|
2305
3411
|
const duplicateIndexes = [];
|
|
@@ -2311,20 +3417,282 @@ const parseTasksSpec = (text) => {
|
|
|
2311
3417
|
groups,
|
|
2312
3418
|
orphanTaskIds,
|
|
2313
3419
|
duplicateIndexes,
|
|
3420
|
+
duplicateTaskIds: [...taskIdGroups].filter(([, copies]) => copies.length > 1).map(([id, copies]) => ({
|
|
3421
|
+
id,
|
|
3422
|
+
groups: copies
|
|
3423
|
+
})),
|
|
2314
3424
|
malformedRiskHeaders,
|
|
2315
|
-
malformedTaskRefs
|
|
3425
|
+
malformedTaskRefs,
|
|
3426
|
+
malformedTaskLines,
|
|
3427
|
+
malformedGroupHeaders
|
|
3428
|
+
};
|
|
3429
|
+
};
|
|
3430
|
+
const readSpecEntries = (lines, blocks, malformedTaskLines, malformedGroupHeaders) => {
|
|
3431
|
+
const entries = [];
|
|
3432
|
+
const lineAt = (index) => lines[index] ?? "";
|
|
3433
|
+
const samples = quotedSampleLines(lines, blocks);
|
|
3434
|
+
const notAGroup = (header, zero) => {
|
|
3435
|
+
malformedGroupHeaders.push({
|
|
3436
|
+
header: header.trim(),
|
|
3437
|
+
defect: zero ? "zero" : "lookalike"
|
|
3438
|
+
});
|
|
3439
|
+
entries.push({ kind: "not-a-group" });
|
|
3440
|
+
};
|
|
3441
|
+
const notATask = (line, owner) => {
|
|
3442
|
+
if (owner !== void 0) {
|
|
3443
|
+
malformedTaskLines.push({
|
|
3444
|
+
line: line.trim(),
|
|
3445
|
+
defect: "nested",
|
|
3446
|
+
parentId: owner
|
|
3447
|
+
});
|
|
3448
|
+
return;
|
|
3449
|
+
}
|
|
3450
|
+
malformedTaskLines.push({
|
|
3451
|
+
line: line.trim(),
|
|
3452
|
+
defect: taskLineDefect(line, writtenIdOf(line))
|
|
3453
|
+
});
|
|
2316
3454
|
};
|
|
2317
|
-
|
|
2318
|
-
const
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
|
|
3455
|
+
const readVerbatim = (block) => {
|
|
3456
|
+
const shown = hidesHtml(block) ? shownPastHiddenHtml(block.content) : [];
|
|
3457
|
+
block.content.split("\n").forEach((line, offset) => {
|
|
3458
|
+
const text = (shown[offset] ?? "").trimStart();
|
|
3459
|
+
if (TASK_LINE_LOOKALIKE.test(line)) malformedTaskLines.push({
|
|
3460
|
+
line: line.trim(),
|
|
3461
|
+
defect: "fenced"
|
|
3462
|
+
});
|
|
3463
|
+
else if (isGroupShaped(line)) malformedGroupHeaders.push({
|
|
3464
|
+
header: line.trim(),
|
|
3465
|
+
defect: "fenced"
|
|
3466
|
+
});
|
|
3467
|
+
else if (TASK_LINE_LOOKALIKE.test(text)) malformedTaskLines.push({
|
|
3468
|
+
line: text.trim(),
|
|
3469
|
+
defect: "shape"
|
|
3470
|
+
});
|
|
3471
|
+
else if (isGroupShaped(text)) malformedGroupHeaders.push({
|
|
3472
|
+
header: text.trim(),
|
|
3473
|
+
defect: "lookalike"
|
|
3474
|
+
});
|
|
3475
|
+
});
|
|
3476
|
+
};
|
|
3477
|
+
const readHeading = (block) => {
|
|
3478
|
+
const line = lineAt(block.start);
|
|
3479
|
+
const header = GROUP_HEADER.exec(line);
|
|
3480
|
+
if (header && Number(header[1]) > 0) {
|
|
3481
|
+
entries.push({
|
|
3482
|
+
kind: "group",
|
|
3483
|
+
line
|
|
3484
|
+
});
|
|
3485
|
+
return;
|
|
3486
|
+
}
|
|
3487
|
+
const shown = `${headingMarks(block.markup)} ${block.text}`;
|
|
3488
|
+
const own = line.replace(LINE_CONTAINER_MARKERS, "");
|
|
3489
|
+
if (![
|
|
3490
|
+
line,
|
|
3491
|
+
shown,
|
|
3492
|
+
block.markup.startsWith("#") ? own : `${headingMarks(block.markup)} ${own}`
|
|
3493
|
+
].some(isGroupShaped)) return;
|
|
3494
|
+
notAGroup(line, isGroupZero(shown));
|
|
3495
|
+
};
|
|
3496
|
+
const readParagraph = (block, owner, itemLine) => {
|
|
3497
|
+
for (let index = block.start; index < block.end; index += 1) {
|
|
3498
|
+
if (index === itemLine) continue;
|
|
3499
|
+
const line = lineAt(index);
|
|
3500
|
+
if (isGroupShaped(line)) notAGroup(line, false);
|
|
3501
|
+
else if (TASK_LINE_LOOKALIKE.test(line)) notATask(line, owner);
|
|
3502
|
+
else if (samples.has(index) && TASK_LINE_LOOKALIKE.test(unquoted(line))) malformedTaskLines.push({
|
|
3503
|
+
line: line.trim(),
|
|
3504
|
+
defect: "shape"
|
|
3505
|
+
});
|
|
3506
|
+
else if (owner === void 0 && BOXLESS_TASK_LINE.test(line)) malformedTaskLines.push({
|
|
3507
|
+
line: line.trim(),
|
|
3508
|
+
defect: "shape"
|
|
3509
|
+
});
|
|
3510
|
+
}
|
|
3511
|
+
};
|
|
3512
|
+
const readItem = (block, owner) => {
|
|
3513
|
+
const line = lineAt(block.start);
|
|
3514
|
+
if (TASK_LINE_LOOKALIKE.test(line)) {
|
|
3515
|
+
const writtenId = writtenIdOf(line);
|
|
3516
|
+
const match = TASK_LINE.exec(line);
|
|
3517
|
+
const id = match?.[2];
|
|
3518
|
+
if (owner !== void 0 || !match || id === void 0) notATask(line, owner);
|
|
3519
|
+
else entries.push({
|
|
3520
|
+
kind: "task",
|
|
3521
|
+
id,
|
|
3522
|
+
ticked: (match[1] ?? " ") !== " ",
|
|
3523
|
+
...taskText(lines, block)
|
|
3524
|
+
});
|
|
3525
|
+
readBlocks(block.children, owner ?? writtenId, block.start);
|
|
3526
|
+
return;
|
|
3527
|
+
}
|
|
3528
|
+
if (isQuotedTask(lines, block)) {
|
|
3529
|
+
malformedTaskLines.push({
|
|
3530
|
+
line: line.trim(),
|
|
3531
|
+
defect: "shape"
|
|
3532
|
+
});
|
|
3533
|
+
readBlocks(block.children, owner, block.start);
|
|
3534
|
+
return;
|
|
3535
|
+
}
|
|
3536
|
+
if (owner === void 0 && BOXLESS_TASK_LINE.test(line)) malformedTaskLines.push({
|
|
3537
|
+
line: line.trim(),
|
|
3538
|
+
defect: "shape"
|
|
3539
|
+
});
|
|
3540
|
+
readBlocks(block.children, owner, block.start);
|
|
3541
|
+
};
|
|
3542
|
+
const readBlocks = (siblings, owner, itemLine) => {
|
|
3543
|
+
for (const block of siblings) if (VERBATIM_BLOCKS.has(block.type)) readVerbatim(block);
|
|
3544
|
+
else if (block.type === "heading") readHeading(block);
|
|
3545
|
+
else if (block.type === "paragraph") readParagraph(block, owner, itemLine);
|
|
3546
|
+
else if (block.type === "list_item") readItem(block, owner);
|
|
3547
|
+
else readBlocks(block.children, owner, -1);
|
|
3548
|
+
};
|
|
3549
|
+
readBlocks(blocks, void 0, -1);
|
|
3550
|
+
return entries;
|
|
3551
|
+
};
|
|
3552
|
+
const withHangingIndentsRead = (source) => {
|
|
3553
|
+
const lines = source.map(withLeadingTabsExpanded);
|
|
3554
|
+
for (;;) {
|
|
3555
|
+
const blocks = parseMarkdownBlocks(lines);
|
|
3556
|
+
const shifting = codeInTasks(lines, blocks, void 0).filter(({ block, task }) => !(block.end >= task.end && continuesParagraph(lines[block.end]))).filter(({ block }) => canShift(lines, block)).map(({ block }) => block);
|
|
3557
|
+
if (shifting.length === 0) return {
|
|
3558
|
+
lines,
|
|
3559
|
+
blocks
|
|
3560
|
+
};
|
|
3561
|
+
const rounds = shifting.reduce((fewest, block) => Math.min(fewest, shiftsLeavingItCode(lines, block)), Number.POSITIVE_INFINITY);
|
|
3562
|
+
let shifted = false;
|
|
3563
|
+
for (const block of shifting) for (let index = block.start; index < block.end; index += 1) {
|
|
3564
|
+
let line = lines[index] ?? "";
|
|
3565
|
+
for (let n = 0; n < rounds && HANGING_INDENT.test(line); n += 1) {
|
|
3566
|
+
line = line.replace(HANGING_INDENT, "");
|
|
3567
|
+
shifted = true;
|
|
3568
|
+
}
|
|
3569
|
+
lines[index] = line;
|
|
3570
|
+
}
|
|
3571
|
+
if (!shifted) return {
|
|
3572
|
+
lines,
|
|
3573
|
+
blocks
|
|
3574
|
+
};
|
|
2324
3575
|
}
|
|
2325
|
-
return end;
|
|
2326
3576
|
};
|
|
2327
|
-
const
|
|
3577
|
+
const canShift = (lines, block) => {
|
|
3578
|
+
const content = block.content.split("\n");
|
|
3579
|
+
return lines.slice(block.start, block.end).every((line, offset) => line.trim() === "" || line.replace(/\0/g, "�").trimStart() === (content[offset] ?? "").trimStart());
|
|
3580
|
+
};
|
|
3581
|
+
const shiftsLeavingItCode = (lines, block) => {
|
|
3582
|
+
const content = block.content.split("\n");
|
|
3583
|
+
let threshold;
|
|
3584
|
+
let least = Number.POSITIVE_INFINITY;
|
|
3585
|
+
for (let index = block.start; index < block.end; index += 1) {
|
|
3586
|
+
const line = lines[index] ?? "";
|
|
3587
|
+
if (line.trim() === "") continue;
|
|
3588
|
+
const code = content[index - block.start] ?? "";
|
|
3589
|
+
const indent = LEADING_SPACES.exec(line)?.[0].length ?? 0;
|
|
3590
|
+
if (line.slice(indent) !== code.trimStart()) return 1;
|
|
3591
|
+
threshold = indent - (LEADING_SPACES.exec(code)?.[0].length ?? 0);
|
|
3592
|
+
least = Math.min(least, indent);
|
|
3593
|
+
}
|
|
3594
|
+
if (threshold === void 0) return 1;
|
|
3595
|
+
return Math.floor((least - threshold) / 4) + 1;
|
|
3596
|
+
};
|
|
3597
|
+
const withLeadingTabsExpanded = (line) => {
|
|
3598
|
+
const [lead = ""] = LEADING_WHITESPACE.exec(line) ?? [];
|
|
3599
|
+
if (!lead.includes(" ")) return line;
|
|
3600
|
+
let column = 0;
|
|
3601
|
+
for (const char of lead) column = char === " " ? column + 4 - column % 4 : column + 1;
|
|
3602
|
+
return " ".repeat(column) + line.slice(lead.length);
|
|
3603
|
+
};
|
|
3604
|
+
const codeInTasks = (lines, blocks, task) => blocks.flatMap((block) => {
|
|
3605
|
+
if (block.type === "code_block") return task ? [{
|
|
3606
|
+
block,
|
|
3607
|
+
task
|
|
3608
|
+
}] : [];
|
|
3609
|
+
const opens = block.type === "list_item" && TASK_LINE_LOOKALIKE.test(lines[block.start] ?? "");
|
|
3610
|
+
return codeInTasks(lines, block.children, task ?? (opens ? block : void 0));
|
|
3611
|
+
});
|
|
3612
|
+
const taskText = (lines, item) => {
|
|
3613
|
+
const nested = /* @__PURE__ */ new Set();
|
|
3614
|
+
const code = /* @__PURE__ */ new Set();
|
|
3615
|
+
const shown = /* @__PURE__ */ new Map();
|
|
3616
|
+
const exclude = (blocks) => {
|
|
3617
|
+
for (const block of blocks) {
|
|
3618
|
+
const hidden = hidesHtml(block);
|
|
3619
|
+
if (hidden) shownPastHiddenHtml(block.content).forEach((text, offset) => {
|
|
3620
|
+
if (text.trim() !== "") shown.set(block.start + offset, text);
|
|
3621
|
+
});
|
|
3622
|
+
const into = CODE_BLOCKS.has(block.type) || hidden ? code : block.type === "list_item" && (TASK_LINE_LOOKALIKE.test(lines[block.start] ?? "") || isQuotedTask(lines, block)) ? nested : void 0;
|
|
3623
|
+
if (!into) {
|
|
3624
|
+
exclude(block.children);
|
|
3625
|
+
continue;
|
|
3626
|
+
}
|
|
3627
|
+
for (let index = block.start; index < block.end; index += 1) into.add(index);
|
|
3628
|
+
}
|
|
3629
|
+
};
|
|
3630
|
+
exclude(item.children);
|
|
3631
|
+
const samples = quotedSampleLines(lines, item.children);
|
|
3632
|
+
const parts = [(lines[item.start] ?? "").trim()];
|
|
3633
|
+
let refInCode = false;
|
|
3634
|
+
for (let index = item.start + 1; index < item.end; index += 1) {
|
|
3635
|
+
const line = lines[index] ?? "";
|
|
3636
|
+
if (nested.has(index) || line.trim() === "") continue;
|
|
3637
|
+
if (code.has(index)) {
|
|
3638
|
+
refInCode ||= hasRefLookalike([line]);
|
|
3639
|
+
const text = shown.get(index);
|
|
3640
|
+
if (text !== void 0 && !isTaskOrHeaderShaped(text.trimStart())) parts.push(text.trim());
|
|
3641
|
+
continue;
|
|
3642
|
+
}
|
|
3643
|
+
if (isTaskOrHeaderShaped(line) || samples.has(index)) continue;
|
|
3644
|
+
parts.push(line.trim());
|
|
3645
|
+
}
|
|
3646
|
+
return {
|
|
3647
|
+
parts,
|
|
3648
|
+
refInCode
|
|
3649
|
+
};
|
|
3650
|
+
};
|
|
3651
|
+
const hidesHtml = (block) => block.type === "html_block" && shownPastHiddenHtml(block.content).join("\n") !== block.content;
|
|
3652
|
+
const shownPastHiddenHtml = (content) => content.replace(HIDDEN_HTML_SPAN, (span) => span.replace(/[^\n]/g, "")).split("\n");
|
|
3653
|
+
const isTaskOrHeaderShaped = (line) => TASK_LINE_LOOKALIKE.test(line) || isGroupShaped(line);
|
|
3654
|
+
const isQuotedTask = (lines, item) => {
|
|
3655
|
+
const line = lines[item.start] ?? "";
|
|
3656
|
+
return !TASK_LINE_LOOKALIKE.test(line) && TASK_LINE_LOOKALIKE.test(unquoted(line)) && !opensItemOn(item.children, item.start);
|
|
3657
|
+
};
|
|
3658
|
+
const quotedSampleLines = (lines, blocks) => {
|
|
3659
|
+
const sample = /* @__PURE__ */ new Set();
|
|
3660
|
+
const visit = (siblings, inQuote) => {
|
|
3661
|
+
for (const block of siblings) {
|
|
3662
|
+
if (inQuote && block.type === "paragraph") {
|
|
3663
|
+
let folded = false;
|
|
3664
|
+
for (let index = block.start; index < block.end; index += 1) {
|
|
3665
|
+
const line = lines[index] ?? "";
|
|
3666
|
+
folded ||= TASK_LINE_LOOKALIKE.test(unquoted(line));
|
|
3667
|
+
if (folded) sample.add(index);
|
|
3668
|
+
}
|
|
3669
|
+
}
|
|
3670
|
+
visit(block.children, inQuote || block.type === "blockquote");
|
|
3671
|
+
}
|
|
3672
|
+
};
|
|
3673
|
+
visit(blocks, false);
|
|
3674
|
+
return sample;
|
|
3675
|
+
};
|
|
3676
|
+
const opensItemOn = (blocks, start) => blocks.some((block) => block.type === "list_item" && block.start === start || opensItemOn(block.children, start));
|
|
3677
|
+
const unquoted = (line) => {
|
|
3678
|
+
const markers = LINE_CONTAINER_MARKERS.exec(line)?.[0] ?? "";
|
|
3679
|
+
return line.slice(markers.lastIndexOf(">") + 1);
|
|
3680
|
+
};
|
|
3681
|
+
const continuesParagraph = (line) => {
|
|
3682
|
+
if (line === void 0 || line.trim() === "") return false;
|
|
3683
|
+
const [only, ...rest] = parseMarkdownBlocks(["text", line]);
|
|
3684
|
+
return rest.length === 0 && (only?.type === "paragraph" || only?.type === "heading" && only.markup.startsWith("=")) && only.end === 2;
|
|
3685
|
+
};
|
|
3686
|
+
const headingMarks = (markup) => markup.startsWith("#") ? markup : markup.startsWith("=") ? "#" : "##";
|
|
3687
|
+
const isGroupShaped = (line) => GROUP_HEADER.test(line) || GROUP_HEADER_LOOKALIKE.test(line) || GROUP_SHAPED_HEADING.test(line);
|
|
3688
|
+
const isGroupZero = (line) => Number(GROUP_HEADER.exec(line)?.[1] ?? NaN) === 0;
|
|
3689
|
+
const writtenIdOf = (line) => TASK_LOOKALIKE_ID.exec(line)?.[1] ?? "";
|
|
3690
|
+
const taskLineDefect = (line, writtenId) => {
|
|
3691
|
+
if (!/^[ xX]$/.test(TASK_BOX.exec(line)?.[1] ?? "")) return "checkbox";
|
|
3692
|
+
if (!/^T\d+$/.test(writtenId)) return "id";
|
|
3693
|
+
return "shape";
|
|
3694
|
+
};
|
|
3695
|
+
const parseTaskBlock = (id, ticked, parts, refInCode) => {
|
|
2328
3696
|
const block = parts.join(" ");
|
|
2329
3697
|
const opener = refsOpener(parts, block);
|
|
2330
3698
|
const refs = opener === void 0 ? null : declarationAt(parts, block, opener);
|
|
@@ -2336,7 +3704,7 @@ const parseTaskBlock = (id, ticked, parts) => {
|
|
|
2336
3704
|
ticked,
|
|
2337
3705
|
requirementRefs: [...new Set(requirementRefs)]
|
|
2338
3706
|
},
|
|
2339
|
-
malformedRefs: refs === null && hasRefLookalike(parts)
|
|
3707
|
+
malformedRefs: refs === null && (refInCode || hasRefLookalike(parts))
|
|
2340
3708
|
};
|
|
2341
3709
|
};
|
|
2342
3710
|
const declarationAt = (parts, block, opener) => {
|
|
@@ -2418,9 +3786,11 @@ const parseRiskMarker = (line) => {
|
|
|
2418
3786
|
};
|
|
2419
3787
|
};
|
|
2420
3788
|
const parseRequirementIds = (text) => {
|
|
3789
|
+
const lines = text.split(LINE_BREAK).map((line) => line.replace(LINE_BOM, ""));
|
|
2421
3790
|
const ids = [];
|
|
2422
|
-
for (const
|
|
2423
|
-
|
|
3791
|
+
for (const list of parseMarkdownBlocks(lines)) for (const item of list.children) {
|
|
3792
|
+
if (item.type !== "list_item") continue;
|
|
3793
|
+
const id = REQUIREMENT_LINE.exec(lines[item.start] ?? "")?.[1];
|
|
2424
3794
|
if (id && !ids.includes(id)) ids.push(id);
|
|
2425
3795
|
}
|
|
2426
3796
|
return ids;
|
|
@@ -2514,7 +3884,7 @@ const runLedgerValidate = async (inputs) => {
|
|
|
2514
3884
|
});
|
|
2515
3885
|
return result;
|
|
2516
3886
|
}
|
|
2517
|
-
const spec =
|
|
3887
|
+
const spec = parsedFrom(tasksRel, await readFile(join(inputs.repoRoot, tasksRel), "utf8"), parseTasksSpec);
|
|
2518
3888
|
for (const index of spec.duplicateIndexes) problems.push({
|
|
2519
3889
|
kind: "duplicate-group-index",
|
|
2520
3890
|
message: `tasks.md declares "## Group ${index}" more than once. A step resolves to the first, so every later group of that number is never reviewed.`,
|
|
@@ -2524,14 +3894,32 @@ const runLedgerValidate = async (inputs) => {
|
|
|
2524
3894
|
kind: "orphan-task",
|
|
2525
3895
|
message: `${spec.orphanTaskIds.join(", ")} sit above the first group header, so they belong to no step and no review covers them.`
|
|
2526
3896
|
});
|
|
3897
|
+
for (const duplicate of spec.duplicateTaskIds) {
|
|
3898
|
+
const where = duplicate.groups.map((group) => group === null ? "no group" : `Group ${group}`).join(", ");
|
|
3899
|
+
problems.push({
|
|
3900
|
+
kind: "duplicate-task-id",
|
|
3901
|
+
message: `${duplicate.id} is written ${duplicate.groups.length} times in tasks.md (${where}). A task id names the task in the ledger's criteria (a task declaring no ref is its own criterion) and in every report, so the copies cannot be told apart and one can pass on the other's evidence. Give each copy its own id — the next free "T<n>" in tasks.md.`,
|
|
3902
|
+
subject: duplicate.id
|
|
3903
|
+
});
|
|
3904
|
+
}
|
|
2527
3905
|
for (const header of spec.malformedRiskHeaders) problems.push({
|
|
2528
3906
|
kind: "malformed-risk-marker",
|
|
2529
3907
|
message: `"${header}" carries a risk token that is not a parseable "[risk: <class>]" marker at end of line. Left as-is it reads as no declared risk.`,
|
|
2530
3908
|
subject: header
|
|
2531
3909
|
});
|
|
3910
|
+
for (const malformed of spec.malformedGroupHeaders) problems.push({
|
|
3911
|
+
kind: "malformed-group-header",
|
|
3912
|
+
message: malformedGroupHeaderMessage(malformed),
|
|
3913
|
+
subject: malformed.header
|
|
3914
|
+
});
|
|
3915
|
+
for (const malformed of spec.malformedTaskLines) problems.push({
|
|
3916
|
+
kind: "malformed-task-line",
|
|
3917
|
+
message: malformedTaskLineMessage(malformed),
|
|
3918
|
+
subject: malformed.line
|
|
3919
|
+
});
|
|
2532
3920
|
for (const id of spec.malformedTaskRefs) problems.push({
|
|
2533
3921
|
kind: "malformed-task-refs",
|
|
2534
|
-
message: `${id} carries a "(R<n>"-shaped ref list that is not a declaration: a declaration is one comma-separated "(R1, R2)" group that ends a line — no ranges ("R1–R3"), nothing after the closing paren on that line — at the end of the task's first line, right after the title's bold close, or the only group ending a continuation line. Left as-is it reads as a task that references no requirement, and its slice shrinks to itself.`,
|
|
3922
|
+
message: `${id} carries a "(R<n>"-shaped ref list that is not a declaration: a declaration is one comma-separated "(R1, R2)" group that ends a line — no ranges ("R1–R3"), nothing after the closing paren on that line — at the end of the task's first line, right after the title's bold close, or the only group ending a continuation line. If the group is a prose aside rather than the list, open it with "per" ("(per R42, …)"): a bare aside falls under the same rules as the declaration, so only the real list may be declaration-shaped. Left as-is it reads as a task that references no requirement, and its slice shrinks to itself.`,
|
|
2535
3923
|
subject: id
|
|
2536
3924
|
});
|
|
2537
3925
|
const groups = resolveGroups(spec.groups, address, problems);
|
|
@@ -2572,6 +3960,23 @@ const runLedgerValidate = async (inputs) => {
|
|
|
2572
3960
|
return result;
|
|
2573
3961
|
};
|
|
2574
3962
|
const reviewLedgerPath = (taskId, address) => join(TASKS_DIR, taskId, REVIEW_LEDGER_DIRNAME, address.kind === "full-pass" ? FULL_PASS_FILENAME : `step-${address.step}.json`);
|
|
3963
|
+
const malformedGroupHeaderMessage = (malformed) => {
|
|
3964
|
+
switch (malformed.defect) {
|
|
3965
|
+
case "zero": return `"${malformed.header}" — group numbers start at 1 and no --step addresses 0, so the tasks under it are read into no group. Renumber the groups from 1.`;
|
|
3966
|
+
case "lookalike": return `"${malformed.header}" looks like a group header but is not a top-level "## Group <n>" (two # at the start of the line, "Group" capitalized and in English whatever the spec's language, a bare number, outside any list or quote), so the tasks under it are read into no group — not even the one above. Write it as "## Group <n> — …" at the start of a line of its own; a line underlined with "---" or "===" is a heading too, so put a blank line above a rule that is not one; if it is not a group, reword it so it no longer reads "Group <word>", or "<word> <n>" with a "_(…)_" or "*(…)*" rationale or a "[risk: …]" tag.`;
|
|
3967
|
+
case "fenced": return `"${malformed.header}" is a group header inside a code block (a fence, four-space-indented code, raw HTML) — a quoted sample and a real header under a fence that never closed cannot be told apart, so it is not read as a group. If it is a sample, reword it so it no longer reads "Group <n>", or "<word> <n>" with a "_(…)_" or "*(…)*" rationale or a "[risk: …]" tag; if it is a group, close the fence above it or remove the indentation, and write it as "## Group <n> — …".`;
|
|
3968
|
+
}
|
|
3969
|
+
};
|
|
3970
|
+
const malformedTaskLineMessage = (malformed) => {
|
|
3971
|
+
const left = "so it is not read as a task — it leaves its group's slice and tick check, and its refs with it";
|
|
3972
|
+
switch (malformed.defect) {
|
|
3973
|
+
case "checkbox": return `"${malformed.line}" has a checkbox that is neither "[ ]" nor "[x]" (an in-progress "[-]", a padded "[ ]"), ${left}. Write "[ ]", or "[x]" once the task is green.`;
|
|
3974
|
+
case "id": return `"${malformed.line}" has a task id that is not a bare "T<n>" (a suffixed "T12b", a sub-numbered "T1.1"), ${left}. Give it the next free "T<n>" in tasks.md instead.`;
|
|
3975
|
+
case "shape": return `"${malformed.line}" is not written as a task line "- [ ] T<n>" (a bullet with no checkbox, a numbered "1. [ ]" item, an id in single "*" or "_", a task inside a ">" quote), ${left}. Write it "- [ ] T<n> — …" outside any quote (a bold "**T<n> — …**" title is fine); if it is a note, not a task, reword it so it does not open with "T<n>".`;
|
|
3976
|
+
case "nested": return `"${malformed.line}" is a task line nested inside ${malformed.parentId}'s list item — a sub-step of it and a task of its own cannot be told apart, ${left}. Outdent it to the task list if it is a task; drop its "T<n>" id if it is a sub-step.`;
|
|
3977
|
+
case "fenced": return `"${malformed.line}" is a task line inside a code block (a fence, four-space-indented code, raw HTML) — a quoted sample and a task under a fence that never closed cannot be told apart, ${left}. If it is a sample, drop its "T<n>" id; if it is a task, close the fence above it or remove the indentation.`;
|
|
3978
|
+
}
|
|
3979
|
+
};
|
|
2575
3980
|
const resolveGroups = (groups, address, problems) => {
|
|
2576
3981
|
if (address.kind === "full-pass") {
|
|
2577
3982
|
if (groups.length > 0) return groups;
|
|
@@ -2609,7 +4014,7 @@ const collectDanglingRefs = async (requirementsPath, sliceIds, problems) => {
|
|
|
2609
4014
|
});
|
|
2610
4015
|
return referenced;
|
|
2611
4016
|
}
|
|
2612
|
-
const declared =
|
|
4017
|
+
const declared = parsedFrom(requirementsPath, await readFile(requirementsPath, "utf8"), parseRequirementIds);
|
|
2613
4018
|
const dangling = referenced.filter((id) => !declared.includes(id));
|
|
2614
4019
|
for (const id of dangling) problems.push({
|
|
2615
4020
|
kind: "dangling-requirement-ref",
|
|
@@ -2710,19 +4115,7 @@ const checkMutantsFloor = (ledger, owedFiles, anchor, problems) => {
|
|
|
2710
4115
|
});
|
|
2711
4116
|
}
|
|
2712
4117
|
};
|
|
2713
|
-
const collectDeclaredGates = async (repoRoot) =>
|
|
2714
|
-
if (!await configEntryExists(join(repoRoot, "harness.config.yml"))) return;
|
|
2715
|
-
return (await readHarnessConfig(repoRoot)).gates;
|
|
2716
|
-
};
|
|
2717
|
-
const configEntryExists = async (path) => {
|
|
2718
|
-
try {
|
|
2719
|
-
await lstat(path);
|
|
2720
|
-
return true;
|
|
2721
|
-
} catch (error) {
|
|
2722
|
-
if (error.code === "ENOENT") return false;
|
|
2723
|
-
throw error;
|
|
2724
|
-
}
|
|
2725
|
-
};
|
|
4118
|
+
const collectDeclaredGates = async (repoRoot) => (await readOptionalHarnessConfig(repoRoot))?.gates;
|
|
2726
4119
|
const owedGates = (declared) => ({
|
|
2727
4120
|
basis: declared === void 0 ? "undeclared" : "config",
|
|
2728
4121
|
required: [...new Set(declared ?? [])],
|
|
@@ -2782,6 +4175,13 @@ const checkCriteria = (required, dangling, ledger, problems) => {
|
|
|
2782
4175
|
present
|
|
2783
4176
|
};
|
|
2784
4177
|
};
|
|
4178
|
+
const parsedFrom = (path, text, parse) => {
|
|
4179
|
+
try {
|
|
4180
|
+
return parse(text);
|
|
4181
|
+
} catch (error) {
|
|
4182
|
+
throw new Error(`${path}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
4183
|
+
}
|
|
4184
|
+
};
|
|
2785
4185
|
//#endregion
|
|
2786
4186
|
//#region src/spinoff/spinoff.constant.ts
|
|
2787
4187
|
const MANAGED_LABEL = "harness:managed";
|
|
@@ -2917,7 +4317,8 @@ const FIELD_AXIS = {
|
|
|
2917
4317
|
cycle_time_h: "metric",
|
|
2918
4318
|
review_rejections: "metric",
|
|
2919
4319
|
mode: "internal-enum",
|
|
2920
|
-
steps: "metric"
|
|
4320
|
+
steps: "metric",
|
|
4321
|
+
checkpoints: "metric"
|
|
2921
4322
|
},
|
|
2922
4323
|
review_rejected: {
|
|
2923
4324
|
...ENVELOPE_AXIS,
|
|
@@ -3549,7 +4950,7 @@ const inspectDevDependency = async (repoRoot) => {
|
|
|
3549
4950
|
const pkgPath = join(repoRoot, "package.json");
|
|
3550
4951
|
if (!await pathExists(pkgPath)) return "no-package-json";
|
|
3551
4952
|
try {
|
|
3552
|
-
const pkg = JSON.parse(await
|
|
4953
|
+
const pkg = JSON.parse(await readRegularFile(pkgPath));
|
|
3553
4954
|
return Object.hasOwn(pkg.dependencies ?? {}, "@lemoncode/lemony") || Object.hasOwn(pkg.devDependencies ?? {}, "@lemoncode/lemony") ? "declared" : "missing";
|
|
3554
4955
|
} catch {
|
|
3555
4956
|
return "no-package-json";
|
|
@@ -3708,93 +5109,390 @@ const renderRoleSkills = (selected, role) => {
|
|
|
3708
5109
|
return groups.length > 0 ? groups.join("\n\n") : "_None._";
|
|
3709
5110
|
};
|
|
3710
5111
|
//#endregion
|
|
3711
|
-
//#region src/
|
|
3712
|
-
const
|
|
5112
|
+
//#region src/merge/conflict-markers.constant.ts
|
|
5113
|
+
const CONFLICT_MARKER_CLIENT = "<<<<<<< client";
|
|
5114
|
+
const CONFLICT_MARKER_SEPARATOR = "=======";
|
|
5115
|
+
const CONFLICT_MARKER_VENDOR = ">>>>>>> vendor";
|
|
3713
5116
|
//#endregion
|
|
3714
|
-
//#region src/
|
|
3715
|
-
const
|
|
3716
|
-
|
|
3717
|
-
await access(path, constants.X_OK);
|
|
3718
|
-
return true;
|
|
3719
|
-
} catch {
|
|
3720
|
-
return false;
|
|
3721
|
-
}
|
|
3722
|
-
};
|
|
3723
|
-
const isCliOnPath = async (pathEnv = process.env.PATH, isExe = isExecutable) => {
|
|
3724
|
-
if (!pathEnv) return false;
|
|
3725
|
-
for (const dir of pathEnv.split(delimiter)) {
|
|
3726
|
-
if (dir === "") continue;
|
|
3727
|
-
if (await isExe(join(dir, "lemony"))) return true;
|
|
3728
|
-
}
|
|
3729
|
-
return false;
|
|
3730
|
-
};
|
|
5117
|
+
//#region src/merge/conflict-markers.ts
|
|
5118
|
+
const CONFLICT_FENCE = new RegExp(`^(?:${CONFLICT_MARKER_CLIENT}|${CONFLICT_MARKER_VENDOR})$`, "m");
|
|
5119
|
+
const hasConflictMarkers = (content) => CONFLICT_FENCE.test(content);
|
|
3731
5120
|
//#endregion
|
|
3732
|
-
//#region src/
|
|
3733
|
-
const
|
|
3734
|
-
|
|
3735
|
-
|
|
3736
|
-
|
|
3737
|
-
|
|
3738
|
-
];
|
|
3739
|
-
|
|
3740
|
-
const
|
|
3741
|
-
|
|
3742
|
-
|
|
3743
|
-
|
|
3744
|
-
|
|
3745
|
-
|
|
3746
|
-
|
|
3747
|
-
|
|
3748
|
-
|
|
3749
|
-
}
|
|
3750
|
-
} catch (error) {
|
|
3751
|
-
checks.push({
|
|
3752
|
-
name: "config",
|
|
3753
|
-
status: "fail",
|
|
3754
|
-
detail: error.message,
|
|
3755
|
-
remediation: `Fix ${HARNESS_CONFIG_FILENAME} (the error above names the key), then re-run \`lemony doctor\`.`
|
|
3756
|
-
});
|
|
5121
|
+
//#region src/merge/three-way-merge.ts
|
|
5122
|
+
const threeWayMerge = (base, client, vendor) => {
|
|
5123
|
+
const baseLines = base.split("\n");
|
|
5124
|
+
const clientLines = client.split("\n");
|
|
5125
|
+
const vendorLines = vendor.split("\n");
|
|
5126
|
+
const regions = diff3Regions(baseLines, clientLines, vendorLines);
|
|
5127
|
+
const out = [];
|
|
5128
|
+
let conflicted = false;
|
|
5129
|
+
for (const region of regions) {
|
|
5130
|
+
if (region.stable) {
|
|
5131
|
+
out.push(...region.lines);
|
|
5132
|
+
continue;
|
|
5133
|
+
}
|
|
5134
|
+
if (linesEqual(region.client, region.vendor)) out.push(...region.client);
|
|
5135
|
+
else {
|
|
5136
|
+
conflicted = true;
|
|
5137
|
+
out.push(CONFLICT_MARKER_CLIENT, ...region.client, CONFLICT_MARKER_SEPARATOR, ...region.vendor, CONFLICT_MARKER_VENDOR);
|
|
5138
|
+
}
|
|
3757
5139
|
}
|
|
3758
|
-
checks.push(await checkLabels(deps, config));
|
|
3759
|
-
checks.push(await checkHooks(repoRoot));
|
|
3760
|
-
checks.push(await checkGitUser(deps));
|
|
3761
|
-
checks.push(await checkGhAuth(deps, config));
|
|
3762
|
-
checks.push(checkVersionPin(deps, config));
|
|
3763
|
-
checks.push(await checkCliResolution(deps));
|
|
3764
|
-
checks.push(await checkCapabilities(deps, config));
|
|
3765
|
-
checks.push(await checkDesignToolDrift(deps));
|
|
3766
|
-
checks.push(await checkHookLib(deps));
|
|
3767
5140
|
return {
|
|
3768
|
-
|
|
3769
|
-
|
|
5141
|
+
merged: out.join("\n"),
|
|
5142
|
+
conflicted
|
|
3770
5143
|
};
|
|
3771
5144
|
};
|
|
3772
|
-
const
|
|
3773
|
-
|
|
3774
|
-
|
|
3775
|
-
|
|
3776
|
-
|
|
3777
|
-
|
|
3778
|
-
|
|
3779
|
-
|
|
3780
|
-
|
|
3781
|
-
|
|
3782
|
-
|
|
3783
|
-
|
|
3784
|
-
|
|
3785
|
-
|
|
3786
|
-
|
|
3787
|
-
|
|
3788
|
-
|
|
3789
|
-
|
|
3790
|
-
|
|
5145
|
+
const linesEqual = (a, b) => a.length === b.length && a.every((line, i) => line === b[i]);
|
|
5146
|
+
const diff3Regions = (base, client, vendor) => {
|
|
5147
|
+
const hunks = [...diffRegions(base, client).map((d) => ({
|
|
5148
|
+
...d,
|
|
5149
|
+
side: "client"
|
|
5150
|
+
})), ...diffRegions(base, vendor).map((d) => ({
|
|
5151
|
+
...d,
|
|
5152
|
+
side: "vendor"
|
|
5153
|
+
}))].toSorted((a, b) => a.baseStart - b.baseStart || a.baseEnd - b.baseEnd);
|
|
5154
|
+
const regions = [];
|
|
5155
|
+
let baseCursor = 0;
|
|
5156
|
+
let i = 0;
|
|
5157
|
+
const emitStable = (until) => {
|
|
5158
|
+
if (until > baseCursor) {
|
|
5159
|
+
regions.push({
|
|
5160
|
+
stable: true,
|
|
5161
|
+
lines: base.slice(baseCursor, until)
|
|
5162
|
+
});
|
|
5163
|
+
baseCursor = until;
|
|
5164
|
+
}
|
|
3791
5165
|
};
|
|
3792
|
-
|
|
3793
|
-
|
|
3794
|
-
|
|
3795
|
-
|
|
3796
|
-
|
|
3797
|
-
|
|
5166
|
+
while (i < hunks.length) {
|
|
5167
|
+
const first = hunks[i];
|
|
5168
|
+
if (first === void 0) break;
|
|
5169
|
+
let regionBaseStart = first.baseStart;
|
|
5170
|
+
let regionBaseEnd = first.baseEnd;
|
|
5171
|
+
const group = [first];
|
|
5172
|
+
i += 1;
|
|
5173
|
+
while (i < hunks.length) {
|
|
5174
|
+
const next = hunks[i];
|
|
5175
|
+
if (next === void 0 || next.baseStart > regionBaseEnd) break;
|
|
5176
|
+
regionBaseEnd = Math.max(regionBaseEnd, next.baseEnd);
|
|
5177
|
+
group.push(next);
|
|
5178
|
+
i += 1;
|
|
5179
|
+
}
|
|
5180
|
+
emitStable(regionBaseStart);
|
|
5181
|
+
const touchedByClient = group.some((h) => h.side === "client");
|
|
5182
|
+
const touchedByVendor = group.some((h) => h.side === "vendor");
|
|
5183
|
+
if (touchedByClient && touchedByVendor) regions.push({
|
|
5184
|
+
stable: false,
|
|
5185
|
+
client: projectSide(group, "client", client, regionBaseStart, regionBaseEnd),
|
|
5186
|
+
vendor: projectSide(group, "vendor", vendor, regionBaseStart, regionBaseEnd)
|
|
5187
|
+
});
|
|
5188
|
+
else {
|
|
5189
|
+
const side = touchedByClient ? "client" : "vendor";
|
|
5190
|
+
regions.push({
|
|
5191
|
+
stable: true,
|
|
5192
|
+
lines: projectSide(group, side, side === "client" ? client : vendor, regionBaseStart, regionBaseEnd)
|
|
5193
|
+
});
|
|
5194
|
+
}
|
|
5195
|
+
baseCursor = regionBaseEnd;
|
|
5196
|
+
}
|
|
5197
|
+
emitStable(base.length);
|
|
5198
|
+
return regions;
|
|
5199
|
+
};
|
|
5200
|
+
const projectSide = (group, side, sideLines, regionBaseStart, regionBaseEnd) => {
|
|
5201
|
+
const own = group.filter((h) => h.side === side);
|
|
5202
|
+
let sideStart = Number.POSITIVE_INFINITY;
|
|
5203
|
+
let sideEnd = Number.NEGATIVE_INFINITY;
|
|
5204
|
+
let baseStart = Number.POSITIVE_INFINITY;
|
|
5205
|
+
let baseEnd = Number.NEGATIVE_INFINITY;
|
|
5206
|
+
for (const h of own) {
|
|
5207
|
+
sideStart = Math.min(sideStart, h.sideStart);
|
|
5208
|
+
sideEnd = Math.max(sideEnd, h.sideEnd);
|
|
5209
|
+
baseStart = Math.min(baseStart, h.baseStart);
|
|
5210
|
+
baseEnd = Math.max(baseEnd, h.baseEnd);
|
|
5211
|
+
}
|
|
5212
|
+
const start = sideStart - (baseStart - regionBaseStart);
|
|
5213
|
+
const end = sideEnd + (regionBaseEnd - baseEnd);
|
|
5214
|
+
return sideLines.slice(start, end);
|
|
5215
|
+
};
|
|
5216
|
+
const diffRegions = (base, other) => {
|
|
5217
|
+
const matches = lcsMatches(base, other);
|
|
5218
|
+
const diffs = [];
|
|
5219
|
+
let baseTail = 0;
|
|
5220
|
+
let otherTail = 0;
|
|
5221
|
+
for (const [baseIdx, otherIdx] of matches) {
|
|
5222
|
+
if (baseIdx > baseTail || otherIdx > otherTail) diffs.push({
|
|
5223
|
+
baseStart: baseTail,
|
|
5224
|
+
baseEnd: baseIdx,
|
|
5225
|
+
sideStart: otherTail,
|
|
5226
|
+
sideEnd: otherIdx
|
|
5227
|
+
});
|
|
5228
|
+
baseTail = baseIdx + 1;
|
|
5229
|
+
otherTail = otherIdx + 1;
|
|
5230
|
+
}
|
|
5231
|
+
if (base.length > baseTail || other.length > otherTail) diffs.push({
|
|
5232
|
+
baseStart: baseTail,
|
|
5233
|
+
baseEnd: base.length,
|
|
5234
|
+
sideStart: otherTail,
|
|
5235
|
+
sideEnd: other.length
|
|
5236
|
+
});
|
|
5237
|
+
return diffs;
|
|
5238
|
+
};
|
|
5239
|
+
const lcsMatches = (base, other) => {
|
|
5240
|
+
const n = base.length;
|
|
5241
|
+
const m = other.length;
|
|
5242
|
+
const width = m + 1;
|
|
5243
|
+
const dp = new Int32Array((n + 1) * width);
|
|
5244
|
+
const at = (i, j) => dp[i * width + j] ?? 0;
|
|
5245
|
+
for (let i = n - 1; i >= 0; i -= 1) for (let j = m - 1; j >= 0; j -= 1) dp[i * width + j] = base[i] === other[j] ? at(i + 1, j + 1) + 1 : Math.max(at(i + 1, j), at(i, j + 1));
|
|
5246
|
+
const matches = [];
|
|
5247
|
+
let i = 0;
|
|
5248
|
+
let j = 0;
|
|
5249
|
+
while (i < n && j < m) if (base[i] === other[j]) {
|
|
5250
|
+
matches.push([i, j]);
|
|
5251
|
+
i += 1;
|
|
5252
|
+
j += 1;
|
|
5253
|
+
} else if (at(i + 1, j) >= at(i, j + 1)) i += 1;
|
|
5254
|
+
else j += 1;
|
|
5255
|
+
return matches;
|
|
5256
|
+
};
|
|
5257
|
+
//#endregion
|
|
5258
|
+
//#region src/doctor/agent-frontmatter.ts
|
|
5259
|
+
const CLAUDE_CODE_FRONTMATTER = /^---\s*\n([\s\S]*?)---\s*\n?/;
|
|
5260
|
+
const PLAIN_KEY_LINE = /^(?)([^\s#'"{}[\],&*!|>%@`].*?):(?:[ \t]|$)/;
|
|
5261
|
+
const DOUBLE_QUOTED_KEY_LINE = /^"((?:[^"\\]|\\.)*)"[ \t]*:(?:[ \t]|$)/;
|
|
5262
|
+
const SINGLE_QUOTED_KEY_LINE = /^'((?:[^']|'')*)'[ \t]*:(?:[ \t]|$)/;
|
|
5263
|
+
const topLevelKey = (line) => {
|
|
5264
|
+
const double = DOUBLE_QUOTED_KEY_LINE.exec(line)?.[1];
|
|
5265
|
+
if (double !== void 0) try {
|
|
5266
|
+
return JSON.parse(`"${double}"`);
|
|
5267
|
+
} catch {
|
|
5268
|
+
return double;
|
|
5269
|
+
}
|
|
5270
|
+
const single = SINGLE_QUOTED_KEY_LINE.exec(line)?.[1];
|
|
5271
|
+
if (single !== void 0) return single.replaceAll("''", "'");
|
|
5272
|
+
return PLAIN_KEY_LINE.exec(line)?.[1]?.replace(/[ \t]+$/, "") ?? null;
|
|
5273
|
+
};
|
|
5274
|
+
const inspectAgentFrontmatter = (content) => {
|
|
5275
|
+
if (hasConflictMarkers(content)) return { kind: "conflict-markers" };
|
|
5276
|
+
const block = CLAUDE_CODE_FRONTMATTER.exec(content.replace(/^\uFEFF/, ""))?.[1];
|
|
5277
|
+
if (block === void 0) return null;
|
|
5278
|
+
const seen = /* @__PURE__ */ new Set();
|
|
5279
|
+
const duplicated = /* @__PURE__ */ new Set();
|
|
5280
|
+
for (const line of block.split(/\r?\n/)) {
|
|
5281
|
+
const key = topLevelKey(line);
|
|
5282
|
+
if (key === null) continue;
|
|
5283
|
+
if (seen.has(key)) duplicated.add(key);
|
|
5284
|
+
seen.add(key);
|
|
5285
|
+
}
|
|
5286
|
+
return duplicated.size > 0 ? {
|
|
5287
|
+
kind: "duplicate-keys",
|
|
5288
|
+
keys: [...duplicated]
|
|
5289
|
+
} : null;
|
|
5290
|
+
};
|
|
5291
|
+
//#endregion
|
|
5292
|
+
//#region src/doctor/cli-bin.constant.ts
|
|
5293
|
+
const LEMONY_BIN = "lemony";
|
|
5294
|
+
//#endregion
|
|
5295
|
+
//#region src/doctor/hook-lib.constant.ts
|
|
5296
|
+
const HOOK_LIB_CONSEQUENCES = {
|
|
5297
|
+
"lemony.sh": {
|
|
5298
|
+
consequence: "agent and hook calls through the `lemony` CLI launcher fail (event emits, spinoff, discovery resume)",
|
|
5299
|
+
sourced: false
|
|
5300
|
+
},
|
|
5301
|
+
"live-branch.sh": {
|
|
5302
|
+
consequence: "init.sh and session-close.sh read the task branch from HEAD only (a rebase in progress reads as no branch)",
|
|
5303
|
+
sourced: true
|
|
5304
|
+
},
|
|
5305
|
+
"merge-pr.sh": {
|
|
5306
|
+
consequence: "agent-executed merge paths (merge gate, closeout, hotfix) fail at runtime",
|
|
5307
|
+
sourced: false
|
|
5308
|
+
},
|
|
5309
|
+
"playbook-scan.sh": {
|
|
5310
|
+
consequence: "require-playbook.sh and suggest-playbook.sh error out, so playbook enforcement is off",
|
|
5311
|
+
sourced: true
|
|
5312
|
+
},
|
|
5313
|
+
"transcript-grep.sh": {
|
|
5314
|
+
consequence: "require-playbook.sh blocks matching writes even after the playbook was read",
|
|
5315
|
+
sourced: true
|
|
5316
|
+
}
|
|
5317
|
+
};
|
|
5318
|
+
const HOOK_LIB_UNKNOWN_CONSEQUENCE = "the hooks and agents that use it fail at runtime";
|
|
5319
|
+
const HOOK_LIB_SOURCED_EXEC_BIT_CONSEQUENCE = "the hooks `source` it, which needs only the read bit, so nothing breaks yet";
|
|
5320
|
+
//#endregion
|
|
5321
|
+
//#region src/doctor/resolve-cli.ts
|
|
5322
|
+
const isExecutable = async (path) => {
|
|
5323
|
+
try {
|
|
5324
|
+
await access(path, constants.X_OK);
|
|
5325
|
+
return true;
|
|
5326
|
+
} catch {
|
|
5327
|
+
return false;
|
|
5328
|
+
}
|
|
5329
|
+
};
|
|
5330
|
+
const isCliOnPath = async (pathEnv = process.env.PATH, isExe = isExecutable) => {
|
|
5331
|
+
if (!pathEnv) return false;
|
|
5332
|
+
for (const dir of pathEnv.split(delimiter)) {
|
|
5333
|
+
if (dir === "") continue;
|
|
5334
|
+
if (await isExe(join(dir, "lemony"))) return true;
|
|
5335
|
+
}
|
|
5336
|
+
return false;
|
|
5337
|
+
};
|
|
5338
|
+
//#endregion
|
|
5339
|
+
//#region src/doctor/doctor.ts
|
|
5340
|
+
const VENDOR_HOOK_COMMANDS = [
|
|
5341
|
+
".claude/hooks/init.sh",
|
|
5342
|
+
".claude/hooks/session-close.sh",
|
|
5343
|
+
".claude/hooks/require-playbook.sh",
|
|
5344
|
+
".claude/hooks/suggest-playbook.sh"
|
|
5345
|
+
];
|
|
5346
|
+
const runDoctor = async (deps) => {
|
|
5347
|
+
const { repoRoot } = deps;
|
|
5348
|
+
const checks = [];
|
|
5349
|
+
let config = null;
|
|
5350
|
+
let configSkip = "";
|
|
5351
|
+
let configState = "loaded";
|
|
5352
|
+
try {
|
|
5353
|
+
config = await readHarnessConfig(repoRoot);
|
|
5354
|
+
checks.push({
|
|
5355
|
+
name: "config",
|
|
5356
|
+
status: "ok",
|
|
5357
|
+
detail: `${HARNESS_CONFIG_FILENAME} is valid.`
|
|
5358
|
+
});
|
|
5359
|
+
} catch (error) {
|
|
5360
|
+
const fault = await configLoadFault(repoRoot, error);
|
|
5361
|
+
configSkip = fault.skip;
|
|
5362
|
+
configState = fault.state;
|
|
5363
|
+
checks.push({
|
|
5364
|
+
name: "config",
|
|
5365
|
+
status: "fail",
|
|
5366
|
+
detail: error.message,
|
|
5367
|
+
remediation: fault.remediation
|
|
5368
|
+
});
|
|
5369
|
+
}
|
|
5370
|
+
checks.push(await checkLabels(deps, config, configSkip));
|
|
5371
|
+
const restore = await planRestore(deps, configState);
|
|
5372
|
+
const configCheck = checks.find((check) => check.name === "config");
|
|
5373
|
+
if (configState === "absent" && configCheck !== void 0) configCheck.remediation = `${capitalize(verbStep(restore))}, then re-run \`lemony doctor\`.`;
|
|
5374
|
+
checks.push(await checkHooks(repoRoot, restore));
|
|
5375
|
+
checks.push(await checkGitUser(deps));
|
|
5376
|
+
checks.push(await checkGhAuth(deps, config));
|
|
5377
|
+
checks.push(checkVersionPin(deps, config, configSkip));
|
|
5378
|
+
checks.push(await checkCliResolution(deps));
|
|
5379
|
+
checks.push(await checkCapabilities(deps, config, configSkip));
|
|
5380
|
+
checks.push(await checkDesignToolDrift(deps, config, configSkip));
|
|
5381
|
+
checks.push(await checkHookLib(deps, restore));
|
|
5382
|
+
checks.push(await checkAgentFrontmatter(repoRoot));
|
|
5383
|
+
return {
|
|
5384
|
+
checks,
|
|
5385
|
+
ok: checks.every((check) => check.status !== "fail")
|
|
5386
|
+
};
|
|
5387
|
+
};
|
|
5388
|
+
const configLoadFault = async (repoRoot, loadError) => {
|
|
5389
|
+
const configPath = join(repoRoot, HARNESS_CONFIG_FILENAME);
|
|
5390
|
+
const readError = await readRegularFile(configPath).then(() => void 0, (error) => error);
|
|
5391
|
+
switch (await classifyConfigLinkFault(configPath, readError)) {
|
|
5392
|
+
case "target-missing": return {
|
|
5393
|
+
skip: `Skipped — ${HARNESS_CONFIG_FILENAME} is a symlink whose target is missing.`,
|
|
5394
|
+
remediation: "Fix the symlink as the error above says, then re-run `lemony doctor`.",
|
|
5395
|
+
state: "broken"
|
|
5396
|
+
};
|
|
5397
|
+
case "loop": return {
|
|
5398
|
+
skip: `Skipped — ${HARNESS_CONFIG_FILENAME} is a symlink that loops or runs through too many links.`,
|
|
5399
|
+
remediation: "Fix the symlink as the error above says, then re-run `lemony doctor`.",
|
|
5400
|
+
state: "broken"
|
|
5401
|
+
};
|
|
5402
|
+
case "target-unreadable": return {
|
|
5403
|
+
skip: `Skipped — ${HARNESS_CONFIG_FILENAME} is a symlink whose target cannot be read.`,
|
|
5404
|
+
remediation: "Fix the permissions or the symlink as the error above says, then re-run `lemony doctor`.",
|
|
5405
|
+
state: "broken"
|
|
5406
|
+
};
|
|
5407
|
+
case void 0:
|
|
5408
|
+
}
|
|
5409
|
+
if (readError?.code === "ENOENT") {
|
|
5410
|
+
if (await hasInstallBaseline(repoRoot)) return {
|
|
5411
|
+
skip: `Skipped — no ${HARNESS_CONFIG_FILENAME}.`,
|
|
5412
|
+
remediation: "Do what the error above says for your case, then re-run `lemony doctor`.",
|
|
5413
|
+
state: "broken"
|
|
5414
|
+
};
|
|
5415
|
+
return {
|
|
5416
|
+
skip: `Skipped — no ${HARNESS_CONFIG_FILENAME}.`,
|
|
5417
|
+
remediation: "Run `lemony install`, then re-run `lemony doctor`.",
|
|
5418
|
+
state: "absent"
|
|
5419
|
+
};
|
|
5420
|
+
}
|
|
5421
|
+
if (readError !== void 0) return {
|
|
5422
|
+
skip: `Skipped — ${HARNESS_CONFIG_FILENAME} could not be read.`,
|
|
5423
|
+
remediation: "Fix what the error above names, then re-run `lemony doctor`.",
|
|
5424
|
+
state: "broken"
|
|
5425
|
+
};
|
|
5426
|
+
if (loadError.message === CONFIG_MALFORMED_MESSAGE) return {
|
|
5427
|
+
skip: "Skipped — config did not validate.",
|
|
5428
|
+
remediation: `Make ${HARNESS_CONFIG_FILENAME} a YAML mapping of config keys (fix the syntax, or restore its content), then re-run \`lemony doctor\`.`,
|
|
5429
|
+
state: "broken"
|
|
5430
|
+
};
|
|
5431
|
+
return {
|
|
5432
|
+
skip: "Skipped — config did not validate.",
|
|
5433
|
+
remediation: `Fix ${HARNESS_CONFIG_FILENAME} (the error above names the key), then re-run \`lemony doctor\`.`,
|
|
5434
|
+
state: "broken"
|
|
5435
|
+
};
|
|
5436
|
+
};
|
|
5437
|
+
const FIX_CONFIG_FIRST = `Fix ${HARNESS_CONFIG_FILENAME} first (the config check says how), then re-run \`lemony doctor\``;
|
|
5438
|
+
const restoreVerb = (state) => state === "absent" ? "install" : "repair";
|
|
5439
|
+
const planRestore = async (deps, state) => {
|
|
5440
|
+
const helpers = await listFiles(join(deps.vendorRoot, "hooks", "lib")).then((files) => files.filter((rel) => rel.endsWith(".sh")), () => []);
|
|
5441
|
+
const leaves = [
|
|
5442
|
+
SETTINGS_FILE,
|
|
5443
|
+
".gitignore",
|
|
5444
|
+
...helpers.map((helper) => join(HOOKS_DIR, "lib", helper))
|
|
5445
|
+
];
|
|
5446
|
+
const links = await findSymlinkTraversals(deps.repoRoot, leaves);
|
|
5447
|
+
const copyable = await Promise.all(links.map((rel) => stat(join(deps.repoRoot, rel)).then((target) => !leaves.includes(rel) || target.isFile(), (error) => !NOWHERE_CODES.has(error.code ?? ""))));
|
|
5448
|
+
return {
|
|
5449
|
+
state,
|
|
5450
|
+
linked: links.filter((_, index) => copyable[index]),
|
|
5451
|
+
dangling: links.filter((_, index) => !copyable[index])
|
|
5452
|
+
};
|
|
5453
|
+
};
|
|
5454
|
+
const NOWHERE_CODES = /* @__PURE__ */ new Set([
|
|
5455
|
+
"ENOENT",
|
|
5456
|
+
"ENOTDIR",
|
|
5457
|
+
"ELOOP"
|
|
5458
|
+
]);
|
|
5459
|
+
const verbStep = (restore, removed = []) => {
|
|
5460
|
+
const verb = restoreVerb(restore.state);
|
|
5461
|
+
const kept = (rels) => rels.filter((rel) => !removed.includes(rel));
|
|
5462
|
+
const linked = kept(restore.linked);
|
|
5463
|
+
const dangling = kept(restore.dangling);
|
|
5464
|
+
const steps = [];
|
|
5465
|
+
if (dangling.length > 0) steps.push(dangling.length === 1 ? `remove the symlink ${dangling[0]}, which leads to no file` : `remove the symlinks ${dangling.join(" ")}, which lead to no file`);
|
|
5466
|
+
if (linked.length > 0) steps.push(linked.length === 1 ? `replace the symlink ${linked[0]} with a regular copy of its target` : `replace the symlinks ${linked.join(" ")} with regular copies of their targets`);
|
|
5467
|
+
if (steps.length === 0) return `run \`lemony ${verb}\``;
|
|
5468
|
+
return `${steps.join(", ")} (\`${verb}\` refuses to write through a symlink), then run \`lemony ${verb}\``;
|
|
5469
|
+
};
|
|
5470
|
+
const checkLabels = async (deps, config, configSkip) => {
|
|
5471
|
+
const name = "labels";
|
|
5472
|
+
if (!config) return {
|
|
5473
|
+
name,
|
|
5474
|
+
status: "warn",
|
|
5475
|
+
detail: configSkip
|
|
5476
|
+
};
|
|
5477
|
+
const slug = config.task_storage.repo;
|
|
5478
|
+
if (slug === "OWNER/REPO") return {
|
|
5479
|
+
name,
|
|
5480
|
+
status: "warn",
|
|
5481
|
+
detail: `task_storage.repo is the ${TASK_STORAGE_REPO_PLACEHOLDER} placeholder — set a real slug to manage labels.`,
|
|
5482
|
+
remediation: `Set task_storage.repo in ${HARNESS_CONFIG_FILENAME}, then run \`lemony repair\`.`
|
|
5483
|
+
};
|
|
5484
|
+
const list = await createTaskTrackerProvider(config.task_storage.type, deps.runCommand).listLabels(slug);
|
|
5485
|
+
if (list.code !== 0) return {
|
|
5486
|
+
name,
|
|
5487
|
+
status: "warn",
|
|
5488
|
+
detail: `Could not list labels via \`gh\` (exit ${list.code}). Is gh installed and authenticated?`
|
|
5489
|
+
};
|
|
5490
|
+
let existing;
|
|
5491
|
+
try {
|
|
5492
|
+
existing = JSON.parse(list.stdout || "[]");
|
|
5493
|
+
} catch {
|
|
5494
|
+
return {
|
|
5495
|
+
name,
|
|
3798
5496
|
status: "warn",
|
|
3799
5497
|
detail: "Could not parse `gh label list` output."
|
|
3800
5498
|
};
|
|
@@ -3815,18 +5513,32 @@ const checkLabels = async (deps, config) => {
|
|
|
3815
5513
|
remediation: "Run `lemony repair` to reconcile the labels."
|
|
3816
5514
|
};
|
|
3817
5515
|
};
|
|
3818
|
-
const checkHooks = async (repoRoot) => {
|
|
5516
|
+
const checkHooks = async (repoRoot, restore) => {
|
|
3819
5517
|
const name = "hooks";
|
|
5518
|
+
const remedy = (steps) => restore.state === "broken" ? `${FIX_CONFIG_FIRST}.` : `${capitalize(steps)}.`;
|
|
3820
5519
|
const settingsPath = join(repoRoot, SETTINGS_FILE);
|
|
3821
5520
|
let raw;
|
|
3822
5521
|
try {
|
|
3823
|
-
raw = await
|
|
3824
|
-
} catch {
|
|
3825
|
-
|
|
5522
|
+
raw = await readRegularFile(settingsPath);
|
|
5523
|
+
} catch (error) {
|
|
5524
|
+
const code = error.code;
|
|
5525
|
+
if (code === "ENOENT" && await lstat(settingsPath).then(() => true, () => false)) return {
|
|
5526
|
+
name,
|
|
5527
|
+
status: "fail",
|
|
5528
|
+
detail: ".claude/settings.json is a symlink whose target is missing.",
|
|
5529
|
+
remediation: remedy(`remove the symlink, then ${verbStep(restore, [SETTINGS_FILE])}`)
|
|
5530
|
+
};
|
|
5531
|
+
if (code === "ENOENT") return {
|
|
3826
5532
|
name,
|
|
3827
5533
|
status: "fail",
|
|
3828
5534
|
detail: ".claude/settings.json not found.",
|
|
3829
|
-
remediation:
|
|
5535
|
+
remediation: remedy(verbStep(restore))
|
|
5536
|
+
};
|
|
5537
|
+
return {
|
|
5538
|
+
name,
|
|
5539
|
+
status: "fail",
|
|
5540
|
+
detail: `Cannot read .claude/settings.json: ${code === void 0 ? error.message : `${code}.`}`,
|
|
5541
|
+
remediation: remedy(`replace it with a regular, readable file (or remove it), then ${verbStep(restore, [SETTINGS_FILE])}`)
|
|
3830
5542
|
};
|
|
3831
5543
|
}
|
|
3832
5544
|
let commands;
|
|
@@ -3838,7 +5550,7 @@ const checkHooks = async (repoRoot) => {
|
|
|
3838
5550
|
name,
|
|
3839
5551
|
status: "fail",
|
|
3840
5552
|
detail: ".claude/settings.json is not valid JSON.",
|
|
3841
|
-
remediation:
|
|
5553
|
+
remediation: remedy(`fix the JSON or delete the file, then ${verbStep(restore)}`)
|
|
3842
5554
|
};
|
|
3843
5555
|
}
|
|
3844
5556
|
const missing = VENDOR_HOOK_COMMANDS.filter((want) => !commands.some((cmd) => cmd === want || cmd.endsWith(`/${want}`)));
|
|
@@ -3851,7 +5563,7 @@ const checkHooks = async (repoRoot) => {
|
|
|
3851
5563
|
name,
|
|
3852
5564
|
status: "warn",
|
|
3853
5565
|
detail: `Missing vendor hook wiring: ${missing.join(", ")}.`,
|
|
3854
|
-
remediation:
|
|
5566
|
+
remediation: remedy(verbStep(restore))
|
|
3855
5567
|
};
|
|
3856
5568
|
};
|
|
3857
5569
|
const checkGitUser = async (deps) => {
|
|
@@ -3888,12 +5600,12 @@ const checkGhAuth = async (deps, config) => {
|
|
|
3888
5600
|
remediation: "Run `gh auth login`."
|
|
3889
5601
|
};
|
|
3890
5602
|
};
|
|
3891
|
-
const checkVersionPin = (deps, config) => {
|
|
5603
|
+
const checkVersionPin = (deps, config, configSkip) => {
|
|
3892
5604
|
const name = "version-pin";
|
|
3893
5605
|
if (!config) return {
|
|
3894
5606
|
name,
|
|
3895
5607
|
status: "warn",
|
|
3896
|
-
detail:
|
|
5608
|
+
detail: configSkip
|
|
3897
5609
|
};
|
|
3898
5610
|
if (config.vendor_version === deps.installedVersion) return {
|
|
3899
5611
|
name,
|
|
@@ -3950,12 +5662,12 @@ const checkCliResolution = async (deps) => {
|
|
|
3950
5662
|
remediation: `Install \`${LEMONY_BIN}\` globally and keep it on PATH (this repo has no package.json to pin a devDependency).`
|
|
3951
5663
|
};
|
|
3952
5664
|
};
|
|
3953
|
-
const checkCapabilities = async (deps, config) => {
|
|
5665
|
+
const checkCapabilities = async (deps, config, configSkip) => {
|
|
3954
5666
|
const name = "capabilities";
|
|
3955
5667
|
if (!config) return {
|
|
3956
5668
|
name,
|
|
3957
5669
|
status: "warn",
|
|
3958
|
-
detail:
|
|
5670
|
+
detail: configSkip
|
|
3959
5671
|
};
|
|
3960
5672
|
let latent;
|
|
3961
5673
|
try {
|
|
@@ -3982,29 +5694,52 @@ const checkCapabilities = async (deps, config) => {
|
|
|
3982
5694
|
remediation: "Run /add-capability in Claude Code to activate one — the Architect authors the artifact, then `repair` installs the skill (opt-in, never imposed)."
|
|
3983
5695
|
};
|
|
3984
5696
|
};
|
|
3985
|
-
const checkDesignToolDrift = async (deps) => {
|
|
5697
|
+
const checkDesignToolDrift = async (deps, config, configSkip) => {
|
|
3986
5698
|
const name = "design-tool-drift";
|
|
3987
|
-
|
|
3988
|
-
|
|
5699
|
+
if (!config) return {
|
|
5700
|
+
name,
|
|
5701
|
+
status: "warn",
|
|
5702
|
+
detail: configSkip
|
|
5703
|
+
};
|
|
5704
|
+
const tokenFile = resolveTokenFile(config.design_tokens.file);
|
|
5705
|
+
let raw;
|
|
5706
|
+
try {
|
|
5707
|
+
raw = await readOptionalTokenFile(deps.repoRoot, tokenFile);
|
|
5708
|
+
} catch (error) {
|
|
5709
|
+
return {
|
|
5710
|
+
name,
|
|
5711
|
+
status: "fail",
|
|
5712
|
+
detail: error.message,
|
|
5713
|
+
remediation: "Run `lemony design-tokens validate` to find the problem."
|
|
5714
|
+
};
|
|
5715
|
+
}
|
|
5716
|
+
if (raw === void 0) return {
|
|
3989
5717
|
name,
|
|
3990
5718
|
status: "info",
|
|
3991
|
-
detail: `No ${
|
|
5719
|
+
detail: `No ${tokenFile} — token sync not in use.`
|
|
3992
5720
|
};
|
|
3993
5721
|
let parsed;
|
|
3994
5722
|
try {
|
|
3995
|
-
parsed = JSON.parse(
|
|
5723
|
+
parsed = JSON.parse(raw);
|
|
3996
5724
|
} catch (error) {
|
|
3997
5725
|
return {
|
|
3998
5726
|
name,
|
|
3999
|
-
status: "
|
|
4000
|
-
detail:
|
|
5727
|
+
status: "fail",
|
|
5728
|
+
detail: `${tokenFile} is not valid JSON: ${error.message}`,
|
|
4001
5729
|
remediation: "Run `lemony design-tokens validate` to find the problem."
|
|
4002
5730
|
};
|
|
4003
5731
|
}
|
|
4004
5732
|
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return {
|
|
4005
5733
|
name,
|
|
4006
|
-
status: "
|
|
4007
|
-
detail: `${
|
|
5734
|
+
status: "fail",
|
|
5735
|
+
detail: `${tokenFile} is not a JSON object.`,
|
|
5736
|
+
remediation: "Run `lemony design-tokens validate` to find the problem."
|
|
5737
|
+
};
|
|
5738
|
+
const invalid = summarizeTokenFileViolations(raw, tokenFile);
|
|
5739
|
+
if (invalid !== void 0) return {
|
|
5740
|
+
name,
|
|
5741
|
+
status: "fail",
|
|
5742
|
+
detail: `${tokenFile} fails validation: ${invalid}`,
|
|
4008
5743
|
remediation: "Run `lemony design-tokens validate` to find the problem."
|
|
4009
5744
|
};
|
|
4010
5745
|
const report = driftReport(parsed);
|
|
@@ -4033,7 +5768,7 @@ const checkDesignToolDrift = async (deps) => {
|
|
|
4033
5768
|
};
|
|
4034
5769
|
}
|
|
4035
5770
|
};
|
|
4036
|
-
const checkHookLib = async (deps) => {
|
|
5771
|
+
const checkHookLib = async (deps, restore) => {
|
|
4037
5772
|
const name = "hook-lib";
|
|
4038
5773
|
let helpers;
|
|
4039
5774
|
try {
|
|
@@ -4083,7 +5818,8 @@ const checkHookLib = async (deps) => {
|
|
|
4083
5818
|
if (!await isExecutable(installed)) return {
|
|
4084
5819
|
helper,
|
|
4085
5820
|
kind: "mode",
|
|
4086
|
-
description: `${helper} is not executable
|
|
5821
|
+
description: `${helper} is not executable`,
|
|
5822
|
+
execBitOnly: await isReadable(installed)
|
|
4087
5823
|
};
|
|
4088
5824
|
if (!await isReadable(installed)) return {
|
|
4089
5825
|
helper,
|
|
@@ -4099,15 +5835,104 @@ const checkHookLib = async (deps) => {
|
|
|
4099
5835
|
};
|
|
4100
5836
|
const pathsOf = (kind) => problems.filter((problem) => problem.kind === kind).map((problem) => `${HOOKS_DIR}/lib/${problem.helper}`).join(" ");
|
|
4101
5837
|
const remediations = [];
|
|
4102
|
-
|
|
4103
|
-
|
|
5838
|
+
const verb = restoreVerb(restore.state);
|
|
5839
|
+
const missing = problems.some((problem) => problem.kind === "missing");
|
|
5840
|
+
const obstructed = problems.some((problem) => problem.kind === "obstructed");
|
|
5841
|
+
const obstructedPaths = problems.filter((problem) => problem.kind === "obstructed").map((problem) => join(HOOKS_DIR, "lib", problem.helper));
|
|
5842
|
+
if ((missing || obstructed) && restore.state === "broken") remediations.push(FIX_CONFIG_FIRST);
|
|
5843
|
+
else if (obstructed) remediations.push(`remove ${pathsOf("obstructed")} (\`${verb}\` cannot fix a non-file entry), then ${verbStep(restore, obstructedPaths)}${missing ? " to restore them and the missing helpers" : ""}`);
|
|
5844
|
+
else if (missing) remediations.push(`${verbStep(restore)} to restore the missing helpers`);
|
|
4104
5845
|
if (problems.some((problem) => problem.kind === "mode")) remediations.push(`run \`chmod +rx ${pathsOf("mode")}\` to restore the read/exec bits (\`repair\` only rewrites changed content, so it cannot)`);
|
|
4105
5846
|
if (problems.some((problem) => problem.kind === "unreadable")) remediations.push(`fix permissions so ${HOOKS_DIR}/lib and its entries are readable, then re-run \`lemony doctor\``);
|
|
4106
5847
|
return {
|
|
4107
5848
|
name,
|
|
4108
5849
|
status: "warn",
|
|
4109
|
-
detail: `${HOOKS_DIR}/lib: ${problems.map((problem) => problem.description
|
|
4110
|
-
remediation: `${remediations.join("; ")}.`
|
|
5850
|
+
detail: `${HOOKS_DIR}/lib: ${problems.map((problem) => `${problem.description} — ${hookLibConsequence(problem.helper, "execBitOnly" in problem && problem.execBitOnly === true)}`).join("; ")}.`,
|
|
5851
|
+
remediation: `${capitalize(remediations.join("; "))}.`
|
|
5852
|
+
};
|
|
5853
|
+
};
|
|
5854
|
+
const hookLibConsequence = (helper, execBitOnly) => {
|
|
5855
|
+
const entry = HOOK_LIB_CONSEQUENCES[helper];
|
|
5856
|
+
if (entry === void 0) return HOOK_LIB_UNKNOWN_CONSEQUENCE;
|
|
5857
|
+
return entry.sourced && execBitOnly ? HOOK_LIB_SOURCED_EXEC_BIT_CONSEQUENCE : entry.consequence;
|
|
5858
|
+
};
|
|
5859
|
+
const checkAgentFrontmatter = async (repoRoot) => {
|
|
5860
|
+
const name = "agent-frontmatter";
|
|
5861
|
+
let files;
|
|
5862
|
+
try {
|
|
5863
|
+
files = (await readdir(join(repoRoot, AGENTS_DIR), { withFileTypes: true })).filter((entry) => !entry.isDirectory() && entry.name.endsWith(".md")).map((entry) => entry.name).toSorted();
|
|
5864
|
+
} catch (error) {
|
|
5865
|
+
const code = error.code;
|
|
5866
|
+
if (code === "ENOENT") return {
|
|
5867
|
+
name,
|
|
5868
|
+
status: "ok",
|
|
5869
|
+
detail: `No ${AGENTS_DIR} directory — no agent file to check.`
|
|
5870
|
+
};
|
|
5871
|
+
return {
|
|
5872
|
+
name,
|
|
5873
|
+
status: "warn",
|
|
5874
|
+
detail: `Cannot read ${AGENTS_DIR}: ${code ?? error.message}.`,
|
|
5875
|
+
remediation: `Make ${AGENTS_DIR} a readable directory (fix its permissions, or remove what stands in its place), then re-run \`lemony doctor\`.`
|
|
5876
|
+
};
|
|
5877
|
+
}
|
|
5878
|
+
const checked = (await Promise.all(files.map(async (file) => {
|
|
5879
|
+
const path = `${AGENTS_DIR}/${file}`;
|
|
5880
|
+
try {
|
|
5881
|
+
const problem = inspectAgentFrontmatter(await readRegularFile(join(repoRoot, AGENTS_DIR, file)));
|
|
5882
|
+
return problem === null ? null : {
|
|
5883
|
+
path,
|
|
5884
|
+
problem
|
|
5885
|
+
};
|
|
5886
|
+
} catch (error) {
|
|
5887
|
+
const code = error.code;
|
|
5888
|
+
if (await stat(join(repoRoot, AGENTS_DIR, file)).then((target) => target.isDirectory(), () => false)) return "skipped";
|
|
5889
|
+
return {
|
|
5890
|
+
path,
|
|
5891
|
+
problem: {
|
|
5892
|
+
kind: "unreadable",
|
|
5893
|
+
reason: error.message === "the entry is not a regular file (a FIFO, a socket or a device), so it was not read." ? "not a regular file" : code ?? error.message
|
|
5894
|
+
}
|
|
5895
|
+
};
|
|
5896
|
+
}
|
|
5897
|
+
}))).filter((finding) => finding !== "skipped");
|
|
5898
|
+
const conflicted = [];
|
|
5899
|
+
const duplicated = [];
|
|
5900
|
+
const unreadable = [];
|
|
5901
|
+
const descriptions = [];
|
|
5902
|
+
for (const finding of checked) {
|
|
5903
|
+
if (finding === null) continue;
|
|
5904
|
+
const { path, problem } = finding;
|
|
5905
|
+
if (problem.kind === "conflict-markers") {
|
|
5906
|
+
conflicted.push(path);
|
|
5907
|
+
descriptions.push(`${path} has unresolved conflict markers`);
|
|
5908
|
+
} else if (problem.kind === "duplicate-keys") {
|
|
5909
|
+
duplicated.push(path);
|
|
5910
|
+
descriptions.push(`${path} declares ${problem.keys.map((key) => `\`${key}\``).join(", ")} more than once in its frontmatter`);
|
|
5911
|
+
} else {
|
|
5912
|
+
unreadable.push(path);
|
|
5913
|
+
descriptions.push(`${path} is unreadable (${problem.reason})`);
|
|
5914
|
+
}
|
|
5915
|
+
}
|
|
5916
|
+
if (checked.length === 0) return {
|
|
5917
|
+
name,
|
|
5918
|
+
status: "ok",
|
|
5919
|
+
detail: `No agent file in ${AGENTS_DIR} to check.`
|
|
5920
|
+
};
|
|
5921
|
+
if (descriptions.length === 0) return {
|
|
5922
|
+
name,
|
|
5923
|
+
status: "ok",
|
|
5924
|
+
detail: `All ${checked.length} agent files in ${AGENTS_DIR} are free of conflict markers and duplicate frontmatter keys.`
|
|
5925
|
+
};
|
|
5926
|
+
const remediations = [];
|
|
5927
|
+
if (conflicted.length > 0) remediations.push(`resolve the conflict in ${conflicted.join(" ")} by hand — keep the side you want and delete the \`<<<<<<< client\` / \`=======\` / \`>>>>>>> vendor\` lines (\`update\` and \`repair\` refuse to run while any remain)`);
|
|
5928
|
+
if (duplicated.length > 0) remediations.push(`edit ${duplicated.join(" ")} so each frontmatter key is declared once (merge the values if both matter — only one of them applies)`);
|
|
5929
|
+
if (unreadable.length > 0) remediations.push(`make ${unreadable.join(" ")} ${unreadable.length === 1 ? "a readable file" : "readable files"} (fix the permissions, repoint a broken symlink, or remove what is not a file), then re-run \`lemony doctor\``);
|
|
5930
|
+
const remediation = remediations.join("; ");
|
|
5931
|
+
return {
|
|
5932
|
+
name,
|
|
5933
|
+
status: conflicted.length + duplicated.length > 0 ? "fail" : "warn",
|
|
5934
|
+
detail: `${descriptions.join("; ")}.`,
|
|
5935
|
+
remediation: `${remediation.charAt(0).toUpperCase()}${remediation.slice(1)}.`
|
|
4111
5936
|
};
|
|
4112
5937
|
};
|
|
4113
5938
|
const isReadable = async (path) => {
|
|
@@ -4118,10 +5943,89 @@ const isReadable = async (path) => {
|
|
|
4118
5943
|
return false;
|
|
4119
5944
|
}
|
|
4120
5945
|
};
|
|
5946
|
+
const capitalize = (text) => text.charAt(0).toUpperCase() + text.slice(1);
|
|
5947
|
+
//#endregion
|
|
5948
|
+
//#region src/status/live-branch.ts
|
|
5949
|
+
const REBASE_HEAD_NAME_FILES = [join("rebase-merge", "head-name"), join("rebase-apply", "head-name")];
|
|
5950
|
+
const REFS_HEADS = "refs/heads/";
|
|
5951
|
+
const stripRefsHeads = (text) => text.split("\n").filter((line) => line.startsWith(REFS_HEADS)).map((line) => line.slice(11)).join("\n");
|
|
5952
|
+
const readLiveBranch = async (repoRoot, run) => {
|
|
5953
|
+
const head = await run("git", [
|
|
5954
|
+
"-C",
|
|
5955
|
+
repoRoot,
|
|
5956
|
+
"symbolic-ref",
|
|
5957
|
+
"--quiet",
|
|
5958
|
+
"HEAD"
|
|
5959
|
+
]);
|
|
5960
|
+
const attached = head.code === 0 ? stripRefsHeads(head.stdout.trim()) : "";
|
|
5961
|
+
if (attached !== "") return attached;
|
|
5962
|
+
const gitDir = await run("git", [
|
|
5963
|
+
"-C",
|
|
5964
|
+
repoRoot,
|
|
5965
|
+
"rev-parse",
|
|
5966
|
+
"--absolute-git-dir"
|
|
5967
|
+
]);
|
|
5968
|
+
const dir = gitDir.code === 0 ? gitDir.stdout.trim() : "";
|
|
5969
|
+
if (dir === "") return null;
|
|
5970
|
+
const recorded = (await Promise.all(REBASE_HEAD_NAME_FILES.map(async (file) => {
|
|
5971
|
+
const path = join(dir, file);
|
|
5972
|
+
return (await stat(path).catch(() => void 0))?.isFile() ? readRegularFile(path).catch(() => "") : void 0;
|
|
5973
|
+
}))).find((content) => content !== void 0);
|
|
5974
|
+
return recorded === void 0 ? null : stripRefsHeads(recorded.trim()) || null;
|
|
5975
|
+
};
|
|
5976
|
+
//#endregion
|
|
5977
|
+
//#region src/status/git-behind.ts
|
|
5978
|
+
const readGitBehind = async (repoRoot, run) => {
|
|
5979
|
+
const branch = await readLiveBranch(repoRoot, run);
|
|
5980
|
+
if ((await run("git", [
|
|
5981
|
+
"-C",
|
|
5982
|
+
repoRoot,
|
|
5983
|
+
"symbolic-ref",
|
|
5984
|
+
"--quiet",
|
|
5985
|
+
"HEAD"
|
|
5986
|
+
])).code !== 0) return {
|
|
5987
|
+
branch,
|
|
5988
|
+
behind: null
|
|
5989
|
+
};
|
|
5990
|
+
const headRef = await run("git", [
|
|
5991
|
+
"-C",
|
|
5992
|
+
repoRoot,
|
|
5993
|
+
"symbolic-ref",
|
|
5994
|
+
"--quiet",
|
|
5995
|
+
"refs/remotes/origin/HEAD"
|
|
5996
|
+
]);
|
|
5997
|
+
if (headRef.code !== 0) return {
|
|
5998
|
+
branch,
|
|
5999
|
+
behind: null
|
|
6000
|
+
};
|
|
6001
|
+
const count = await run("git", [
|
|
6002
|
+
"-C",
|
|
6003
|
+
repoRoot,
|
|
6004
|
+
"rev-list",
|
|
6005
|
+
"--count",
|
|
6006
|
+
`HEAD..${headRef.stdout.trim().replace(/^refs\/remotes\//, "")}`
|
|
6007
|
+
]);
|
|
6008
|
+
if (count.code !== 0) return {
|
|
6009
|
+
branch,
|
|
6010
|
+
behind: null
|
|
6011
|
+
};
|
|
6012
|
+
const parsed = Number.parseInt(count.stdout.trim(), 10);
|
|
6013
|
+
return {
|
|
6014
|
+
branch,
|
|
6015
|
+
behind: Number.isNaN(parsed) ? null : parsed
|
|
6016
|
+
};
|
|
6017
|
+
};
|
|
4121
6018
|
//#endregion
|
|
4122
6019
|
//#region src/status/parse-task-branch.ts
|
|
4123
|
-
const
|
|
4124
|
-
const parseTaskBranch = (branch) =>
|
|
6020
|
+
const TASK_BRANCHES = [/^harness\/(\d+)-.+$/, /^harness\/closeout-(\d+)$/];
|
|
6021
|
+
const parseTaskBranch = (branch) => {
|
|
6022
|
+
if (branch === null) return null;
|
|
6023
|
+
for (const shape of TASK_BRANCHES) {
|
|
6024
|
+
const id = shape.exec(branch)?.[1];
|
|
6025
|
+
if (id !== void 0) return id;
|
|
6026
|
+
}
|
|
6027
|
+
return null;
|
|
6028
|
+
};
|
|
4125
6029
|
//#endregion
|
|
4126
6030
|
//#region src/status/status.ts
|
|
4127
6031
|
const PAUSED_LABEL = "harness:status:paused-for-clarification";
|
|
@@ -4130,8 +6034,7 @@ const runStatus = async (deps) => {
|
|
|
4130
6034
|
const config = await readHarnessConfig(repoRoot);
|
|
4131
6035
|
const lastSession = await readPointer(repoRoot, deps.readGitUserEmail);
|
|
4132
6036
|
const { branch, behind } = await deps.gitBehind(repoRoot);
|
|
4133
|
-
const
|
|
4134
|
-
const openDiscoveries = await countOpenDiscoveries(provider, config.task_storage.repo);
|
|
6037
|
+
const openDiscoveries = deps.local === true ? null : await countOpenDiscoveries(createTaskTrackerProvider(config.task_storage.type, deps.runCommand), config.task_storage.repo);
|
|
4135
6038
|
return {
|
|
4136
6039
|
vendorVersion: config.vendor_version,
|
|
4137
6040
|
taskStorageRepo: config.task_storage.repo,
|
|
@@ -4140,15 +6043,16 @@ const runStatus = async (deps) => {
|
|
|
4140
6043
|
behind,
|
|
4141
6044
|
lastSession,
|
|
4142
6045
|
openDiscoveries,
|
|
4143
|
-
designToolDrift: await readDriftState(repoRoot)
|
|
6046
|
+
designToolDrift: await readDriftState(repoRoot, resolveTokenFile(config.design_tokens.file))
|
|
4144
6047
|
};
|
|
4145
6048
|
};
|
|
4146
|
-
const readDriftState = async (repoRoot) => {
|
|
4147
|
-
const tokenPath = join(repoRoot, DESIGN_TOKENS_FILE);
|
|
4148
|
-
if (!await pathExists(tokenPath)) return null;
|
|
6049
|
+
const readDriftState = async (repoRoot, tokenFile) => {
|
|
4149
6050
|
try {
|
|
4150
|
-
const
|
|
6051
|
+
const raw = await readOptionalTokenFile(repoRoot, tokenFile);
|
|
6052
|
+
if (raw === void 0) return null;
|
|
6053
|
+
const parsed = JSON.parse(raw);
|
|
4151
6054
|
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
|
|
6055
|
+
if (summarizeTokenFileViolations(raw, tokenFile) !== void 0) return null;
|
|
4152
6056
|
return driftReport(parsed).state;
|
|
4153
6057
|
} catch {
|
|
4154
6058
|
return null;
|
|
@@ -4163,7 +6067,7 @@ const readPointer = async (repoRoot, readGitUserEmail) => {
|
|
|
4163
6067
|
const pointerPath = join(repoRoot, STATE_DIR, `current-${slug}.md`);
|
|
4164
6068
|
let raw;
|
|
4165
6069
|
try {
|
|
4166
|
-
raw = await
|
|
6070
|
+
raw = await readRegularFile(pointerPath);
|
|
4167
6071
|
} catch {
|
|
4168
6072
|
return empty;
|
|
4169
6073
|
}
|
|
@@ -4223,8 +6127,8 @@ const COMMANDS = [
|
|
|
4223
6127
|
},
|
|
4224
6128
|
{
|
|
4225
6129
|
name: "status",
|
|
4226
|
-
summary: "Show installed version, branch drift, and open tasks.",
|
|
4227
|
-
usage: "lemony status"
|
|
6130
|
+
summary: "Show installed version, branch drift, and open tasks (`--local`: no network call).",
|
|
6131
|
+
usage: "lemony status [--local]"
|
|
4228
6132
|
},
|
|
4229
6133
|
{
|
|
4230
6134
|
name: "emit",
|
|
@@ -4236,9 +6140,14 @@ const COMMANDS = [
|
|
|
4236
6140
|
summary: "Reflect a raised/resolved discovery onto its issue (label flip; `pause` also comments); used by the Orchestrator's resolve-discovery skill.",
|
|
4237
6141
|
usage: "lemony discovery <pause|resume> --task-id=<id> --tier=<T1..T6> --status=<spec-in-progress|in-progress|in-review> [--note=<text>]"
|
|
4238
6142
|
},
|
|
6143
|
+
{
|
|
6144
|
+
name: "checkpoint",
|
|
6145
|
+
summary: "Resolve a human checkpoint answer in one call: the commits (each with a Lemony-Checkpoint trailer), a best-effort push and the step_completed emit, emit last; used by the Orchestrator.",
|
|
6146
|
+
usage: "lemony checkpoint <ok|changes|ok_downgrade> --task-id=<id> --auto-commit=<on|off> [--step=<N> --review-iterations=<K>] [--attributed-kind=<agent|skill|playbook> --attributed-name=<name>]"
|
|
6147
|
+
},
|
|
4239
6148
|
{
|
|
4240
6149
|
name: "design-tokens",
|
|
4241
|
-
summary: "Validate the 3-tier design-token file (and gate UI source against hardcoded values), check WCAG contrast of token pairs, or sync tokens with a design tool (consume-if-exists
|
|
6150
|
+
summary: "Validate the 3-tier design-token file (and gate UI source against hardcoded values), check WCAG contrast of token pairs, or sync tokens with a design tool (consume-if-exists: the gates never create the file; `import --apply` bootstraps it).",
|
|
4242
6151
|
usage: "lemony design-tokens <validate [--scan=<dir>] | contrast | import --from=<file> [--apply] [--only=<paths>] | export [--tool-state=<file>] [--out=<file>] [--record]>"
|
|
4243
6152
|
},
|
|
4244
6153
|
{
|
|
@@ -4292,70 +6201,6 @@ const renderTemplate = (template, vars) => template.replace(PLACEHOLDER, (_match
|
|
|
4292
6201
|
return vars[key];
|
|
4293
6202
|
});
|
|
4294
6203
|
//#endregion
|
|
4295
|
-
//#region src/baseline/baseline.ts
|
|
4296
|
-
const STAGING_DIR$1 = ".staging";
|
|
4297
|
-
const isENOENT$1 = (error) => typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
|
|
4298
|
-
const baselineRootDir = (repoRoot) => join(repoRoot, BASELINE_ROOT_REL);
|
|
4299
|
-
const baselineVersionDir = (repoRoot, version) => join(baselineRootDir(repoRoot), version);
|
|
4300
|
-
const writeBaseline = async (repoRoot, version, files) => {
|
|
4301
|
-
const root = baselineRootDir(repoRoot);
|
|
4302
|
-
const staging = join(root, STAGING_DIR$1);
|
|
4303
|
-
await rm(staging, {
|
|
4304
|
-
recursive: true,
|
|
4305
|
-
force: true
|
|
4306
|
-
});
|
|
4307
|
-
await mkdir(staging, { recursive: true });
|
|
4308
|
-
await Promise.all(files.map(async ({ relPath, content }) => {
|
|
4309
|
-
const dest = join(staging, relPath);
|
|
4310
|
-
await mkdir(dirname(dest), { recursive: true });
|
|
4311
|
-
await writeFile(dest, content);
|
|
4312
|
-
}));
|
|
4313
|
-
const versionDir = baselineVersionDir(repoRoot, version);
|
|
4314
|
-
await rm(versionDir, {
|
|
4315
|
-
recursive: true,
|
|
4316
|
-
force: true
|
|
4317
|
-
});
|
|
4318
|
-
await rename(staging, versionDir);
|
|
4319
|
-
await pruneOtherVersions(root, version);
|
|
4320
|
-
};
|
|
4321
|
-
const pruneOtherVersions = async (root, keep) => {
|
|
4322
|
-
const entries = await readdir(root, { withFileTypes: true });
|
|
4323
|
-
await Promise.all(entries.filter((entry) => entry.isDirectory() && entry.name !== keep).map((entry) => rm(join(root, entry.name), {
|
|
4324
|
-
recursive: true,
|
|
4325
|
-
force: true
|
|
4326
|
-
})));
|
|
4327
|
-
};
|
|
4328
|
-
const readBaseline = async (repoRoot, version) => {
|
|
4329
|
-
const versionDir = baselineVersionDir(repoRoot, version);
|
|
4330
|
-
let relPaths;
|
|
4331
|
-
try {
|
|
4332
|
-
relPaths = await listFiles(versionDir);
|
|
4333
|
-
} catch (error) {
|
|
4334
|
-
if (isENOENT$1(error)) return /* @__PURE__ */ new Map();
|
|
4335
|
-
throw error;
|
|
4336
|
-
}
|
|
4337
|
-
const entries = await Promise.all(relPaths.map(async (relPath) => [relPath, await readFile(join(versionDir, relPath), "utf8")]));
|
|
4338
|
-
return new Map(entries);
|
|
4339
|
-
};
|
|
4340
|
-
const findBaselineVersion = async (repoRoot) => {
|
|
4341
|
-
const root = baselineRootDir(repoRoot);
|
|
4342
|
-
let entries;
|
|
4343
|
-
try {
|
|
4344
|
-
entries = await readdir(root, { withFileTypes: true });
|
|
4345
|
-
} catch (error) {
|
|
4346
|
-
if (isENOENT$1(error)) return null;
|
|
4347
|
-
throw error;
|
|
4348
|
-
}
|
|
4349
|
-
const versions = entries.filter((entry) => entry.isDirectory() && !entry.name.startsWith(".")).map((entry) => entry.name);
|
|
4350
|
-
if (versions.length <= 1) return versions[0] ?? null;
|
|
4351
|
-
const withMtime = await Promise.all(versions.map(async (version) => ({
|
|
4352
|
-
version,
|
|
4353
|
-
mtimeMs: (await stat(join(root, version))).mtimeMs
|
|
4354
|
-
})));
|
|
4355
|
-
withMtime.sort((a, b) => a.mtimeMs - b.mtimeMs || a.version.localeCompare(b.version, void 0, { numeric: true }));
|
|
4356
|
-
return withMtime.at(-1)?.version ?? null;
|
|
4357
|
-
};
|
|
4358
|
-
//#endregion
|
|
4359
6204
|
//#region src/snapshot/snapshot.ts
|
|
4360
6205
|
const STAGING_DIR = ".staging";
|
|
4361
6206
|
const WORKING_SUBDIR = "working";
|
|
@@ -4440,183 +6285,37 @@ const readSnapshot = async (repoRoot, version) => {
|
|
|
4440
6285
|
const filePath = join(workingRoot, relPath);
|
|
4441
6286
|
const [content, info] = await Promise.all([readFile(filePath, "utf8"), stat(filePath)]);
|
|
4442
6287
|
return {
|
|
4443
|
-
relPath,
|
|
4444
|
-
content,
|
|
4445
|
-
executable: (info.mode & 73) !== 0
|
|
4446
|
-
};
|
|
4447
|
-
}));
|
|
4448
|
-
const baselineRoot = join(dir, BASELINE_SUBDIR);
|
|
4449
|
-
const baselinePaths = await listFiles(baselineRoot);
|
|
4450
|
-
const baselineEntries = await Promise.all(baselinePaths.map(async (relPath) => [relPath, await readFile(join(baselineRoot, relPath), "utf8")]));
|
|
4451
|
-
return {
|
|
4452
|
-
working,
|
|
4453
|
-
baseline: new Map(baselineEntries)
|
|
4454
|
-
};
|
|
4455
|
-
};
|
|
4456
|
-
const rotateSnapshots = async (repoRoot, keep) => {
|
|
4457
|
-
if (keep === "unlimited") return [];
|
|
4458
|
-
const toRemove = (await listSnapshots(repoRoot)).slice(keep);
|
|
4459
|
-
await assertNoSymlinkTraversal(repoRoot, toRemove.map((version) => join(SNAPSHOTS_ROOT_REL, version)));
|
|
4460
|
-
await Promise.all(toRemove.map((version) => rm(snapshotDir(repoRoot, version), {
|
|
4461
|
-
recursive: true,
|
|
4462
|
-
force: true
|
|
4463
|
-
})));
|
|
4464
|
-
return toRemove;
|
|
4465
|
-
};
|
|
4466
|
-
const cleanupSnapshots = async (repoRoot) => {
|
|
4467
|
-
const versions = await listSnapshots(repoRoot);
|
|
4468
|
-
await assertNoSymlinkTraversal(repoRoot, [SNAPSHOTS_ROOT_REL]);
|
|
4469
|
-
await rm(snapshotsRootDir(repoRoot), {
|
|
4470
|
-
recursive: true,
|
|
4471
|
-
force: true
|
|
4472
|
-
});
|
|
4473
|
-
return versions;
|
|
4474
|
-
};
|
|
4475
|
-
//#endregion
|
|
4476
|
-
//#region src/merge/conflict-markers.constant.ts
|
|
4477
|
-
const CONFLICT_MARKER_CLIENT = "<<<<<<< client";
|
|
4478
|
-
const CONFLICT_MARKER_SEPARATOR = "=======";
|
|
4479
|
-
const CONFLICT_MARKER_VENDOR = ">>>>>>> vendor";
|
|
4480
|
-
//#endregion
|
|
4481
|
-
//#region src/merge/conflict-markers.ts
|
|
4482
|
-
const CONFLICT_FENCE = new RegExp(`^(?:${CONFLICT_MARKER_CLIENT}|${CONFLICT_MARKER_VENDOR})$`, "m");
|
|
4483
|
-
const hasConflictMarkers = (content) => CONFLICT_FENCE.test(content);
|
|
4484
|
-
//#endregion
|
|
4485
|
-
//#region src/merge/three-way-merge.ts
|
|
4486
|
-
const threeWayMerge = (base, client, vendor) => {
|
|
4487
|
-
const baseLines = base.split("\n");
|
|
4488
|
-
const clientLines = client.split("\n");
|
|
4489
|
-
const vendorLines = vendor.split("\n");
|
|
4490
|
-
const regions = diff3Regions(baseLines, clientLines, vendorLines);
|
|
4491
|
-
const out = [];
|
|
4492
|
-
let conflicted = false;
|
|
4493
|
-
for (const region of regions) {
|
|
4494
|
-
if (region.stable) {
|
|
4495
|
-
out.push(...region.lines);
|
|
4496
|
-
continue;
|
|
4497
|
-
}
|
|
4498
|
-
if (linesEqual(region.client, region.vendor)) out.push(...region.client);
|
|
4499
|
-
else {
|
|
4500
|
-
conflicted = true;
|
|
4501
|
-
out.push(CONFLICT_MARKER_CLIENT, ...region.client, CONFLICT_MARKER_SEPARATOR, ...region.vendor, CONFLICT_MARKER_VENDOR);
|
|
4502
|
-
}
|
|
4503
|
-
}
|
|
4504
|
-
return {
|
|
4505
|
-
merged: out.join("\n"),
|
|
4506
|
-
conflicted
|
|
4507
|
-
};
|
|
4508
|
-
};
|
|
4509
|
-
const linesEqual = (a, b) => a.length === b.length && a.every((line, i) => line === b[i]);
|
|
4510
|
-
const diff3Regions = (base, client, vendor) => {
|
|
4511
|
-
const hunks = [...diffRegions(base, client).map((d) => ({
|
|
4512
|
-
...d,
|
|
4513
|
-
side: "client"
|
|
4514
|
-
})), ...diffRegions(base, vendor).map((d) => ({
|
|
4515
|
-
...d,
|
|
4516
|
-
side: "vendor"
|
|
4517
|
-
}))].toSorted((a, b) => a.baseStart - b.baseStart || a.baseEnd - b.baseEnd);
|
|
4518
|
-
const regions = [];
|
|
4519
|
-
let baseCursor = 0;
|
|
4520
|
-
let i = 0;
|
|
4521
|
-
const emitStable = (until) => {
|
|
4522
|
-
if (until > baseCursor) {
|
|
4523
|
-
regions.push({
|
|
4524
|
-
stable: true,
|
|
4525
|
-
lines: base.slice(baseCursor, until)
|
|
4526
|
-
});
|
|
4527
|
-
baseCursor = until;
|
|
4528
|
-
}
|
|
4529
|
-
};
|
|
4530
|
-
while (i < hunks.length) {
|
|
4531
|
-
const first = hunks[i];
|
|
4532
|
-
if (first === void 0) break;
|
|
4533
|
-
let regionBaseStart = first.baseStart;
|
|
4534
|
-
let regionBaseEnd = first.baseEnd;
|
|
4535
|
-
const group = [first];
|
|
4536
|
-
i += 1;
|
|
4537
|
-
while (i < hunks.length) {
|
|
4538
|
-
const next = hunks[i];
|
|
4539
|
-
if (next === void 0 || next.baseStart > regionBaseEnd) break;
|
|
4540
|
-
regionBaseEnd = Math.max(regionBaseEnd, next.baseEnd);
|
|
4541
|
-
group.push(next);
|
|
4542
|
-
i += 1;
|
|
4543
|
-
}
|
|
4544
|
-
emitStable(regionBaseStart);
|
|
4545
|
-
const touchedByClient = group.some((h) => h.side === "client");
|
|
4546
|
-
const touchedByVendor = group.some((h) => h.side === "vendor");
|
|
4547
|
-
if (touchedByClient && touchedByVendor) regions.push({
|
|
4548
|
-
stable: false,
|
|
4549
|
-
client: projectSide(group, "client", client, regionBaseStart, regionBaseEnd),
|
|
4550
|
-
vendor: projectSide(group, "vendor", vendor, regionBaseStart, regionBaseEnd)
|
|
4551
|
-
});
|
|
4552
|
-
else {
|
|
4553
|
-
const side = touchedByClient ? "client" : "vendor";
|
|
4554
|
-
regions.push({
|
|
4555
|
-
stable: true,
|
|
4556
|
-
lines: projectSide(group, side, side === "client" ? client : vendor, regionBaseStart, regionBaseEnd)
|
|
4557
|
-
});
|
|
4558
|
-
}
|
|
4559
|
-
baseCursor = regionBaseEnd;
|
|
4560
|
-
}
|
|
4561
|
-
emitStable(base.length);
|
|
4562
|
-
return regions;
|
|
6288
|
+
relPath,
|
|
6289
|
+
content,
|
|
6290
|
+
executable: (info.mode & 73) !== 0
|
|
6291
|
+
};
|
|
6292
|
+
}));
|
|
6293
|
+
const baselineRoot = join(dir, BASELINE_SUBDIR);
|
|
6294
|
+
const baselinePaths = await listFiles(baselineRoot);
|
|
6295
|
+
const baselineEntries = await Promise.all(baselinePaths.map(async (relPath) => [relPath, await readFile(join(baselineRoot, relPath), "utf8")]));
|
|
6296
|
+
return {
|
|
6297
|
+
working,
|
|
6298
|
+
baseline: new Map(baselineEntries)
|
|
6299
|
+
};
|
|
4563
6300
|
};
|
|
4564
|
-
const
|
|
4565
|
-
|
|
4566
|
-
|
|
4567
|
-
|
|
4568
|
-
|
|
4569
|
-
|
|
4570
|
-
|
|
4571
|
-
|
|
4572
|
-
|
|
4573
|
-
baseStart = Math.min(baseStart, h.baseStart);
|
|
4574
|
-
baseEnd = Math.max(baseEnd, h.baseEnd);
|
|
4575
|
-
}
|
|
4576
|
-
const start = sideStart - (baseStart - regionBaseStart);
|
|
4577
|
-
const end = sideEnd + (regionBaseEnd - baseEnd);
|
|
4578
|
-
return sideLines.slice(start, end);
|
|
6301
|
+
const rotateSnapshots = async (repoRoot, keep) => {
|
|
6302
|
+
if (keep === "unlimited") return [];
|
|
6303
|
+
const toRemove = (await listSnapshots(repoRoot)).slice(keep);
|
|
6304
|
+
await assertNoSymlinkTraversal(repoRoot, toRemove.map((version) => join(SNAPSHOTS_ROOT_REL, version)));
|
|
6305
|
+
await Promise.all(toRemove.map((version) => rm(snapshotDir(repoRoot, version), {
|
|
6306
|
+
recursive: true,
|
|
6307
|
+
force: true
|
|
6308
|
+
})));
|
|
6309
|
+
return toRemove;
|
|
4579
6310
|
};
|
|
4580
|
-
const
|
|
4581
|
-
const
|
|
4582
|
-
|
|
4583
|
-
|
|
4584
|
-
|
|
4585
|
-
|
|
4586
|
-
if (baseIdx > baseTail || otherIdx > otherTail) diffs.push({
|
|
4587
|
-
baseStart: baseTail,
|
|
4588
|
-
baseEnd: baseIdx,
|
|
4589
|
-
sideStart: otherTail,
|
|
4590
|
-
sideEnd: otherIdx
|
|
4591
|
-
});
|
|
4592
|
-
baseTail = baseIdx + 1;
|
|
4593
|
-
otherTail = otherIdx + 1;
|
|
4594
|
-
}
|
|
4595
|
-
if (base.length > baseTail || other.length > otherTail) diffs.push({
|
|
4596
|
-
baseStart: baseTail,
|
|
4597
|
-
baseEnd: base.length,
|
|
4598
|
-
sideStart: otherTail,
|
|
4599
|
-
sideEnd: other.length
|
|
6311
|
+
const cleanupSnapshots = async (repoRoot) => {
|
|
6312
|
+
const versions = await listSnapshots(repoRoot);
|
|
6313
|
+
await assertNoSymlinkTraversal(repoRoot, [SNAPSHOTS_ROOT_REL]);
|
|
6314
|
+
await rm(snapshotsRootDir(repoRoot), {
|
|
6315
|
+
recursive: true,
|
|
6316
|
+
force: true
|
|
4600
6317
|
});
|
|
4601
|
-
return
|
|
4602
|
-
};
|
|
4603
|
-
const lcsMatches = (base, other) => {
|
|
4604
|
-
const n = base.length;
|
|
4605
|
-
const m = other.length;
|
|
4606
|
-
const width = m + 1;
|
|
4607
|
-
const dp = new Int32Array((n + 1) * width);
|
|
4608
|
-
const at = (i, j) => dp[i * width + j] ?? 0;
|
|
4609
|
-
for (let i = n - 1; i >= 0; i -= 1) for (let j = m - 1; j >= 0; j -= 1) dp[i * width + j] = base[i] === other[j] ? at(i + 1, j + 1) + 1 : Math.max(at(i + 1, j), at(i, j + 1));
|
|
4610
|
-
const matches = [];
|
|
4611
|
-
let i = 0;
|
|
4612
|
-
let j = 0;
|
|
4613
|
-
while (i < n && j < m) if (base[i] === other[j]) {
|
|
4614
|
-
matches.push([i, j]);
|
|
4615
|
-
i += 1;
|
|
4616
|
-
j += 1;
|
|
4617
|
-
} else if (at(i + 1, j) >= at(i, j + 1)) i += 1;
|
|
4618
|
-
else j += 1;
|
|
4619
|
-
return matches;
|
|
6318
|
+
return versions;
|
|
4620
6319
|
};
|
|
4621
6320
|
//#endregion
|
|
4622
6321
|
//#region src/update/engine.ts
|
|
@@ -4808,54 +6507,6 @@ const materializeVendorFiles = async (ctx) => {
|
|
|
4808
6507
|
];
|
|
4809
6508
|
};
|
|
4810
6509
|
//#endregion
|
|
4811
|
-
//#region src/install/gitignore.ts
|
|
4812
|
-
const GITIGNORE_BEGIN = "# BEGIN lemony";
|
|
4813
|
-
const GITIGNORE_END = "# END lemony";
|
|
4814
|
-
const GITIGNORE_BLOCK = [
|
|
4815
|
-
GITIGNORE_BEGIN,
|
|
4816
|
-
".claude/state/current-*.md",
|
|
4817
|
-
".claude/state/.ptr.*",
|
|
4818
|
-
".claude/state/sessions/",
|
|
4819
|
-
".claude/state/events.jsonl",
|
|
4820
|
-
".claude/state/telemetry-cursor.json",
|
|
4821
|
-
".claude/state/telemetry-consent.json",
|
|
4822
|
-
".claude/state/.telemetry-notice-shown",
|
|
4823
|
-
".claude/state/telemetry-rejected.jsonl",
|
|
4824
|
-
".claude/.harness/snapshots/",
|
|
4825
|
-
GITIGNORE_END
|
|
4826
|
-
].join("\n");
|
|
4827
|
-
const appendGitignoreBlock = async (repoRoot) => {
|
|
4828
|
-
const gitignorePath = join(repoRoot, ".gitignore");
|
|
4829
|
-
const current = await pathExists(gitignorePath) ? await readFile(gitignorePath, "utf8") : "";
|
|
4830
|
-
const beginIdx = current.indexOf(GITIGNORE_BEGIN);
|
|
4831
|
-
const endIdx = current.indexOf(GITIGNORE_END, beginIdx);
|
|
4832
|
-
if (beginIdx !== -1 && endIdx !== -1) {
|
|
4833
|
-
const next = current.slice(0, beginIdx) + GITIGNORE_BLOCK + current.slice(endIdx + 12);
|
|
4834
|
-
if (next === current) return null;
|
|
4835
|
-
await writeFile(gitignorePath, next);
|
|
4836
|
-
return ".gitignore";
|
|
4837
|
-
}
|
|
4838
|
-
const prefix = current.length === 0 ? "" : current.endsWith("\n") ? "\n" : "\n\n";
|
|
4839
|
-
await writeFile(gitignorePath, `${current}${prefix}${GITIGNORE_BLOCK}\n`);
|
|
4840
|
-
return ".gitignore";
|
|
4841
|
-
};
|
|
4842
|
-
const removeGitignoreBlock = async (repoRoot) => {
|
|
4843
|
-
const relPath = ".gitignore";
|
|
4844
|
-
const gitignorePath = join(repoRoot, relPath);
|
|
4845
|
-
if (!await pathExists(gitignorePath)) return null;
|
|
4846
|
-
const lines = (await readFile(gitignorePath, "utf8")).split("\n");
|
|
4847
|
-
const startLine = lines.findIndex((line) => line.trim() === GITIGNORE_BEGIN);
|
|
4848
|
-
const endLine = startLine === -1 ? -1 : lines.findIndex((line, index) => index > startLine && line.trim() === GITIGNORE_END);
|
|
4849
|
-
if (startLine === -1 || endLine === -1) return null;
|
|
4850
|
-
const result = [...lines.slice(0, startLine), ...lines.slice(endLine + 1)].join("\n").replace(/\n+$/, "\n");
|
|
4851
|
-
if (result.trim() === "") {
|
|
4852
|
-
await rm(gitignorePath, { force: true });
|
|
4853
|
-
return relPath;
|
|
4854
|
-
}
|
|
4855
|
-
await writeFile(gitignorePath, result);
|
|
4856
|
-
return relPath;
|
|
4857
|
-
};
|
|
4858
|
-
//#endregion
|
|
4859
6510
|
//#region src/install/merge-settings.ts
|
|
4860
6511
|
const mergeSettingsHooks = async (templatePath, destPath) => {
|
|
4861
6512
|
const templateRaw = await readFile(templatePath, "utf8");
|
|
@@ -4863,7 +6514,7 @@ const mergeSettingsHooks = async (templatePath, destPath) => {
|
|
|
4863
6514
|
if (template.hooks === void 0) throw new Error(`settings template at ${templatePath} has no "hooks" key — vendor authority is the hooks block.`);
|
|
4864
6515
|
let existing = {};
|
|
4865
6516
|
if (await pathExists(destPath)) {
|
|
4866
|
-
const existingRaw = await
|
|
6517
|
+
const existingRaw = await readSettings(destPath, mergeContext(destPath));
|
|
4867
6518
|
if (existingRaw.trim().length > 0) try {
|
|
4868
6519
|
existing = JSON.parse(existingRaw);
|
|
4869
6520
|
} catch (cause) {
|
|
@@ -4881,7 +6532,7 @@ const mergeSettingsHooks = async (templatePath, destPath) => {
|
|
|
4881
6532
|
};
|
|
4882
6533
|
const removeSettingsHooks = async (settingsPath) => {
|
|
4883
6534
|
if (!await pathExists(settingsPath)) return "absent";
|
|
4884
|
-
const raw = await
|
|
6535
|
+
const raw = await readSettings(settingsPath, removeContext(settingsPath));
|
|
4885
6536
|
if (raw.trim().length === 0) return "absent";
|
|
4886
6537
|
let parsed;
|
|
4887
6538
|
try {
|
|
@@ -4898,6 +6549,24 @@ const removeSettingsHooks = async (settingsPath) => {
|
|
|
4898
6549
|
await writeFile(settingsPath, `${JSON.stringify(rest, null, 2)}\n`);
|
|
4899
6550
|
return "stripped";
|
|
4900
6551
|
};
|
|
6552
|
+
const mergeContext = (path) => `Cannot merge the harness hooks into ${path}`;
|
|
6553
|
+
const removeContext = (path) => `Cannot remove the harness hooks from ${path}`;
|
|
6554
|
+
const assertSettingsNotSpecial = async (path, action) => {
|
|
6555
|
+
const target = await stat(path).catch(() => void 0);
|
|
6556
|
+
if (target === void 0 || target.isFile()) return;
|
|
6557
|
+
const context = action === "merge" ? mergeContext(path) : removeContext(path);
|
|
6558
|
+
const reason = target.isDirectory() ? "the entry is a directory." : SPECIAL_FILE_REFUSAL;
|
|
6559
|
+
throw new Error(`${context}: ${reason} ${REMOVE_IT}`);
|
|
6560
|
+
};
|
|
6561
|
+
const REMOVE_IT = "Remove it before re-running.";
|
|
6562
|
+
const readSettings = async (path, context) => {
|
|
6563
|
+
try {
|
|
6564
|
+
return await readRegularFile(path);
|
|
6565
|
+
} catch (error) {
|
|
6566
|
+
if (error.code !== void 0) throw error;
|
|
6567
|
+
throw new Error(`${context}: ${error.message} ${REMOVE_IT}`, { cause: error });
|
|
6568
|
+
}
|
|
6569
|
+
};
|
|
4901
6570
|
const collectCommands = (hooks) => {
|
|
4902
6571
|
const result = /* @__PURE__ */ new Set();
|
|
4903
6572
|
if (!hooks || typeof hooks !== "object") return result;
|
|
@@ -4922,6 +6591,54 @@ const diffLostCommands = (existingHooks, templateHooks) => {
|
|
|
4922
6591
|
return lost.toSorted();
|
|
4923
6592
|
};
|
|
4924
6593
|
//#endregion
|
|
6594
|
+
//#region src/install/gitignore.ts
|
|
6595
|
+
const GITIGNORE_BEGIN = "# BEGIN lemony";
|
|
6596
|
+
const GITIGNORE_END = "# END lemony";
|
|
6597
|
+
const GITIGNORE_BLOCK = [
|
|
6598
|
+
GITIGNORE_BEGIN,
|
|
6599
|
+
".claude/state/current-*.md",
|
|
6600
|
+
".claude/state/.ptr.*",
|
|
6601
|
+
".claude/state/sessions/",
|
|
6602
|
+
".claude/state/events.jsonl",
|
|
6603
|
+
".claude/state/telemetry-cursor.json",
|
|
6604
|
+
".claude/state/telemetry-consent.json",
|
|
6605
|
+
".claude/state/.telemetry-notice-shown",
|
|
6606
|
+
".claude/state/telemetry-rejected.jsonl",
|
|
6607
|
+
".claude/.harness/snapshots/",
|
|
6608
|
+
GITIGNORE_END
|
|
6609
|
+
].join("\n");
|
|
6610
|
+
const appendGitignoreBlock = async (repoRoot) => {
|
|
6611
|
+
const gitignorePath = join(repoRoot, ".gitignore");
|
|
6612
|
+
const current = await pathExists(gitignorePath) ? await readFile(gitignorePath, "utf8") : "";
|
|
6613
|
+
const beginIdx = current.indexOf(GITIGNORE_BEGIN);
|
|
6614
|
+
const endIdx = current.indexOf(GITIGNORE_END, beginIdx);
|
|
6615
|
+
if (beginIdx !== -1 && endIdx !== -1) {
|
|
6616
|
+
const next = current.slice(0, beginIdx) + GITIGNORE_BLOCK + current.slice(endIdx + 12);
|
|
6617
|
+
if (next === current) return null;
|
|
6618
|
+
await writeFile(gitignorePath, next);
|
|
6619
|
+
return ".gitignore";
|
|
6620
|
+
}
|
|
6621
|
+
const prefix = current.length === 0 ? "" : current.endsWith("\n") ? "\n" : "\n\n";
|
|
6622
|
+
await writeFile(gitignorePath, `${current}${prefix}${GITIGNORE_BLOCK}\n`);
|
|
6623
|
+
return ".gitignore";
|
|
6624
|
+
};
|
|
6625
|
+
const removeGitignoreBlock = async (repoRoot) => {
|
|
6626
|
+
const relPath = ".gitignore";
|
|
6627
|
+
const gitignorePath = join(repoRoot, relPath);
|
|
6628
|
+
if (!await pathExists(gitignorePath)) return null;
|
|
6629
|
+
const lines = (await readFile(gitignorePath, "utf8")).split("\n");
|
|
6630
|
+
const startLine = lines.findIndex((line) => line.trim() === GITIGNORE_BEGIN);
|
|
6631
|
+
const endLine = startLine === -1 ? -1 : lines.findIndex((line, index) => index > startLine && line.trim() === GITIGNORE_END);
|
|
6632
|
+
if (startLine === -1 || endLine === -1) return null;
|
|
6633
|
+
const result = [...lines.slice(0, startLine), ...lines.slice(endLine + 1)].join("\n").replace(/\n+$/, "\n");
|
|
6634
|
+
if (result.trim() === "") {
|
|
6635
|
+
await rm(gitignorePath, { force: true });
|
|
6636
|
+
return relPath;
|
|
6637
|
+
}
|
|
6638
|
+
await writeFile(gitignorePath, result);
|
|
6639
|
+
return relPath;
|
|
6640
|
+
};
|
|
6641
|
+
//#endregion
|
|
4925
6642
|
//#region src/install/resync.ts
|
|
4926
6643
|
const resyncSpecialCased = async (repoRoot, templateRoot) => {
|
|
4927
6644
|
const { lostCommands } = await mergeSettingsHooks(join(templateRoot, CLAUDE_DIR, "settings.json.tpl"), join(repoRoot, SETTINGS_FILE));
|
|
@@ -4978,6 +6695,7 @@ const runInstall = async (options) => {
|
|
|
4978
6695
|
...COMMITTED_STATE_DIRS.map((dir) => join(STATE_DIR, dir, ".gitkeep")),
|
|
4979
6696
|
HARNESS_CONFIG_FILENAME
|
|
4980
6697
|
]);
|
|
6698
|
+
await assertSettingsNotSpecial(join(repoRoot, SETTINGS_FILE), "merge");
|
|
4981
6699
|
const readClient = async (relPath) => {
|
|
4982
6700
|
const filePath = join(repoRoot, relPath);
|
|
4983
6701
|
if (!await pathExists(filePath)) return null;
|
|
@@ -5126,6 +6844,7 @@ const runReconcile = async (inputs) => {
|
|
|
5126
6844
|
SETTINGS_FILE,
|
|
5127
6845
|
".gitignore"
|
|
5128
6846
|
]);
|
|
6847
|
+
await assertSettingsNotSpecial(join(repoRoot, SETTINGS_FILE), "merge");
|
|
5129
6848
|
const actions = await runEngine({
|
|
5130
6849
|
baseline,
|
|
5131
6850
|
vendorNew,
|
|
@@ -5270,7 +6989,7 @@ const runRepair = async (options) => {
|
|
|
5270
6989
|
const DOCS_PREFIX = `docs${sep}`;
|
|
5271
6990
|
const runUninstall = async (options) => {
|
|
5272
6991
|
const { repoRoot } = options;
|
|
5273
|
-
if (!await
|
|
6992
|
+
if (!await entryExists(join(repoRoot, "harness.config.yml")) && !await hasInstallBaseline(repoRoot)) throw new Error(`Lemony is not installed here (${HARNESS_CONFIG_FILENAME} not found).`);
|
|
5274
6993
|
const baselineVersion = await findBaselineVersion(repoRoot);
|
|
5275
6994
|
if (baselineVersion === null) throw new Error("Refusing to uninstall — no baseline registry under .claude/.harness/baseline/. The baseline is the record of which files are vendor-managed; without it uninstall cannot distinguish vendor files from your own. Run `lemony repair` to restore it, or remove the harness files manually.");
|
|
5276
6995
|
const config = await readHarnessConfig(repoRoot);
|
|
@@ -5282,6 +7001,7 @@ const runUninstall = async (options) => {
|
|
|
5282
7001
|
HARNESS_DIR,
|
|
5283
7002
|
HARNESS_CONFIG_FILENAME
|
|
5284
7003
|
]);
|
|
7004
|
+
await assertSettingsNotSpecial(join(repoRoot, SETTINGS_FILE), "remove");
|
|
5285
7005
|
await Promise.all(removedFiles.map((relPath) => rm(join(repoRoot, relPath), { force: true })));
|
|
5286
7006
|
await pruneEmptyDirs(repoRoot, removedFiles);
|
|
5287
7007
|
const settings = await removeSettingsHooks(join(repoRoot, SETTINGS_FILE));
|
|
@@ -5493,8 +7213,32 @@ const PACKAGE_JSON_PATH = join(PACKAGE_ROOT, "package.json");
|
|
|
5493
7213
|
const execFileAsync = promisify(execFile);
|
|
5494
7214
|
const parseFlag = (args, name) => {
|
|
5495
7215
|
const prefix = `--${name}=`;
|
|
5496
|
-
const
|
|
5497
|
-
return
|
|
7216
|
+
const joined = args.find((arg) => arg.startsWith(prefix))?.slice(prefix.length);
|
|
7217
|
+
if (joined !== void 0) return joined ? joined : void 0;
|
|
7218
|
+
const at = args.indexOf(`--${name}`);
|
|
7219
|
+
const next = at === -1 ? void 0 : args[at + 1];
|
|
7220
|
+
return next !== void 0 && next !== "" && !next.startsWith("--") ? next : void 0;
|
|
7221
|
+
};
|
|
7222
|
+
const rejectValuelessFlags = (verb, args, names) => {
|
|
7223
|
+
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.`);
|
|
7224
|
+
};
|
|
7225
|
+
const rejectStrayArgs = (verb, args, valued) => {
|
|
7226
|
+
const seen = /* @__PURE__ */ new Set();
|
|
7227
|
+
let positionals = 0;
|
|
7228
|
+
for (let i = 0; i < args.length; i++) {
|
|
7229
|
+
const arg = args[i];
|
|
7230
|
+
if (!arg.startsWith("--")) {
|
|
7231
|
+
positionals += 1;
|
|
7232
|
+
if (positionals > 1) throw new Error(`${verb}: unexpected argument "${arg}".`);
|
|
7233
|
+
continue;
|
|
7234
|
+
}
|
|
7235
|
+
const name = arg.slice(2).split("=")[0];
|
|
7236
|
+
if (!valued.includes(name)) throw new Error(`${verb}: unknown flag --${name}.`);
|
|
7237
|
+
if (seen.has(name)) throw new Error(`${verb}: --${name} was given more than once.`);
|
|
7238
|
+
seen.add(name);
|
|
7239
|
+
const next = args[i + 1];
|
|
7240
|
+
if (!arg.includes("=") && next !== void 0 && !next.startsWith("--")) i += 1;
|
|
7241
|
+
}
|
|
5498
7242
|
};
|
|
5499
7243
|
const readHarnessVersion = async () => {
|
|
5500
7244
|
const raw = await readFile(PACKAGE_JSON_PATH, "utf8");
|
|
@@ -5545,7 +7289,8 @@ const makeVerifierRunCommand = () => (cmd, args) => new Promise((resolveResult)
|
|
|
5545
7289
|
resolveResult({
|
|
5546
7290
|
code: code ?? 1,
|
|
5547
7291
|
stdout: Buffer.concat(out).toString("utf8"),
|
|
5548
|
-
stderr:
|
|
7292
|
+
stderr: Buffer.concat(err).toString("utf8"),
|
|
7293
|
+
...signal === null ? {} : { signal }
|
|
5549
7294
|
});
|
|
5550
7295
|
});
|
|
5551
7296
|
});
|
|
@@ -5566,45 +7311,6 @@ const buildResolveDeps = () => {
|
|
|
5566
7311
|
close: () => rl.close()
|
|
5567
7312
|
};
|
|
5568
7313
|
};
|
|
5569
|
-
const gitBehind = async (repoRoot) => {
|
|
5570
|
-
const run = makeRunCommand();
|
|
5571
|
-
const branchResult = await run("git", [
|
|
5572
|
-
"-C",
|
|
5573
|
-
repoRoot,
|
|
5574
|
-
"symbolic-ref",
|
|
5575
|
-
"--quiet",
|
|
5576
|
-
"HEAD"
|
|
5577
|
-
]);
|
|
5578
|
-
const ref = branchResult.code === 0 ? branchResult.stdout.trim() : "";
|
|
5579
|
-
const branch = ref.startsWith("refs/heads/") ? ref.slice(11) || null : null;
|
|
5580
|
-
const headRef = await run("git", [
|
|
5581
|
-
"-C",
|
|
5582
|
-
repoRoot,
|
|
5583
|
-
"symbolic-ref",
|
|
5584
|
-
"--quiet",
|
|
5585
|
-
"refs/remotes/origin/HEAD"
|
|
5586
|
-
]);
|
|
5587
|
-
if (headRef.code !== 0) return {
|
|
5588
|
-
branch,
|
|
5589
|
-
behind: null
|
|
5590
|
-
};
|
|
5591
|
-
const count = await run("git", [
|
|
5592
|
-
"-C",
|
|
5593
|
-
repoRoot,
|
|
5594
|
-
"rev-list",
|
|
5595
|
-
"--count",
|
|
5596
|
-
`HEAD..${headRef.stdout.trim().replace(/^refs\/remotes\//, "")}`
|
|
5597
|
-
]);
|
|
5598
|
-
if (count.code !== 0) return {
|
|
5599
|
-
branch,
|
|
5600
|
-
behind: null
|
|
5601
|
-
};
|
|
5602
|
-
const parsed = Number.parseInt(count.stdout.trim(), 10);
|
|
5603
|
-
return {
|
|
5604
|
-
branch,
|
|
5605
|
-
behind: Number.isNaN(parsed) ? null : parsed
|
|
5606
|
-
};
|
|
5607
|
-
};
|
|
5608
7314
|
const install = async (args) => {
|
|
5609
7315
|
const repoRoot = cwd();
|
|
5610
7316
|
if (await pathExists(join(repoRoot, "harness.config.yml"))) {
|
|
@@ -5929,6 +7635,42 @@ const discovery = async (args) => {
|
|
|
5929
7635
|
if (result.applied.length > 0) console.error(` Applied before failure: ${result.applied.join(", ")}.`);
|
|
5930
7636
|
exit(1);
|
|
5931
7637
|
};
|
|
7638
|
+
const checkpoint = async (args) => {
|
|
7639
|
+
const valued = [
|
|
7640
|
+
"task-id",
|
|
7641
|
+
"auto-commit",
|
|
7642
|
+
"step",
|
|
7643
|
+
"review-iterations",
|
|
7644
|
+
"attributed-kind",
|
|
7645
|
+
"attributed-name"
|
|
7646
|
+
];
|
|
7647
|
+
rejectValuelessFlags("checkpoint", args, valued);
|
|
7648
|
+
rejectStrayArgs("checkpoint", args, valued);
|
|
7649
|
+
const result = await runCheckpoint({
|
|
7650
|
+
repoRoot: cwd(),
|
|
7651
|
+
answer: args[0]?.startsWith("--") ? void 0 : args[0],
|
|
7652
|
+
taskId: parseFlag(args, "task-id"),
|
|
7653
|
+
autoCommit: parseFlag(args, "auto-commit"),
|
|
7654
|
+
step: parseFlag(args, "step"),
|
|
7655
|
+
reviewIterations: parseFlag(args, "review-iterations"),
|
|
7656
|
+
attributedKind: parseFlag(args, "attributed-kind"),
|
|
7657
|
+
attributedName: parseFlag(args, "attributed-name"),
|
|
7658
|
+
harnessVersion: await readHarnessVersion(),
|
|
7659
|
+
runCommand: makeRunCommand()
|
|
7660
|
+
});
|
|
7661
|
+
if (result.ok) {
|
|
7662
|
+
console.log(`Checkpoint ${args[0]} resolved:`);
|
|
7663
|
+
for (const line of result.done) console.log(` ${line}`);
|
|
7664
|
+
for (const warning of result.warnings) console.error(` Warning: ${warning}`);
|
|
7665
|
+
return;
|
|
7666
|
+
}
|
|
7667
|
+
console.error(`checkpoint ${args[0]} failed at ${result.failedStep}: ${result.reason}`);
|
|
7668
|
+
for (const line of result.done) console.error(` Done before failure: ${line}`);
|
|
7669
|
+
for (const warning of result.warnings) console.error(` Warning: ${warning}`);
|
|
7670
|
+
console.error(" Fix the cause and run the same command again: what already landed is skipped, and the emit runs once, last.");
|
|
7671
|
+
exit(1);
|
|
7672
|
+
};
|
|
7673
|
+
const tokenFileOf = (config) => resolveTokenFile(config?.design_tokens.file);
|
|
5932
7674
|
const designTokens = async (args) => {
|
|
5933
7675
|
const action = args[0] ?? "validate";
|
|
5934
7676
|
if (action === "validate") return designTokensValidate(args);
|
|
@@ -5938,18 +7680,20 @@ const designTokens = async (args) => {
|
|
|
5938
7680
|
throw new Error(`Usage: lemony design-tokens <validate [--scan=<dir>] | contrast | import --from=<file> [--apply] [--only=<paths>] | export [--tool-state=<file>] [--out=<file>] [--record]>. Unknown action "${action}".`);
|
|
5939
7681
|
};
|
|
5940
7682
|
const designTokensValidate = async (args) => {
|
|
5941
|
-
const config = await
|
|
7683
|
+
const config = await readOptionalHarnessConfig(cwd());
|
|
7684
|
+
const tokenFile = tokenFileOf(config);
|
|
5942
7685
|
const result = await runValidate({
|
|
5943
7686
|
repoRoot: cwd(),
|
|
7687
|
+
tokenFile,
|
|
5944
7688
|
scanDir: parseFlag(args, "scan"),
|
|
5945
7689
|
extraExtensions: config?.design_tokens.scan_extensions
|
|
5946
7690
|
});
|
|
5947
7691
|
if (!result.tokensFound) {
|
|
5948
|
-
console.log(`No
|
|
7692
|
+
console.log(`No ${tokenFile} — design-tokens validate skipped (consume-if-exists; the gates never create it).`);
|
|
5949
7693
|
return;
|
|
5950
7694
|
}
|
|
5951
7695
|
if (result.ok) {
|
|
5952
|
-
console.log(`design-tokens valid: ${result.tokenCount} token(s), ${result.filesScanned} file(s) scanned, no hardcoded values.`);
|
|
7696
|
+
console.log(result.filesScanned === 0 ? `design-tokens valid: ${result.tokenCount} token(s), but 0 file(s) scanned — no readable source with a scanned extension was found, so nothing was checked for hardcoded values.` : `design-tokens valid: ${result.tokenCount} token(s), ${result.filesScanned} file(s) scanned, no hardcoded values.`);
|
|
5953
7697
|
return;
|
|
5954
7698
|
}
|
|
5955
7699
|
console.error(`design-tokens validate found ${result.violations.length} violation(s):`);
|
|
@@ -5959,19 +7703,32 @@ const designTokensValidate = async (args) => {
|
|
|
5959
7703
|
}
|
|
5960
7704
|
exit(1);
|
|
5961
7705
|
};
|
|
7706
|
+
const verifierEnding = (outcome) => outcome.signal === void 0 ? `exited ${outcome.code}` : `was killed by ${outcome.signal}`;
|
|
7707
|
+
const quotedCommand = (command) => {
|
|
7708
|
+
const lines = command.trimEnd().split("\n");
|
|
7709
|
+
return lines.length > 1 ? `\`${lines[0]} …\`` : `\`${command}\``;
|
|
7710
|
+
};
|
|
7711
|
+
const drained = (stream) => new Promise((resolve) => {
|
|
7712
|
+
stream.once("error", () => resolve());
|
|
7713
|
+
stream.write("", () => resolve());
|
|
7714
|
+
});
|
|
7715
|
+
const contrastFailedLine = (pairCheck, verify) => {
|
|
7716
|
+
if (verify === void 0) return `design-tokens contrast failed: ${pairCheck}.`;
|
|
7717
|
+
const outcome = verify.code === 0 && verify.signal === void 0 ? "passed (exited 0)" : verifierEnding(verify);
|
|
7718
|
+
return `design-tokens contrast failed: ${pairCheck}; design_tokens.verify ${quotedCommand(verify.command)} ${outcome}.`;
|
|
7719
|
+
};
|
|
5962
7720
|
const designTokensContrast = async () => {
|
|
5963
|
-
const
|
|
7721
|
+
const config = await readOptionalHarnessConfig(cwd());
|
|
7722
|
+
const verify = config?.design_tokens.verify;
|
|
7723
|
+
const tokenFile = tokenFileOf(config);
|
|
5964
7724
|
const result = await runContrast({
|
|
5965
7725
|
repoRoot: cwd(),
|
|
7726
|
+
tokenFile,
|
|
5966
7727
|
...verify === void 0 ? {} : { verify: {
|
|
5967
7728
|
command: verify,
|
|
5968
7729
|
run: makeVerifierRunCommand()
|
|
5969
7730
|
} }
|
|
5970
7731
|
});
|
|
5971
|
-
if (!result.tokensFound) {
|
|
5972
|
-
console.log(`No docs/design-tokens.json — design-tokens contrast skipped (consume-if-exists; the harness never creates it).`);
|
|
5973
|
-
return;
|
|
5974
|
-
}
|
|
5975
7732
|
const failures = result.pairs.filter((pair) => !pair.passes);
|
|
5976
7733
|
const forwardVerifier = () => {
|
|
5977
7734
|
if (result.verify === void 0) return;
|
|
@@ -5979,13 +7736,28 @@ const designTokensContrast = async () => {
|
|
|
5979
7736
|
if (out.length > 0) stdout.write(out.endsWith("\n") ? out : `${out}\n`);
|
|
5980
7737
|
if (err.length > 0) stderr.write(err.endsWith("\n") ? err : `${err}\n`);
|
|
5981
7738
|
};
|
|
7739
|
+
const failedVerifier = result.verify !== void 0 && result.verify.code !== 0 ? result.verify : void 0;
|
|
7740
|
+
if (!result.tokensFound) {
|
|
7741
|
+
console.log(result.verify === void 0 ? `No ${tokenFile} — design-tokens contrast skipped (consume-if-exists; the gates never create it).` : `No ${tokenFile} — design-tokens contrast pair check skipped (consume-if-exists; the gates never create it); running the declared design_tokens.verify.`);
|
|
7742
|
+
if (result.verify === void 0) return;
|
|
7743
|
+
if (failedVerifier) console.error(`design-tokens contrast found 1 issue(s):`);
|
|
7744
|
+
forwardVerifier();
|
|
7745
|
+
await Promise.all([drained(stdout), drained(stderr)]);
|
|
7746
|
+
if (failedVerifier) {
|
|
7747
|
+
console.error(` design_tokens.verify ${quotedCommand(failedVerifier.command)} ${verifierEnding(failedVerifier)}.`);
|
|
7748
|
+
console.error(contrastFailedLine(`pair check skipped (no ${tokenFile})`, failedVerifier));
|
|
7749
|
+
process.exitCode = 1;
|
|
7750
|
+
return;
|
|
7751
|
+
}
|
|
7752
|
+
console.log(`design-tokens verify OK: ${quotedCommand(result.verify.command)} exited 0.`);
|
|
7753
|
+
return;
|
|
7754
|
+
}
|
|
5982
7755
|
if (result.ok) {
|
|
5983
7756
|
console.log(result.pairsChecked === 0 ? `design-tokens contrast: no foreground/background pairs found (use the on-* convention or $extensions["com.lemony.contrast"]).` : `design-tokens contrast OK: ${result.pairsChecked} pair(s) meet their WCAG floor.`);
|
|
5984
7757
|
forwardVerifier();
|
|
5985
|
-
if (result.verify !== void 0) console.log(`design-tokens verify OK:
|
|
7758
|
+
if (result.verify !== void 0) console.log(`design-tokens verify OK: ${quotedCommand(result.verify.command)} exited 0.`);
|
|
5986
7759
|
return;
|
|
5987
7760
|
}
|
|
5988
|
-
const failedVerifier = result.verify !== void 0 && result.verify.code !== 0 ? result.verify : void 0;
|
|
5989
7761
|
console.error(`design-tokens contrast found ${failures.length + result.problems.length + (failedVerifier ? 1 : 0)} issue(s):`);
|
|
5990
7762
|
for (const pair of failures) {
|
|
5991
7763
|
const over = pair.over === void 0 ? "" : ` over ${pair.over}`;
|
|
@@ -5993,54 +7765,80 @@ const designTokensContrast = async () => {
|
|
|
5993
7765
|
}
|
|
5994
7766
|
for (const problem of result.problems) console.error(` ${problem}`);
|
|
5995
7767
|
forwardVerifier();
|
|
5996
|
-
|
|
7768
|
+
await Promise.all([drained(stdout), drained(stderr)]);
|
|
7769
|
+
if (failedVerifier) console.error(` design_tokens.verify ${quotedCommand(failedVerifier.command)} ${verifierEnding(failedVerifier)}.`);
|
|
7770
|
+
const pairIssues = failures.length + result.problems.length;
|
|
7771
|
+
console.error(contrastFailedLine(pairIssues > 0 ? result.pairsChecked === 0 ? `pair check measured no pair (${pairIssues} issue(s) above)` : `pair check found ${pairIssues} issue(s)` : result.pairsChecked === 0 ? "pair check found no pairs" : "pair check passed", result.verify));
|
|
5997
7772
|
process.exitCode = 1;
|
|
5998
7773
|
};
|
|
5999
7774
|
const designTokensImport = async (args) => {
|
|
6000
7775
|
const from = parseFlag(args, "from");
|
|
6001
7776
|
if (from === void 0) throw new Error("Usage: lemony design-tokens import --from=<neutral-file> [--apply] [--only=<dotted,paths>]");
|
|
7777
|
+
rejectValuelessFlags("design-tokens import", args, ["only"]);
|
|
6002
7778
|
const only = parseFlag(args, "only")?.split(",").map((path) => path.trim()).filter((path) => path.length > 0);
|
|
7779
|
+
if (only?.length === 0) throw new Error("design-tokens import: --only names no path.");
|
|
7780
|
+
const tokenFile = tokenFileOf(await readOptionalHarnessConfig(cwd()));
|
|
6003
7781
|
const result = await runImport({
|
|
6004
7782
|
repoRoot: cwd(),
|
|
7783
|
+
tokenFile,
|
|
6005
7784
|
neutralPath: resolve(cwd(), from),
|
|
6006
7785
|
apply: args.includes("--apply"),
|
|
6007
7786
|
only
|
|
6008
7787
|
});
|
|
6009
7788
|
if (result.problems.length > 0) {
|
|
6010
|
-
console.error("design-tokens import could not run:");
|
|
7789
|
+
console.error(result.refused ? "design-tokens import --apply refused:" : "design-tokens import could not run:");
|
|
6011
7790
|
for (const problem of result.problems) console.error(` ${problem}`);
|
|
6012
|
-
|
|
7791
|
+
if (result.changes.length > 0) printImportPreview(result);
|
|
7792
|
+
process.exitCode = 1;
|
|
7793
|
+
return;
|
|
6013
7794
|
}
|
|
6014
7795
|
if (result.applied) {
|
|
6015
|
-
console.log(`design-tokens import applied: wrote ${result.written.length} token(s) to ${
|
|
7796
|
+
console.log(`design-tokens import applied: wrote ${result.written.length} token(s) to ${tokenFile}.`);
|
|
6016
7797
|
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(", ")}`);
|
|
7798
|
+
if (result.unsynced.length > 0) console.log(` not applied: ${result.unsynced.length} token(s) outside the design-tool sync, which one tool value cannot replace: ${result.unsynced.join(", ")}`);
|
|
6017
7799
|
return;
|
|
6018
7800
|
}
|
|
7801
|
+
const landing = printImportPreview(result);
|
|
7802
|
+
for (const warning of result.warnings) console.error(` Warning: ${warning}`);
|
|
7803
|
+
if (landing > 0) console.log(`Curate the slice, then: lemony design-tokens import --from=${from} --apply --only=<paths>`);
|
|
7804
|
+
};
|
|
7805
|
+
const printImportPreview = (result) => {
|
|
6019
7806
|
const landing = result.changes.filter((change) => change.kind !== "unchanged");
|
|
6020
7807
|
const news = landing.filter((change) => change.kind === "new").length;
|
|
6021
7808
|
const changed = landing.length - news;
|
|
6022
7809
|
console.log(`design-tokens import preview: ${news} new, ${changed} changed, ${result.changes.length - landing.length} unchanged.`);
|
|
6023
7810
|
for (const change of landing) {
|
|
6024
|
-
const detail = change.kind === "changed" ?
|
|
7811
|
+
const detail = change.kind === "changed" ? changedDetail(change) : change.after;
|
|
6025
7812
|
console.log(` [${change.kind}] ${change.path} (${change.tier}): ${detail}`);
|
|
6026
7813
|
}
|
|
6027
|
-
|
|
7814
|
+
return landing.length;
|
|
6028
7815
|
};
|
|
7816
|
+
const changedDetail = (change) => {
|
|
7817
|
+
const parts = [];
|
|
7818
|
+
if (change.before !== change.after) parts.push(`${shown(change.before)} → ${shown(change.after)}`);
|
|
7819
|
+
if (change.typeChange) parts.push(`$type ${shown(change.typeChange.before)} → ${shown(change.typeChange.after)}`);
|
|
7820
|
+
for (const mode of change.modeChanges ?? []) parts.push(`${mode.mode}: ${shown(mode.before)} → ${shown(mode.after)}`);
|
|
7821
|
+
return parts.join("; ");
|
|
7822
|
+
};
|
|
7823
|
+
const shown = (value) => value === void 0 || value === "" ? "(none)" : value;
|
|
6029
7824
|
const designTokensExport = async (args) => {
|
|
7825
|
+
rejectValuelessFlags("design-tokens export", args, ["tool-state", "out"]);
|
|
6030
7826
|
const toolState = parseFlag(args, "tool-state");
|
|
6031
7827
|
const out = parseFlag(args, "out");
|
|
6032
7828
|
if (out !== void 0 && args.includes("--record")) {
|
|
6033
7829
|
console.error("design-tokens export: --out and --record are mutually exclusive — preview/push with --out, then --record only after the push succeeds.");
|
|
6034
7830
|
exit(1);
|
|
6035
7831
|
}
|
|
7832
|
+
const tokenFile = tokenFileOf(await readOptionalHarnessConfig(cwd()));
|
|
6036
7833
|
const result = await runExport({
|
|
6037
7834
|
repoRoot: cwd(),
|
|
7835
|
+
tokenFile,
|
|
6038
7836
|
...toolState ? { toolStatePath: resolve(cwd(), toolState) } : {},
|
|
6039
7837
|
...out ? { outPath: resolve(cwd(), out) } : {},
|
|
6040
7838
|
record: args.includes("--record")
|
|
6041
7839
|
});
|
|
6042
7840
|
if (!result.tokensFound) {
|
|
6043
|
-
console.log(`No ${
|
|
7841
|
+
console.log(`No ${tokenFile} — design-tokens export skipped (consume-if-exists; export never creates it).`);
|
|
6044
7842
|
return;
|
|
6045
7843
|
}
|
|
6046
7844
|
if (result.problems.length > 0) {
|
|
@@ -6055,6 +7853,7 @@ const designTokensExport = async (args) => {
|
|
|
6055
7853
|
const creates = result.plan.filter((entry) => entry.kind === "create").length;
|
|
6056
7854
|
const updates = result.plan.filter((entry) => entry.kind === "update").length;
|
|
6057
7855
|
console.log(`design-tokens export plan: ${creates} to create, ${updates} to update (tool-only variables are never deleted).`);
|
|
7856
|
+
if (result.unprojected.length > 0) console.log(` not projected: ${result.unprojected.length} token(s) a design tool variable cannot hold: ${result.unprojected.map(({ path, reason }) => `${path} (${reason})`).join(", ")}`);
|
|
6058
7857
|
if (out !== void 0) console.log(` projection written to ${out}`);
|
|
6059
7858
|
};
|
|
6060
7859
|
const REVIEW_LEDGER_USAGE = "Usage: lemony review-ledger validate --task-id=<id> --anchor=<oid> (--step=<N> | --full-pass).";
|
|
@@ -6128,12 +7927,14 @@ const spinoff = async (args) => {
|
|
|
6128
7927
|
console.error(`spinoff failed at "${result.failedStep}": ${result.reason}`);
|
|
6129
7928
|
exit(1);
|
|
6130
7929
|
};
|
|
6131
|
-
const status = async () => {
|
|
7930
|
+
const status = async (args) => {
|
|
7931
|
+
const local = args.includes("--local");
|
|
6132
7932
|
const report = await runStatus({
|
|
6133
7933
|
repoRoot: cwd(),
|
|
6134
7934
|
readGitUserEmail,
|
|
6135
|
-
gitBehind,
|
|
6136
|
-
runCommand: makeRunCommand()
|
|
7935
|
+
gitBehind: (root) => readGitBehind(root, makeRunCommand()),
|
|
7936
|
+
runCommand: makeRunCommand(),
|
|
7937
|
+
local
|
|
6137
7938
|
});
|
|
6138
7939
|
const behindText = report.behind === null ? "behind unknown" : report.behind === 0 ? "up to date" : `${report.behind} behind`;
|
|
6139
7940
|
const discoveries = report.openDiscoveries === null ? "unknown" : String(report.openDiscoveries);
|
|
@@ -6143,7 +7944,7 @@ const status = async () => {
|
|
|
6143
7944
|
console.log(` Active task: ${report.activeTask ?? "(none)"}`);
|
|
6144
7945
|
console.log(` Branch: ${report.branch ?? "(unknown)"} (${behindText})`);
|
|
6145
7946
|
console.log(` Last session: ${report.lastSession ?? "(none)"}`);
|
|
6146
|
-
console.log(` Open discoveries: ${discoveries}`);
|
|
7947
|
+
if (!local) console.log(` Open discoveries: ${discoveries}`);
|
|
6147
7948
|
if (report.designToolDrift !== null) console.log(` Design tool: ${DESIGN_TOOL_STATUS[report.designToolDrift]}`);
|
|
6148
7949
|
};
|
|
6149
7950
|
const DESIGN_TOOL_STATUS = {
|
|
@@ -6230,6 +8031,9 @@ const main = async () => {
|
|
|
6230
8031
|
case "discovery":
|
|
6231
8032
|
await discovery(args);
|
|
6232
8033
|
return;
|
|
8034
|
+
case "checkpoint":
|
|
8035
|
+
await checkpoint(args);
|
|
8036
|
+
return;
|
|
6233
8037
|
case "design-tokens":
|
|
6234
8038
|
await designTokens(args);
|
|
6235
8039
|
return;
|
|
@@ -6243,7 +8047,7 @@ const main = async () => {
|
|
|
6243
8047
|
await runTelemetry(args);
|
|
6244
8048
|
return;
|
|
6245
8049
|
case "status":
|
|
6246
|
-
await status();
|
|
8050
|
+
await status(args);
|
|
6247
8051
|
return;
|
|
6248
8052
|
case "doctor":
|
|
6249
8053
|
await doctor();
|