comprehende 0.5.5 → 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 CHANGED
@@ -42,7 +42,7 @@ This tool is for preventing this cognitive surrender while trying to maintain mo
42
42
 
43
43
  `comprehende serve` and `comprehende export` share one UI and one git payload. Serve resolves refs to commit SHAs when it starts, then computes those payloads from the objects on each request. Export writes the same JSON (and image bytes) next to the UI so any static file server can host the review.
44
44
 
45
- `pnpm dev` and `pnpm exec` run with this package as cwd, so they only make sense when _this_ repo is the one under review. To review a different project from a checkout, `cd` into it and run `npx comprehende@0.5.5` (or `node /path/to/comprehende/dist/cli/main.js` after `pnpm build`).
45
+ `pnpm dev` and `pnpm exec` run with this package as cwd, so they only make sense when _this_ repo is the one under review. To review a different project from a checkout, `cd` into it and run `npx comprehende@0.6.0` (or `node /path/to/comprehende/dist/cli/main.js` after `pnpm build`).
46
46
 
47
47
  ## Release
48
48
 
@@ -23,7 +23,7 @@ function overviewAgentMd(review) {
23
23
  "Answer questions about this git change.",
24
24
  overviewSteps(review),
25
25
  pinBlock(review, { commits: true }),
26
- ticketsBlock(review),
26
+ sourcesBlock(review),
27
27
  coverageBlock(review),
28
28
  joinBlocks(["The title:", review.document.title]),
29
29
  review.document.why !== undefined ? joinBlocks(["The why:", review.document.why]) : null,
@@ -79,17 +79,17 @@ function pinBlock(review, options) {
79
79
  commits,
80
80
  ]);
81
81
  }
