comprehende 0.2.0 → 0.4.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/dist/git/diff.js CHANGED
@@ -1,5 +1,8 @@
1
+ import { isImagePath, isLfsPointerText } from "../schema/image.js";
2
+ import { isLockfilePath } from "../schema/lockfile.js";
1
3
  import { git } from "./exec.js";
2
- import { rangeLabel, resolveCommit } from "./repo.js";
4
+ import { parseNameStatus, parseNumstat } from "./name-status.js";
5
+ import { assertSafePath, rangeLabel, resolveCommit } from "./repo.js";
3
6
  const HUNK_HEADER = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/;
4
7
  export async function resolveSource(cwd, baseRef, headRef) {
5
8
  const baseSha = await resolveCommit(cwd, baseRef);
@@ -11,6 +14,53 @@ export async function resolveSource(cwd, baseRef, headRef) {
11
14
  };
12
15
  }
13
16
  export async function readDiff(cwd, baseRef, headRef) {
17
+ await resolveCommit(cwd, baseRef);
18
+ await resolveCommit(cwd, headRef);
19
+ const range = `${baseRef}...${headRef}`;
20
+ const entries = parseNameStatus(await git(cwd, ["diff", "--find-renames", "--find-copies", "--name-status", "-z", "--end-of-options", range]));
21
+ const lockEntries = entries.filter((entry) => isLockfilePath(entry.path));
22
+ const diffArgs = [
23
+ "diff",
24
+ "--find-renames",
25
+ "--find-copies",
26
+ "-U3",
27
+ "--no-color",
28
+ "--no-ext-diff",
29
+ "--end-of-options",
30
+ range,
31
+ ];
32
+ if (lockEntries.length > 0) {
33
+ const excludes = [
34
+ ...new Set(lockEntries.flatMap((entry) => {
35
+ const paths = [`:(exclude)${entry.path}`];
36
+ if (entry.oldPath !== undefined) {
37
+ paths.push(`:(exclude)${entry.oldPath}`);
38
+ }
39
+ return paths;
40
+ })),
41
+ ];
42
+ diffArgs.push("--", ".", ...excludes);
43
+ }
44
+ const files = classifyDiffFiles(parseUnifiedDiff(await git(cwd, diffArgs))).filter((file) => !isLockfilePath(file.path));
45
+ if (lockEntries.length === 0) {
46
+ return files;
47
+ }
48
+ const stats = parseNumstat(await git(cwd, [
49
+ "diff",
50
+ "--find-renames",
51
+ "--find-copies",
52
+ "--numstat",
53
+ "-z",
54
+ "--end-of-options",
55
+ range,
56
+ "--",
57
+ ...lockEntries.map((entry) => entry.path),
58
+ ]));
59
+ const stubs = lockEntries.map((entry) => lockfileDiffFile(entry, stats.get(entry.path)));
60
+ return mergeDiffFiles(entries, files, stubs);
61
+ }
62
+ export async function readPathDiff(cwd, baseRef, headRef, path) {
63
+ assertSafePath(path);
14
64
  await resolveCommit(cwd, baseRef);
15
65
  await resolveCommit(cwd, headRef);
16
66
  const stdout = await git(cwd, [
@@ -22,8 +72,11 @@ export async function readDiff(cwd, baseRef, headRef) {
22
72
  "--no-ext-diff",
23
73
  "--end-of-options",
24
74
  `${baseRef}...${headRef}`,
75
+ "--",
76
+ path,
25
77
  ]);
26
- return parseUnifiedDiff(stdout);
78
+ const files = classifyDiffFiles(parseUnifiedDiff(stdout));
79
+ return files.find((file) => file.path === path || file.oldPath === path);
27
80
  }
28
81
  export async function readHunkIndex(cwd, baseRef, headRef) {
29
82
  const { source } = await resolveSource(cwd, baseRef, headRef);
@@ -31,10 +84,20 @@ export async function readHunkIndex(cwd, baseRef, headRef) {
31
84
  const hunks = [];
32
85
  const skipped = [];
33
86
  for (const file of files) {
87
+ if (file.image) {
88
+ for (const hunk of file.hunks) {
89
+ hunks.push(toHunkRef(hunk));
90
+ }
91
+ continue;
92
+ }
34
93
  if (file.binary) {
35
94
  skipped.push({ path: file.path, reason: "binary" });
36
95
  continue;
37
96
  }
97
+ if (isLockfilePath(file.path)) {
98
+ skipped.push({ path: file.path, reason: "lockfile" });
99
+ continue;
100
+ }
38
101
  for (const hunk of file.hunks) {
39
102
  hunks.push(toHunkRef(hunk));
40
103
  }
@@ -196,6 +259,7 @@ class FileBuilder {
196
259
  path,
197
260
  status,
198
261
  binary: this.binary,
262
+ image: false,
199
263
  headerPatch: this.headerPatch,
200
264
  patch: this.patch,
201
265
  hunks,
@@ -282,14 +346,91 @@ function stripDiffPath(raw) {
282
346
  export function flattenHunks(files) {
283
347
  const hunks = [];
284
348
  for (const file of files) {
285
- if (file.binary) {
349
+ if (file.binary && !file.image) {
350
+ continue;
351
+ }
352
+ if (isLockfilePath(file.path)) {
286
353
  continue;
287
354
  }
288
355
  hunks.push(...file.hunks);
289
356
  }
290
357
  return hunks;
291
358
  }
359
+ export function classifyDiffFiles(files) {
360
+ return files.map(classifyDiffFile);
361
+ }
362
+ function classifyDiffFile(file) {
363
+ if (!isImagePath(file.path) && (file.oldPath === undefined || !isImagePath(file.oldPath))) {
364
+ return file;
365
+ }
366
+ if (file.binary || isLfsPointerText(file.patch)) {
367
+ return asImageFile(file);
368
+ }
369
+ return file;
370
+ }
371
+ function asImageFile(file) {
372
+ return { ...file, image: true, hunks: [imageLiveHunk(file.path, file.oldPath)] };
373
+ }
374
+ export function imageLiveHunk(path, oldPath) {
375
+ const hunk = {
376
+ path,
377
+ oldStart: 0,
378
+ oldLines: 0,
379
+ newStart: 0,
380
+ newLines: 0,
381
+ header: "image",
382
+ lines: [],
383
+ patch: "",
384
+ };
385
+ if (oldPath !== undefined) {
386
+ hunk.oldPath = oldPath;
387
+ }
388
+ return hunk;
389
+ }
390
+ function lockfileDiffFile(entry, stat) {
391
+ const binary = stat === undefined || stat.added === null || stat.removed === null;
392
+ const file = {
393
+ path: entry.path,
394
+ status: entry.status,
395
+ binary,
396
+ image: false,
397
+ headerPatch: "",
398
+ patch: "",
399
+ hunks: [],
400
+ };
401
+ if (entry.oldPath !== undefined) {
402
+ file.oldPath = entry.oldPath;
403
+ }
404
+ if (!binary && stat !== undefined && stat.added !== null && stat.removed !== null) {
405
+ file.added = stat.added;
406
+ file.removed = stat.removed;
407
+ }
408
+ return file;
409
+ }
410
+ function mergeDiffFiles(entries, files, stubs) {
411
+ const filesByPath = new Map(files.map((file) => [file.path, file]));
412
+ const stubsByPath = new Map(stubs.map((file) => [file.path, file]));
413
+ const out = [];
414
+ const seen = new Set();
415
+ for (const entry of entries) {
416
+ const file = stubsByPath.get(entry.path) ?? filesByPath.get(entry.path);
417
+ if (file === undefined || seen.has(file.path)) {
418
+ continue;
419
+ }
420
+ seen.add(file.path);
421
+ out.push(file);
422
+ }
423
+ for (const file of files) {
424
+ if (!seen.has(file.path)) {
425
+ out.push(file);
426
+ }
427
+ }
428
+ return out;
429
+ }
292
430
  export function fileLanguage(path) {
431
+ if (isImagePath(path)) {
432
+ return "image";
433
+ }
293
434
  const ext = path.split(".").pop()?.toLowerCase();
294
435
  switch (ext) {
295
436
  case "ts":
package/dist/git/exec.js CHANGED
@@ -27,7 +27,21 @@ export async function git(cwd, args, opts) {
27
27
  if (opts?.allowFail) {
28
28
  return failure.stdout;
29
29
  }
30
- throw new GitError(`git ${args.join(" ")} failed: ${failure.stderr.trim() || failure.message}`, args, failure.stderr, failure.code);
30
+ throw gitFailure(args, failure);
31
+ }
32
+ }
33
+ export async function gitBuffer(cwd, args, opts) {
34
+ try {
35
+ const { stdout } = await execFileAsync("git", ["-c", "core.quotepath=false", ...args], {
36
+ cwd,
37
+ encoding: "buffer",
38
+ maxBuffer: 64 * 1024 * 1024,
39
+ ...(opts?.input !== undefined ? { input: Buffer.from(opts.input) } : {}),
40
+ });
41
+ return stdout;
42
+ }
43
+ catch (error) {
44
+ throw gitFailure(args, asExecFailure(error));
31
45
  }
32
46
  }
33
47
  export async function gitOk(cwd, args) {
@@ -44,10 +58,22 @@ function asExecFailure(error) {
44
58
  const record = error;
45
59
  return {
46
60
  message: typeof record.message === "string" ? record.message : "git failed",
47
- stdout: typeof record.stdout === "string" ? record.stdout : "",
48
- stderr: typeof record.stderr === "string" ? record.stderr : "",
61
+ stdout: bufferText(record.stdout),
62
+ stderr: bufferText(record.stderr),
49
63
  code: typeof record.code === "number" ? record.code : null,
50
64
  };
51
65
  }
52
66
  return { message: "git failed", stdout: "", stderr: "", code: null };
53
67
  }
68
+ function bufferText(value) {
69
+ if (typeof value === "string") {
70
+ return value;
71
+ }
72
+ if (value instanceof Buffer) {
73
+ return value.toString("utf8");
74
+ }
75
+ return "";
76
+ }
77
+ function gitFailure(args, failure) {
78
+ return new GitError(`git ${args.join(" ")} failed: ${failure.stderr.trim() || failure.message}`, args, failure.stderr, failure.code);
79
+ }
@@ -0,0 +1,39 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { existsSync } from "node:fs";
3
+ import { isAbsolute, join, resolve } from "node:path";
4
+ import { LFS_POINTER_VERSION } from "../schema/image.js";
5
+ import { git } from "./exec.js";
6
+ const POINTER_MAX = 1024;
7
+ export function parseLfsPointer(bytes) {
8
+ if (bytes.length === 0 || bytes.length > POINTER_MAX) {
9
+ return undefined;
10
+ }
11
+ if (bytes.includes(0)) {
12
+ return undefined;
13
+ }
14
+ const text = new TextDecoder("utf8").decode(bytes);
15
+ if (!text.startsWith(`${LFS_POINTER_VERSION}\n`)) {
16
+ return undefined;
17
+ }
18
+ const oid = /^oid sha256:([a-f0-9]{64})$/m.exec(text)?.[1];
19
+ const sizeRaw = /^size (\d+)$/m.exec(text)?.[1];
20
+ if (oid === undefined || sizeRaw === undefined) {
21
+ return undefined;
22
+ }
23
+ return { oid, size: Number(sizeRaw) };
24
+ }
25
+ export function lfsObjectPath(gitCommonDir, oid) {
26
+ return join(gitCommonDir, "lfs", "objects", oid.slice(0, 2), oid.slice(2, 4), oid);
27
+ }
28
+ export async function gitCommonDir(cwd) {
29
+ const raw = (await git(cwd, ["rev-parse", "--git-common-dir"])).trim();
30
+ return isAbsolute(raw) ? raw : resolve(cwd, raw);
31
+ }
32
+ export async function readLfsObject(cwd, pointer) {
33
+ const common = await gitCommonDir(cwd);
34
+ const stored = lfsObjectPath(common, pointer.oid);
35
+ if (!existsSync(stored)) {
36
+ return undefined;
37
+ }
38
+ return readFile(stored);
39
+ }
package/dist/git/log.js CHANGED
@@ -1,8 +1,10 @@
1
1
  import { git } from "./exec.js";
2
+ const RECORD_SEP = "\x1e";
3
+ const FIELD_SEP = "\0";
2
4
  export async function listCommits(cwd, baseRef, headRef) {
3
5
  const stdout = await git(cwd, [
4
6
  "log",
5
- "--format=%H%x00%h%x00%s%x00%an%x00%ad",
7
+ "--format=%H%x00%h%x00%s%x00%an%x00%ad%x00%b%x1e",
6
8
  "--date=short",
7
9
  "--end-of-options",
8
10
  `${baseRef}...${headRef}`,
@@ -11,16 +13,19 @@ export async function listCommits(cwd, baseRef, headRef) {
11
13
  return [];
12
14
  }
13
15
  return stdout
14
- .split("\n")
15
- .filter((line) => line.length > 0)
16
- .map((line) => {
17
- const [sha, shortSha, subject, author, date] = line.split("\0");
18
- return {
19
- sha: sha ?? "",
20
- shortSha: shortSha ?? "",
21
- subject: subject ?? "",
22
- author: author ?? "",
23
- date: date ?? "",
24
- };
25
- });
16
+ .split(RECORD_SEP)
17
+ .map((record) => record.replace(/^\n/, ""))
18
+ .filter((record) => record.length > 0)
19
+ .map(parseCommitRecord);
20
+ }
21
+ function parseCommitRecord(record) {
22
+ const [sha, shortSha, subject, author, date, ...bodyParts] = record.split(FIELD_SEP);
23
+ return {
24
+ sha: sha ?? "",
25
+ shortSha: shortSha ?? "",
26
+ subject: subject ?? "",
27
+ author: author ?? "",
28
+ date: date ?? "",
29
+ body: (bodyParts.join(FIELD_SEP) ?? "").replace(/\n+$/u, ""),
30
+ };
26
31
  }
@@ -0,0 +1,73 @@
1
+ export function parseNameStatus(stdout) {
2
+ const parts = stdout.split("\0").filter((part) => part !== "");
3
+ const entries = [];
4
+ let index = 0;
5
+ while (index < parts.length) {
6
+ const code = parts[index];
7
+ index += 1;
8
+ if (code === undefined) {
9
+ break;
10
+ }
11
+ if (code.startsWith("R") || code.startsWith("C")) {
12
+ const oldPath = parts[index];
13
+ const path = parts[index + 1];
14
+ index += 2;
15
+ if (oldPath === undefined || path === undefined) {
16
+ break;
17
+ }
18
+ entries.push({ status: "renamed", path, oldPath });
19
+ continue;
20
+ }
21
+ const path = parts[index];
22
+ index += 1;
23
+ if (path === undefined) {
24
+ break;
25
+ }
26
+ entries.push({ status: statusFrom(code), path });
27
+ }
28
+ return entries;
29
+ }
30
+ /** Git `--numstat -z`: `added\\tdeleted\\tpath\\0`, or `added\\tdeleted\\t\\0old\\0new\\0` for a rename. */
31
+ export function parseNumstat(stdout) {
32
+ const parts = stdout.split("\0");
33
+ const out = new Map();
34
+ let index = 0;
35
+ while (index < parts.length) {
36
+ const field = parts[index];
37
+ if (field === undefined || field === "") {
38
+ index += 1;
39
+ continue;
40
+ }
41
+ const match = /^(-|\d+)\t(-|\d+)\t(.*)$/.exec(field);
42
+ if (match === null || match[1] === undefined || match[2] === undefined || match[3] === undefined) {
43
+ break;
44
+ }
45
+ index += 1;
46
+ const added = parseCount(match[1]);
47
+ const removed = parseCount(match[2]);
48
+ if (match[3] !== "") {
49
+ out.set(match[3], { path: match[3], added, removed });
50
+ continue;
51
+ }
52
+ const oldPath = parts[index];
53
+ const path = parts[index + 1];
54
+ index += 2;
55
+ if (oldPath === undefined || path === undefined || oldPath === "" || path === "") {
56
+ break;
57
+ }
58
+ out.set(path, { path, oldPath, added, removed });
59
+ }
60
+ return out;
61
+ }
62
+ function parseCount(value) {
63
+ return value === "-" ? null : Number(value);
64
+ }
65
+ function statusFrom(code) {
66
+ if (code.startsWith("A")) {
67
+ return "added";
68
+ }
69
+ if (code.startsWith("D")) {
70
+ return "deleted";
71
+ }
72
+ return "modified";
73
+ }
@@ -1,5 +1,6 @@
1
1
  import { flattenHunks, readDiff, toHunkRef } from "../git/diff.js";
2
2
  import { hunkKey } from "../schema/identity.js";
3
+ import { isLockfilePath } from "../schema/lockfile.js";
3
4
  export async function coverReview(cwd, document) {
4
5
  const files = await readDiff(cwd, document.source.baseRef, document.source.headRef);
5
6
  const live = flattenHunks(files);
@@ -16,6 +17,9 @@ export function joinCoverage(document, live) {
16
17
  const hunks = [];
17
18
  const stale = [];
18
19
  for (const ref of group.hunkRefs) {
20
+ if (isLockfilePath(ref.path)) {
21
+ continue;
22
+ }
19
23
  const match = liveByKey.get(hunkKey(ref));
20
24
  if (match === undefined) {
21
25
  stale.push(ref);
@@ -23,7 +27,7 @@ export function joinCoverage(document, live) {
23
27
  continue;
24
28
  }
25
29
  hunks.push(match);
26
- assignedKeys.add(hunkKey(ref));
30
+ assignedKeys.add(hunkKey(match));
27
31
  }
28
32
  return { group, hunks, stale };
29
33
  });
@@ -0,0 +1,51 @@
1
+ const UNCHANGED_KEEP = 0.35;
2
+ const THRESHOLD = 8;
3
+ /** Paint a same-size RGBA pair. Unchanged pixels go gray; changed pixels go magenta. */
4
+ export function diffRgba(oldPixels, newPixels, width, height, threshold = THRESHOLD) {
5
+ const total = width * height;
6
+ const pixels = new Uint8ClampedArray(total * 4);
7
+ let changed = 0;
8
+ for (let i = 0; i < total; i += 1) {
9
+ const o = i * 4;
10
+ const oldA = oldPixels[o + 3] ?? 0;
11
+ const newA = newPixels[o + 3] ?? 0;
12
+ const empty = oldA === 0 && newA === 0;
13
+ const dr = Math.abs((oldPixels[o] ?? 0) - (newPixels[o] ?? 0));
14
+ const dg = Math.abs((oldPixels[o + 1] ?? 0) - (newPixels[o + 1] ?? 0));
15
+ const db = Math.abs((oldPixels[o + 2] ?? 0) - (newPixels[o + 2] ?? 0));
16
+ const da = Math.abs(oldA - newA);
17
+ const delta = Math.max(dr, dg, db, da);
18
+ if (empty || delta <= threshold) {
19
+ const gray = grayOf(newPixels[o] ?? 0, newPixels[o + 1] ?? 0, newPixels[o + 2] ?? 0);
20
+ const faded = Math.round(gray * UNCHANGED_KEEP);
21
+ pixels[o] = faded;
22
+ pixels[o + 1] = faded;
23
+ pixels[o + 2] = faded;
24
+ pixels[o + 3] = 255;
25
+ continue;
26
+ }
27
+ changed += 1;
28
+ if (oldA === 0 && newA > 0) {
29
+ pixels[o] = 26;
30
+ pixels[o + 1] = 127;
31
+ pixels[o + 2] = 55;
32
+ pixels[o + 3] = 255;
33
+ continue;
34
+ }
35
+ if (newA === 0 && oldA > 0) {
36
+ pixels[o] = 207;
37
+ pixels[o + 1] = 34;
38
+ pixels[o + 2] = 46;
39
+ pixels[o + 3] = 255;
40
+ continue;
41
+ }
42
+ pixels[o] = 200;
43
+ pixels[o + 1] = 40;
44
+ pixels[o + 2] = 160;
45
+ pixels[o + 3] = 255;
46
+ }
47
+ return { pixels, changed, total };
48
+ }
49
+ function grayOf(r, g, b) {
50
+ return Math.round(0.2126 * r + 0.7152 * g + 0.0722 * b);
51
+ }
@@ -0,0 +1,35 @@
1
+ export const IMAGE_EXTENSIONS = ["png", "jpg", "jpeg", "gif", "webp", "bmp", "avif", "ico"];
2
+ const IMAGE_EXT = new Set(IMAGE_EXTENSIONS);
3
+ export const LFS_POINTER_VERSION = "version https://git-lfs.github.com/spec/v1";
4
+ const MEDIA = {
5
+ png: "image/png",
6
+ jpg: "image/jpeg",
7
+ jpeg: "image/jpeg",
8
+ gif: "image/gif",
9
+ webp: "image/webp",
10
+ bmp: "image/bmp",
11
+ avif: "image/avif",
12
+ ico: "image/x-icon",
13
+ };
14
+ export function imageExtension(path) {
15
+ const base = path.split("/").pop() ?? path;
16
+ const dot = base.lastIndexOf(".");
17
+ if (dot <= 0) {
18
+ return undefined;
19
+ }
20
+ const ext = base.slice(dot + 1).toLowerCase();
21
+ return IMAGE_EXT.has(ext) ? ext : undefined;
22
+ }
23
+ export function isImagePath(path) {
24
+ return imageExtension(path) !== undefined;
25
+ }
26
+ export function imageMediaType(path) {
27
+ const ext = imageExtension(path);
28
+ return ext === undefined ? "application/octet-stream" : MEDIA[ext];
29
+ }
30
+ export function isLfsPointerText(text) {
31
+ return text.includes(LFS_POINTER_VERSION) && /oid sha256:[a-f0-9]{64}/.test(text);
32
+ }
33
+ export function isImageHunkRef(ref) {
34
+ return ref.oldStart === 0 && ref.oldLines === 0 && ref.newStart === 0 && ref.newLines === 0;
35
+ }
@@ -0,0 +1,38 @@
1
+ const LOCKFILE_NAMES = new Set([
2
+ ".terraform.lock.hcl",
3
+ "bun.lock",
4
+ "bun.lockb",
5
+ "cabal.project.freeze",
6
+ "Cargo.lock",
7
+ "Cartfile.resolved",
8
+ "composer.lock",
9
+ "conda-lock.yml",
10
+ "deno.lock",
11
+ "flake.lock",
12
+ "Gemfile.lock",
13
+ "go.sum",
14
+ "go.work.sum",
15
+ "Gopkg.lock",
16
+ "gradle.lockfile",
17
+ "lazy-lock.json",
18
+ "mix.lock",
19
+ "npm-shrinkwrap.json",
20
+ "package-lock.json",
21
+ "Package.resolved",
22
+ "pdm.lock",
23
+ "Pipfile.lock",
24
+ "pixi.lock",
25
+ "pnpm-lock.yaml",
26
+ "Podfile.lock",
27
+ "poetry.lock",
28
+ "pubspec.lock",
29
+ "renv.lock",
30
+ "shrinkwrap.yaml",
31
+ "stack.yaml.lock",
32
+ "uv.lock",
33
+ "yarn.lock",
34
+ ]);
35
+ export function isLockfilePath(path) {
36
+ const base = path.split("/").pop() ?? path;
37
+ return LOCKFILE_NAMES.has(base) || base.endsWith(".gradle.lockfile");
38
+ }
@@ -1,8 +1,8 @@
1
1
  import { isReviewSize, REVIEW_SIZES } from "./types.js";
2
- const DOCUMENT_KEYS = new Set(["version", "source", "size", "walkthrough", "tickets", "groups"]);
2
+ const DOCUMENT_KEYS = new Set(["version", "source", "size", "summary", "why", "tickets", "groups"]);
3
3
  const SOURCE_KEYS = new Set(["baseRef", "headRef", "range"]);
4
- const TICKET_KEYS = new Set(["id", "url", "title"]);
5
- const GROUP_KEYS = new Set(["id", "title", "summary", "lookFor", "dependsOn", "part", "suggestedOrder", "hunkRefs"]);
4
+ const TICKET_KEYS = new Set(["id", "url", "title", "part"]);
5
+ const GROUP_KEYS = new Set(["id", "title", "why", "summary", "lookFor", "dependsOn", "part", "suggestedOrder", "hunkRefs"]);
6
6
  const HUNK_KEYS = new Set(["path", "oldPath", "oldStart", "oldLines", "newStart", "newLines"]);
7
7
  export function parseReviewDocument(input) {
8
8
  const errors = [];
@@ -15,20 +15,22 @@ export function parseReviewDocument(input) {
15
15
  }
16
16
  const source = parseSource(input.source, errors);
17
17
  const size = parseSize(input.size, errors);
18
+ const summary = requiredString(input.summary, "summary", errors);
18
19
  const tickets = parseTickets(input.tickets, errors);
19
20
  const groups = parseGroups(input.groups, errors);
20
- const walkthrough = input.walkthrough === undefined ? undefined : requiredString(input.walkthrough, "walkthrough", errors);
21
- if (errors.length > 0 || source === undefined || size === undefined) {
21
+ const why = input.why === undefined ? undefined : requiredString(input.why, "why", errors);
22
+ if (errors.length > 0 || source === undefined || size === undefined || summary === undefined) {
22
23
  return { ok: false, errors };
23
24
  }
24
25
  const document = {
25
26
  version: 1,
26
27
  source,
27
28
  size,
29
+ summary,
28
30
  groups,
29
31
  };
30
- if (walkthrough !== undefined) {
31
- document.walkthrough = walkthrough;
32
+ if (why !== undefined) {
33
+ document.why = why;
32
34
  }
33
35
  if (tickets !== undefined) {
34
36
  document.tickets = tickets;
@@ -105,6 +107,12 @@ function parseTickets(value, errors) {
105
107
  ticket.title = title;
106
108
  }
107
109
  }
110
+ if (item.part !== undefined) {
111
+ const part = requiredString(item.part, `tickets[${i}].part`, errors);
112
+ if (part !== undefined) {
113
+ ticket.part = part;
114
+ }
115
+ }
108
116
  tickets.push(ticket);
109
117
  });
110
118
  return tickets;
@@ -124,20 +132,21 @@ function parseGroups(value, errors) {
124
132
  extraKeys(item, GROUP_KEYS, `groups[${i}]`, errors);
125
133
  const id = requiredString(item.id, `groups[${i}].id`, errors);
126
134
  const title = requiredString(item.title, `groups[${i}].title`, errors);
135
+ const why = requiredString(item.why, `groups[${i}].why`, errors);
127
136
  const summary = requiredString(item.summary, `groups[${i}].summary`, errors, { allowEmpty: true });
128
137
  const suggestedOrder = requiredNumber(item.suggestedOrder, `groups[${i}].suggestedOrder`, errors);
129
138
  const hunkRefs = parseHunkRefs(item.hunkRefs, `groups[${i}].hunkRefs`, errors);
130
139
  const lookFor = parseStringList(item.lookFor, `groups[${i}].lookFor`, errors);
131
140
  const dependsOn = parseStringList(item.dependsOn, `groups[${i}].dependsOn`, errors);
132
141
  const part = item.part === undefined ? undefined : requiredString(item.part, `groups[${i}].part`, errors);
133
- if (id === undefined || title === undefined || summary === undefined || suggestedOrder === undefined) {
142
+ if (id === undefined || title === undefined || why === undefined || summary === undefined || suggestedOrder === undefined) {
134
143
  return;
135
144
  }
136
145
  if (ids.has(id)) {
137
146
  errors.push(`duplicate group id "${id}"`);
138
147
  }
139
148
  ids.add(id);
140
- const group = { id, title, summary, suggestedOrder, hunkRefs };
149
+ const group = { id, title, why, summary, suggestedOrder, hunkRefs };
141
150
  if (lookFor !== undefined) {
142
151
  group.lookFor = lookFor;
143
152
  }
@@ -3,9 +3,9 @@ import { findPackageRoot } from "../package-root.js";
3
3
  export function skillPaths(root = findPackageRoot()) {
4
4
  return {
5
5
  canonicalSchema: join(root, "src/schema/review.schema.json"),
6
+ nextSkill: join(root, "skills-next/comprehende"),
6
7
  publishedSkill: join(root, "skills/comprehende"),
7
8
  installedSkill: join(root, ".agents/skills/comprehende"),
8
- publishedSchema: join(root, "skills/comprehende/references/review.schema.json"),
9
- installedSchema: join(root, ".agents/skills/comprehende/references/review.schema.json"),
9
+ nextSchema: join(root, "skills-next/comprehende/references/review.schema.json"),
10
10
  };
11
11
  }