@shiyuphan/follow-me 0.9.0 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/dist/src/cli.js +150 -34
  2. package/dist/src/comments.js +25 -4
  3. package/dist/src/coverage.js +26 -15
  4. package/dist/src/host.js +1 -0
  5. package/dist/src/plan.js +44 -22
  6. package/dist/src/prompt.js +58 -17
  7. package/dist/ui/dist/assets/{abnfDiagram-WEYOIA3C-DztIEwNE.js → abnfDiagram-WEYOIA3C-BWgk01ve.js} +1 -1
  8. package/dist/ui/dist/assets/{arc-DUYh-EqZ.js → arc-Cg_Qb5c4.js} +1 -1
  9. package/dist/ui/dist/assets/{architecture-WOLXFQ4H-BQYTB92_.js → architecture-WOLXFQ4H-EdfV1etv.js} +1 -1
  10. package/dist/ui/dist/assets/{architectureDiagram-ZHVYKT53-Dl7095A5.js → architectureDiagram-ZHVYKT53-Dz-gytQU.js} +1 -1
  11. package/dist/ui/dist/assets/{blockDiagram-RJAE7QA3-QDZ5kE1J.js → blockDiagram-RJAE7QA3-B7Q5KhYC.js} +1 -1
  12. package/dist/ui/dist/assets/{c4Diagram-XDPPPE54-CKJZ1xOw.js → c4Diagram-XDPPPE54-Cq9IvGR1.js} +1 -1
  13. package/dist/ui/dist/assets/channel-Da0YvuQF.js +1 -0
  14. package/dist/ui/dist/assets/{chunk-2E7WHVMD-BXBjaqd1.js → chunk-2E7WHVMD-BTJB-4C8.js} +1 -1
  15. package/dist/ui/dist/assets/{chunk-4EMNKR2T-DPzfVuke.js → chunk-4EMNKR2T-BILthc2j.js} +1 -1
  16. package/dist/ui/dist/assets/{chunk-55XCU76K-B33Sj3w8.js → chunk-55XCU76K-DH3fYrJH.js} +1 -1
  17. package/dist/ui/dist/assets/{chunk-7S6XSTFO-B_u7dS03.js → chunk-7S6XSTFO-DDIiBvc2.js} +1 -1
  18. package/dist/ui/dist/assets/{chunk-AQDTSKZB-B4EzH5gx.js → chunk-AQDTSKZB-b1EeJ3gb.js} +1 -1
  19. package/dist/ui/dist/assets/{chunk-DSNLSHJZ-CfWeHABr.js → chunk-DSNLSHJZ-DZZ635s5.js} +1 -1
  20. package/dist/ui/dist/assets/{chunk-HAGFOTED-B8G8PjLZ.js → chunk-HAGFOTED-DhhrHTIh.js} +1 -1
  21. package/dist/ui/dist/assets/{chunk-HNE4Q36P-BA-PqoTF.js → chunk-HNE4Q36P-B8wvhc2e.js} +1 -1
  22. package/dist/ui/dist/assets/{chunk-NQOKRVIF-JvhuFcnR.js → chunk-NQOKRVIF-CLtGMwDx.js} +1 -1
  23. package/dist/ui/dist/assets/{chunk-UHW7PFBP-BrfL5CGY.js → chunk-UHW7PFBP-B9fVLGl0.js} +1 -1
  24. package/dist/ui/dist/assets/{chunk-VQ5W62QI-dBJqgdDy.js → chunk-VQ5W62QI-CgPvvMAv.js} +1 -1
  25. package/dist/ui/dist/assets/{chunk-WQQL4CDK-dfqd3k_P.js → chunk-WQQL4CDK-DdqKu6R9.js} +1 -1
  26. package/dist/ui/dist/assets/{chunk-XXDRQBXY-kViWdB9M.js → chunk-XXDRQBXY-GlM07vbp.js} +1 -1
  27. package/dist/ui/dist/assets/{chunk-YZO67HFG-ukfs4wT3.js → chunk-YZO67HFG-YqT6M0W9.js} +1 -1
  28. package/dist/ui/dist/assets/{chunk-ZAUX2LTX-BxZHki81.js → chunk-ZAUX2LTX-DqHK51Gc.js} +1 -1
  29. package/dist/ui/dist/assets/{chunk-ZCHMQSOR-PjluhR_E.js → chunk-ZCHMQSOR-DEeXkjWz.js} +1 -1
  30. package/dist/ui/dist/assets/{classDiagram-v2-4MYWR3UA-Ct89LJec.js → classDiagram-v2-4MYWR3UA-BcKsX4up.js} +1 -1
  31. package/dist/ui/dist/assets/{cose-bilkent-JH36ORCC-BnQceGmB.js → cose-bilkent-JH36ORCC-fu9djgYc.js} +1 -1
  32. package/dist/ui/dist/assets/{cynefin-EF2NZ3EQ-D6WkCVE8.js → cynefin-EF2NZ3EQ-CFJASBsJ.js} +1 -1
  33. package/dist/ui/dist/assets/{cynefinDiagram-WF24UDRP-dn6DUPAb.js → cynefinDiagram-WF24UDRP-BVJD_n0g.js} +1 -1
  34. package/dist/ui/dist/assets/{dagre-NK4GIZVX-BtZSlLk7.js → dagre-NK4GIZVX-Cx8pjz9O.js} +1 -1
  35. package/dist/ui/dist/assets/{diagram-CBK3MWTC-Ds4_tv6X.js → diagram-CBK3MWTC-CVIkEZ1X.js} +1 -1
  36. package/dist/ui/dist/assets/{diagram-I5XIIV2D-DcoVkg45.js → diagram-I5XIIV2D-2rxpF8BZ.js} +1 -1
  37. package/dist/ui/dist/assets/{diagram-ODIL2UCW-T47FAA3k.js → diagram-ODIL2UCW-NJfGHnLk.js} +1 -1
  38. package/dist/ui/dist/assets/{diagram-PFBEJUXE-CRZcaVKP.js → diagram-PFBEJUXE-DZHVJSeM.js} +1 -1
  39. package/dist/ui/dist/assets/{diagram-RJA3I4VV-D6KXeUPg.js → diagram-RJA3I4VV-CQAfOObI.js} +1 -1
  40. package/dist/ui/dist/assets/{diagram-TYUNFSNR-BHEIVyNN.js → diagram-TYUNFSNR-DGGaMOl6.js} +1 -1
  41. package/dist/ui/dist/assets/{dist-DLlKIWBp.js → dist-CW-4NrUq.js} +1 -1
  42. package/dist/ui/dist/assets/{ebnfDiagram-V4P3TTPT-nUDvYRBV.js → ebnfDiagram-V4P3TTPT-92pb2Fcg.js} +1 -1
  43. package/dist/ui/dist/assets/{elk-QONTBCTQ-D21BcRzc.js → elk-QONTBCTQ-vu0Z4rYG.js} +1 -1
  44. package/dist/ui/dist/assets/{erDiagram-KYQIGQAA-CTpHuftU.js → erDiagram-KYQIGQAA-BXnsRzCQ.js} +1 -1
  45. package/dist/ui/dist/assets/{eventmodeling-K75KTNOO-B6JaKuDY.js → eventmodeling-K75KTNOO-bSr88MT1.js} +1 -1
  46. package/dist/ui/dist/assets/flowDiagram-IUGLLWBU-CUH45H9n.js +1 -0
  47. package/dist/ui/dist/assets/{ganttDiagram-R7GPNTJY-D2-pMcnC.js → ganttDiagram-R7GPNTJY-BQzVkGbj.js} +1 -1
  48. package/dist/ui/dist/assets/{gitGraph-VSP46ZUC-B2yxwhEo.js → gitGraph-VSP46ZUC-BegblPQi.js} +1 -1
  49. package/dist/ui/dist/assets/{gitGraphDiagram-PDYL7XHY-DnrhBMH1.js → gitGraphDiagram-PDYL7XHY-BaGlGpEX.js} +1 -1
  50. package/dist/ui/dist/assets/{index-Dnte9tPd.js → index-D8ErIacA.js} +4 -4
  51. package/dist/ui/dist/assets/{info-OHQRW6UA-D8oh6gvs.js → info-OHQRW6UA-D3HVGJDc.js} +1 -1
  52. package/dist/ui/dist/assets/{infoDiagram-MVDRKAX5-BLSZjjgB.js → infoDiagram-MVDRKAX5-Ckcp-D0C.js} +1 -1
  53. package/dist/ui/dist/assets/{ishikawaDiagram-HPEHV2GE-BXiznLSn.js → ishikawaDiagram-HPEHV2GE-CWhd0CTS.js} +1 -1
  54. package/dist/ui/dist/assets/{journeyDiagram-BOSHW7O7-BSYmVcO_.js → journeyDiagram-BOSHW7O7-CrH5cFXY.js} +1 -1
  55. package/dist/ui/dist/assets/{kanban-definition-FNSA4HC5-COthkkzK.js → kanban-definition-FNSA4HC5-CpORMTDZ.js} +1 -1
  56. package/dist/ui/dist/assets/{line-6ND8OaOL.js → line-B3El9R0Z.js} +1 -1
  57. package/dist/ui/dist/assets/{linear-DbWEPFJX.js → linear-DLfthkbB.js} +1 -1
  58. package/dist/ui/dist/assets/{mermaid-parser.core-Btsk294z.js → mermaid-parser.core-QtQjLaoK.js} +3 -3
  59. package/dist/ui/dist/assets/{mermaid.core-B4llDSFO.js → mermaid.core-C2DGgMSc.js} +4 -4
  60. package/dist/ui/dist/assets/{mindmap-definition-6OY57UG3-hv2PfX-x.js → mindmap-definition-6OY57UG3-CW1lSXnX.js} +1 -1
  61. package/dist/ui/dist/assets/{packet-JDAUHWVQ-Dr4vBgjt.js → packet-JDAUHWVQ-Dhu_m_17.js} +1 -1
  62. package/dist/ui/dist/assets/{pegDiagram-ECIUJNBX-DvlFov3N.js → pegDiagram-ECIUJNBX-BGOhbzN4.js} +1 -1
  63. package/dist/ui/dist/assets/{pie-XZMESJXO-C9QsqC4n.js → pie-XZMESJXO-Citt3cMX.js} +1 -1
  64. package/dist/ui/dist/assets/{pieDiagram-GNMPY74T-CrrHOj67.js → pieDiagram-GNMPY74T-DPVZikvz.js} +1 -1
  65. package/dist/ui/dist/assets/{prism-core-Cc8H7LPG.js → prism-core-YgwsYh9d.js} +1 -1
  66. package/dist/ui/dist/assets/{quadrantDiagram-D57K4MC3-DQ7J6-ti.js → quadrantDiagram-D57K4MC3-Bwye1B9e.js} +1 -1
  67. package/dist/ui/dist/assets/{radar-ABXABTNO-BBBPaujh.js → radar-ABXABTNO-DRUb4G6V.js} +1 -1
  68. package/dist/ui/dist/assets/{railroad-I3PHUGI6-C-P6Igaz.js → railroad-I3PHUGI6-x3Ug2mxX.js} +1 -1
  69. package/dist/ui/dist/assets/{railroad-abnf-LNEFI6M7-CAkorRLs.js → railroad-abnf-LNEFI6M7-Dw14XhkZ.js} +1 -1
  70. package/dist/ui/dist/assets/{railroad-ebnf-DWYD2UWJ-D8Hz6gCf.js → railroad-ebnf-DWYD2UWJ-AwoWTWgL.js} +1 -1
  71. package/dist/ui/dist/assets/{railroad-peg-JR7BVNR7-D-XNMkOs.js → railroad-peg-JR7BVNR7-U7MXfciE.js} +1 -1
  72. package/dist/ui/dist/assets/{railroadDiagram-Y72NRBL7-DE9LwxJQ.js → railroadDiagram-Y72NRBL7-BxWlCZYs.js} +1 -1
  73. package/dist/ui/dist/assets/{requirementDiagram-EQYSW5RA-FrKFY6NJ.js → requirementDiagram-EQYSW5RA-TPmnZguV.js} +1 -1
  74. package/dist/ui/dist/assets/{sankeyDiagram-SY24JHO5-BDxI6RDX.js → sankeyDiagram-SY24JHO5-nPGftpl5.js} +1 -1
  75. package/dist/ui/dist/assets/{sequenceDiagram-BLEHNHQU-pV1XcKX7.js → sequenceDiagram-BLEHNHQU-BdctgaWa.js} +1 -1
  76. package/dist/ui/dist/assets/{src-snNJTXXq.js → src-zq235FjH.js} +1 -1
  77. package/dist/ui/dist/assets/{stateDiagram-v2-A6IESKWZ-BSc_5aAf.js → stateDiagram-v2-A6IESKWZ-BK_E6kPg.js} +1 -1
  78. package/dist/ui/dist/assets/{swimlanes-B3ENRKFQ-DC6qUXLh.js → swimlanes-B3ENRKFQ-BY50Ne4l.js} +1 -1
  79. package/dist/ui/dist/assets/swimlanesDiagram-AYQRV4AK-Ctw23I3i.js +8 -0
  80. package/dist/ui/dist/assets/{timeline-definition-JXYKGLGB-C8L0Dxtu.js → timeline-definition-JXYKGLGB-Bg8AXJ2P.js} +1 -1
  81. package/dist/ui/dist/assets/{treeView-D4JQ5SDB-DDyZIG5T.js → treeView-D4JQ5SDB-B4hz6M4l.js} +1 -1
  82. package/dist/ui/dist/assets/{treemap-SAJKECNS-CUaLgge0.js → treemap-SAJKECNS-e_Vc97Ox.js} +1 -1
  83. package/dist/ui/dist/assets/{usecaseDiagram-WPOLBNNX-BHRBd5pV.js → usecaseDiagram-WPOLBNNX-Butu_LCX.js} +1 -1
  84. package/dist/ui/dist/assets/{vennDiagram-Q3Y3IDCW-Du4iwwkh.js → vennDiagram-Q3Y3IDCW-BGIyxlbf.js} +1 -1
  85. package/dist/ui/dist/assets/{wardley-7MLQ67FV-DUgENI0s.js → wardley-7MLQ67FV-Btv95mqq.js} +1 -1
  86. package/dist/ui/dist/assets/{wardleyDiagram-IVAIDKWA-B86zOdke.js → wardleyDiagram-IVAIDKWA-DwTKExJ4.js} +1 -1
  87. package/dist/ui/dist/assets/{xychartDiagram-4URNHDU4-twq9irqO.js → xychartDiagram-4URNHDU4-UmlA5uxc.js} +1 -1
  88. package/dist/ui/dist/index.html +1 -1
  89. package/package.json +1 -1
  90. package/skills/follow-me/SKILL.md +7 -7
  91. package/dist/ui/dist/assets/channel-DF08Hd8f.js +0 -1
  92. package/dist/ui/dist/assets/flowDiagram-IUGLLWBU-BTeOUR-p.js +0 -1
  93. package/dist/ui/dist/assets/swimlanesDiagram-AYQRV4AK-F1HykV3o.js +0 -8