82
- function ticketsBlock(review) {
83
- const tickets = review.document.tickets ?? [];
84
- if (tickets.length === 0) {
82
+ function sourcesBlock(review) {
83
+ const sources = review.document.sources ?? [];
84
+ if (sources.length === 0) {
85
85
  return null;
86
86
  }
87
- const lines = tickets.map((ticket) => {
88
- const title = ticket.title !== undefined ? ` ${ticket.title}` : "";
89
- const url = ticket.url !== undefined ? `\n ${ticket.url}` : "";
90
- return `- ${ticket.id}${title}${url}`;
87
+ const lines = sources.map((source) => {
88
+ const gist = source.gist !== undefined ? ` ${source.gist}` : "";
89
+ const url = source.url !== undefined ? `\n ${source.url}` : "";
90
+ return `- ${source.kind} ${source.label}${gist}${url}`;
91
91
  });
92
- return ["Tickets:", ...lines].join("\n");
92
+ return ["Sources:", ...lines].join("\n");
93
93
  }
94
94
  function coverageBlock(review) {
95
95
  const lines = [];
package/dist/api/live.js CHANGED
@@ -6,8 +6,10 @@ import { GitError } from "../git/exec.js";
6
6
  import { listCommits } from "../git/log.js";
7
7
  import { pinRange, readRepoIdentity } from "../git/repo.js";
8
8
  import { showFile } from "../git/show.js";
9
- import { coverReview } from "../review/coverage.js";
9
+ import { coverReview, coverageErrors } from "../review/coverage.js";
10
+ import { commentPinErrors, staleCommentPins } from "../review/pins.js";
10
11
  import { isLockfilePath } from "../schema/lockfile.js";
12
+ import { sourceCitationErrors } from "../schema/source.js";
11
13
  import { AGENT_MD_MEDIA_TYPE, agentMd } from "./agent-md.js";
12
14
  import { ApiError } from "./error.js";
13
15
  export async function pinReviewSource(cwd, dataPath) {
@@ -18,6 +20,7 @@ export async function openReview(cwd, dataPath, pin) {
18
20
  const document = await loadDocument(dataPath);
19
21
  const range = pin ?? (await pinRange(cwd, document.source.baseRef, document.source.headRef));
20
22
  const { files, coverage } = await coverReview(cwd, document, range);
23
+ const pins = await staleCommentPins(cwd, document, range);
21
24
  const commits = await listCommits(cwd, range.baseSha, range.headSha);
22
25
  const repo = await readRepoIdentity(cwd);
23
26
  return {
@@ -33,12 +36,13 @@ export async function openReview(cwd, dataPath, pin) {
33
36
  },
34
37
  files,
35
38
  coverage,
39
+ staleCommentPins: pins,
36
40
  mergeBaseSha: range.mergeBaseSha,
37
41
  commits,
38
42
  };
39
43
  }
40
44
  export function reviewPayload(ctx) {
41
- const { document, repo, resolved, files, coverage, commits } = ctx;
45
+ const { document, repo, resolved, files, coverage, staleCommentPins, commits } = ctx;
42
46
  const lockfiles = lockfileFiles(files);
43
47
  return {
44
48
  document,
@@ -49,6 +53,7 @@ export function reviewPayload(ctx) {
49
53
  assignedHunks: coverage.assignedHunks,
50
54
  unassignedCount: coverage.unassigned.length,
51
55
  staleCount: coverage.stale.length,
56
+ staleSourceCount: staleCommentPins.length,
52
57
  },
53
58
  groups: coverage.groups
54
59
  .slice()
@@ -61,6 +66,7 @@ export function reviewPayload(ctx) {
61
66
  lookFor: group.group.lookFor ?? [],
62
67
  dependsOn: group.group.dependsOn ?? [],
63
68
  part: group.group.part,
69
+ sources: group.group.sources ?? [],
64
70
  suggestedOrder: group.group.suggestedOrder,
65
71
  hunkCount: group.hunks.length,
66
72
  staleCount: group.stale.length,
@@ -75,6 +81,7 @@ export function reviewPayload(ctx) {
75
81
  files: lockfiles.map((file) => file.path),
76
82
  },
77
83
  stale: coverage.stale,
84
+ staleSources: staleCommentPins,
78
85
  files: files.map((file) => {
79
86
  const entry = {
80
87
  path: file.path,
@@ -92,6 +99,13 @@ export function reviewPayload(ctx) {
92
99
  commits,
93
100
  };
94
101
  }
102
+ export function reviewProblems(ctx) {
103
+ return [
104
+ ...coverageErrors(ctx.coverage),
105
+ ...sourceCitationErrors(ctx.document),
106
+ ...commentPinErrors(ctx.staleCommentPins),
107
+ ];
108
+ }
95
109
  export function hunksPayload(ctx, groupId) {
96
110
  if (groupId === "") {
97
111
  throw new ApiError(400, "missing group");
package/dist/cli/args.js CHANGED
@@ -8,7 +8,7 @@ Commands:
8
8
  List hunk refs from live git (no patch text)
9
9
 
10
10
  validate --data <review.json>
11
- Check schema, ref resolution, and hunk coverage
11
+ Check schema, ref resolution, hunk coverage, and source citations
12
12
 
13
13
  serve --data <review.json> [--port <n>] [--open]
14
14
  Serve the local UI on 127.0.0.1 (pins commit SHAs at start)
@@ -3,7 +3,9 @@ import { isAbsolute, resolve } from "node:path";
3
3
  import { readHunkIndex, resolveSource } from "../git/diff.js";
4
4
  import { defaultBaseRef } from "../git/repo.js";
5
5
  import { coverReview, coverageErrors } from "../review/coverage.js";
6
+ import { commentPinErrors, staleCommentPins } from "../review/pins.js";
6
7
  import { parseReviewJson } from "../schema/parse.js";
8
+ import { sourceCitationErrors } from "../schema/source.js";
7
9
  export async function resolveRange(cwd, base, head) {
8
10
  const baseRef = base ?? (await defaultBaseRef(cwd));
9
11
  const headRef = head ?? "HEAD";
@@ -36,9 +38,10 @@ export function resolveOutPath(out, cwd) {
36
38
  }
37
39
  export async function cmdValidate(cwd, dataPath) {
38
40
  const document = await loadDocument(dataPath);
39
- await resolveSource(cwd, document.source.baseRef, document.source.headRef);
41
+ const resolved = await resolveSource(cwd, document.source.baseRef, document.source.headRef);
40
42
  const { coverage } = await coverReview(cwd, document);
41
- const errors = coverageErrors(coverage);
43
+ const pins = await staleCommentPins(cwd, document, resolved);
44
+ const errors = [...coverageErrors(coverage), ...sourceCitationErrors(document), ...commentPinErrors(pins)];
42
45
  if (errors.length > 0) {
43
46
  throw new Error(errors.join("\n\n"));
44
47
  }
package/dist/cli/main.js CHANGED
@@ -6,8 +6,7 @@ import { fileURLToPath } from "node:url";
6
6
  import { parseArgv, USAGE } from "./args.js";
7
7
  import { cmdIndex, cmdValidate, resolveDataPath, resolveOutPath } from "./commands.js";
8
8
  import { exportStaticSite } from "../api/snapshot.js";
9
- import { openReview, pinReviewSource } from "../api/live.js";
10
- import { coverageErrors } from "../review/coverage.js";
9
+ import { openReview, pinReviewSource, reviewProblems } from "../api/live.js";
11
10
  import { assertWorkTree } from "../git/repo.js";
12
11
  import { readPackageVersion } from "../package-root.js";
13
12
  import { startServer } from "../server/http.js";
@@ -44,7 +43,7 @@ export async function run(argv) {
44
43
  const dataPath = resolveDataPath(request.data, request.cwd);
45
44
  const pin = await pinReviewSource(request.cwd, dataPath);
46
45
  const ctx = await openReview(request.cwd, dataPath, pin);
47
- warnCoverage(ctx.coverage, "serve continues; git wins, unassigned/stale are visible");
46
+ warnReview(ctx, "serve continues; git wins, unassigned/stale are visible");
48
47
  const running = await startServer({ cwd: request.cwd, dataPath, port: request.port, pin });
49
48
  console.log(running.url);
50
49
  console.error(`serving ${dataPath} cwd=${request.cwd} localhost only`);
@@ -58,7 +57,7 @@ export async function run(argv) {
58
57
  const dataPath = resolveDataPath(request.data, request.cwd);
59
58
  const outDir = resolveOutPath(request.out, request.cwd);
60
59
  const ctx = await openReview(request.cwd, dataPath);
61
- warnCoverage(ctx.coverage, "export continues; git wins, unassigned/stale are visible");
60
+ warnReview(ctx, "export continues; git wins, unassigned/stale are visible");
62
61
  const result = await exportStaticSite({ cwd: request.cwd, dataPath, outDir, ctx });
63
62
  console.log(result.outDir);
64
63
  console.error(`exported ${dataPath} ${result.apiFiles.length} api files no git in the folder`);
@@ -71,8 +70,8 @@ export async function run(argv) {
71
70
  return 1;
72
71
  }
73
72
  }
74
- function warnCoverage(coverage, note) {
75
- const problems = coverageErrors(coverage);
73
+ function warnReview(ctx, note) {
74
+ const problems = reviewProblems(ctx);
76
75
  if (problems.length > 0) {
77
76
  console.error(`coverage issues (${note}):\n${problems.join("\n\n")}`);
78
77
  }
@@ -0,0 +1,30 @@
1
+ import { fileExistsAt, showFile } from "../git/show.js";
2
+ import { isLinePinned, textLineCount } from "../schema/source.js";
3
+ export async function staleCommentPins(cwd, document, range) {
4
+ const stale = [];
5
+ for (const source of document.sources ?? []) {
6
+ if (!isLinePinned(source)) {
7
+ continue;
8
+ }
9
+ if (!(await commentPinMatches(cwd, source, range))) {
10
+ stale.push({ id: source.id, path: source.path, side: source.side, line: source.line });
11
+ }
12
+ }
13
+ return stale;
14
+ }
15
+ export function commentPinErrors(pins) {
16
+ if (pins.length === 0) {
17
+ return [];
18
+ }
19
+ const lines = pins.map((pin) => ` ${pin.id} ${pin.path} ${pin.side}:${pin.line}`);
20
+ return [`stale: ${pins.length} comment pin(s) do not match live git:\n${lines.join("\n")}`];
21
+ }
22
+ async function commentPinMatches(cwd, source, range) {
23
+ const sha = source.side === "old" ? range.baseSha : range.headSha;
24
+ if (!(await fileExistsAt(cwd, sha, source.path))) {
25
+ return false;
26
+ }
27
+ const content = await showFile(cwd, sha, source.path);
28
+ const count = textLineCount(content);
29
+ return source.line >= 1 && source.line <= count;
30
+ }
@@ -1,8 +1,34 @@
1
- import { isReviewSize, REVIEW_SIZES } from "./types.js";
2
- const DOCUMENT_KEYS = new Set(["version", "source", "size", "title", "summary", "why", "tickets", "groups"]);
1
+ import { isReviewSize, isSourceKind, isSourceSide, REVIEW_SIZES, SOURCE_KINDS, } from "./types.js";
2
+ const DOCUMENT_KEYS = new Set(["version", "source", "size", "title", "summary", "why", "tickets", "sources", "groups"]);
3
3
  const SOURCE_KEYS = new Set(["baseRef", "headRef", "range"]);
4
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"]);
5
+ const REVIEW_SOURCE_KEYS = new Set([
6
+ "id",
7
+ "kind",
8
+ "label",
9
+ "url",
10
+ "title",
11
+ "gist",
12
+ "part",
13
+ "author",
14
+ "body",
15
+ "path",
16
+ "side",
17
+ "line",
18
+ ]);
19
+ const COMMENT_ONLY_KEYS = ["author", "body", "path", "side", "line"];
20
+ const GROUP_KEYS = new Set([
21
+ "id",
22
+ "title",
23
+ "why",
24
+ "summary",
25
+ "lookFor",
26
+ "dependsOn",
27
+ "part",
28
+ "sources",
29
+ "suggestedOrder",
30
+ "hunkRefs",
31
+ ]);
6
32
  const HUNK_KEYS = new Set(["path", "oldPath", "oldStart", "oldLines", "newStart", "newLines"]);
7
33
  export function parseReviewDocument(input) {
8
34
  const errors = [];
@@ -17,8 +43,8 @@ export function parseReviewDocument(input) {
17
43
  const size = parseSize(input.size, errors);
18
44
  const title = requiredString(input.title, "title", errors);
19
45
  const summary = requiredString(input.summary, "summary", errors);
20
- const tickets = parseTickets(input.tickets, errors);
21
- const groups = parseGroups(input.groups, errors);
46
+ const sources = parseDocumentSources(input.sources, input.tickets, errors);
47
+ const groups = parseGroups(input.groups, sources, errors);
22
48
  const why = input.why === undefined ? undefined : requiredString(input.why, "why", errors);
23
49
  if (errors.length > 0 || source === undefined || size === undefined || title === undefined || summary === undefined) {
24
50
  return { ok: false, errors };
@@ -34,8 +60,8 @@ export function parseReviewDocument(input) {
34
60
  if (why !== undefined) {
35
61
  document.why = why;
36
62
  }
37
- if (tickets !== undefined) {
38
- document.tickets = tickets;
63
+ if (sources !== undefined) {
64
+ document.sources = sources;
39
65
  }
40
66
  return { ok: true, document };
41
67
  }
@@ -77,15 +103,154 @@ function parseSource(value, errors) {
77
103
  }
78
104
  return source;
79
105
  }
80
- function parseTickets(value, errors) {
81
- if (value === undefined) {
106
+ function parseDocumentSources(sourcesValue, ticketsValue, errors) {
107
+ if (sourcesValue !== undefined && ticketsValue !== undefined) {
108
+ errors.push("document has both sources and tickets; use sources");
109
+ }
110
+ if (sourcesValue !== undefined) {
111
+ return parseSources(sourcesValue, errors);
112
+ }
113
+ if (ticketsValue !== undefined) {
114
+ return parseLegacyTickets(ticketsValue, errors);
115
+ }
116
+ return undefined;
117
+ }
118
+ function parseSources(value, errors) {
119
+ if (!Array.isArray(value)) {
120
+ errors.push("sources must be an array");
121
+ return undefined;
122
+ }
123
+ const sources = [];
124
+ const ids = new Set();
125
+ value.forEach((item, i) => {
126
+ const source = parseSourceItem(item, `sources[${i}]`, errors);
127
+ if (source === undefined) {
128
+ return;
129
+ }
130
+ if (ids.has(source.id)) {
131
+ errors.push(`duplicate source id "${source.id}"`);
132
+ }
133
+ ids.add(source.id);
134
+ sources.push(source);
135
+ });
136
+ return sources;
137
+ }
138
+ function parseSourceItem(value, label, errors) {
139
+ if (!isRecord(value)) {
140
+ errors.push(`${label} must be an object`);
141
+ return undefined;
142
+ }
143
+ extraKeys(value, REVIEW_SOURCE_KEYS, label, errors);
144
+ const id = requiredString(value.id, `${label}.id`, errors);
145
+ const kind = parseKind(value.kind, `${label}.kind`, errors);
146
+ const sourceLabel = requiredString(value.label, `${label}.label`, errors);
147
+ if (id === undefined || kind === undefined || sourceLabel === undefined) {
82
148
  return undefined;
83
149
  }
150
+ const source = { id, kind, label: sourceLabel };
151
+ if (value.url !== undefined) {
152
+ const url = requiredString(value.url, `${label}.url`, errors);
153
+ if (url !== undefined) {
154
+ source.url = url;
155
+ }
156
+ }
157
+ if (value.title !== undefined) {
158
+ const title = requiredString(value.title, `${label}.title`, errors);
159
+ if (title !== undefined) {
160
+ source.title = title;
161
+ }
162
+ }
163
+ if (value.gist !== undefined) {
164
+ const gist = requiredString(value.gist, `${label}.gist`, errors);
165
+ if (gist !== undefined) {
166
+ source.gist = gist;
167
+ }
168
+ }
169
+ if (value.part !== undefined) {
170
+ const part = requiredString(value.part, `${label}.part`, errors);
171
+ if (part !== undefined) {
172
+ source.part = part;
173
+ }
174
+ }
175
+ if (kind === "transcript" && source.url !== undefined) {
176
+ errors.push(`${label}.url must be omitted for transcripts`);
177
+ }
178
+ assignCommentFields(value, source, kind, label, errors);
179
+ return source;
180
+ }
181
+ function assignCommentFields(value, source, kind, label, errors) {
182
+ const present = COMMENT_ONLY_KEYS.filter((key) => value[key] !== undefined);
183
+ if (kind !== "pr-comment") {
184
+ for (const key of present) {
185
+ errors.push(`${label}.${key} is only valid on pr-comment sources`);
186
+ }
187
+ return;
188
+ }
189
+ if (value.author !== undefined) {
190
+ const author = requiredString(value.author, `${label}.author`, errors);
191
+ if (author !== undefined) {
192
+ source.author = author;
193
+ }
194
+ }
195
+ if (value.body !== undefined) {
196
+ const body = requiredString(value.body, `${label}.body`, errors, { allowEmpty: true });
197
+ if (body !== undefined) {
198
+ source.body = body;
199
+ }
200
+ }
201
+ if (source.author === undefined) {
202
+ errors.push(`${label}.author is required on pr-comment sources`);
203
+ }
204
+ if (source.body === undefined) {
205
+ errors.push(`${label}.body is required on pr-comment sources`);
206
+ }
207
+ const pinCount = [value.path, value.side, value.line].filter((item) => item !== undefined).length;
208
+ if (pinCount === 0) {
209
+ return;
210
+ }
211
+ if (pinCount !== 3) {
212
+ errors.push(`${label} line pin needs path, side, and line together`);
213
+ }
214
+ if (value.path !== undefined) {
215
+ const path = requiredString(value.path, `${label}.path`, errors);
216
+ if (path !== undefined) {
217
+ source.path = path;
218
+ }
219
+ }
220
+ if (value.side !== undefined) {
221
+ if (!isSourceSide(value.side)) {
222
+ errors.push(`${label}.side must be old or new`);
223
+ }
224
+ else {
225
+ source.side = value.side;
226
+ }
227
+ }
228
+ if (value.line !== undefined) {
229
+ const line = requiredInt(value.line, `${label}.line`, errors);
230
+ if (line !== undefined) {
231
+ if (line < 1) {
232
+ errors.push(`${label}.line must be a positive integer`);
233
+ }
234
+ else {
235
+ source.line = line;
236
+ }
237
+ }
238
+ }
239
+ }
240
+ function parseKind(value, label, errors) {
241
+ if (isSourceKind(value)) {
242
+ return value;
243
+ }
244
+ errors.push(`${label} must be one of ${SOURCE_KINDS.join(", ")}`);
245
+ return undefined;
246
+ }
247
+ function parseLegacyTickets(value, errors) {
84
248
  if (!Array.isArray(value)) {
85
249
  errors.push("tickets must be an array");
86
250
  return undefined;
87
251
  }
88
- const tickets = [];
252
+ const sources = [];
253
+ const ids = new Set();
89
254
  value.forEach((item, i) => {
90
255
  if (!isRecord(item)) {
91
256
  errors.push(`tickets[${i}] must be an object`);
@@ -96,30 +261,34 @@ function parseTickets(value, errors) {
96
261
  if (id === undefined) {
97
262
  return;
98
263
  }
99
- const ticket = { id };
264
+ if (ids.has(id)) {
265
+ errors.push(`duplicate source id "${id}"`);
266
+ }
267
+ ids.add(id);
268
+ const source = { id, kind: "ticket", label: id };
100
269
  if (item.url !== undefined) {
101
270
  const url = requiredString(item.url, `tickets[${i}].url`, errors);
102
271
  if (url !== undefined) {
103
- ticket.url = url;
272
+ source.url = url;
104
273
  }
105
274
  }
106
275
  if (item.title !== undefined) {
107
276
  const title = requiredString(item.title, `tickets[${i}].title`, errors);
108
277
  if (title !== undefined) {
109
- ticket.title = title;
278
+ source.title = title;
110
279
  }
111
280
  }
112
281
  if (item.part !== undefined) {
113
282
  const part = requiredString(item.part, `tickets[${i}].part`, errors);
114
283
  if (part !== undefined) {
115
- ticket.part = part;
284
+ source.part = part;
116
285
  }
117
286
  }
118
- tickets.push(ticket);
287
+ sources.push(source);
119
288
  });
120
- return tickets;
289
+ return sources;
121
290
  }
122
- function parseGroups(value, errors) {
291
+ function parseGroups(value, sources, errors) {
123
292
  if (!Array.isArray(value)) {
124
293
  errors.push("groups must be an array");
125
294
  return [];
@@ -140,6 +309,7 @@ function parseGroups(value, errors) {
140
309
  const hunkRefs = parseHunkRefs(item.hunkRefs, `groups[${i}].hunkRefs`, errors);
141
310
  const lookFor = parseStringList(item.lookFor, `groups[${i}].lookFor`, errors);
142
311
  const dependsOn = parseStringList(item.dependsOn, `groups[${i}].dependsOn`, errors);
312
+ const groupSources = parseStringList(item.sources, `groups[${i}].sources`, errors);
143
313
  const part = item.part === undefined ? undefined : requiredString(item.part, `groups[${i}].part`, errors);
144
314
  if (id === undefined || title === undefined || why === undefined || summary === undefined || suggestedOrder === undefined) {
145
315
  return;
@@ -158,18 +328,32 @@ function parseGroups(value, errors) {
158
328
  if (part !== undefined) {
159
329
  group.part = part;
160
330
  }
331
+ if (groupSources !== undefined) {
332
+ group.sources = groupSources;
333
+ }
161
334
  groups.push(group);
162
335
  });
163
- const known = new Set(groups.map((group) => group.id));
336
+ const knownGroups = new Set(groups.map((group) => group.id));
337
+ const knownSources = new Set((sources ?? []).map((source) => source.id));
164
338
  for (const group of groups) {
165
339
  for (const dep of group.dependsOn ?? []) {
166
340
  if (dep === group.id) {
167
341
  errors.push(`groups id "${group.id}" depends on itself`);
168
342
  }
169
- else if (!known.has(dep)) {
343
+ else if (!knownGroups.has(dep)) {
170
344
  errors.push(`groups id "${group.id}" dependsOn unknown group "${dep}"`);
171
345
  }
172
346
  }
347
+ const seen = new Set();
348
+ for (const sourceId of group.sources ?? []) {
349
+ if (seen.has(sourceId)) {
350
+ errors.push(`groups id "${group.id}" lists source "${sourceId}" twice`);
351
+ }
352
+ seen.add(sourceId);
353
+ if (!knownSources.has(sourceId)) {
354
+ errors.push(`groups id "${group.id}" sources unknown id "${sourceId}"`);
355
+ }
356
+ }
173
357
  }
174
358
  return groups;
175
359
  }
@@ -0,0 +1,89 @@
1
+ const SOURCE_LINK = /\]\(\s*source:([^)\s]+)(?:\s+"[^"]*")?\s*\)/g;
2
+ export function isLinePinned(source) {
3
+ return (source.kind === "pr-comment" &&
4
+ source.path !== undefined &&
5
+ source.side !== undefined &&
6
+ source.line !== undefined);
7
+ }
8
+ export function citationIds(text) {
9
+ const ids = [];
10
+ SOURCE_LINK.lastIndex = 0;
11
+ for (const match of text.matchAll(SOURCE_LINK)) {
12
+ const id = match[1];
13
+ if (id !== undefined && id !== "") {
14
+ ids.push(id);
15
+ }
16
+ }
17
+ return ids;
18
+ }
19
+ export function documentCitationRefs(document) {
20
+ const out = [];
21
+ const add = (where, text) => {
22
+ if (text === undefined) {
23
+ return;
24
+ }
25
+ for (const id of citationIds(text)) {
26
+ out.push({ where, id });
27
+ }
28
+ };
29
+ add("why", document.why);
30
+ add("summary", document.summary);
31
+ document.groups.forEach((group, i) => {
32
+ add(`groups[${i}].why`, group.why);
33
+ add(`groups[${i}].summary`, group.summary);
34
+ (group.lookFor ?? []).forEach((item, j) => {
35
+ add(`groups[${i}].lookFor[${j}]`, item);
36
+ });
37
+ });
38
+ return out;
39
+ }
40
+ export function sourceCitationErrors(document) {
41
+ const known = new Set((document.sources ?? []).map((source) => source.id));
42
+ const seen = new Set();
43
+ const errors = [];
44
+ for (const { where, id } of documentCitationRefs(document)) {
45
+ const key = `${where}\0${id}`;
46
+ if (seen.has(key)) {
47
+ continue;
48
+ }
49
+ seen.add(key);
50
+ if (!known.has(id)) {
51
+ errors.push(`source: unknown id "${id}" in ${where}`);
52
+ }
53
+ }
54
+ return errors;
55
+ }
56
+ export function parseSourceHref(href) {
57
+ if (href === undefined || !href.startsWith("source:")) {
58
+ return undefined;
59
+ }
60
+ const id = href.slice("source:".length);
61
+ return id === "" ? undefined : id;
62
+ }
63
+ export function linePinnedSources(sources) {
64
+ return (sources ?? []).filter(isLinePinned);
65
+ }
66
+ export function citedSourceIds(text) {
67
+ return [...new Set(citationIds(text))];
68
+ }
69
+ export function groupIdForPinnedSource(document, source) {
70
+ if (!isLinePinned(source)) {
71
+ return undefined;
72
+ }
73
+ const named = document.groups.filter((group) => (group.sources ?? []).includes(source.id));
74
+ const covering = document.groups.filter((group) => group.hunkRefs.some((ref) => ref.path === source.path || ref.oldPath === source.path));
75
+ const both = covering.filter((group) => named.some((item) => item.id === group.id));
76
+ return (both[0] ?? covering[0] ?? named[0])?.id;
77
+ }
78
+ export function groupSourceIds(group) {
79
+ const named = group.sources ?? [];
80
+ const cited = [group.why, group.summary, ...(group.lookFor ?? [])].flatMap(citationIds);
81
+ return [...new Set([...named, ...cited])];
82
+ }
83
+ export function textLineCount(content) {
84
+ if (content === "") {
85
+ return 0;
86
+ }
87
+ const stripped = content.endsWith("\n") ? content.slice(0, -1) : content;
88
+ return stripped.split("\n").length;
89
+ }
@@ -2,3 +2,11 @@ export const REVIEW_SIZES = ["trivial", "small", "medium", "large", "very-large"
2
2
  export function isReviewSize(value) {
3
3
  return typeof value === "string" && REVIEW_SIZES.includes(value);
4
4
  }
5
+ export const SOURCE_KINDS = ["ticket", "pr", "pr-comment", "commit", "transcript"];
6
+ export function isSourceKind(value) {
7
+ return typeof value === "string" && SOURCE_KINDS.includes(value);
8
+ }
9
+ export const SOURCE_SIDES = ["old", "new"];
10
+ export function isSourceSide(value) {
11
+ return value === "old" || value === "new";
12
+ }