@lemoncode/lemony 0.5.1 → 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 +3 -1
- package/catalog/harness.config.schema.json +4 -0
- package/catalog/hooks/init.sh +60 -9
- package/catalog/hooks/lib/live-branch.sh +37 -0
- package/catalog/hooks/lib/merge-pr.sh +14 -4
- 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 +15 -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 +8 -2
- package/dist/cli.mjs +1694 -513
- 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
9
|
import { constants } from "node:fs";
|
|
10
10
|
import { z } from "zod";
|
|
11
11
|
import { createHash, randomBytes } from "node:crypto";
|
|
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"];
|
|
@@ -150,14 +154,18 @@ const openForWrite = async (path) => {
|
|
|
150
154
|
try {
|
|
151
155
|
return await open(path, WRITE | constants.O_CREAT | constants.O_EXCL);
|
|
152
156
|
} catch (error) {
|
|
153
|
-
|
|
154
|
-
if (
|
|
155
|
-
throw 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 });
|
|
156
159
|
}
|
|
160
|
+
return await open(path, WRITE);
|
|
157
161
|
};
|
|
158
162
|
//#endregion
|
|
159
163
|
//#region src/fs/symlink-guard.ts
|
|
160
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) => {
|
|
161
169
|
const components = /* @__PURE__ */ new Set();
|
|
162
170
|
for (const relPath of relPaths) {
|
|
163
171
|
let current = "";
|
|
@@ -167,16 +175,112 @@ const assertNoSymlinkTraversal = async (root, relPaths) => {
|
|
|
167
175
|
components.add(current);
|
|
168
176
|
}
|
|
169
177
|
}
|
|
170
|
-
|
|
178
|
+
return (await Promise.all([...components].map(async (rel) => {
|
|
171
179
|
try {
|
|
172
180
|
return (await lstat(join(root, rel))).isSymbolicLink() ? rel : null;
|
|
173
181
|
} catch {
|
|
174
182
|
return null;
|
|
175
183
|
}
|
|
176
184
|
}))).filter((rel) => rel !== null);
|
|
177
|
-
if (offenders.length > 0) throw new Error("Refusing to write — a managed path traverses a symlink (a redirect could land outside the repo):\n" + offenders.map((rel) => ` ${rel}`).join("\n"));
|
|
178
185
|
};
|
|
179
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
|
|
180
284
|
//#region src/config/config-error.ts
|
|
181
285
|
const formatConfigError = (schema, error) => {
|
|
182
286
|
return error.issues.flatMap((issue) => formatIssue(schema, issue)).join("\n");
|
|
@@ -240,6 +344,16 @@ const levenshtein = (a, b) => {
|
|
|
240
344
|
return prev[b.length] ?? 0;
|
|
241
345
|
};
|
|
242
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
|
|
243
357
|
//#region src/config/config.schema.ts
|
|
244
358
|
const pathsSchema = z.object({
|
|
245
359
|
playbooks: z.string().default(PATHS_DEFAULTS.playbooks),
|
|
@@ -253,7 +367,15 @@ const rollbackSchema = z.object({ keep_snapshots: z.union([z.int().positive(), z
|
|
|
253
367
|
const telemetrySchema = z.object({ enabled: z.boolean().default(true) }).strict().prefault({});
|
|
254
368
|
const designTokensSchema = z.object({
|
|
255
369
|
scan_extensions: z.array(z.string()).default([]),
|
|
256
|
-
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()
|
|
257
379
|
}).strict().prefault({});
|
|
258
380
|
const mergeSchema = z.object({
|
|
259
381
|
checks_timeout_secs: z.int().positive().default(600),
|
|
@@ -275,24 +397,31 @@ const harnessConfigSchema = z.object({
|
|
|
275
397
|
}).strict();
|
|
276
398
|
//#endregion
|
|
277
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);
|
|
278
401
|
const readHarnessConfig = async (repoRoot) => {
|
|
279
402
|
const configPath = join(repoRoot, HARNESS_CONFIG_FILENAME);
|
|
280
403
|
let raw;
|
|
281
404
|
try {
|
|
282
405
|
raw = await readRegularFile(configPath);
|
|
283
406
|
} catch (cause) {
|
|
284
|
-
|
|
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
|
+
}
|
|
285
413
|
throw new Error(`Cannot read ${HARNESS_CONFIG_FILENAME} at ${repoRoot}: ${cause instanceof Error ? cause.message : String(cause)}`, { cause });
|
|
286
414
|
}
|
|
287
415
|
let parsed;
|
|
288
416
|
try {
|
|
289
417
|
parsed = parse(raw);
|
|
290
418
|
} catch (cause) {
|
|
291
|
-
throw new Error(
|
|
419
|
+
throw new Error(CONFIG_MALFORMED_MESSAGE, { cause });
|
|
292
420
|
}
|
|
293
|
-
if (
|
|
421
|
+
if (typeof parsed !== "object" || parsed === null || Object.getPrototypeOf(parsed) !== Object.prototype) throw new Error(CONFIG_MALFORMED_MESSAGE);
|
|
294
422
|
const candidate = { ...parsed };
|
|
295
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];
|
|
296
425
|
const paths = candidate.paths;
|
|
297
426
|
if (paths && typeof paths === "object" && !Array.isArray(paths)) {
|
|
298
427
|
const pathsCandidate = { ...paths };
|
|
@@ -309,6 +438,16 @@ const readHarnessConfig = async (repoRoot) => {
|
|
|
309
438
|
if (!result.success) throw new Error(`${HARNESS_CONFIG_FILENAME} is invalid:\n${formatConfigError(harnessConfigSchema, result.error)}`);
|
|
310
439
|
return result.data;
|
|
311
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
|
+
};
|
|
312
451
|
const readOptionalHarnessConfig = async (repoRoot) => {
|
|
313
452
|
let present;
|
|
314
453
|
try {
|
|
@@ -316,13 +455,8 @@ const readOptionalHarnessConfig = async (repoRoot) => {
|
|
|
316
455
|
} catch (error) {
|
|
317
456
|
throw new Error(`Cannot read ${HARNESS_CONFIG_FILENAME} at ${repoRoot}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
318
457
|
}
|
|
319
|
-
if (!present) return void 0;
|
|
320
|
-
|
|
321
|
-
return await readHarnessConfig(repoRoot);
|
|
322
|
-
} catch (error) {
|
|
323
|
-
if (error.cause?.code === "ENOENT") throw new Error(`Cannot read ${HARNESS_CONFIG_FILENAME} at ${repoRoot}: the entry is there but its target is missing (a broken symlink?).`, { cause: error });
|
|
324
|
-
throw error;
|
|
325
|
-
}
|
|
458
|
+
if (!present && !await hasInstallBaseline(repoRoot)) return void 0;
|
|
459
|
+
return readHarnessConfig(repoRoot);
|
|
326
460
|
};
|
|
327
461
|
//#endregion
|
|
328
462
|
//#region src/config/compare-version.ts
|
|
@@ -380,6 +514,34 @@ const setConfigValues = (rawYaml, updates) => {
|
|
|
380
514
|
for (const key of DEPRECATED_CONFIG_KEYS) if (doc.has(key)) doc.delete(key);
|
|
381
515
|
for (const key of DEPRECATED_PATHS_KEYS) if (doc.hasIn(["paths", key])) doc.deleteIn(["paths", key]);
|
|
382
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;
|
|
383
545
|
return doc.toString();
|
|
384
546
|
};
|
|
385
547
|
const writeConfigValues = async (repoRoot, updates) => {
|
|
@@ -411,20 +573,6 @@ const pointerFrontmatterSchema = z.object({
|
|
|
411
573
|
});
|
|
412
574
|
Object.keys(pointerFrontmatterSchema.shape);
|
|
413
575
|
//#endregion
|
|
414
|
-
//#region src/paths/claude-paths.constant.ts
|
|
415
|
-
const CLAUDE_DIR = ".claude";
|
|
416
|
-
const STATE_DIR = join(CLAUDE_DIR, "state");
|
|
417
|
-
const TASKS_DIR = join(STATE_DIR, "tasks");
|
|
418
|
-
const SKILLS_DIR = join(CLAUDE_DIR, "skills");
|
|
419
|
-
const AGENTS_DIR = join(CLAUDE_DIR, "agents");
|
|
420
|
-
const HOOKS_DIR = join(CLAUDE_DIR, "hooks");
|
|
421
|
-
const COMMANDS_DIR = join(CLAUDE_DIR, "commands");
|
|
422
|
-
const SETTINGS_FILE = join(CLAUDE_DIR, "settings.json");
|
|
423
|
-
const HARNESS_DIR = join(CLAUDE_DIR, ".harness");
|
|
424
|
-
const BASELINE_ROOT_REL = join(HARNESS_DIR, "baseline");
|
|
425
|
-
const SNAPSHOTS_ROOT_REL = join(HARNESS_DIR, "snapshots");
|
|
426
|
-
const EVENTS_RELPATH = join(STATE_DIR, "events.jsonl");
|
|
427
|
-
//#endregion
|
|
428
576
|
//#region src/events/envelope.ts
|
|
429
577
|
const buildEnvelope = (input) => {
|
|
430
578
|
const ts = (input.now ?? /* @__PURE__ */ new Date()).toISOString();
|
|
@@ -537,7 +685,8 @@ const taskDoneSchema = envelopeBase.extend({
|
|
|
537
685
|
cycle_time_h: z.number().nonnegative().finite(),
|
|
538
686
|
review_rejections: z.number().int().nonnegative(),
|
|
539
687
|
mode: z.enum(IMPLEMENTATION_MODES).optional(),
|
|
540
|
-
steps: z.number().int().positive().optional()
|
|
688
|
+
steps: z.number().int().positive().optional(),
|
|
689
|
+
checkpoints: z.number().int().positive().optional()
|
|
541
690
|
}).strict();
|
|
542
691
|
const reviewRejectedSchema = envelopeBase.extend({
|
|
543
692
|
type: z.literal("review_rejected"),
|
|
@@ -584,6 +733,16 @@ const eventSchema = z.discriminatedUnion("type", [
|
|
|
584
733
|
stepCompletedSchema
|
|
585
734
|
]);
|
|
586
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
|
|
587
746
|
//#region src/events/emit.ts
|
|
588
747
|
const NUMERIC_FIELDS = /* @__PURE__ */ new Set([
|
|
589
748
|
"session_active_h",
|
|
@@ -595,6 +754,7 @@ const NUMERIC_FIELDS = /* @__PURE__ */ new Set([
|
|
|
595
754
|
"discovered_h",
|
|
596
755
|
"step",
|
|
597
756
|
"steps",
|
|
757
|
+
"checkpoints",
|
|
598
758
|
"review_iterations"
|
|
599
759
|
]);
|
|
600
760
|
const BOOLEAN_FIELDS = /* @__PURE__ */ new Set(["auto_close"]);
|
|
@@ -625,7 +785,7 @@ const parseFlags = (args) => {
|
|
|
625
785
|
}
|
|
626
786
|
return result;
|
|
627
787
|
};
|
|
628
|
-
const
|
|
788
|
+
const prepareEmit = async (options) => {
|
|
629
789
|
const { repoRoot, harnessVersion, args } = options;
|
|
630
790
|
const [rawType, ...rest] = args;
|
|
631
791
|
if (!rawType) throw new Error(`Usage: lemony emit <type> [--field=value …]. Types: ${EVENT_TYPES.join(", ")}.`);
|
|
@@ -652,11 +812,217 @@ const runEmit = async (options) => {
|
|
|
652
812
|
const issues = parsed.error.issues.map((issue) => ` - ${issue.path.join(".") || "(root)"}: ${issue.message}`).join("\n");
|
|
653
813
|
throw new Error(`Event validation failed for type "${rawType}":\n${issues}`);
|
|
654
814
|
}
|
|
655
|
-
const
|
|
656
|
-
|
|
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")}`);
|
|
657
817
|
return {
|
|
658
818
|
event: parsed.data,
|
|
659
|
-
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
|
|
660
1026
|
};
|
|
661
1027
|
};
|
|
662
1028
|
//#endregion
|
|
@@ -1140,8 +1506,17 @@ const failureReason$1 = (result) => {
|
|
|
1140
1506
|
return `\`gh\` failed (exit ${result.code})${suffix}${detail ? `: ${detail}` : ""}`;
|
|
1141
1507
|
};
|
|
1142
1508
|
//#endregion
|
|
1143
|
-
//#region src/design-tokens/
|
|
1144
|
-
const
|
|
1509
|
+
//#region src/design-tokens/read-token-file.ts
|
|
1510
|
+
const readOptionalTokenFile = async (repoRoot, tokenFile) => {
|
|
1511
|
+
const tokenPath = join(repoRoot, tokenFile);
|
|
1512
|
+
try {
|
|
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 });
|
|
1518
|
+
}
|
|
1519
|
+
};
|
|
1145
1520
|
const SCANNED_EXTENSIONS = [
|
|
1146
1521
|
".css",
|
|
1147
1522
|
".scss",
|
|
@@ -1182,19 +1557,6 @@ const IGNORED_DIRS = /* @__PURE__ */ new Set([
|
|
|
1182
1557
|
".cache",
|
|
1183
1558
|
".claude"
|
|
1184
1559
|
]);
|
|
1185
|
-
const NESTED_TOO_DEEPLY = `${DESIGN_TOKENS_FILE} nests deeper than 64 levels, the most Lemony reads.`;
|
|
1186
|
-
//#endregion
|
|
1187
|
-
//#region src/design-tokens/read-token-file.ts
|
|
1188
|
-
const readOptionalTokenFile = async (repoRoot) => {
|
|
1189
|
-
const tokenPath = join(repoRoot, DESIGN_TOKENS_FILE);
|
|
1190
|
-
try {
|
|
1191
|
-
if (!await entryExists(tokenPath)) return void 0;
|
|
1192
|
-
return await readRegularFile(tokenPath);
|
|
1193
|
-
} catch (error) {
|
|
1194
|
-
if (error.code === "ENOENT") throw new Error(`Cannot read ${DESIGN_TOKENS_FILE} at ${repoRoot}: the entry is there but its target is missing (a broken symlink?).`, { cause: error });
|
|
1195
|
-
throw new Error(`Cannot read ${DESIGN_TOKENS_FILE} at ${repoRoot}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
1196
|
-
}
|
|
1197
|
-
};
|
|
1198
1560
|
//#endregion
|
|
1199
1561
|
//#region src/design-tokens/design-tokens.model.ts
|
|
1200
1562
|
const TOKEN_TIERS = [
|
|
@@ -1215,6 +1577,7 @@ const WCAG_FLOORS = {
|
|
|
1215
1577
|
};
|
|
1216
1578
|
//#endregion
|
|
1217
1579
|
//#region src/design-tokens/nesting-depth.ts
|
|
1580
|
+
const nestedTooDeeply = (tokenFile) => `${tokenFile} nests deeper than 64 levels, the most Lemony reads.`;
|
|
1218
1581
|
const exceedsNestingLimit = (value, limit = 64) => {
|
|
1219
1582
|
const pending = [{
|
|
1220
1583
|
node: value,
|
|
@@ -1233,7 +1596,7 @@ const exceedsNestingLimit = (value, limit = 64) => {
|
|
|
1233
1596
|
};
|
|
1234
1597
|
//#endregion
|
|
1235
1598
|
//#region src/design-tokens/non-finite-number.ts
|
|
1236
|
-
const nonFiniteNumberFaults = (value) => nonFiniteNumbers(value, []).map(({ path, number }) => `${
|
|
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.`);
|
|
1237
1600
|
const nonFiniteNumbers = (value, path) => {
|
|
1238
1601
|
if (typeof value === "number") return Number.isFinite(value) ? [] : [{
|
|
1239
1602
|
path: path.join("."),
|
|
@@ -1251,11 +1614,15 @@ const aliasTarget = (value) => {
|
|
|
1251
1614
|
};
|
|
1252
1615
|
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1253
1616
|
//#endregion
|
|
1617
|
+
//#region src/design-tokens/resolve-token-file.ts
|
|
1618
|
+
const resolveTokenFile = (declared) => declared ?? "docs/design-tokens.json";
|
|
1619
|
+
//#endregion
|
|
1254
1620
|
//#region src/design-tokens/design-tokens.ts
|
|
1255
1621
|
const runValidate = async (inputs) => {
|
|
1622
|
+
const tokenFile = resolveTokenFile(inputs.tokenFile);
|
|
1256
1623
|
let raw;
|
|
1257
1624
|
try {
|
|
1258
|
-
raw = await readOptionalTokenFile(inputs.repoRoot);
|
|
1625
|
+
raw = await readOptionalTokenFile(inputs.repoRoot, tokenFile);
|
|
1259
1626
|
} catch (error) {
|
|
1260
1627
|
return {
|
|
1261
1628
|
ok: false,
|
|
@@ -1275,7 +1642,7 @@ const runValidate = async (inputs) => {
|
|
|
1275
1642
|
filesScanned: 0,
|
|
1276
1643
|
violations: []
|
|
1277
1644
|
};
|
|
1278
|
-
const fileCheck = validateTokenFile(raw);
|
|
1645
|
+
const fileCheck = validateTokenFile(raw, tokenFile);
|
|
1279
1646
|
if (!fileCheck.ok) return {
|
|
1280
1647
|
ok: false,
|
|
1281
1648
|
tokensFound: true,
|
|
@@ -1298,7 +1665,7 @@ const runValidate = async (inputs) => {
|
|
|
1298
1665
|
};
|
|
1299
1666
|
}
|
|
1300
1667
|
const extensions = mergeExtensions(inputs.extraExtensions);
|
|
1301
|
-
const sourceViolations = await scanSource(inputs.repoRoot, scanRoot, extensions);
|
|
1668
|
+
const sourceViolations = await scanSource(inputs.repoRoot, scanRoot, extensions, tokenFile);
|
|
1302
1669
|
return {
|
|
1303
1670
|
ok: sourceViolations.violations.length === 0,
|
|
1304
1671
|
tokensFound: true,
|
|
@@ -1307,14 +1674,15 @@ const runValidate = async (inputs) => {
|
|
|
1307
1674
|
violations: sourceViolations.violations
|
|
1308
1675
|
};
|
|
1309
1676
|
};
|
|
1310
|
-
const summarizeTokenFileViolations = (raw) => summarizeMessages(tokenFileViolationMessages(raw));
|
|
1311
|
-
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);
|
|
1312
1680
|
const summarizeMessages = (messages) => {
|
|
1313
1681
|
const [first, ...more] = messages;
|
|
1314
1682
|
if (first === void 0) return void 0;
|
|
1315
1683
|
return `${first}${more.length > 0 ? ` (and ${more.length} more)` : ""}`;
|
|
1316
1684
|
};
|
|
1317
|
-
const validateTokenFile = (raw) => {
|
|
1685
|
+
const validateTokenFile = (raw, tokenFile) => {
|
|
1318
1686
|
let parsed;
|
|
1319
1687
|
try {
|
|
1320
1688
|
parsed = JSON.parse(raw);
|
|
@@ -1324,7 +1692,7 @@ const validateTokenFile = (raw) => {
|
|
|
1324
1692
|
tokenCount: 0,
|
|
1325
1693
|
violations: [{
|
|
1326
1694
|
kind: "invalid-json",
|
|
1327
|
-
message: `${
|
|
1695
|
+
message: `${tokenFile} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`
|
|
1328
1696
|
}]
|
|
1329
1697
|
};
|
|
1330
1698
|
}
|
|
@@ -1333,7 +1701,7 @@ const validateTokenFile = (raw) => {
|
|
|
1333
1701
|
tokenCount: 0,
|
|
1334
1702
|
violations: [{
|
|
1335
1703
|
kind: "malformed-token-file",
|
|
1336
|
-
message: `${
|
|
1704
|
+
message: `${tokenFile} must be a JSON object with primitive/semantic/component tiers.`
|
|
1337
1705
|
}]
|
|
1338
1706
|
};
|
|
1339
1707
|
if (exceedsNestingLimit(parsed)) return {
|
|
@@ -1341,11 +1709,11 @@ const validateTokenFile = (raw) => {
|
|
|
1341
1709
|
tokenCount: 0,
|
|
1342
1710
|
violations: [{
|
|
1343
1711
|
kind: "malformed-token-file",
|
|
1344
|
-
message:
|
|
1712
|
+
message: nestedTooDeeply(tokenFile)
|
|
1345
1713
|
}]
|
|
1346
1714
|
};
|
|
1347
1715
|
const violations = [];
|
|
1348
|
-
for (const message of nonFiniteNumberFaults(parsed)) violations.push({
|
|
1716
|
+
for (const message of nonFiniteNumberFaults(parsed, tokenFile)) violations.push({
|
|
1349
1717
|
kind: "malformed-token-file",
|
|
1350
1718
|
message
|
|
1351
1719
|
});
|
|
@@ -1409,53 +1777,160 @@ const collectAliasViolations = (tokens) => {
|
|
|
1409
1777
|
const violations = [];
|
|
1410
1778
|
for (const [path, token] of tokens) for (const [mode, value] of Object.entries(token.modes)) {
|
|
1411
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
|
+
}
|
|
1412
1787
|
if (alias !== void 0 && !tokens.has(alias)) violations.push({
|
|
1413
1788
|
kind: "unresolved-alias",
|
|
1414
1789
|
message: `token "${path}" references "{${alias}}" in mode "${mode}", which does not exist.`
|
|
1415
1790
|
});
|
|
1416
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()]);
|
|
1417
1807
|
for (const [path, token] of tokens) {
|
|
1418
1808
|
const alias = aliasTarget(token.value);
|
|
1419
|
-
if (alias
|
|
1420
|
-
if (token.tier === "primitive") {
|
|
1809
|
+
if (alias !== void 0 && token.tier === "primitive") {
|
|
1421
1810
|
violations.push({
|
|
1422
1811
|
kind: "primitive-not-literal",
|
|
1423
1812
|
message: `primitive token "${path}" must hold a literal value, not the alias "{${alias}}".`
|
|
1424
1813
|
});
|
|
1425
1814
|
continue;
|
|
1426
1815
|
}
|
|
1427
|
-
if (!tokens.has(alias)) {
|
|
1816
|
+
if (alias !== void 0 && !tokens.has(alias)) {
|
|
1428
1817
|
violations.push({
|
|
1429
1818
|
kind: "unresolved-alias",
|
|
1430
1819
|
message: `token "${path}" references "{${alias}}", which does not exist.`
|
|
1431
1820
|
});
|
|
1432
1821
|
continue;
|
|
1433
1822
|
}
|
|
1434
|
-
if (
|
|
1823
|
+
if (token.tier === "primitive") continue;
|
|
1824
|
+
if (loopsInBase.has(path)) violations.push({
|
|
1435
1825
|
kind: "cyclic-alias",
|
|
1436
1826
|
message: `token "${path}" has an alias chain that never resolves to a literal value (alias cycle).`
|
|
1437
1827
|
});
|
|
1438
1828
|
}
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
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({
|
|
1442
1863
|
kind: "cyclic-alias",
|
|
1443
1864
|
message: `token "${path}" has an alias chain in mode "${mode}" that never resolves to a literal value (alias cycle).`
|
|
1444
1865
|
});
|
|
1445
1866
|
}
|
|
1446
1867
|
return violations;
|
|
1447
1868
|
};
|
|
1448
|
-
const
|
|
1449
|
-
const
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
if (
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
current = aliasTarget(mode !== void 0 && Object.hasOwn(token.modes, mode) ? token.modes[mode] : 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);
|
|
1457
1877
|
}
|
|
1458
|
-
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));
|
|
1459
1934
|
};
|
|
1460
1935
|
const mergeExtensions = (extra) => {
|
|
1461
1936
|
const set = new Set(SCANNED_EXTENSIONS);
|
|
@@ -1483,7 +1958,7 @@ const describeUnreadableDir = async (dir) => {
|
|
|
1483
1958
|
return `can be listed but not searched (${codeOf(error)})`;
|
|
1484
1959
|
}
|
|
1485
1960
|
};
|
|
1486
|
-
const scanSource = async (repoRoot, scanRoot, extensions) => {
|
|
1961
|
+
const scanSource = async (repoRoot, scanRoot, extensions, tokenFile) => {
|
|
1487
1962
|
if (!await pathExists(scanRoot)) return {
|
|
1488
1963
|
filesScanned: 0,
|
|
1489
1964
|
violations: []
|
|
@@ -1501,7 +1976,7 @@ const scanSource = async (repoRoot, scanRoot, extensions) => {
|
|
|
1501
1976
|
if (content === void 0) continue;
|
|
1502
1977
|
filesScanned += 1;
|
|
1503
1978
|
const relPath = relative(repoRoot, file).split(sep).join("/");
|
|
1504
|
-
violations.push(...scanFile(relPath, extname(file).toLowerCase(), content));
|
|
1979
|
+
violations.push(...scanFile(relPath, extname(file).toLowerCase(), content, tokenFile));
|
|
1505
1980
|
}
|
|
1506
1981
|
return {
|
|
1507
1982
|
filesScanned,
|
|
@@ -1532,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]{
|
|
|
1532
2007
|
const FUNCTIONAL_COLOR = /\b(?:rgb|rgba|hsl|hsla)\s*\(/i;
|
|
1533
2008
|
const PX_DIMENSION = /\b(\d*\.?\d+)px\b/g;
|
|
1534
2009
|
const EXEMPT_DIMENSIONS = /* @__PURE__ */ new Set(["0", "1"]);
|
|
1535
|
-
const scanFile = (relPath, ext, content) => {
|
|
2010
|
+
const scanFile = (relPath, ext, content, tokenFile) => {
|
|
1536
2011
|
const isStylesheet = STYLESHEET_EXTENSIONS.includes(ext);
|
|
1537
2012
|
const violations = [];
|
|
1538
2013
|
content.split("\n").forEach((line, index) => {
|
|
@@ -1540,13 +2015,13 @@ const scanFile = (relPath, ext, content) => {
|
|
|
1540
2015
|
const lineNo = index + 1;
|
|
1541
2016
|
if (HEX_COLOR.test(line) || FUNCTIONAL_COLOR.test(line)) violations.push({
|
|
1542
2017
|
kind: "hardcoded-color",
|
|
1543
|
-
message: `raw color literal — reference a token from ${
|
|
2018
|
+
message: `raw color literal — reference a token from ${tokenFile} instead.`,
|
|
1544
2019
|
file: relPath,
|
|
1545
2020
|
line: lineNo
|
|
1546
2021
|
});
|
|
1547
2022
|
if (isStylesheet && hasRawDimension(line)) violations.push({
|
|
1548
2023
|
kind: "hardcoded-dimension",
|
|
1549
|
-
message: `raw dimension literal — reference a token from ${
|
|
2024
|
+
message: `raw dimension literal — reference a token from ${tokenFile} instead.`,
|
|
1550
2025
|
file: relPath,
|
|
1551
2026
|
line: lineNo
|
|
1552
2027
|
});
|
|
@@ -1694,7 +2169,11 @@ const contrastRatio = (foreground, background) => {
|
|
|
1694
2169
|
const l2 = relativeLuminance(bg);
|
|
1695
2170
|
const light = Math.max(l1, l2);
|
|
1696
2171
|
const dark = Math.min(l1, l2);
|
|
1697
|
-
return
|
|
2172
|
+
return (light + .05) / (dark + .05);
|
|
2173
|
+
};
|
|
2174
|
+
const truncateRatio = (ratio) => {
|
|
2175
|
+
const hundredths = Math.floor(ratio * 100);
|
|
2176
|
+
return hundredths / 100 > ratio ? (hundredths - 1) / 100 : hundredths / 100;
|
|
1698
2177
|
};
|
|
1699
2178
|
//#endregion
|
|
1700
2179
|
//#region src/design-tokens/contrast.model.ts
|
|
@@ -1706,10 +2185,11 @@ const CONTRAST_LEVELS = [
|
|
|
1706
2185
|
//#endregion
|
|
1707
2186
|
//#region src/design-tokens/contrast.ts
|
|
1708
2187
|
const runContrast = async (inputs) => {
|
|
2188
|
+
const tokenFile = resolveTokenFile(inputs.tokenFile);
|
|
1709
2189
|
let raw;
|
|
1710
2190
|
let unreadable;
|
|
1711
2191
|
try {
|
|
1712
|
-
raw = await readOptionalTokenFile(inputs.repoRoot);
|
|
2192
|
+
raw = await readOptionalTokenFile(inputs.repoRoot, tokenFile);
|
|
1713
2193
|
} catch (error) {
|
|
1714
2194
|
unreadable = error instanceof Error ? error.message : String(error);
|
|
1715
2195
|
}
|
|
@@ -1732,18 +2212,18 @@ const runContrast = async (inputs) => {
|
|
|
1732
2212
|
return withVerify({
|
|
1733
2213
|
...emptyResult$1(true),
|
|
1734
2214
|
ok: false,
|
|
1735
|
-
problems: [`${
|
|
2215
|
+
problems: [`${tokenFile} is not valid JSON (${error instanceof Error ? error.message : String(error)}) — run \`design-tokens validate\` first.`]
|
|
1736
2216
|
});
|
|
1737
2217
|
}
|
|
1738
2218
|
if (!isRecord(parsed)) return withVerify({
|
|
1739
2219
|
...emptyResult$1(true),
|
|
1740
2220
|
ok: false,
|
|
1741
|
-
problems: [`${
|
|
2221
|
+
problems: [`${tokenFile} must be a JSON object — run \`design-tokens validate\` first.`]
|
|
1742
2222
|
});
|
|
1743
2223
|
if (exceedsNestingLimit(parsed)) return withVerify({
|
|
1744
2224
|
...emptyResult$1(true),
|
|
1745
2225
|
ok: false,
|
|
1746
|
-
problems: [`${
|
|
2226
|
+
problems: [`${nestedTooDeeply(tokenFile)} Run \`design-tokens validate\` first.`]
|
|
1747
2227
|
});
|
|
1748
2228
|
const tokens = collectTokens(parsed);
|
|
1749
2229
|
const problems = [];
|
|
@@ -2025,7 +2505,7 @@ const makeRow = (spec, mode, foreground, background) => {
|
|
|
2025
2505
|
...spec.over === void 0 ? {} : { over: spec.over },
|
|
2026
2506
|
mode,
|
|
2027
2507
|
level: spec.level,
|
|
2028
|
-
ratio,
|
|
2508
|
+
ratio: truncateRatio(ratio),
|
|
2029
2509
|
floor,
|
|
2030
2510
|
passes: ratio >= floor,
|
|
2031
2511
|
source: spec.source
|
|
@@ -2040,7 +2520,7 @@ const resolveColor = (path, mode, tokens, seen = /* @__PURE__ */ new Set()) => {
|
|
|
2040
2520
|
let value = token.value;
|
|
2041
2521
|
if (mode !== "base") {
|
|
2042
2522
|
const modes = readExtension(token.node, MODES_EXTENSION);
|
|
2043
|
-
if (isRecord(modes) && modes
|
|
2523
|
+
if (isRecord(modes) && Object.hasOwn(modes, mode)) value = modes[mode];
|
|
2044
2524
|
}
|
|
2045
2525
|
const alias = aliasTarget(value);
|
|
2046
2526
|
if (alias !== void 0) return resolveColor(alias, mode, tokens, seen);
|
|
@@ -2076,7 +2556,7 @@ const NEUTRAL_VALUE_OR_REF = "Each variable carries a string \"value\" (a litera
|
|
|
2076
2556
|
const NEUTRAL_NAME_SHAPE = "A name is dot-separated segments, like \"primitive.color.brand\" — fix the adapter that wrote the file.";
|
|
2077
2557
|
const VALIDATE_HINT = "Run `lemony design-tokens validate` for the full list.";
|
|
2078
2558
|
const IMPORT_INVALID_HINT = "Change the `--only` slice (add what it references), or fix the file first; nothing was written.";
|
|
2079
|
-
const
|
|
2559
|
+
const IMPORT_FAULT_CLAUSE = "Fix the variables named above in the design tool, or leave them out with `--only`";
|
|
2080
2560
|
const IMPORT_PREVIEW_INVALID_HINT = "`--apply` refuses any slice that leaves it failing: include what repairs it, or fix the file first.";
|
|
2081
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.";
|
|
2082
2562
|
//#endregion
|
|
@@ -2152,7 +2632,7 @@ const ownUnsyncedReason = (node) => {
|
|
|
2152
2632
|
for (const [name, value] of Object.entries(modes)) {
|
|
2153
2633
|
if (name.trim().length === 0) return "a mode with a blank name";
|
|
2154
2634
|
if (name === "__proto__") return "a mode named \"__proto__\"";
|
|
2155
|
-
if (!isScalar(value)) return `mode "${name}" holding neither text, a number nor a boolean`;
|
|
2635
|
+
if (!isScalar$1(value)) return `mode "${name}" holding neither text, a number nor a boolean`;
|
|
2156
2636
|
}
|
|
2157
2637
|
};
|
|
2158
2638
|
const aliasTargetsOf = (node) => {
|
|
@@ -2164,7 +2644,7 @@ const modesExtension = (node) => {
|
|
|
2164
2644
|
return isRecord(extensions) ? extensions[MODES_EXTENSION] : void 0;
|
|
2165
2645
|
};
|
|
2166
2646
|
const isCompositeValue = (value) => typeof value === "object" && value !== null;
|
|
2167
|
-
const isScalar = (value) => typeof value === "string" || typeof value === "number" || typeof value === "boolean";
|
|
2647
|
+
const isScalar$1 = (value) => typeof value === "string" || typeof value === "number" || typeof value === "boolean";
|
|
2168
2648
|
const byName = (a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
|
|
2169
2649
|
const toVariable$1 = (name, node) => {
|
|
2170
2650
|
const type = typeof node["$type"] === "string" ? node["$type"] : "";
|
|
@@ -2189,7 +2669,7 @@ const readModes$1 = (node) => {
|
|
|
2189
2669
|
const modes = modesExtension(node);
|
|
2190
2670
|
if (!isRecord(modes)) return void 0;
|
|
2191
2671
|
const out = {};
|
|
2192
|
-
for (const [name, value] of Object.entries(modes)) if (isScalar(value)) out[name] = String(value);
|
|
2672
|
+
for (const [name, value] of Object.entries(modes)) if (isScalar$1(value)) out[name] = String(value);
|
|
2193
2673
|
return Object.keys(out).length > 0 ? out : void 0;
|
|
2194
2674
|
};
|
|
2195
2675
|
const landingPath = (variable, known) => {
|
|
@@ -2197,7 +2677,7 @@ const landingPath = (variable, known) => {
|
|
|
2197
2677
|
if (KNOWN_TIERS.has(first)) return variable.name;
|
|
2198
2678
|
const existing = existingTierPath(variable.name, known);
|
|
2199
2679
|
if (existing !== void 0) return existing;
|
|
2200
|
-
return `${variable.ref
|
|
2680
|
+
return `${variable.ref !== void 0 || Object.values(variable.modes ?? {}).some((modeValue) => aliasTarget(modeValue) !== void 0) ? "semantic" : "primitive"}.${variable.name}`;
|
|
2201
2681
|
};
|
|
2202
2682
|
const existingTierPath = (bare, known) => {
|
|
2203
2683
|
const matches = TOKEN_TIERS.map((tier) => `${tier}.${bare}`).filter((path) => known.has(path));
|
|
@@ -2286,6 +2766,7 @@ const diffNeutralAgainstJson = (neutral, parsed) => {
|
|
|
2286
2766
|
}
|
|
2287
2767
|
return changes.toSorted((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
|
|
2288
2768
|
};
|
|
2769
|
+
const ownMode = (modes, mode) => Object.hasOwn(modes, mode) ? modes[mode] : void 0;
|
|
2289
2770
|
const changeDetail = (existing, incoming) => {
|
|
2290
2771
|
const detail = {};
|
|
2291
2772
|
if (existing.type !== incoming.type) detail.typeChange = {
|
|
@@ -2294,11 +2775,15 @@ const changeDetail = (existing, incoming) => {
|
|
|
2294
2775
|
};
|
|
2295
2776
|
const before = existing.modes ?? {};
|
|
2296
2777
|
const after = incoming.modes ?? {};
|
|
2297
|
-
const modeChanges = [.../* @__PURE__ */ new Set([...Object.keys(before), ...Object.keys(after)])].toSorted().filter((mode) => before
|
|
2298
|
-
mode
|
|
2299
|
-
|
|
2300
|
-
|
|
2301
|
-
|
|
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
|
+
});
|
|
2302
2787
|
if (modeChanges.length > 0) detail.modeChanges = modeChanges;
|
|
2303
2788
|
return detail;
|
|
2304
2789
|
};
|
|
@@ -2469,15 +2954,15 @@ const jsonKind = (value) => {
|
|
|
2469
2954
|
const kind = typeof value;
|
|
2470
2955
|
return kind === "object" ? "an object" : `a ${kind}`;
|
|
2471
2956
|
};
|
|
2472
|
-
const readJsonObject = (raw) => {
|
|
2957
|
+
const readJsonObject = (raw, tokenFile) => {
|
|
2473
2958
|
let parsed;
|
|
2474
2959
|
try {
|
|
2475
2960
|
parsed = JSON.parse(raw);
|
|
2476
2961
|
} catch (error) {
|
|
2477
|
-
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 });
|
|
2478
2963
|
}
|
|
2479
|
-
if (!isRecord(parsed)) throw new Error(`${
|
|
2480
|
-
if (exceedsNestingLimit(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.`);
|
|
2481
2966
|
return parsed;
|
|
2482
2967
|
};
|
|
2483
2968
|
const writeJsonFile = (value) => `${JSON.stringify(value, null, 2)}\n`;
|
|
@@ -2509,13 +2994,14 @@ const runImport = async (inputs) => {
|
|
|
2509
2994
|
...empty,
|
|
2510
2995
|
problems: [`neutral file schemaVersion ${neutral.schemaVersion} is not supported (expected 1).`]
|
|
2511
2996
|
};
|
|
2997
|
+
const tokenFile = resolveTokenFile(inputs.tokenFile);
|
|
2512
2998
|
let tokensFound = false;
|
|
2513
2999
|
let raw;
|
|
2514
3000
|
let parsed;
|
|
2515
3001
|
try {
|
|
2516
|
-
raw = await readOptionalTokenFile(inputs.repoRoot);
|
|
3002
|
+
raw = await readOptionalTokenFile(inputs.repoRoot, tokenFile);
|
|
2517
3003
|
tokensFound = raw !== void 0;
|
|
2518
|
-
parsed = raw === void 0 ? {} : readJsonObject(raw);
|
|
3004
|
+
parsed = raw === void 0 ? {} : readJsonObject(raw, tokenFile);
|
|
2519
3005
|
} catch (error) {
|
|
2520
3006
|
return {
|
|
2521
3007
|
...empty,
|
|
@@ -2525,12 +3011,12 @@ const runImport = async (inputs) => {
|
|
|
2525
3011
|
}
|
|
2526
3012
|
const changes = diffNeutralAgainstJson(neutral, parsed);
|
|
2527
3013
|
if (!inputs.apply) {
|
|
2528
|
-
const faults = summarizeMessages(raw === void 0 ? [] : tokenFileViolationMessages(raw));
|
|
3014
|
+
const faults = summarizeMessages(raw === void 0 ? [] : tokenFileViolationMessages(raw, tokenFile));
|
|
2529
3015
|
return {
|
|
2530
3016
|
...empty,
|
|
2531
3017
|
tokensFound,
|
|
2532
3018
|
changes,
|
|
2533
|
-
warnings: faults === void 0 ? [] : [`${
|
|
3019
|
+
warnings: faults === void 0 ? [] : [`${tokenFile} already fails validation: ${faults} ${IMPORT_PREVIEW_INVALID_HINT} ${VALIDATE_HINT}`]
|
|
2534
3020
|
};
|
|
2535
3021
|
}
|
|
2536
3022
|
const unmatched = (inputs.only ?? []).filter((path) => !changes.some((change) => change.path === path));
|
|
@@ -2547,28 +3033,30 @@ const runImport = async (inputs) => {
|
|
|
2547
3033
|
tokensFound,
|
|
2548
3034
|
changes,
|
|
2549
3035
|
refused: true,
|
|
2550
|
-
problems: [`not applied — with this import, ${
|
|
3036
|
+
problems: [`not applied — with this import, ${tokenFile} would fail validation: ${nestedTooDeeply(tokenFile)} ${IMPORT_TOO_DEEP_HINT}`]
|
|
2551
3037
|
};
|
|
2552
3038
|
const text = writeJsonFile(merged);
|
|
2553
|
-
const
|
|
3039
|
+
const mergedViolations = tokenFileViolations(text, tokenFile);
|
|
3040
|
+
const mergedFaults = [...nonFiniteNumberFaults(merged, tokenFile), ...mergedViolations.map((violation) => violation.message)];
|
|
2554
3041
|
if (mergedFaults.length > 0) {
|
|
2555
|
-
const onDisk = new Set(raw === void 0 ? [] : tokenFileViolationMessages(raw));
|
|
3042
|
+
const onDisk = new Set(raw === void 0 ? [] : tokenFileViolationMessages(raw, tokenFile));
|
|
2556
3043
|
const introduced = mergedFaults.filter((fault) => !onDisk.has(fault));
|
|
2557
3044
|
const landings = new Set(changes.map((change) => change.path));
|
|
2558
|
-
const dangling = danglingAliasTargets(merged, written);
|
|
3045
|
+
const dangling = danglingAliasTargets(merged, written.filter((path) => !path.startsWith("primitive.")));
|
|
2559
3046
|
const leftBehind = dangling.filter((target) => landings.has(target));
|
|
2560
3047
|
const notCarried = dangling.filter((target) => !landings.has(target));
|
|
2561
|
-
const
|
|
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);
|
|
2562
3050
|
return {
|
|
2563
3051
|
...empty,
|
|
2564
3052
|
tokensFound,
|
|
2565
3053
|
changes,
|
|
2566
3054
|
refused: true,
|
|
2567
|
-
problems: [introduced.length > 0 ? `not applied — with this import, ${
|
|
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}`]
|
|
2568
3056
|
};
|
|
2569
3057
|
}
|
|
2570
3058
|
if (written.length > 0) {
|
|
2571
|
-
const tokenPath = join(inputs.repoRoot,
|
|
3059
|
+
const tokenPath = join(inputs.repoRoot, tokenFile);
|
|
2572
3060
|
await mkdir(dirname(tokenPath), { recursive: true });
|
|
2573
3061
|
await writeFile(tokenPath, text);
|
|
2574
3062
|
}
|
|
@@ -2584,6 +3072,14 @@ const runImport = async (inputs) => {
|
|
|
2584
3072
|
warnings: []
|
|
2585
3073
|
};
|
|
2586
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
|
+
};
|
|
2587
3083
|
const asMessage$1 = (error) => error instanceof Error ? error.message : String(error);
|
|
2588
3084
|
//#endregion
|
|
2589
3085
|
//#region src/design-tokens/drift.ts
|
|
@@ -2640,16 +3136,17 @@ const runExport = async (inputs) => {
|
|
|
2640
3136
|
recorded: false,
|
|
2641
3137
|
problems: []
|
|
2642
3138
|
};
|
|
3139
|
+
const tokenFile = resolveTokenFile(inputs.tokenFile);
|
|
2643
3140
|
let parsed;
|
|
2644
3141
|
try {
|
|
2645
|
-
const raw = await readOptionalTokenFile(inputs.repoRoot);
|
|
3142
|
+
const raw = await readOptionalTokenFile(inputs.repoRoot, tokenFile);
|
|
2646
3143
|
if (raw === void 0) return empty;
|
|
2647
|
-
parsed = readJsonObject(raw);
|
|
2648
|
-
const invalid = summarizeTokenFileViolations(raw);
|
|
3144
|
+
parsed = readJsonObject(raw, tokenFile);
|
|
3145
|
+
const invalid = summarizeTokenFileViolations(raw, tokenFile);
|
|
2649
3146
|
if (invalid !== void 0) return {
|
|
2650
3147
|
...empty,
|
|
2651
3148
|
tokensFound: true,
|
|
2652
|
-
problems: [`${
|
|
3149
|
+
problems: [`${tokenFile} fails validation: ${invalid} ${VALIDATE_HINT}`]
|
|
2653
3150
|
};
|
|
2654
3151
|
} catch (error) {
|
|
2655
3152
|
return {
|
|
@@ -2662,7 +3159,7 @@ const runExport = async (inputs) => {
|
|
|
2662
3159
|
if (!binding) return {
|
|
2663
3160
|
...empty,
|
|
2664
3161
|
tokensFound: true,
|
|
2665
|
-
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.`]
|
|
2666
3163
|
};
|
|
2667
3164
|
const hash = projectionHash(parsed);
|
|
2668
3165
|
let toolState;
|
|
@@ -2681,11 +3178,11 @@ const runExport = async (inputs) => {
|
|
|
2681
3178
|
if (inputs.outPath !== void 0) {
|
|
2682
3179
|
const projection = dtcgToNeutral(parsed);
|
|
2683
3180
|
projection.provider = binding.provider;
|
|
2684
|
-
if (await sameEntry(inputs.outPath, join(inputs.repoRoot,
|
|
3181
|
+
if (await sameEntry(inputs.outPath, join(inputs.repoRoot, tokenFile))) return {
|
|
2685
3182
|
...empty,
|
|
2686
3183
|
tokensFound: true,
|
|
2687
3184
|
hash,
|
|
2688
|
-
problems: [`Cannot write the projection to ${inputs.outPath}: it is ${
|
|
3185
|
+
problems: [`Cannot write the projection to ${inputs.outPath}: it is ${tokenFile} itself, which the projection would replace. Name another path.`]
|
|
2689
3186
|
};
|
|
2690
3187
|
try {
|
|
2691
3188
|
await writeRegularFile(inputs.outPath, writeNeutralFile(projection));
|
|
@@ -2700,7 +3197,7 @@ const runExport = async (inputs) => {
|
|
|
2700
3197
|
}
|
|
2701
3198
|
if (inputs.record) {
|
|
2702
3199
|
const stamped = writeJsonFile(stampBaseline(parsed, hash));
|
|
2703
|
-
const tokenPath = join(inputs.repoRoot,
|
|
3200
|
+
const tokenPath = join(inputs.repoRoot, tokenFile);
|
|
2704
3201
|
await writeFile(tokenPath, stamped);
|
|
2705
3202
|
return {
|
|
2706
3203
|
tokensFound: true,
|
|
@@ -2735,6 +3232,51 @@ const sameEntry = async (a, b) => {
|
|
|
2735
3232
|
};
|
|
2736
3233
|
const asMessage = (error) => error instanceof Error ? error.message : String(error);
|
|
2737
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
|
|
2738
3280
|
//#region src/review-ledger/review-ledger.constant.ts
|
|
2739
3281
|
const REVIEW_LEDGER_DIRNAME = "review-ledger";
|
|
2740
3282
|
const FULL_PASS_FILENAME = "full-pass.json";
|
|
@@ -2745,18 +3287,29 @@ const SPEC_SIDE_KINDS = [
|
|
|
2745
3287
|
"group-missing",
|
|
2746
3288
|
"group-empty",
|
|
2747
3289
|
"duplicate-group-index",
|
|
3290
|
+
"duplicate-task-id",
|
|
2748
3291
|
"orphan-task",
|
|
2749
3292
|
"malformed-risk-marker",
|
|
2750
3293
|
"malformed-task-refs",
|
|
3294
|
+
"malformed-task-line",
|
|
3295
|
+
"malformed-group-header",
|
|
2751
3296
|
"unknown-risk-class",
|
|
2752
3297
|
"dangling-requirement-ref",
|
|
2753
3298
|
"unticked-completed-task"
|
|
2754
3299
|
];
|
|
2755
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*/;
|
|
2756
3304
|
const RISK_MARKER = /\[risk:\s*([^\]]*)\]\s*$/;
|
|
2757
3305
|
const RISK_MARKER_ALL = /\[risk:/gi;
|
|
2758
3306
|
const RISK_MARKER_LOOKALIKE = /\[\s*risks?\s*:/i;
|
|
2759
|
-
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/;
|
|
2760
3313
|
const TASK_REFS = /^\(((?:R\d+)(?:\s*,\s*R\d+)*)\)/;
|
|
2761
3314
|
const TASK_REFS_LOOKALIKE_ALL = /\(\s*R\d+/g;
|
|
2762
3315
|
const TASK_TITLE_WRAPPER = /^\s*[-*+]\s*\[[ xX]\]\s*(\*\*|__)/;
|
|
@@ -2782,42 +3335,66 @@ const NOT_APPLICABLE_REASONS = [
|
|
|
2782
3335
|
];
|
|
2783
3336
|
//#endregion
|
|
2784
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 = /^ */;
|
|
2785
3349
|
const parseTasksSpec = (text) => {
|
|
2786
|
-
const lines = text.split(
|
|
2787
|
-
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");
|
|
2788
3355
|
const groups = [];
|
|
2789
3356
|
const orphanTaskIds = [];
|
|
2790
3357
|
const malformedRiskHeaders = [];
|
|
2791
3358
|
const malformedTaskRefs = [];
|
|
3359
|
+
const taskIdGroups = /* @__PURE__ */ new Map();
|
|
3360
|
+
const record = (id, group) => {
|
|
3361
|
+
taskIdGroups.set(id, [...taskIdGroups.get(id) ?? [], group]);
|
|
3362
|
+
};
|
|
2792
3363
|
let current;
|
|
2793
|
-
|
|
2794
|
-
|
|
2795
|
-
|
|
2796
|
-
|
|
2797
|
-
|
|
2798
|
-
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());
|
|
2799
3369
|
current = {
|
|
2800
|
-
index: Number(
|
|
2801
|
-
header: line.trim(),
|
|
3370
|
+
index: Number(GROUP_HEADER.exec(entry.line)?.[1]),
|
|
3371
|
+
header: entry.line.trim(),
|
|
2802
3372
|
riskClasses: risk.riskClasses,
|
|
2803
3373
|
unknownRiskClasses: risk.unknownRiskClasses,
|
|
2804
3374
|
tasks: []
|
|
2805
3375
|
};
|
|
2806
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);
|
|
2807
3387
|
continue;
|
|
2808
3388
|
}
|
|
2809
|
-
const
|
|
2810
|
-
if (!match) continue;
|
|
2811
|
-
const blockEnd = taskBlockEnd(lines, index);
|
|
2812
|
-
const parts = lines.slice(index, blockEnd).map((part) => part.trim());
|
|
2813
|
-
index = blockEnd - 1;
|
|
2814
|
-
const task = parseTaskBlock(match[2] ?? "", (match[1] ?? " ") !== " ", parts);
|
|
3389
|
+
const task = parseTaskBlock(id, entry.ticked, entry.parts, entry.refInCode);
|
|
2815
3390
|
if (task.malformedRefs) malformedTaskRefs.push(task.id);
|
|
2816
3391
|
if (current) {
|
|
3392
|
+
record(id, current.index);
|
|
2817
3393
|
current.tasks.push(task.task);
|
|
2818
3394
|
continue;
|
|
2819
3395
|
}
|
|
2820
3396
|
if (grouped) {
|
|
3397
|
+
record(id, null);
|
|
2821
3398
|
orphanTaskIds.push(task.id);
|
|
2822
3399
|
continue;
|
|
2823
3400
|
}
|
|
@@ -2828,6 +3405,7 @@ const parseTasksSpec = (text) => {
|
|
|
2828
3405
|
unknownRiskClasses: [],
|
|
2829
3406
|
tasks: [task.task]
|
|
2830
3407
|
});
|
|
3408
|
+
record(id, groups.length);
|
|
2831
3409
|
}
|
|
2832
3410
|
const seen = /* @__PURE__ */ new Set();
|
|
2833
3411
|
const duplicateIndexes = [];
|
|
@@ -2839,36 +3417,298 @@ const parseTasksSpec = (text) => {
|
|
|
2839
3417
|
groups,
|
|
2840
3418
|
orphanTaskIds,
|
|
2841
3419
|
duplicateIndexes,
|
|
3420
|
+
duplicateTaskIds: [...taskIdGroups].filter(([, copies]) => copies.length > 1).map(([id, copies]) => ({
|
|
3421
|
+
id,
|
|
3422
|
+
groups: copies
|
|
3423
|
+
})),
|
|
2842
3424
|
malformedRiskHeaders,
|
|
2843
|
-
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
|
+
});
|
|
2844
3454
|
};
|
|
2845
|
-
|
|
2846
|
-
const
|
|
2847
|
-
|
|
2848
|
-
|
|
2849
|
-
|
|
2850
|
-
|
|
2851
|
-
|
|
2852
|
-
|
|
2853
|
-
|
|
2854
|
-
|
|
2855
|
-
|
|
2856
|
-
|
|
2857
|
-
|
|
2858
|
-
|
|
2859
|
-
|
|
2860
|
-
|
|
2861
|
-
|
|
2862
|
-
|
|
2863
|
-
|
|
2864
|
-
|
|
2865
|
-
|
|
2866
|
-
},
|
|
2867
|
-
malformedRefs: refs === null && hasRefLookalike(parts)
|
|
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
|
+
});
|
|
2868
3476
|
};
|
|
2869
|
-
|
|
2870
|
-
const
|
|
2871
|
-
|
|
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
|
+
};
|
|
3575
|
+
}
|
|
3576
|
+
};
|
|
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) => {
|
|
3696
|
+
const block = parts.join(" ");
|
|
3697
|
+
const opener = refsOpener(parts, block);
|
|
3698
|
+
const refs = opener === void 0 ? null : declarationAt(parts, block, opener);
|
|
3699
|
+
const requirementRefs = refs ? (refs[1] ?? "").split(",").map((ref) => ref.trim()).filter(Boolean) : [];
|
|
3700
|
+
return {
|
|
3701
|
+
id,
|
|
3702
|
+
task: {
|
|
3703
|
+
id,
|
|
3704
|
+
ticked,
|
|
3705
|
+
requirementRefs: [...new Set(requirementRefs)]
|
|
3706
|
+
},
|
|
3707
|
+
malformedRefs: refs === null && (refInCode || hasRefLookalike(parts))
|
|
3708
|
+
};
|
|
3709
|
+
};
|
|
3710
|
+
const declarationAt = (parts, block, opener) => {
|
|
3711
|
+
const candidate = TASK_REFS.exec(block.slice(opener));
|
|
2872
3712
|
if (!candidate) return null;
|
|
2873
3713
|
return declarationClosesLine(parts, opener + candidate[0].length) ? candidate : null;
|
|
2874
3714
|
};
|
|
@@ -2946,9 +3786,11 @@ const parseRiskMarker = (line) => {
|
|
|
2946
3786
|
};
|
|
2947
3787
|
};
|
|
2948
3788
|
const parseRequirementIds = (text) => {
|
|
3789
|
+
const lines = text.split(LINE_BREAK).map((line) => line.replace(LINE_BOM, ""));
|
|
2949
3790
|
const ids = [];
|
|
2950
|
-
for (const
|
|
2951
|
-
|
|
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];
|
|
2952
3794
|
if (id && !ids.includes(id)) ids.push(id);
|
|
2953
3795
|
}
|
|
2954
3796
|
return ids;
|
|
@@ -3042,7 +3884,7 @@ const runLedgerValidate = async (inputs) => {
|
|
|
3042
3884
|
});
|
|
3043
3885
|
return result;
|
|
3044
3886
|
}
|
|
3045
|
-
const spec =
|
|
3887
|
+
const spec = parsedFrom(tasksRel, await readFile(join(inputs.repoRoot, tasksRel), "utf8"), parseTasksSpec);
|
|
3046
3888
|
for (const index of spec.duplicateIndexes) problems.push({
|
|
3047
3889
|
kind: "duplicate-group-index",
|
|
3048
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.`,
|
|
@@ -3052,14 +3894,32 @@ const runLedgerValidate = async (inputs) => {
|
|
|
3052
3894
|
kind: "orphan-task",
|
|
3053
3895
|
message: `${spec.orphanTaskIds.join(", ")} sit above the first group header, so they belong to no step and no review covers them.`
|
|
3054
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
|
+
}
|
|
3055
3905
|
for (const header of spec.malformedRiskHeaders) problems.push({
|
|
3056
3906
|
kind: "malformed-risk-marker",
|
|
3057
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.`,
|
|
3058
3908
|
subject: header
|
|
3059
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
|
+
});
|
|
3060
3920
|
for (const id of spec.malformedTaskRefs) problems.push({
|
|
3061
3921
|
kind: "malformed-task-refs",
|
|
3062
|
-
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.`,
|
|
3063
3923
|
subject: id
|
|
3064
3924
|
});
|
|
3065
3925
|
const groups = resolveGroups(spec.groups, address, problems);
|
|
@@ -3100,6 +3960,23 @@ const runLedgerValidate = async (inputs) => {
|
|
|
3100
3960
|
return result;
|
|
3101
3961
|
};
|
|
3102
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
|
+
};
|
|
3103
3980
|
const resolveGroups = (groups, address, problems) => {
|
|
3104
3981
|
if (address.kind === "full-pass") {
|
|
3105
3982
|
if (groups.length > 0) return groups;
|
|
@@ -3137,7 +4014,7 @@ const collectDanglingRefs = async (requirementsPath, sliceIds, problems) => {
|
|
|
3137
4014
|
});
|
|
3138
4015
|
return referenced;
|
|
3139
4016
|
}
|
|
3140
|
-
const declared =
|
|
4017
|
+
const declared = parsedFrom(requirementsPath, await readFile(requirementsPath, "utf8"), parseRequirementIds);
|
|
3141
4018
|
const dangling = referenced.filter((id) => !declared.includes(id));
|
|
3142
4019
|
for (const id of dangling) problems.push({
|
|
3143
4020
|
kind: "dangling-requirement-ref",
|
|
@@ -3298,6 +4175,13 @@ const checkCriteria = (required, dangling, ledger, problems) => {
|
|
|
3298
4175
|
present
|
|
3299
4176
|
};
|
|
3300
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
|
+
};
|
|
3301
4185
|
//#endregion
|
|
3302
4186
|
//#region src/spinoff/spinoff.constant.ts
|
|
3303
4187
|
const MANAGED_LABEL = "harness:managed";
|
|
@@ -3433,7 +4317,8 @@ const FIELD_AXIS = {
|
|
|
3433
4317
|
cycle_time_h: "metric",
|
|
3434
4318
|
review_rejections: "metric",
|
|
3435
4319
|
mode: "internal-enum",
|
|
3436
|
-
steps: "metric"
|
|
4320
|
+
steps: "metric",
|
|
4321
|
+
checkpoints: "metric"
|
|
3437
4322
|
},
|
|
3438
4323
|
review_rejected: {
|
|
3439
4324
|
...ENVELOPE_AXIS,
|
|
@@ -4224,9 +5109,215 @@ const renderRoleSkills = (selected, role) => {
|
|
|
4224
5109
|
return groups.length > 0 ? groups.join("\n\n") : "_None._";
|
|
4225
5110
|
};
|
|
4226
5111
|
//#endregion
|
|
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";
|
|
5116
|
+
//#endregion
|
|
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);
|
|
5120
|
+
//#endregion
|
|
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
|
+
}
|
|
5139
|
+
}
|
|
5140
|
+
return {
|
|
5141
|
+
merged: out.join("\n"),
|
|
5142
|
+
conflicted
|
|
5143
|
+
};
|
|
5144
|
+
};
|
|
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
|
+
}
|
|
5165
|
+
};
|
|
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
|
|
4227
5292
|
//#region src/doctor/cli-bin.constant.ts
|
|
4228
5293
|
const LEMONY_BIN = "lemony";
|
|
4229
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
|
|
4230
5321
|
//#region src/doctor/resolve-cli.ts
|
|
4231
5322
|
const isExecutable = async (path) => {
|
|
4232
5323
|
try {
|
|
@@ -4256,6 +5347,8 @@ const runDoctor = async (deps) => {
|
|
|
4256
5347
|
const { repoRoot } = deps;
|
|
4257
5348
|
const checks = [];
|
|
4258
5349
|
let config = null;
|
|
5350
|
+
let configSkip = "";
|
|
5351
|
+
let configState = "loaded";
|
|
4259
5352
|
try {
|
|
4260
5353
|
config = await readHarnessConfig(repoRoot);
|
|
4261
5354
|
checks.push({
|
|
@@ -4264,33 +5357,122 @@ const runDoctor = async (deps) => {
|
|
|
4264
5357
|
detail: `${HARNESS_CONFIG_FILENAME} is valid.`
|
|
4265
5358
|
});
|
|
4266
5359
|
} catch (error) {
|
|
5360
|
+
const fault = await configLoadFault(repoRoot, error);
|
|
5361
|
+
configSkip = fault.skip;
|
|
5362
|
+
configState = fault.state;
|
|
4267
5363
|
checks.push({
|
|
4268
5364
|
name: "config",
|
|
4269
5365
|
status: "fail",
|
|
4270
5366
|
detail: error.message,
|
|
4271
|
-
remediation:
|
|
5367
|
+
remediation: fault.remediation
|
|
4272
5368
|
});
|
|
4273
5369
|
}
|
|
4274
|
-
checks.push(await checkLabels(deps, config));
|
|
4275
|
-
|
|
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));
|
|
4276
5375
|
checks.push(await checkGitUser(deps));
|
|
4277
5376
|
checks.push(await checkGhAuth(deps, config));
|
|
4278
|
-
checks.push(checkVersionPin(deps, config));
|
|
5377
|
+
checks.push(checkVersionPin(deps, config, configSkip));
|
|
4279
5378
|
checks.push(await checkCliResolution(deps));
|
|
4280
|
-
checks.push(await checkCapabilities(deps, config));
|
|
4281
|
-
checks.push(await checkDesignToolDrift(deps));
|
|
4282
|
-
checks.push(await checkHookLib(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));
|
|
4283
5383
|
return {
|
|
4284
5384
|
checks,
|
|
4285
5385
|
ok: checks.every((check) => check.status !== "fail")
|
|
4286
5386
|
};
|
|
4287
5387
|
};
|
|
4288
|
-
const
|
|
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) => {
|
|
4289
5471
|
const name = "labels";
|
|
4290
5472
|
if (!config) return {
|
|
4291
5473
|
name,
|
|
4292
5474
|
status: "warn",
|
|
4293
|
-
detail:
|
|
5475
|
+
detail: configSkip
|
|
4294
5476
|
};
|
|
4295
5477
|
const slug = config.task_storage.repo;
|
|
4296
5478
|
if (slug === "OWNER/REPO") return {
|
|
@@ -4331,8 +5513,9 @@ const checkLabels = async (deps, config) => {
|
|
|
4331
5513
|
remediation: "Run `lemony repair` to reconcile the labels."
|
|
4332
5514
|
};
|
|
4333
5515
|
};
|
|
4334
|
-
const checkHooks = async (repoRoot) => {
|
|
5516
|
+
const checkHooks = async (repoRoot, restore) => {
|
|
4335
5517
|
const name = "hooks";
|
|
5518
|
+
const remedy = (steps) => restore.state === "broken" ? `${FIX_CONFIG_FIRST}.` : `${capitalize(steps)}.`;
|
|
4336
5519
|
const settingsPath = join(repoRoot, SETTINGS_FILE);
|
|
4337
5520
|
let raw;
|
|
4338
5521
|
try {
|
|
@@ -4343,19 +5526,19 @@ const checkHooks = async (repoRoot) => {
|
|
|
4343
5526
|
name,
|
|
4344
5527
|
status: "fail",
|
|
4345
5528
|
detail: ".claude/settings.json is a symlink whose target is missing.",
|
|
4346
|
-
remediation:
|
|
5529
|
+
remediation: remedy(`remove the symlink, then ${verbStep(restore, [SETTINGS_FILE])}`)
|
|
4347
5530
|
};
|
|
4348
5531
|
if (code === "ENOENT") return {
|
|
4349
5532
|
name,
|
|
4350
5533
|
status: "fail",
|
|
4351
5534
|
detail: ".claude/settings.json not found.",
|
|
4352
|
-
remediation:
|
|
5535
|
+
remediation: remedy(verbStep(restore))
|
|
4353
5536
|
};
|
|
4354
5537
|
return {
|
|
4355
5538
|
name,
|
|
4356
5539
|
status: "fail",
|
|
4357
5540
|
detail: `Cannot read .claude/settings.json: ${code === void 0 ? error.message : `${code}.`}`,
|
|
4358
|
-
remediation:
|
|
5541
|
+
remediation: remedy(`replace it with a regular, readable file (or remove it), then ${verbStep(restore, [SETTINGS_FILE])}`)
|
|
4359
5542
|
};
|
|
4360
5543
|
}
|
|
4361
5544
|
let commands;
|
|
@@ -4367,7 +5550,7 @@ const checkHooks = async (repoRoot) => {
|
|
|
4367
5550
|
name,
|
|
4368
5551
|
status: "fail",
|
|
4369
5552
|
detail: ".claude/settings.json is not valid JSON.",
|
|
4370
|
-
remediation:
|
|
5553
|
+
remediation: remedy(`fix the JSON or delete the file, then ${verbStep(restore)}`)
|
|
4371
5554
|
};
|
|
4372
5555
|
}
|
|
4373
5556
|
const missing = VENDOR_HOOK_COMMANDS.filter((want) => !commands.some((cmd) => cmd === want || cmd.endsWith(`/${want}`)));
|
|
@@ -4380,7 +5563,7 @@ const checkHooks = async (repoRoot) => {
|
|
|
4380
5563
|
name,
|
|
4381
5564
|
status: "warn",
|
|
4382
5565
|
detail: `Missing vendor hook wiring: ${missing.join(", ")}.`,
|
|
4383
|
-
remediation:
|
|
5566
|
+
remediation: remedy(verbStep(restore))
|
|
4384
5567
|
};
|
|
4385
5568
|
};
|
|
4386
5569
|
const checkGitUser = async (deps) => {
|
|
@@ -4417,12 +5600,12 @@ const checkGhAuth = async (deps, config) => {
|
|
|
4417
5600
|
remediation: "Run `gh auth login`."
|
|
4418
5601
|
};
|
|
4419
5602
|
};
|
|
4420
|
-
const checkVersionPin = (deps, config) => {
|
|
5603
|
+
const checkVersionPin = (deps, config, configSkip) => {
|
|
4421
5604
|
const name = "version-pin";
|
|
4422
5605
|
if (!config) return {
|
|
4423
5606
|
name,
|
|
4424
5607
|
status: "warn",
|
|
4425
|
-
detail:
|
|
5608
|
+
detail: configSkip
|
|
4426
5609
|
};
|
|
4427
5610
|
if (config.vendor_version === deps.installedVersion) return {
|
|
4428
5611
|
name,
|
|
@@ -4479,12 +5662,12 @@ const checkCliResolution = async (deps) => {
|
|
|
4479
5662
|
remediation: `Install \`${LEMONY_BIN}\` globally and keep it on PATH (this repo has no package.json to pin a devDependency).`
|
|
4480
5663
|
};
|
|
4481
5664
|
};
|
|
4482
|
-
const checkCapabilities = async (deps, config) => {
|
|
5665
|
+
const checkCapabilities = async (deps, config, configSkip) => {
|
|
4483
5666
|
const name = "capabilities";
|
|
4484
5667
|
if (!config) return {
|
|
4485
5668
|
name,
|
|
4486
5669
|
status: "warn",
|
|
4487
|
-
detail:
|
|
5670
|
+
detail: configSkip
|
|
4488
5671
|
};
|
|
4489
5672
|
let latent;
|
|
4490
5673
|
try {
|
|
@@ -4511,11 +5694,17 @@ const checkCapabilities = async (deps, config) => {
|
|
|
4511
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)."
|
|
4512
5695
|
};
|
|
4513
5696
|
};
|
|
4514
|
-
const checkDesignToolDrift = async (deps) => {
|
|
5697
|
+
const checkDesignToolDrift = async (deps, config, configSkip) => {
|
|
4515
5698
|
const name = "design-tool-drift";
|
|
5699
|
+
if (!config) return {
|
|
5700
|
+
name,
|
|
5701
|
+
status: "warn",
|
|
5702
|
+
detail: configSkip
|
|
5703
|
+
};
|
|
5704
|
+
const tokenFile = resolveTokenFile(config.design_tokens.file);
|
|
4516
5705
|
let raw;
|
|
4517
5706
|
try {
|
|
4518
|
-
raw = await readOptionalTokenFile(deps.repoRoot);
|
|
5707
|
+
raw = await readOptionalTokenFile(deps.repoRoot, tokenFile);
|
|
4519
5708
|
} catch (error) {
|
|
4520
5709
|
return {
|
|
4521
5710
|
name,
|
|
@@ -4527,7 +5716,7 @@ const checkDesignToolDrift = async (deps) => {
|
|
|
4527
5716
|
if (raw === void 0) return {
|
|
4528
5717
|
name,
|
|
4529
5718
|
status: "info",
|
|
4530
|
-
detail: `No ${
|
|
5719
|
+
detail: `No ${tokenFile} — token sync not in use.`
|
|
4531
5720
|
};
|
|
4532
5721
|
let parsed;
|
|
4533
5722
|
try {
|
|
@@ -4536,21 +5725,21 @@ const checkDesignToolDrift = async (deps) => {
|
|
|
4536
5725
|
return {
|
|
4537
5726
|
name,
|
|
4538
5727
|
status: "fail",
|
|
4539
|
-
detail: `${
|
|
5728
|
+
detail: `${tokenFile} is not valid JSON: ${error.message}`,
|
|
4540
5729
|
remediation: "Run `lemony design-tokens validate` to find the problem."
|
|
4541
5730
|
};
|
|
4542
5731
|
}
|
|
4543
5732
|
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return {
|
|
4544
5733
|
name,
|
|
4545
5734
|
status: "fail",
|
|
4546
|
-
detail: `${
|
|
5735
|
+
detail: `${tokenFile} is not a JSON object.`,
|
|
4547
5736
|
remediation: "Run `lemony design-tokens validate` to find the problem."
|
|
4548
5737
|
};
|
|
4549
|
-
const invalid = summarizeTokenFileViolations(raw);
|
|
5738
|
+
const invalid = summarizeTokenFileViolations(raw, tokenFile);
|
|
4550
5739
|
if (invalid !== void 0) return {
|
|
4551
5740
|
name,
|
|
4552
5741
|
status: "fail",
|
|
4553
|
-
detail: `${
|
|
5742
|
+
detail: `${tokenFile} fails validation: ${invalid}`,
|
|
4554
5743
|
remediation: "Run `lemony design-tokens validate` to find the problem."
|
|
4555
5744
|
};
|
|
4556
5745
|
const report = driftReport(parsed);
|
|
@@ -4579,7 +5768,7 @@ const checkDesignToolDrift = async (deps) => {
|
|
|
4579
5768
|
};
|
|
4580
5769
|
}
|
|
4581
5770
|
};
|
|
4582
|
-
const checkHookLib = async (deps) => {
|
|
5771
|
+
const checkHookLib = async (deps, restore) => {
|
|
4583
5772
|
const name = "hook-lib";
|
|
4584
5773
|
let helpers;
|
|
4585
5774
|
try {
|
|
@@ -4629,7 +5818,8 @@ const checkHookLib = async (deps) => {
|
|
|
4629
5818
|
if (!await isExecutable(installed)) return {
|
|
4630
5819
|
helper,
|
|
4631
5820
|
kind: "mode",
|
|
4632
|
-
description: `${helper} is not executable
|
|
5821
|
+
description: `${helper} is not executable`,
|
|
5822
|
+
execBitOnly: await isReadable(installed)
|
|
4633
5823
|
};
|
|
4634
5824
|
if (!await isReadable(installed)) return {
|
|
4635
5825
|
helper,
|
|
@@ -4645,15 +5835,104 @@ const checkHookLib = async (deps) => {
|
|
|
4645
5835
|
};
|
|
4646
5836
|
const pathsOf = (kind) => problems.filter((problem) => problem.kind === kind).map((problem) => `${HOOKS_DIR}/lib/${problem.helper}`).join(" ");
|
|
4647
5837
|
const remediations = [];
|
|
4648
|
-
|
|
4649
|
-
|
|
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`);
|
|
4650
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)`);
|
|
4651
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\``);
|
|
4652
5847
|
return {
|
|
4653
5848
|
name,
|
|
4654
5849
|
status: "warn",
|
|
4655
|
-
detail: `${HOOKS_DIR}/lib: ${problems.map((problem) => problem.description
|
|
4656
|
-
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)}.`
|
|
4657
5936
|
};
|
|
4658
5937
|
};
|
|
4659
5938
|
const isReadable = async (path) => {
|
|
@@ -4664,10 +5943,89 @@ const isReadable = async (path) => {
|
|
|
4664
5943
|
return false;
|
|
4665
5944
|
}
|
|
4666
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
|
+
};
|
|
4667
6018
|
//#endregion
|
|
4668
6019
|
//#region src/status/parse-task-branch.ts
|
|
4669
|
-
const
|
|
4670
|
-
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
|
+
};
|
|
4671
6029
|
//#endregion
|
|
4672
6030
|
//#region src/status/status.ts
|
|
4673
6031
|
const PAUSED_LABEL = "harness:status:paused-for-clarification";
|
|
@@ -4676,8 +6034,7 @@ const runStatus = async (deps) => {
|
|
|
4676
6034
|
const config = await readHarnessConfig(repoRoot);
|
|
4677
6035
|
const lastSession = await readPointer(repoRoot, deps.readGitUserEmail);
|
|
4678
6036
|
const { branch, behind } = await deps.gitBehind(repoRoot);
|
|
4679
|
-
const
|
|
4680
|
-
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);
|
|
4681
6038
|
return {
|
|
4682
6039
|
vendorVersion: config.vendor_version,
|
|
4683
6040
|
taskStorageRepo: config.task_storage.repo,
|
|
@@ -4686,16 +6043,16 @@ const runStatus = async (deps) => {
|
|
|
4686
6043
|
behind,
|
|
4687
6044
|
lastSession,
|
|
4688
6045
|
openDiscoveries,
|
|
4689
|
-
designToolDrift: await readDriftState(repoRoot)
|
|
6046
|
+
designToolDrift: await readDriftState(repoRoot, resolveTokenFile(config.design_tokens.file))
|
|
4690
6047
|
};
|
|
4691
6048
|
};
|
|
4692
|
-
const readDriftState = async (repoRoot) => {
|
|
6049
|
+
const readDriftState = async (repoRoot, tokenFile) => {
|
|
4693
6050
|
try {
|
|
4694
|
-
const raw = await readOptionalTokenFile(repoRoot);
|
|
6051
|
+
const raw = await readOptionalTokenFile(repoRoot, tokenFile);
|
|
4695
6052
|
if (raw === void 0) return null;
|
|
4696
6053
|
const parsed = JSON.parse(raw);
|
|
4697
6054
|
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
|
|
4698
|
-
if (summarizeTokenFileViolations(raw) !== void 0) return null;
|
|
6055
|
+
if (summarizeTokenFileViolations(raw, tokenFile) !== void 0) return null;
|
|
4699
6056
|
return driftReport(parsed).state;
|
|
4700
6057
|
} catch {
|
|
4701
6058
|
return null;
|
|
@@ -4770,8 +6127,8 @@ const COMMANDS = [
|
|
|
4770
6127
|
},
|
|
4771
6128
|
{
|
|
4772
6129
|
name: "status",
|
|
4773
|
-
summary: "Show installed version, branch drift, and open tasks.",
|
|
4774
|
-
usage: "lemony status"
|
|
6130
|
+
summary: "Show installed version, branch drift, and open tasks (`--local`: no network call).",
|
|
6131
|
+
usage: "lemony status [--local]"
|
|
4775
6132
|
},
|
|
4776
6133
|
{
|
|
4777
6134
|
name: "emit",
|
|
@@ -4783,6 +6140,11 @@ const COMMANDS = [
|
|
|
4783
6140
|
summary: "Reflect a raised/resolved discovery onto its issue (label flip; `pause` also comments); used by the Orchestrator's resolve-discovery skill.",
|
|
4784
6141
|
usage: "lemony discovery <pause|resume> --task-id=<id> --tier=<T1..T6> --status=<spec-in-progress|in-progress|in-review> [--note=<text>]"
|
|
4785
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
|
+
},
|
|
4786
6148
|
{
|
|
4787
6149
|
name: "design-tokens",
|
|
4788
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).",
|
|
@@ -4801,107 +6163,43 @@ const COMMANDS = [
|
|
|
4801
6163
|
{
|
|
4802
6164
|
name: "telemetry",
|
|
4803
6165
|
summary: "Inspect and control anonymous telemetry (on by default): show what is sent, force a send, opt out, opt back in.",
|
|
4804
|
-
usage: "lemony telemetry <status|show|flush|enable|disable [--purge-local]>"
|
|
4805
|
-
}
|
|
4806
|
-
];
|
|
4807
|
-
//#endregion
|
|
4808
|
-
//#region src/help/help.ts
|
|
4809
|
-
const pad = (text, width) => text + " ".repeat(Math.max(0, width - text.length));
|
|
4810
|
-
const topLevelHelp = () => {
|
|
4811
|
-
const width = Math.max(...COMMANDS.map((c) => c.name.length));
|
|
4812
|
-
return [
|
|
4813
|
-
"Lemony — the SDD harness for AI coding (vendor CLI).",
|
|
4814
|
-
"",
|
|
4815
|
-
"Usage: lemony <command> [flags]",
|
|
4816
|
-
"",
|
|
4817
|
-
"Commands:",
|
|
4818
|
-
...COMMANDS.map((c) => ` ${pad(c.name, width)} ${c.summary}`),
|
|
4819
|
-
"",
|
|
4820
|
-
"Run `lemony <command> --help` (or `-h`) for command-specific usage.",
|
|
4821
|
-
"`lemony version` (or `--version` / `-v`) prints the installed CLI version."
|
|
4822
|
-
].join("\n");
|
|
4823
|
-
};
|
|
4824
|
-
const commandHelp = (name) => {
|
|
4825
|
-
const command = COMMANDS.find((c) => c.name === name);
|
|
4826
|
-
if (!command) return void 0;
|
|
4827
|
-
return [
|
|
4828
|
-
`Usage: ${command.usage}`,
|
|
4829
|
-
"",
|
|
4830
|
-
command.summary
|
|
4831
|
-
].join("\n");
|
|
4832
|
-
};
|
|
4833
|
-
const unknownCommand = (name) => `Unknown command "${name ?? ""}". Run \`lemony --help\` to see available commands.`;
|
|
4834
|
-
//#endregion
|
|
4835
|
-
//#region src/render/render.ts
|
|
4836
|
-
const PLACEHOLDER = /\{\{\s*([\w.]+)\s*\}\}/g;
|
|
4837
|
-
const renderTemplate = (template, vars) => template.replace(PLACEHOLDER, (_match, key) => {
|
|
4838
|
-
if (!Object.hasOwn(vars, key)) throw new Error(`renderTemplate: no value provided for "${key}"`);
|
|
4839
|
-
return vars[key];
|
|
4840
|
-
});
|
|
4841
|
-
//#endregion
|
|
4842
|
-
//#region src/baseline/baseline.ts
|
|
4843
|
-
const STAGING_DIR$1 = ".staging";
|
|
4844
|
-
const isENOENT$1 = (error) => typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
|
|
4845
|
-
const baselineRootDir = (repoRoot) => join(repoRoot, BASELINE_ROOT_REL);
|
|
4846
|
-
const baselineVersionDir = (repoRoot, version) => join(baselineRootDir(repoRoot), version);
|
|
4847
|
-
const writeBaseline = async (repoRoot, version, files) => {
|
|
4848
|
-
const root = baselineRootDir(repoRoot);
|
|
4849
|
-
const staging = join(root, STAGING_DIR$1);
|
|
4850
|
-
await rm(staging, {
|
|
4851
|
-
recursive: true,
|
|
4852
|
-
force: true
|
|
4853
|
-
});
|
|
4854
|
-
await mkdir(staging, { recursive: true });
|
|
4855
|
-
await Promise.all(files.map(async ({ relPath, content }) => {
|
|
4856
|
-
const dest = join(staging, relPath);
|
|
4857
|
-
await mkdir(dirname(dest), { recursive: true });
|
|
4858
|
-
await writeFile(dest, content);
|
|
4859
|
-
}));
|
|
4860
|
-
const versionDir = baselineVersionDir(repoRoot, version);
|
|
4861
|
-
await rm(versionDir, {
|
|
4862
|
-
recursive: true,
|
|
4863
|
-
force: true
|
|
4864
|
-
});
|
|
4865
|
-
await rename(staging, versionDir);
|
|
4866
|
-
await pruneOtherVersions(root, version);
|
|
4867
|
-
};
|
|
4868
|
-
const pruneOtherVersions = async (root, keep) => {
|
|
4869
|
-
const entries = await readdir(root, { withFileTypes: true });
|
|
4870
|
-
await Promise.all(entries.filter((entry) => entry.isDirectory() && entry.name !== keep).map((entry) => rm(join(root, entry.name), {
|
|
4871
|
-
recursive: true,
|
|
4872
|
-
force: true
|
|
4873
|
-
})));
|
|
4874
|
-
};
|
|
4875
|
-
const readBaseline = async (repoRoot, version) => {
|
|
4876
|
-
const versionDir = baselineVersionDir(repoRoot, version);
|
|
4877
|
-
let relPaths;
|
|
4878
|
-
try {
|
|
4879
|
-
relPaths = await listFiles(versionDir);
|
|
4880
|
-
} catch (error) {
|
|
4881
|
-
if (isENOENT$1(error)) return /* @__PURE__ */ new Map();
|
|
4882
|
-
throw error;
|
|
6166
|
+
usage: "lemony telemetry <status|show|flush|enable|disable [--purge-local]>"
|
|
4883
6167
|
}
|
|
4884
|
-
|
|
4885
|
-
|
|
6168
|
+
];
|
|
6169
|
+
//#endregion
|
|
6170
|
+
//#region src/help/help.ts
|
|
6171
|
+
const pad = (text, width) => text + " ".repeat(Math.max(0, width - text.length));
|
|
6172
|
+
const topLevelHelp = () => {
|
|
6173
|
+
const width = Math.max(...COMMANDS.map((c) => c.name.length));
|
|
6174
|
+
return [
|
|
6175
|
+
"Lemony — the SDD harness for AI coding (vendor CLI).",
|
|
6176
|
+
"",
|
|
6177
|
+
"Usage: lemony <command> [flags]",
|
|
6178
|
+
"",
|
|
6179
|
+
"Commands:",
|
|
6180
|
+
...COMMANDS.map((c) => ` ${pad(c.name, width)} ${c.summary}`),
|
|
6181
|
+
"",
|
|
6182
|
+
"Run `lemony <command> --help` (or `-h`) for command-specific usage.",
|
|
6183
|
+
"`lemony version` (or `--version` / `-v`) prints the installed CLI version."
|
|
6184
|
+
].join("\n");
|
|
4886
6185
|
};
|
|
4887
|
-
const
|
|
4888
|
-
const
|
|
4889
|
-
|
|
4890
|
-
|
|
4891
|
-
|
|
4892
|
-
|
|
4893
|
-
|
|
4894
|
-
|
|
4895
|
-
}
|
|
4896
|
-
const versions = entries.filter((entry) => entry.isDirectory() && !entry.name.startsWith(".")).map((entry) => entry.name);
|
|
4897
|
-
if (versions.length <= 1) return versions[0] ?? null;
|
|
4898
|
-
const withMtime = await Promise.all(versions.map(async (version) => ({
|
|
4899
|
-
version,
|
|
4900
|
-
mtimeMs: (await stat(join(root, version))).mtimeMs
|
|
4901
|
-
})));
|
|
4902
|
-
withMtime.sort((a, b) => a.mtimeMs - b.mtimeMs || a.version.localeCompare(b.version, void 0, { numeric: true }));
|
|
4903
|
-
return withMtime.at(-1)?.version ?? null;
|
|
6186
|
+
const commandHelp = (name) => {
|
|
6187
|
+
const command = COMMANDS.find((c) => c.name === name);
|
|
6188
|
+
if (!command) return void 0;
|
|
6189
|
+
return [
|
|
6190
|
+
`Usage: ${command.usage}`,
|
|
6191
|
+
"",
|
|
6192
|
+
command.summary
|
|
6193
|
+
].join("\n");
|
|
4904
6194
|
};
|
|
6195
|
+
const unknownCommand = (name) => `Unknown command "${name ?? ""}". Run \`lemony --help\` to see available commands.`;
|
|
6196
|
+
//#endregion
|
|
6197
|
+
//#region src/render/render.ts
|
|
6198
|
+
const PLACEHOLDER = /\{\{\s*([\w.]+)\s*\}\}/g;
|
|
6199
|
+
const renderTemplate = (template, vars) => template.replace(PLACEHOLDER, (_match, key) => {
|
|
6200
|
+
if (!Object.hasOwn(vars, key)) throw new Error(`renderTemplate: no value provided for "${key}"`);
|
|
6201
|
+
return vars[key];
|
|
6202
|
+
});
|
|
4905
6203
|
//#endregion
|
|
4906
6204
|
//#region src/snapshot/snapshot.ts
|
|
4907
6205
|
const STAGING_DIR = ".staging";
|
|
@@ -5020,152 +6318,6 @@ const cleanupSnapshots = async (repoRoot) => {
|
|
|
5020
6318
|
return versions;
|
|
5021
6319
|
};
|
|
5022
6320
|
//#endregion
|
|
5023
|
-
//#region src/merge/conflict-markers.constant.ts
|
|
5024
|
-
const CONFLICT_MARKER_CLIENT = "<<<<<<< client";
|
|
5025
|
-
const CONFLICT_MARKER_SEPARATOR = "=======";
|
|
5026
|
-
const CONFLICT_MARKER_VENDOR = ">>>>>>> vendor";
|
|
5027
|
-
//#endregion
|
|
5028
|
-
//#region src/merge/conflict-markers.ts
|
|
5029
|
-
const CONFLICT_FENCE = new RegExp(`^(?:${CONFLICT_MARKER_CLIENT}|${CONFLICT_MARKER_VENDOR})$`, "m");
|
|
5030
|
-
const hasConflictMarkers = (content) => CONFLICT_FENCE.test(content);
|
|
5031
|
-
//#endregion
|
|
5032
|
-
//#region src/merge/three-way-merge.ts
|
|
5033
|
-
const threeWayMerge = (base, client, vendor) => {
|
|
5034
|
-
const baseLines = base.split("\n");
|
|
5035
|
-
const clientLines = client.split("\n");
|
|
5036
|
-
const vendorLines = vendor.split("\n");
|
|
5037
|
-
const regions = diff3Regions(baseLines, clientLines, vendorLines);
|
|
5038
|
-
const out = [];
|
|
5039
|
-
let conflicted = false;
|
|
5040
|
-
for (const region of regions) {
|
|
5041
|
-
if (region.stable) {
|
|
5042
|
-
out.push(...region.lines);
|
|
5043
|
-
continue;
|
|
5044
|
-
}
|
|
5045
|
-
if (linesEqual(region.client, region.vendor)) out.push(...region.client);
|
|
5046
|
-
else {
|
|
5047
|
-
conflicted = true;
|
|
5048
|
-
out.push(CONFLICT_MARKER_CLIENT, ...region.client, CONFLICT_MARKER_SEPARATOR, ...region.vendor, CONFLICT_MARKER_VENDOR);
|
|
5049
|
-
}
|
|
5050
|
-
}
|
|
5051
|
-
return {
|
|
5052
|
-
merged: out.join("\n"),
|
|
5053
|
-
conflicted
|
|
5054
|
-
};
|
|
5055
|
-
};
|
|
5056
|
-
const linesEqual = (a, b) => a.length === b.length && a.every((line, i) => line === b[i]);
|
|
5057
|
-
const diff3Regions = (base, client, vendor) => {
|
|
5058
|
-
const hunks = [...diffRegions(base, client).map((d) => ({
|
|
5059
|
-
...d,
|
|
5060
|
-
side: "client"
|
|
5061
|
-
})), ...diffRegions(base, vendor).map((d) => ({
|
|
5062
|
-
...d,
|
|
5063
|
-
side: "vendor"
|
|
5064
|
-
}))].toSorted((a, b) => a.baseStart - b.baseStart || a.baseEnd - b.baseEnd);
|
|
5065
|
-
const regions = [];
|
|
5066
|
-
let baseCursor = 0;
|
|
5067
|
-
let i = 0;
|
|
5068
|
-
const emitStable = (until) => {
|
|
5069
|
-
if (until > baseCursor) {
|
|
5070
|
-
regions.push({
|
|
5071
|
-
stable: true,
|
|
5072
|
-
lines: base.slice(baseCursor, until)
|
|
5073
|
-
});
|
|
5074
|
-
baseCursor = until;
|
|
5075
|
-
}
|
|
5076
|
-
};
|
|
5077
|
-
while (i < hunks.length) {
|
|
5078
|
-
const first = hunks[i];
|
|
5079
|
-
if (first === void 0) break;
|
|
5080
|
-
let regionBaseStart = first.baseStart;
|
|
5081
|
-
let regionBaseEnd = first.baseEnd;
|
|
5082
|
-
const group = [first];
|
|
5083
|
-
i += 1;
|
|
5084
|
-
while (i < hunks.length) {
|
|
5085
|
-
const next = hunks[i];
|
|
5086
|
-
if (next === void 0 || next.baseStart > regionBaseEnd) break;
|
|
5087
|
-
regionBaseEnd = Math.max(regionBaseEnd, next.baseEnd);
|
|
5088
|
-
group.push(next);
|
|
5089
|
-
i += 1;
|
|
5090
|
-
}
|
|
5091
|
-
emitStable(regionBaseStart);
|
|
5092
|
-
const touchedByClient = group.some((h) => h.side === "client");
|
|
5093
|
-
const touchedByVendor = group.some((h) => h.side === "vendor");
|
|
5094
|
-
if (touchedByClient && touchedByVendor) regions.push({
|
|
5095
|
-
stable: false,
|
|
5096
|
-
client: projectSide(group, "client", client, regionBaseStart, regionBaseEnd),
|
|
5097
|
-
vendor: projectSide(group, "vendor", vendor, regionBaseStart, regionBaseEnd)
|
|
5098
|
-
});
|
|
5099
|
-
else {
|
|
5100
|
-
const side = touchedByClient ? "client" : "vendor";
|
|
5101
|
-
regions.push({
|
|
5102
|
-
stable: true,
|
|
5103
|
-
lines: projectSide(group, side, side === "client" ? client : vendor, regionBaseStart, regionBaseEnd)
|
|
5104
|
-
});
|
|
5105
|
-
}
|
|
5106
|
-
baseCursor = regionBaseEnd;
|
|
5107
|
-
}
|
|
5108
|
-
emitStable(base.length);
|
|
5109
|
-
return regions;
|
|
5110
|
-
};
|
|
5111
|
-
const projectSide = (group, side, sideLines, regionBaseStart, regionBaseEnd) => {
|
|
5112
|
-
const own = group.filter((h) => h.side === side);
|
|
5113
|
-
let sideStart = Number.POSITIVE_INFINITY;
|
|
5114
|
-
let sideEnd = Number.NEGATIVE_INFINITY;
|
|
5115
|
-
let baseStart = Number.POSITIVE_INFINITY;
|
|
5116
|
-
let baseEnd = Number.NEGATIVE_INFINITY;
|
|
5117
|
-
for (const h of own) {
|
|
5118
|
-
sideStart = Math.min(sideStart, h.sideStart);
|
|
5119
|
-
sideEnd = Math.max(sideEnd, h.sideEnd);
|
|
5120
|
-
baseStart = Math.min(baseStart, h.baseStart);
|
|
5121
|
-
baseEnd = Math.max(baseEnd, h.baseEnd);
|
|
5122
|
-
}
|
|
5123
|
-
const start = sideStart - (baseStart - regionBaseStart);
|
|
5124
|
-
const end = sideEnd + (regionBaseEnd - baseEnd);
|
|
5125
|
-
return sideLines.slice(start, end);
|
|
5126
|
-
};
|
|
5127
|
-
const diffRegions = (base, other) => {
|
|
5128
|
-
const matches = lcsMatches(base, other);
|
|
5129
|
-
const diffs = [];
|
|
5130
|
-
let baseTail = 0;
|
|
5131
|
-
let otherTail = 0;
|
|
5132
|
-
for (const [baseIdx, otherIdx] of matches) {
|
|
5133
|
-
if (baseIdx > baseTail || otherIdx > otherTail) diffs.push({
|
|
5134
|
-
baseStart: baseTail,
|
|
5135
|
-
baseEnd: baseIdx,
|
|
5136
|
-
sideStart: otherTail,
|
|
5137
|
-
sideEnd: otherIdx
|
|
5138
|
-
});
|
|
5139
|
-
baseTail = baseIdx + 1;
|
|
5140
|
-
otherTail = otherIdx + 1;
|
|
5141
|
-
}
|
|
5142
|
-
if (base.length > baseTail || other.length > otherTail) diffs.push({
|
|
5143
|
-
baseStart: baseTail,
|
|
5144
|
-
baseEnd: base.length,
|
|
5145
|
-
sideStart: otherTail,
|
|
5146
|
-
sideEnd: other.length
|
|
5147
|
-
});
|
|
5148
|
-
return diffs;
|
|
5149
|
-
};
|
|
5150
|
-
const lcsMatches = (base, other) => {
|
|
5151
|
-
const n = base.length;
|
|
5152
|
-
const m = other.length;
|
|
5153
|
-
const width = m + 1;
|
|
5154
|
-
const dp = new Int32Array((n + 1) * width);
|
|
5155
|
-
const at = (i, j) => dp[i * width + j] ?? 0;
|
|
5156
|
-
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));
|
|
5157
|
-
const matches = [];
|
|
5158
|
-
let i = 0;
|
|
5159
|
-
let j = 0;
|
|
5160
|
-
while (i < n && j < m) if (base[i] === other[j]) {
|
|
5161
|
-
matches.push([i, j]);
|
|
5162
|
-
i += 1;
|
|
5163
|
-
j += 1;
|
|
5164
|
-
} else if (at(i + 1, j) >= at(i, j + 1)) i += 1;
|
|
5165
|
-
else j += 1;
|
|
5166
|
-
return matches;
|
|
5167
|
-
};
|
|
5168
|
-
//#endregion
|
|
5169
6321
|
//#region src/update/engine.ts
|
|
5170
6322
|
const cohesionKey = (relPath) => {
|
|
5171
6323
|
const prefix = SKILLS_DIR + sep;
|
|
@@ -5837,7 +6989,7 @@ const runRepair = async (options) => {
|
|
|
5837
6989
|
const DOCS_PREFIX = `docs${sep}`;
|
|
5838
6990
|
const runUninstall = async (options) => {
|
|
5839
6991
|
const { repoRoot } = options;
|
|
5840
|
-
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).`);
|
|
5841
6993
|
const baselineVersion = await findBaselineVersion(repoRoot);
|
|
5842
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.");
|
|
5843
6995
|
const config = await readHarnessConfig(repoRoot);
|
|
@@ -6070,6 +7222,24 @@ const parseFlag = (args, name) => {
|
|
|
6070
7222
|
const rejectValuelessFlags = (verb, args, names) => {
|
|
6071
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.`);
|
|
6072
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
|
+
}
|
|
7242
|
+
};
|
|
6073
7243
|
const readHarnessVersion = async () => {
|
|
6074
7244
|
const raw = await readFile(PACKAGE_JSON_PATH, "utf8");
|
|
6075
7245
|
const parsed = JSON.parse(raw);
|
|
@@ -6141,45 +7311,6 @@ const buildResolveDeps = () => {
|
|
|
6141
7311
|
close: () => rl.close()
|
|
6142
7312
|
};
|
|
6143
7313
|
};
|
|
6144
|
-
const gitBehind = async (repoRoot) => {
|
|
6145
|
-
const run = makeRunCommand();
|
|
6146
|
-
const branchResult = await run("git", [
|
|
6147
|
-
"-C",
|
|
6148
|
-
repoRoot,
|
|
6149
|
-
"symbolic-ref",
|
|
6150
|
-
"--quiet",
|
|
6151
|
-
"HEAD"
|
|
6152
|
-
]);
|
|
6153
|
-
const ref = branchResult.code === 0 ? branchResult.stdout.trim() : "";
|
|
6154
|
-
const branch = ref.startsWith("refs/heads/") ? ref.slice(11) || null : null;
|
|
6155
|
-
const headRef = await run("git", [
|
|
6156
|
-
"-C",
|
|
6157
|
-
repoRoot,
|
|
6158
|
-
"symbolic-ref",
|
|
6159
|
-
"--quiet",
|
|
6160
|
-
"refs/remotes/origin/HEAD"
|
|
6161
|
-
]);
|
|
6162
|
-
if (headRef.code !== 0) return {
|
|
6163
|
-
branch,
|
|
6164
|
-
behind: null
|
|
6165
|
-
};
|
|
6166
|
-
const count = await run("git", [
|
|
6167
|
-
"-C",
|
|
6168
|
-
repoRoot,
|
|
6169
|
-
"rev-list",
|
|
6170
|
-
"--count",
|
|
6171
|
-
`HEAD..${headRef.stdout.trim().replace(/^refs\/remotes\//, "")}`
|
|
6172
|
-
]);
|
|
6173
|
-
if (count.code !== 0) return {
|
|
6174
|
-
branch,
|
|
6175
|
-
behind: null
|
|
6176
|
-
};
|
|
6177
|
-
const parsed = Number.parseInt(count.stdout.trim(), 10);
|
|
6178
|
-
return {
|
|
6179
|
-
branch,
|
|
6180
|
-
behind: Number.isNaN(parsed) ? null : parsed
|
|
6181
|
-
};
|
|
6182
|
-
};
|
|
6183
7314
|
const install = async (args) => {
|
|
6184
7315
|
const repoRoot = cwd();
|
|
6185
7316
|
if (await pathExists(join(repoRoot, "harness.config.yml"))) {
|
|
@@ -6504,6 +7635,42 @@ const discovery = async (args) => {
|
|
|
6504
7635
|
if (result.applied.length > 0) console.error(` Applied before failure: ${result.applied.join(", ")}.`);
|
|
6505
7636
|
exit(1);
|
|
6506
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);
|
|
6507
7674
|
const designTokens = async (args) => {
|
|
6508
7675
|
const action = args[0] ?? "validate";
|
|
6509
7676
|
if (action === "validate") return designTokensValidate(args);
|
|
@@ -6514,13 +7681,15 @@ const designTokens = async (args) => {
|
|
|
6514
7681
|
};
|
|
6515
7682
|
const designTokensValidate = async (args) => {
|
|
6516
7683
|
const config = await readOptionalHarnessConfig(cwd());
|
|
7684
|
+
const tokenFile = tokenFileOf(config);
|
|
6517
7685
|
const result = await runValidate({
|
|
6518
7686
|
repoRoot: cwd(),
|
|
7687
|
+
tokenFile,
|
|
6519
7688
|
scanDir: parseFlag(args, "scan"),
|
|
6520
7689
|
extraExtensions: config?.design_tokens.scan_extensions
|
|
6521
7690
|
});
|
|
6522
7691
|
if (!result.tokensFound) {
|
|
6523
|
-
console.log(`No
|
|
7692
|
+
console.log(`No ${tokenFile} — design-tokens validate skipped (consume-if-exists; the gates never create it).`);
|
|
6524
7693
|
return;
|
|
6525
7694
|
}
|
|
6526
7695
|
if (result.ok) {
|
|
@@ -6549,9 +7718,12 @@ const contrastFailedLine = (pairCheck, verify) => {
|
|
|
6549
7718
|
return `design-tokens contrast failed: ${pairCheck}; design_tokens.verify ${quotedCommand(verify.command)} ${outcome}.`;
|
|
6550
7719
|
};
|
|
6551
7720
|
const designTokensContrast = async () => {
|
|
6552
|
-
const
|
|
7721
|
+
const config = await readOptionalHarnessConfig(cwd());
|
|
7722
|
+
const verify = config?.design_tokens.verify;
|
|
7723
|
+
const tokenFile = tokenFileOf(config);
|
|
6553
7724
|
const result = await runContrast({
|
|
6554
7725
|
repoRoot: cwd(),
|
|
7726
|
+
tokenFile,
|
|
6555
7727
|
...verify === void 0 ? {} : { verify: {
|
|
6556
7728
|
command: verify,
|
|
6557
7729
|
run: makeVerifierRunCommand()
|
|
@@ -6566,14 +7738,14 @@ const designTokensContrast = async () => {
|
|
|
6566
7738
|
};
|
|
6567
7739
|
const failedVerifier = result.verify !== void 0 && result.verify.code !== 0 ? result.verify : void 0;
|
|
6568
7740
|
if (!result.tokensFound) {
|
|
6569
|
-
console.log(result.verify === void 0 ? `No
|
|
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.`);
|
|
6570
7742
|
if (result.verify === void 0) return;
|
|
6571
7743
|
if (failedVerifier) console.error(`design-tokens contrast found 1 issue(s):`);
|
|
6572
7744
|
forwardVerifier();
|
|
6573
7745
|
await Promise.all([drained(stdout), drained(stderr)]);
|
|
6574
7746
|
if (failedVerifier) {
|
|
6575
7747
|
console.error(` design_tokens.verify ${quotedCommand(failedVerifier.command)} ${verifierEnding(failedVerifier)}.`);
|
|
6576
|
-
console.error(contrastFailedLine(
|
|
7748
|
+
console.error(contrastFailedLine(`pair check skipped (no ${tokenFile})`, failedVerifier));
|
|
6577
7749
|
process.exitCode = 1;
|
|
6578
7750
|
return;
|
|
6579
7751
|
}
|
|
@@ -6605,8 +7777,10 @@ const designTokensImport = async (args) => {
|
|
|
6605
7777
|
rejectValuelessFlags("design-tokens import", args, ["only"]);
|
|
6606
7778
|
const only = parseFlag(args, "only")?.split(",").map((path) => path.trim()).filter((path) => path.length > 0);
|
|
6607
7779
|
if (only?.length === 0) throw new Error("design-tokens import: --only names no path.");
|
|
7780
|
+
const tokenFile = tokenFileOf(await readOptionalHarnessConfig(cwd()));
|
|
6608
7781
|
const result = await runImport({
|
|
6609
7782
|
repoRoot: cwd(),
|
|
7783
|
+
tokenFile,
|
|
6610
7784
|
neutralPath: resolve(cwd(), from),
|
|
6611
7785
|
apply: args.includes("--apply"),
|
|
6612
7786
|
only
|
|
@@ -6619,7 +7793,7 @@ const designTokensImport = async (args) => {
|
|
|
6619
7793
|
return;
|
|
6620
7794
|
}
|
|
6621
7795
|
if (result.applied) {
|
|
6622
|
-
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}.`);
|
|
6623
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(", ")}`);
|
|
6624
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(", ")}`);
|
|
6625
7799
|
return;
|
|
@@ -6655,14 +7829,16 @@ const designTokensExport = async (args) => {
|
|
|
6655
7829
|
console.error("design-tokens export: --out and --record are mutually exclusive — preview/push with --out, then --record only after the push succeeds.");
|
|
6656
7830
|
exit(1);
|
|
6657
7831
|
}
|
|
7832
|
+
const tokenFile = tokenFileOf(await readOptionalHarnessConfig(cwd()));
|
|
6658
7833
|
const result = await runExport({
|
|
6659
7834
|
repoRoot: cwd(),
|
|
7835
|
+
tokenFile,
|
|
6660
7836
|
...toolState ? { toolStatePath: resolve(cwd(), toolState) } : {},
|
|
6661
7837
|
...out ? { outPath: resolve(cwd(), out) } : {},
|
|
6662
7838
|
record: args.includes("--record")
|
|
6663
7839
|
});
|
|
6664
7840
|
if (!result.tokensFound) {
|
|
6665
|
-
console.log(`No ${
|
|
7841
|
+
console.log(`No ${tokenFile} — design-tokens export skipped (consume-if-exists; export never creates it).`);
|
|
6666
7842
|
return;
|
|
6667
7843
|
}
|
|
6668
7844
|
if (result.problems.length > 0) {
|
|
@@ -6751,12 +7927,14 @@ const spinoff = async (args) => {
|
|
|
6751
7927
|
console.error(`spinoff failed at "${result.failedStep}": ${result.reason}`);
|
|
6752
7928
|
exit(1);
|
|
6753
7929
|
};
|
|
6754
|
-
const status = async () => {
|
|
7930
|
+
const status = async (args) => {
|
|
7931
|
+
const local = args.includes("--local");
|
|
6755
7932
|
const report = await runStatus({
|
|
6756
7933
|
repoRoot: cwd(),
|
|
6757
7934
|
readGitUserEmail,
|
|
6758
|
-
gitBehind,
|
|
6759
|
-
runCommand: makeRunCommand()
|
|
7935
|
+
gitBehind: (root) => readGitBehind(root, makeRunCommand()),
|
|
7936
|
+
runCommand: makeRunCommand(),
|
|
7937
|
+
local
|
|
6760
7938
|
});
|
|
6761
7939
|
const behindText = report.behind === null ? "behind unknown" : report.behind === 0 ? "up to date" : `${report.behind} behind`;
|
|
6762
7940
|
const discoveries = report.openDiscoveries === null ? "unknown" : String(report.openDiscoveries);
|
|
@@ -6766,7 +7944,7 @@ const status = async () => {
|
|
|
6766
7944
|
console.log(` Active task: ${report.activeTask ?? "(none)"}`);
|
|
6767
7945
|
console.log(` Branch: ${report.branch ?? "(unknown)"} (${behindText})`);
|
|
6768
7946
|
console.log(` Last session: ${report.lastSession ?? "(none)"}`);
|
|
6769
|
-
console.log(` Open discoveries: ${discoveries}`);
|
|
7947
|
+
if (!local) console.log(` Open discoveries: ${discoveries}`);
|
|
6770
7948
|
if (report.designToolDrift !== null) console.log(` Design tool: ${DESIGN_TOOL_STATUS[report.designToolDrift]}`);
|
|
6771
7949
|
};
|
|
6772
7950
|
const DESIGN_TOOL_STATUS = {
|
|
@@ -6853,6 +8031,9 @@ const main = async () => {
|
|
|
6853
8031
|
case "discovery":
|
|
6854
8032
|
await discovery(args);
|
|
6855
8033
|
return;
|
|
8034
|
+
case "checkpoint":
|
|
8035
|
+
await checkpoint(args);
|
|
8036
|
+
return;
|
|
6856
8037
|
case "design-tokens":
|
|
6857
8038
|
await designTokens(args);
|
|
6858
8039
|
return;
|
|
@@ -6866,7 +8047,7 @@ const main = async () => {
|
|
|
6866
8047
|
await runTelemetry(args);
|
|
6867
8048
|
return;
|
|
6868
8049
|
case "status":
|
|
6869
|
-
await status();
|
|
8050
|
+
await status(args);
|
|
6870
8051
|
return;
|
|
6871
8052
|
case "doctor":
|
|
6872
8053
|
await doctor();
|