package/dist/src/cli.js CHANGED
@@ -1,10 +1,10 @@
1
1
  import { execFileSync } from "node:child_process";
2
- import { readFileSync, writeFileSync } from "node:fs";
2
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
4
  import { dirname, join, relative, resolve } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { mapOffTip, parseStateMap } from "./comments.js";
7
- import { changedLines, LcovError, PREVIEW_LINES, parseLcov, parseLocArg, recordRun, runOf, tipPlacer, totalOf, } from "./coverage.js";
7
+ import { changedBranchesTaken, changedLines, LcovError, PREVIEW_LINES, parseLcov, parseLocArg, recordRun, runOf, tipPlacer, totalOf, } from "./coverage.js";
8
8
  import { definitionIndex, isIdentifier } from "./definition.js";
9
9
  import { DiffParseError, formatUnitRef, isRepoPath, isUnitId, splitLines } from "./diff.js";
10
10
  import { BranchRefusedError, blobsAt, branchHead, GitError, isOwnWork, pathsAt, pathsChanged, signing, treeOf, writeBranch, } from "./git.js";
@@ -20,6 +20,10 @@ import { checkSyntax } from "./syntax.js";
20
20
  class UsageError extends Error {
21
21
  name = "UsageError";
22
22
  }
23
+ /** `--help` or `-h` anywhere a command reads its options; main prints that command's usage. */
24
+ class HelpRequested extends Error {
25
+ name = "HelpRequested";
26
+ }
23
27
  /** A test run follow-me will not record, with the reason the agent can act on. */
24
28
  class CoverageError extends Error {
25
29
  name = "CoverageError";
@@ -47,7 +51,8 @@ Other commands:
47
51
  the items to post to a merge request
48
52
  coverage <lcov> [--test <id> [--at <path:line>]] [--reset]
49
53
  record a test run taken at the tip for the page;
50
- --reset alone clears it before per-test runs
54
+ --at is where the test is declared; --reset alone
55
+ clears it before per-test runs
51
56
 
52
57
  help --all lists every command.`;
53
58
  const USAGE = `usage: follow-me <command> [options]
@@ -117,7 +122,8 @@ Interactive loop (while the review page is open):
117
122
  record the lcov of a test run at the branch tip;
118
123
  refuses when a covered file in the working tree
119
124
  differs from the tip. A plain run replaces the last;
120
- --test runs add one test each, --at is where it is.
125
+ --test runs add one test each, --at is the line
126
+ where that test is declared.
121
127
  --reset starts fresh; alone it clears the last run
122
128
  apply, while the page is open, swaps it to the new plan version in place, same URL.
123
129
 
@@ -157,6 +163,8 @@ function parseArgs(argv, spec) {
157
163
  const flags = new Set();
158
164
  for (let i = 0; i < argv.length; i++) {
159
165
  const arg = argv[i] ?? "";
166
+ if (arg === "--help" || arg === "-h")
167
+ throw new HelpRequested();
160
168
  if (!arg.startsWith("--")) {
161
169
  positionals.push(arg);
162
170
  continue;
@@ -176,6 +184,43 @@ function parseArgs(argv, spec) {
176
184
  }
177
185
  return { positionals, values, flags };
178
186
  }
187
+ /**
188
+ * The usage of one command, read from its entries in USAGE: the synopsis, then what it does.
189
+ * Null for a command USAGE does not list.
190
+ */
191
+ function usageOf(command) {
192
+ const blocks = [];
193
+ let block = null;
194
+ for (const line of USAGE.split("\n")) {
195
+ if (line.startsWith(` ${command} `) || line === ` ${command}`) {
196
+ block = [line];
197
+ blocks.push(block);
198
+ }
199
+ else if (block !== null && line.startsWith(" "))
200
+ block.push(line);
201
+ else
202
+ block = null;
203
+ }
204
+ if (blocks.length === 0)
205
+ return null;
206
+ return blocks
207
+ .map((lines) => {
208
+ const synopsis = [];
209
+ const what = [];
210
+ for (const line of lines) {
211
+ const [left = "", right = ""] = line.trim().split(/\s{3,}/, 2);
212
+ if (/^\s{20,}/.test(line))
213
+ what.push(left);
214
+ else {
215
+ synopsis.push(left);
216
+ if (right !== "")
217
+ what.push(right);
218
+ }
219
+ }
220
+ return [`usage: follow-me ${synopsis.join(" ")}`, ...what.map((w) => ` ${w}`)].join("\n");
221
+ })
222
+ .join("\n");
223
+ }
179
224
  function say(line = "") {
180
225
  process.stdout.write(`${line}\n`);
181
226
  }
@@ -265,45 +310,59 @@ function summarize(session, cwd) {
265
310
  }
266
311
  say(`session ${relative(cwd, session.dir) || "."}`);
267
312
  }
313
+ /** Commits are numbered from 1 in everything the agent reads; issues carry 0-based indices. */
314
+ function commitName(k) {
315
+ return `commit ${k + 1}`;
316
+ }
317
+ /** A JSON path into the draft with its commit named from 1: `$.commits[2].units[0]` reads `commit 3, units[0]`. */
318
+ function jsonPlace(at) {
319
+ const m = /^\$\.commits\[(\d+)\]\.?(.*)$/.exec(at);
320
+ if (m === null)
321
+ return at;
322
+ const [, k = "0", rest = ""] = m;
323
+ return rest === "" ? commitName(Number(k)) : `${commitName(Number(k))}, ${rest}`;
324
+ }
268
325
  function formatIssue(issue) {
269
326
  switch (issue.kind) {
270
327
  case "malformed":
271
- return `malformed ${issue.at}: ${issue.reason}`;
328
+ return `malformed ${jsonPlace(issue.at)}: ${issue.reason}`;
272
329
  case "stale":
273
330
  return `stale: the plan is for tip ${short(issue.planTip)} but the session tip is ${short(issue.sessionTip)}`;
274
331
  case "empty-message":
275
- return `$.commits[${issue.commit}]: empty message`;
332
+ return `${commitName(issue.commit)}: empty message`;
276
333
  case "empty-commit":
277
- return `$.commits[${issue.commit}]: no units`;
334
+ return `${commitName(issue.commit)}: no units`;
278
335
  case "unknown-unit":
279
- return `$.commits[${issue.commit}]: unknown unit ${issue.ref}`;
336
+ return `${commitName(issue.commit)}: unknown unit ${issue.ref}`;
280
337
  case "bad-selection":
281
- return `$.commits[${issue.commit}]: ${issue.unit}: ${issue.reason}`;
338
+ return `${commitName(issue.commit)}: ${issue.unit}: ${issue.reason}`;
282
339
  case "duplicate": {
283
340
  const [a, b] = issue.commits;
284
341
  const what = issue.changes.length > 0 ? lines(issue.unit, issue.changes) : issue.unit;
285
- return `${what} is claimed by commits ${a} and ${b}`;
342
+ return `${what} is claimed by commits ${a + 1} and ${b + 1}`;
286
343
  }
287
344
  case "unassigned": {
288
345
  const what = issue.changes.length > 0 ? lines(issue.unit, issue.changes) : issue.unit;
289
346
  return `${what} (${show(issue.path)}) is in no commit`;
290
347
  }
291
348
  case "path-conflict":
292
- return `$.commits[${issue.commit}]: ${show(issue.file)} is a file while ${show(issue.under)} exists`;
349
+ return `${commitName(issue.commit)}: ${show(issue.file)} is a file while ${show(issue.under)} exists`;
293
350
  case "unknown-response":
294
351
  return `response to unknown comment ${issue.comment}`;
295
352
  case "bad-answer":
296
353
  return `response to ${issue.comment}: ${issue.reason}`;
354
+ case "section-order":
355
+ return `${commitName(issue.commit)}: section ${issue.section} comes after ${commitName(issue.before)} in ${issue.beforeSection}; sections read story, steps, details, wiring, so move ${commitName(issue.commit)} before ${commitName(issue.before)}, or change its section`;
297
356
  case "bad-concern":
298
- return `$.commits[${issue.commit}]: concern: ${issue.reason}`;
357
+ return `${commitName(issue.commit)}: concern: ${issue.reason}`;
299
358
  case "too-many-concerns":
300
- return `${issue.unit} has more than one concern, in commits ${issue.commits.join(" and ")}; keep the one that matters`;
359
+ return `${commitName(issue.commit)}: ${issue.unit} has ${issue.count} concerns; a unit takes one per commit (each slice of a split hunk may carry its own), so keep the one that matters`;
301
360
  case "bad-offer":
302
- return `$.commits[${issue.commit}]: offer: ${issue.reason}`;
361
+ return `${commitName(issue.commit)}: offer: ${issue.reason}`;
303
362
  case "too-many-offers":
304
363
  return issue.commit === null
305
364
  ? `the plan has ${issue.count} offers; at most ${OFFERS_PER_PLAN}, and most plans need none`
306
- : `$.commits[${issue.commit}] has ${issue.count} offers; at most ${OFFERS_PER_COMMIT} per commit`;
365
+ : `${commitName(issue.commit)} has ${issue.count} offers; at most ${OFFERS_PER_COMMIT} per commit`;
307
366
  case "final-mismatch": {
308
367
  const tip = issue.expected ? `${issue.expected.mode} ${issue.expected.blob}` : "absent";
309
368
  return `${show(issue.path)}: the last commit has ${issue.got}, the tip has ${tip}`;
@@ -316,10 +375,7 @@ function formatIssue(issue) {
316
375
  }
317
376
  function formatWarning(w) {
318
377
  if (w.kind === "long-title") {
319
- return `warning: $.commits[${w.commit}]: title is ${w.length} characters; keep it to 50`;
320
- }
321
- if (w.kind === "section-order") {
322
- return `warning: $.commits[${w.commit}]: section ${w.section} comes after ${w.after}; sections read story, steps, details, wiring`;
378
+ return `warning: ${commitName(w.commit)}: title is ${w.length} characters; keep it to 50`;
323
379
  }
324
380
  if (w.kind === "question-addressed") {
325
381
  return `warning: question ${w.comment} is marked addressed; explain it with "answered" and "at" unless answering it changed the plan`;
@@ -403,7 +459,7 @@ function syntaxOverride(args) {
403
459
  return { override };
404
460
  }
405
461
  function formatSyntax(report, mark = "") {
406
- const out = report.issues.map((i) => `${mark}$.commits[${i.commit}]: ${show(i.path)}: ${i.message}`);
462
+ const out = report.issues.map((i) => `${mark}${commitName(i.commit)}: ${show(i.path)}: ${i.message}`);
407
463
  for (const i of report.tip) {
408
464
  out.push(`${mark}${show(i.path)}: ${i.message} (the tip itself does not parse here, so earlier commits are not checked for it)`);
409
465
  }
@@ -559,7 +615,7 @@ function projectedCommits(session, version) {
559
615
  const plan = session.plans().find((p) => p.version === version);
560
616
  if (plan === undefined)
561
617
  return [];
562
- const verdict = validatePlan(session.diff, serializePlanFile(plan.file));
618
+ const verdict = validatePlan(session.diff, serializePlanFile(plan.file), { frozen: true });
563
619
  return verdict.ok ? verdict.plan.commits : [];
564
620
  }
565
621
  /** Writes the one-commit-per-file draft with no agent. */
@@ -930,7 +986,8 @@ function reviewedPlan(session) {
930
986
  const reviews = reviewsOf(session);
931
987
  for (const stored of reviews.toReversed()) {
932
988
  const plan = session.plans().find((p) => p.version === stored.version);
933
- if (plan !== undefined && validatePlan(session.diff, serializePlanFile(plan.file)).ok) {
989
+ if (plan !== undefined &&
990
+ validatePlan(session.diff, serializePlanFile(plan.file), { frozen: true }).ok) {
934
991
  return stored;
935
992
  }
936
993
  }
@@ -1076,11 +1133,42 @@ async function offerStep(port, summary) {
1076
1133
  return null;
1077
1134
  return `${STILL_WORKING}; offer ${first.offer} waits for your diagram: follow-me reply ${first.comment} --diagram <file> [--map <file>], then run follow-me again`;
1078
1135
  }
1136
+ /** The per-test step while a state map waits for it, said as a `next:` of the listening loop. */
1137
+ async function testsStep(session, open, cwd) {
1138
+ if (!(await mapWantsTests(session, open)))
1139
+ return null;
1140
+ return `${STILL_WORKING}; ${perTestStep(session, cwd)}; then run follow-me again`;
1141
+ }
1142
+ /**
1143
+ * Where the agent writes lcov files, relative to cwd: inside the session, which `.follow-me/`'s
1144
+ * own .gitignore hides and which goes when the session goes, so no run leaves a file in the repo.
1145
+ */
1146
+ function lcovDir(session, cwd) {
1147
+ const dir = join(session.dir, "lcov");
1148
+ mkdirSync(dir, { recursive: true });
1149
+ return relative(cwd, dir);
1150
+ }
1151
+ /** The aggregate run to take when a review opens. */
1152
+ function coverageStep(session, cwd) {
1153
+ return `if the project has tests, run them with coverage at the tip, lcov to ${lcovDir(session, cwd)}/all.info, and pass it to follow-me coverage`;
1154
+ }
1155
+ /** The per-test runs a state map needs for its badges and its "Not driven" list. */
1156
+ function perTestStep(session, cwd) {
1157
+ const dir = lcovDir(session, cwd);
1158
+ return `the state machine needs one coverage run per test: follow-me coverage --reset, then for each test run it alone with lcov to ${dir}/<n>.info and follow-me coverage ${dir}/<n>.info --test "<test name>" --at <path:line where it is declared>`;
1159
+ }
1160
+ /** Whether a reply of the hosted review carries a state map while no run has per-test detail. */
1161
+ async function mapWantsTests(session, open) {
1162
+ if ((session.coverage()?.tests.size ?? 0) > 0)
1163
+ return false;
1164
+ const said = await fetchComments(open.port, open.summary);
1165
+ return said.some(({ comments }) => comments.some((c) => (c.replies ?? []).some((r) => r.map !== undefined)));
1166
+ }
1079
1167
  /**
1080
1168
  * Listens to the hosted review for up to --wait seconds and prints what the reviewer did. It
1081
1169
  * returns at once with news, and with the report when the reviewer submitted or ended the review.
1082
1170
  */
1083
- async function listen(session, open, args) {
1171
+ async function listen(session, open, args, cwd) {
1084
1172
  const { port, summary } = open;
1085
1173
  const waitMs = waitOf(args) * 1000;
1086
1174
  const deadline = Date.now() + waitMs;
@@ -1136,11 +1224,13 @@ async function listen(session, open, args) {
1136
1224
  return 0;
1137
1225
  }
1138
1226
  next((await offerStep(port, summary)) ??
1227
+ (await testsStep(session, open, cwd)) ??
1139
1228
  `${STILL_WORKING}; answer with follow-me reply <id> --body "<markdown>" [--diagram file], then run follow-me again`);
1140
1229
  return 0;
1141
1230
  }
1142
1231
  if (left <= 0) {
1143
1232
  next((await offerStep(port, summary)) ??
1233
+ (await testsStep(session, open, cwd)) ??
1144
1234
  `${STILL_WORKING}; run follow-me again to keep listening`);
1145
1235
  return 0;
1146
1236
  }
@@ -1165,7 +1255,7 @@ async function entry(cwd, args) {
1165
1255
  return swapOpenPage(session, hosted, args);
1166
1256
  }
1167
1257
  if (hosted !== null)
1168
- return listen(session, hosted, args);
1258
+ return listen(session, hosted, args, cwd);
1169
1259
  const state = draftState(session);
1170
1260
  if (state.kind === "invalid") {
1171
1261
  next("fix the draft, then run follow-me again");
@@ -1210,11 +1300,10 @@ async function entry(cwd, args) {
1210
1300
  const result = await hostReview(session, mode, detached, async () => prep, reportSaved);
1211
1301
  if (result.kind !== "elsewhere")
1212
1302
  return exitCode(result);
1213
- if (session.coverage() === null) {
1214
- next("if the project has tests, run them with coverage at the tip and pass the lcov to follow-me coverage");
1215
- }
1303
+ if (session.coverage() === null)
1304
+ next(coverageStep(session, cwd));
1216
1305
  say("follow-me is interactive: keep running it until it prints the report");
1217
- return listen(session, result, args);
1306
+ return listen(session, result, args, cwd);
1218
1307
  }
1219
1308
  /** Opens the session, re-extracting instead of failing when the branch moved or --base changed. */
1220
1309
  async function openFresh(cwd, args) {
@@ -1237,7 +1326,10 @@ function walkablePlan(session) {
1237
1326
  const lastReview = session.lastReview();
1238
1327
  const stored = session.plans().at(-1);
1239
1328
  if (stored !== undefined) {
1240
- const verdict = validatePlan(session.diff, serializePlanFile(stored.file), { lastReview });
1329
+ const verdict = validatePlan(session.diff, serializePlanFile(stored.file), {
1330
+ lastReview,
1331
+ frozen: true,
1332
+ });
1241
1333
  if (verdict.ok)
1242
1334
  return { version: stored.version, plan: verdict.plan };
1243
1335
  }
@@ -1276,7 +1368,7 @@ async function renderStored(session, stored, format = "markdown") {
1276
1368
  console.error(`follow-me: review v${stored.version} refers to a plan that is missing`);
1277
1369
  return null;
1278
1370
  }
1279
- const verdict = validatePlan(session.diff, serializePlanFile(plan.file));
1371
+ const verdict = validatePlan(session.diff, serializePlanFile(plan.file), { frozen: true });
1280
1372
  if (!verdict.ok) {
1281
1373
  console.error(`follow-me: plan v${plan.version} no longer fits ${session.anchor.branch}`);
1282
1374
  return null;
@@ -1340,7 +1432,7 @@ async function report(cwd, args, walkOnly = false) {
1340
1432
  process.stdout.write(text);
1341
1433
  return 0;
1342
1434
  }
1343
- const COVERAGE_USAGE = "usage: follow-me coverage <lcov> [--test <id> [--at <path:line>]] [--reset]\n follow-me coverage --reset";
1435
+ const COVERAGE_USAGE = "usage: follow-me coverage <lcov> [--test <id> [--at <path:line>]] [--reset]\n follow-me coverage --reset\n--at is the path:line where the test is declared";
1344
1436
  /**
1345
1437
  * Records the lcov of a test run the agent made at the tip in the session's coverage.json and
1346
1438
  * prints how much of the branch's changed code it ran. follow-me never runs the tests itself.
@@ -1404,7 +1496,19 @@ async function coverage(cwd, args) {
1404
1496
  if (open !== null)
1405
1497
  await postCoverage(open.port, open.summary);
1406
1498
  const { covered, executable } = totalOf(changedLines(session.diff.units, recorded.files).values());
1407
- say(`coverage for ${plural(recorded.files.size, "file")} at ${short(tip)}; ${covered} of ${executable} changed lines covered`);
1499
+ const total = `coverage for ${plural(recorded.files.size, "file")} at ${short(tip)}; ${covered} of ${executable} changed lines covered`;
1500
+ if (test === undefined)
1501
+ say(total);
1502
+ else {
1503
+ // A per-test run adds to what is on record, so its own share is said apart from the total.
1504
+ const own = totalOf(changedLines(session.diff.units, run.files).values()).covered;
1505
+ const branches = changedBranchesTaken(session.diff.units, run.files);
1506
+ say(`recorded test ${JSON.stringify(test.id)}: ${plural(own, "changed line")}, ${branches === 1 ? "1 branch" : `${branches} branches`}`);
1507
+ say(`running total of ${plural(recorded.tests.size, "test")}: ${total}`);
1508
+ }
1509
+ if (test === undefined && open !== null && (await mapWantsTests(session, open))) {
1510
+ next(`${perTestStep(session, cwd)}; then run follow-me again`);
1511
+ }
1408
1512
  return 0;
1409
1513
  }
1410
1514
  /**
@@ -1540,7 +1644,11 @@ async function reply(cwd, args) {
1540
1644
  ...(refs.length === 0 ? {} : { refs }),
1541
1645
  });
1542
1646
  say(`replied ${id} to ${commentId}`);
1543
- next(`run follow-me again; ${STILL_WORKING}`);
1647
+ if (map !== undefined && (session.coverage()?.tests.size ?? 0) === 0) {
1648
+ next(`${perTestStep(session, cwd)}; then run follow-me again; ${STILL_WORKING}`);
1649
+ }
1650
+ else
1651
+ next(`run follow-me again; ${STILL_WORKING}`);
1544
1652
  return 0;
1545
1653
  }
1546
1654
  const REPLY_USAGE = `usage: follow-me reply <commentId> --body <text> [--diagram <file> [--map <file>]]
@@ -1824,7 +1932,7 @@ async function showRef(cwd, args) {
1824
1932
  throw new UsageError(`${ref} is not a unit of the branch now; its hunk changed`);
1825
1933
  }
1826
1934
  const commits = latest === undefined ? [] : projectedCommits(session, latest.version);
1827
- process.stdout.write(renderShownUnit(unit, commits));
1935
+ process.stdout.write(renderShownUnit(unit, units, commits));
1828
1936
  return 0;
1829
1937
  }
1830
1938
  if (/^c_[a-z2-7]{6}$/.test(ref) && isCommentId(ref)) {
@@ -1915,6 +2023,9 @@ async function serverCommand(argv) {
1915
2023
  await started.host.closed;
1916
2024
  return 0;
1917
2025
  }
2026
+ case "--help":
2027
+ case "-h":
2028
+ throw new HelpRequested();
1918
2029
  default:
1919
2030
  throw new UsageError("usage: follow-me server status|stop");
1920
2031
  }
@@ -2136,6 +2247,11 @@ export async function main(argv, cwd) {
2136
2247
  }
2137
2248
  }
2138
2249
  catch (error) {
2250
+ if (error instanceof HelpRequested) {
2251
+ const asked = command === undefined || command === "help" ? null : usageOf(command);
2252
+ say(asked ?? SHORT_USAGE);
2253
+ return 0;
2254
+ }
2139
2255
  const known = [
2140
2256
  UsageError,
2141
2257
  CoverageError,
@@ -1,6 +1,7 @@
1
1
  import { parseLoc } from "./coverage.js";
2
2
  import { isRepoPath, isSha, isUnitId } from "./diff.js";
3
3
  import { diagramProblem, isArray, isCommentId, isConcernId, isFields, isOfferId, isPlanVersion, isSeverity, parsePlacedConcern, } from "./plan.js";
4
+ import { parseRefText } from "./refs.js";
4
5
  /**
5
6
  * The wire formats of review state, parsed once at the boundary: comments, replies, concern
6
7
  * decisions, the review file, its autosaved draft and the events a hosted session logs. Every
@@ -49,26 +50,42 @@ function parseAnchor(v) {
49
50
  }
50
51
  return { kind: "hunk", unit: v.unit, commit, changes, offsets };
51
52
  }
53
+ /** A place written as a ref string (`path:a-b`, tip lines) or as { path, start, end }. */
54
+ function parsePlace(raw) {
55
+ if (typeof raw !== "string")
56
+ return parseLoc(raw);
57
+ const spec = parseRefText(raw);
58
+ if (spec === null)
59
+ return null;
60
+ if (spec.at !== null)
61
+ return `${JSON.stringify(raw)}: a map is in tip lines; drop the @`;
62
+ return parseLoc({ path: spec.path, start: spec.start, end: spec.end });
63
+ }
52
64
  function parseLocs(v, at, needed) {
53
- const what = `${at}: expected a list of { path, start, end }${needed ? " with at least one place" : ""}`;
65
+ const what = `${at}: expected a list of "path:a-b" or { path, start, end }${needed ? " with at least one place" : ""}`;
54
66
  if (!isArray(v) || (needed && v.length === 0))
55
67
  return what;
56
68
  const out = [];
57
69
  for (const [i, raw] of v.entries()) {
58
- const loc = parseLoc(raw);
70
+ const loc = parsePlace(raw);
59
71
  if (loc === null)
60
- return `${at}[${i}]: expected { path, start, end } with 1 <= start <= end`;
72
+ return `${at}[${i}]: expected "path:a-b" or { path, start, end } with 1 <= start <= end`;
73
+ if (typeof loc === "string")
74
+ return `${at}[${i}]: ${loc}`;
61
75
  out.push(loc);
62
76
  }
63
77
  return out;
64
78
  }
65
79
  const TRANSITION_ENDS = ["from", "to"];
80
+ /** A stateDiagram's start and end marker. */
81
+ const START_END = "[*]";
66
82
  function escapeRegExp(text) {
67
83
  return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
68
84
  }
69
85
  /**
70
86
  * Boundary parse of a reply's state map. Every state has at least one place, every transition
71
- * names states of the map, and with a stateDiagram every state name appears in it. The reason
87
+ * names states of the map, and with a stateDiagram every state name appears in it. A transition
88
+ * to or from `[*]` is dropped: the diagram marks the start and end. The reason
72
89
  * names the bad entry, as map.states.<name>[i] or map.transitions[i].<field>.
73
90
  */
74
91
  export function parseStateMap(v, diagram) {
@@ -95,6 +112,10 @@ export function parseStateMap(v, diagram) {
95
112
  const at = `map.transitions[${i}]`;
96
113
  if (!isFields(raw))
97
114
  return `${at}: expected { from, to, event, at }`;
115
+ // The diagram's [*] already marks where the machine starts and ends; a move to or from it
116
+ // has no code of its own, so it is dropped rather than refused.
117
+ if (raw.from === START_END || raw.to === START_END)
118
+ continue;
98
119
  for (const end of TRANSITION_ENDS) {
99
120
  const state = raw[end];
100
121
  if (typeof state !== "string" || !named.has(state)) {
@@ -272,12 +272,8 @@ export function recordRun(prior, run, at) {
272
272
  function testEntry(ran) {
273
273
  return { lines: ran.lines, ...(ran.branches.size === 0 ? {} : { branches: ran.branches }) };
274
274
  }
275
- /**
276
- * Per file, the branch's added rows that lcov calls executable, and how many ran. Every added
277
- * row is a tip line, which is where the run was taken. Files without such rows are left out.
278
- */
279
- export function changedLines(units, files) {
280
- const out = new Map();
275
+ /** Calls visit with each added row's tip line, per hunk, for files the run covers. */
276
+ function eachAddedLine(units, files, visit) {
281
277
  for (const unit of units.values()) {
282
278
  if (unit.kind !== "hunk")
283
279
  continue;
@@ -285,22 +281,37 @@ export function changedLines(units, files) {
285
281
  if (file === undefined)
286
282
  continue;
287
283
  let line = tipStartOf(unit, units);
288
- let { covered, executable } = out.get(unit.path) ?? { covered: 0, executable: 0 };
289
284
  for (const row of unit.body) {
290
- const hits = row.op === "+" ? file.lines.get(line) : undefined;
291
- if (hits !== undefined) {
292
- executable += 1;
293
- if (hits > 0)
294
- covered += 1;
295
- }
285
+ if (row.op === "+")
286
+ visit(unit.path, file, line);
296
287
  if (row.op !== "-")
297
288
  line += 1;
298
289
  }
299
- if (executable > 0)
300
- out.set(unit.path, { covered, executable });
301
290
  }
291
+ }
292
+ /**
293
+ * Per file, the branch's added rows that lcov calls executable, and how many ran. Every added
294
+ * row is a tip line, which is where the run was taken. Files without such rows are left out.
295
+ */
296
+ export function changedLines(units, files) {
297
+ const out = new Map();
298
+ eachAddedLine(units, files, (path, file, line) => {
299
+ const hits = file.lines.get(line);
300
+ if (hits === undefined)
301
+ return;
302
+ const { covered, executable } = out.get(path) ?? { covered: 0, executable: 0 };
303
+ out.set(path, { covered: covered + (hits > 0 ? 1 : 0), executable: executable + 1 });
304
+ });
302
305
  return out;
303
306
  }
307
+ /** How many branches the run took on the branch's added rows, summed over every decision. */
308
+ export function changedBranchesTaken(units, files) {
309
+ let taken = 0;
310
+ eachAddedLine(units, files, (_, file, line) => {
311
+ taken += file.branches.get(line)?.[0] ?? 0;
312
+ });
313
+ return taken;
314
+ }
304
315
  export function totalOf(tallies) {
305
316
  let covered = 0;
306
317
  let executable = 0;
package/dist/src/host.js CHANGED
@@ -122,6 +122,7 @@ async function loadHosted(registration, held) {
122
122
  }
123
123
  const verdict = validatePlan(session.diff, serializePlanFile(stored.file), {
124
124
  lastReview: session.lastReview(),
125
+ frozen: true,
125
126
  });
126
127
  if (!verdict.ok) {
127
128
  throw new RegistrationError(`plan v${version} no longer fits ${branch}; check it again`);
package/dist/src/plan.js CHANGED
@@ -556,12 +556,13 @@ function deriveConcernId(unit, c) {
556
556
  }
557
557
  /**
558
558
  * Gives every concern its id and checks where it sits: lines must be change lines the commit
559
- * takes, and a unit carries at most one concern across the whole plan.
559
+ * takes, and a unit carries at most one concern in each commit, so each slice of a hunk split
560
+ * across commits may carry its own.
560
561
  */
561
562
  function placeConcerns(diff, file, claims, issues) {
562
563
  const ids = new Set();
563
- const holders = new Map();
564
564
  const placedPerCommit = file.commits.map((commit, k) => {
565
+ const held = new Map();
565
566
  const placed = [];
566
567
  const add = (c, unit) => {
567
568
  const id = deriveConcernId(unit, c);
@@ -578,7 +579,7 @@ function placeConcerns(diff, file, claims, issues) {
578
579
  const concerns = ref.concerns ?? [];
579
580
  if (concerns.length === 0)
580
581
  return;
581
- holders.set(ref.unit, [...(holders.get(ref.unit) ?? []), ...concerns.map(() => k)]);
582
+ held.set(ref.unit, (held.get(ref.unit) ?? 0) + concerns.length);
582
583
  const taken = claims.resolved[k]?.[j]?.select;
583
584
  const unit = diff.units.get(ref.unit);
584
585
  for (const c of concerns) {
@@ -591,25 +592,25 @@ function placeConcerns(diff, file, claims, issues) {
591
592
  });
592
593
  continue;
593
594
  }
594
- const outside = taken?.kind === "lines" ? lines.filter((n) => !taken.changes.includes(n)) : [];
595
- if (outside.length > 0) {
595
+ const takes = taken?.kind === "lines" ? taken : null;
596
+ const outside = takes === null ? [] : lines.filter((n) => !takes.changes.includes(n));
597
+ if (takes !== null && outside.length > 0) {
596
598
  issues.push({
597
599
  kind: "bad-concern",
598
600
  commit: k,
599
- reason: `${ref.unit}: concern lines ${outside.join(", ")} are not change lines this commit takes`,
601
+ reason: `${ref.unit}: concern lines ${outside.join(", ")} are not change lines this commit takes; \`lines\` are the hunk's [n] as prompt.md shows them, on a slice too, and this commit takes ${formatUnitRef({ unit: ref.unit, select: takes })}`,
600
602
  });
601
603
  }
602
604
  else
603
605
  add(c, ref.unit);
604
606
  }
605
607
  });
608
+ for (const [unit, count] of held) {
609
+ if (count > 1)
610
+ issues.push({ kind: "too-many-concerns", unit, commit: k, count });
611
+ }
606
612
  return placed;
607
613
  });
608
- for (const [unit, commits] of holders) {
609
- if (commits.length > 1) {
610
- issues.push({ kind: "too-many-concerns", unit, commits: [...new Set(commits)] });
611
- }
612
- }
613
614
  return placedPerCommit;
614
615
  }
615
616
  /** "o_" + 8 hex over the unit and the label: stable while the offer stays at its place. */
@@ -744,6 +745,36 @@ function answerProblem(file, r, isQuestion) {
744
745
  * Parses and checks a draft against the diff: every change line used exactly once, every
745
746
  * commit's tree valid, and the last commit equal to the tip. Collects all issues at once.
746
747
  */
748
+ /**
749
+ * Sections read top-down: story, steps, details, wiring. A later section never comes back, so a
750
+ * commit whose section ranks below an earlier commit's must move ahead of the first such commit.
751
+ */
752
+ function sectionOrder(file) {
753
+ const issues = [];
754
+ const placed = [];
755
+ file.commits.forEach((c, commit) => {
756
+ if (c.section === undefined)
757
+ return;
758
+ const rank = SECTIONS.indexOf(c.section);
759
+ const first = placed.find((p) => p.rank > rank);
760
+ if (first !== undefined) {
761
+ issues.push({
762
+ kind: "section-order",
763
+ commit,
764
+ section: c.section,
765
+ before: first.commit,
766
+ beforeSection: first.section,
767
+ });
768
+ }
769
+ else
770
+ placed.push({ commit, rank, section: c.section });
771
+ });
772
+ return issues;
773
+ }
774
+ /**
775
+ * Judges a draft against the diff. `frozen` re-checks a plan that already passed when it was
776
+ * frozen: section order is a rule for drafts, so a plan frozen before it was one still fits.
777
+ */
747
778
  export function validatePlan(diff, draft, opts = {}) {
748
779
  const parsed = parsePlanFile(draft);
749
780
  if (Array.isArray(parsed))
@@ -766,6 +797,8 @@ export function validatePlan(diff, draft, opts = {}) {
766
797
  issues.push({ kind: "empty-commit", commit: k });
767
798
  });
768
799
  issues.push(...offerLimits(file));
800
+ if (opts.frozen !== true)
801
+ issues.push(...sectionOrder(file));
769
802
  const assignment = [];
770
803
  const claims = claim(diff, file, assignment);
771
804
  issues.push(...assignment);
@@ -788,17 +821,6 @@ export function validatePlan(diff, draft, opts = {}) {
788
821
  if (length > LONG_TITLE)
789
822
  warnings.push({ kind: "long-title", commit, length });
790
823
  });
791
- // Sections read top-down: story, steps, details, wiring. A later section never comes back.
792
- let reached = null;
793
- file.commits.forEach((c, commit) => {
794
- if (c.section === undefined)
795
- return;
796
- if (reached !== null && SECTIONS.indexOf(c.section) < SECTIONS.indexOf(reached)) {
797
- warnings.push({ kind: "section-order", commit, section: c.section, after: reached });
798
- }
799
- else
800
- reached = c.section;
801
- });
802
824
  const asked = new Map(review?.comments.map((c) => [c.id, c.kind === "question"]));
803
825
  for (const r of file.responses) {
804
826
  const isQuestion = asked.get(r.comment);