@shiyuphan/follow-me 0.6.0 → 0.7.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 (100) hide show
  1. package/README.md +4 -1
  2. package/dist/src/cli.js +94 -11
  3. package/dist/src/comments.js +6 -1
  4. package/dist/src/coverage.js +397 -0
  5. package/dist/src/git.js +18 -0
  6. package/dist/src/host.js +4 -0
  7. package/dist/src/prompt.js +58 -23
  8. package/dist/src/server.js +50 -8
  9. package/dist/src/session.js +33 -0
  10. package/dist/src/types.js +2 -0
  11. package/dist/ui/dist/assets/{abnfDiagram-WEYOIA3C-fYSYLdMl.js → abnfDiagram-WEYOIA3C-cHEJvcBn.js} +1 -1
  12. package/dist/ui/dist/assets/{arc-DGuGSnwa.js → arc-xolFyc0v.js} +1 -1
  13. package/dist/ui/dist/assets/{architecture-WOLXFQ4H-VFox_hV1.js → architecture-WOLXFQ4H-B3hvvNV2.js} +1 -1
  14. package/dist/ui/dist/assets/{architectureDiagram-ZHVYKT53-CBVK3nHZ.js → architectureDiagram-ZHVYKT53-BNTMbPJW.js} +1 -1
  15. package/dist/ui/dist/assets/{blockDiagram-RJAE7QA3-BFdsIWEr.js → blockDiagram-RJAE7QA3-NYY6qeuY.js} +1 -1
  16. package/dist/ui/dist/assets/{c4Diagram-XDPPPE54-BGntcPId.js → c4Diagram-XDPPPE54-B_bNpXHQ.js} +1 -1
  17. package/dist/ui/dist/assets/channel-DPDY0XJP.js +1 -0
  18. package/dist/ui/dist/assets/{chunk-2E7WHVMD-CNMuK9zj.js → chunk-2E7WHVMD-BTet9STn.js} +1 -1
  19. package/dist/ui/dist/assets/{chunk-4EMNKR2T-ByeTTJWS.js → chunk-4EMNKR2T-qoQt4Fpx.js} +1 -1
  20. package/dist/ui/dist/assets/{chunk-55XCU76K-99xE7cLH.js → chunk-55XCU76K-C3I6EB-H.js} +1 -1
  21. package/dist/ui/dist/assets/{chunk-7S6XSTFO-ZVmT7eMZ.js → chunk-7S6XSTFO-CL-9s_8p.js} +1 -1
  22. package/dist/ui/dist/assets/{chunk-AQDTSKZB-B981JORL.js → chunk-AQDTSKZB-DHoGnGyP.js} +1 -1
  23. package/dist/ui/dist/assets/{chunk-DSNLSHJZ-B2z7FX4h.js → chunk-DSNLSHJZ-Bk8WO5JF.js} +1 -1
  24. package/dist/ui/dist/assets/{chunk-HAGFOTED-CHFbBS-4.js → chunk-HAGFOTED-CwAC85lg.js} +1 -1
  25. package/dist/ui/dist/assets/{chunk-HNE4Q36P-rq11xv9X.js → chunk-HNE4Q36P-CIllywWr.js} +1 -1
  26. package/dist/ui/dist/assets/{chunk-NQOKRVIF-BYmg9kSX.js → chunk-NQOKRVIF-DDlpyYiH.js} +1 -1
  27. package/dist/ui/dist/assets/{chunk-UHW7PFBP-JvL9IXWg.js → chunk-UHW7PFBP-Cxdndcon.js} +1 -1
  28. package/dist/ui/dist/assets/{chunk-VQ5W62QI-CYMQaAKn.js → chunk-VQ5W62QI-CL9d9_iJ.js} +1 -1
  29. package/dist/ui/dist/assets/{chunk-WQQL4CDK-CXnR0PHT.js → chunk-WQQL4CDK-C4kzN8QU.js} +1 -1
  30. package/dist/ui/dist/assets/{chunk-XXDRQBXY-CDUWJCXi.js → chunk-XXDRQBXY-Cv_2FSiY.js} +1 -1
  31. package/dist/ui/dist/assets/{chunk-YZO67HFG-mZfXIx0h.js → chunk-YZO67HFG-oHiuYaCJ.js} +1 -1
  32. package/dist/ui/dist/assets/{chunk-ZAUX2LTX-DBxVTCHX.js → chunk-ZAUX2LTX-Cy_8d0XT.js} +1 -1
  33. package/dist/ui/dist/assets/{chunk-ZCHMQSOR-DrWA4IBo.js → chunk-ZCHMQSOR-piIpIcQi.js} +1 -1
  34. package/dist/ui/dist/assets/{classDiagram-v2-4MYWR3UA-Beah5uxA.js → classDiagram-v2-4MYWR3UA-DNKRIujO.js} +1 -1
  35. package/dist/ui/dist/assets/{cose-bilkent-JH36ORCC-UaE-jnHk.js → cose-bilkent-JH36ORCC-BWKTGCHY.js} +1 -1
  36. package/dist/ui/dist/assets/{cynefin-EF2NZ3EQ-DKfGaCR8.js → cynefin-EF2NZ3EQ-yx7KKkAk.js} +1 -1
  37. package/dist/ui/dist/assets/{cynefinDiagram-WF24UDRP-tTUzTs3D.js → cynefinDiagram-WF24UDRP-BTnZ7QVL.js} +1 -1
  38. package/dist/ui/dist/assets/{dagre-NK4GIZVX-jSn35_T7.js → dagre-NK4GIZVX-CPh5Zn5I.js} +1 -1
  39. package/dist/ui/dist/assets/{diagram-CBK3MWTC-7W2uw8Zc.js → diagram-CBK3MWTC-pI6NFdrD.js} +1 -1
  40. package/dist/ui/dist/assets/{diagram-I5XIIV2D-CcfyQVh9.js → diagram-I5XIIV2D-DUIfARoS.js} +1 -1
  41. package/dist/ui/dist/assets/{diagram-ODIL2UCW-BjGvaLis.js → diagram-ODIL2UCW-lafSPvAw.js} +1 -1
  42. package/dist/ui/dist/assets/{diagram-PFBEJUXE-jvdCrt8B.js → diagram-PFBEJUXE-u1lZas3a.js} +1 -1
  43. package/dist/ui/dist/assets/{diagram-RJA3I4VV-D65_Vpi_.js → diagram-RJA3I4VV-mc0GLMHo.js} +1 -1
  44. package/dist/ui/dist/assets/{diagram-TYUNFSNR-C52roQBE.js → diagram-TYUNFSNR-1NRbBrZW.js} +1 -1
  45. package/dist/ui/dist/assets/{dist-BJd4jp9Y.js → dist-CHZ9mrl_.js} +1 -1
  46. package/dist/ui/dist/assets/{ebnfDiagram-V4P3TTPT-DXysoIBq.js → ebnfDiagram-V4P3TTPT-DdStjTDc.js} +1 -1
  47. package/dist/ui/dist/assets/{elk-QONTBCTQ-BVZcsgV3.js → elk-QONTBCTQ-BzRoFO45.js} +1 -1
  48. package/dist/ui/dist/assets/{erDiagram-KYQIGQAA-BJnT4fVU.js → erDiagram-KYQIGQAA-DlMkgQxj.js} +1 -1
  49. package/dist/ui/dist/assets/{eventmodeling-K75KTNOO-C5MzxCMY.js → eventmodeling-K75KTNOO-BRYIOrod.js} +1 -1
  50. package/dist/ui/dist/assets/flowDiagram-IUGLLWBU-C1IBh_GZ.js +1 -0
  51. package/dist/ui/dist/assets/{ganttDiagram-R7GPNTJY-CZRrnuah.js → ganttDiagram-R7GPNTJY-D8fQZ7Ah.js} +1 -1
  52. package/dist/ui/dist/assets/{gitGraph-VSP46ZUC-l58aGSkh.js → gitGraph-VSP46ZUC-C7ZydyWX.js} +1 -1
  53. package/dist/ui/dist/assets/{gitGraphDiagram-PDYL7XHY-D4ks1WO-.js → gitGraphDiagram-PDYL7XHY-DCItam0v.js} +1 -1
  54. package/dist/ui/dist/assets/index-8BehwQ7E.js +134 -0
  55. package/dist/ui/dist/assets/index-mC2OW2Av.css +1 -0
  56. package/dist/ui/dist/assets/{info-OHQRW6UA-BSqfzkdk.js → info-OHQRW6UA-C3KneBKT.js} +1 -1
  57. package/dist/ui/dist/assets/{infoDiagram-MVDRKAX5-B6NgVF_N.js → infoDiagram-MVDRKAX5-6wK09D32.js} +1 -1
  58. package/dist/ui/dist/assets/{ishikawaDiagram-HPEHV2GE-DvtfssEZ.js → ishikawaDiagram-HPEHV2GE-DTP5C8VX.js} +1 -1
  59. package/dist/ui/dist/assets/{journeyDiagram-BOSHW7O7-DM64gT5v.js → journeyDiagram-BOSHW7O7-B--UIMtq.js} +1 -1
  60. package/dist/ui/dist/assets/{kanban-definition-FNSA4HC5-AliyRJHy.js → kanban-definition-FNSA4HC5-Ku4N9QGt.js} +1 -1
  61. package/dist/ui/dist/assets/{line-CHncXbKO.js → line-C4-H5h1-.js} +1 -1
  62. package/dist/ui/dist/assets/{linear-BWEWydI0.js → linear-DhAID3hm.js} +1 -1
  63. package/dist/ui/dist/assets/{mermaid-parser.core-DGpOP8AM.js → mermaid-parser.core-CMPNc-u6.js} +3 -3
  64. package/dist/ui/dist/assets/{mermaid.core-BJtBFbJx.js → mermaid.core-Nd_xOg2y.js} +4 -4
  65. package/dist/ui/dist/assets/{mindmap-definition-6OY57UG3-NjavnWBi.js → mindmap-definition-6OY57UG3-BlVsym5h.js} +1 -1
  66. package/dist/ui/dist/assets/{packet-JDAUHWVQ-zuhpVsME.js → packet-JDAUHWVQ-rsk7SM7S.js} +1 -1
  67. package/dist/ui/dist/assets/{pegDiagram-ECIUJNBX-sL4grM97.js → pegDiagram-ECIUJNBX-C0L20M8z.js} +1 -1
  68. package/dist/ui/dist/assets/{pie-XZMESJXO-C9l1Mp09.js → pie-XZMESJXO-B23-iZSN.js} +1 -1
  69. package/dist/ui/dist/assets/{pieDiagram-GNMPY74T-rlRBr2U4.js → pieDiagram-GNMPY74T-BqFhe4Oh.js} +1 -1
  70. package/dist/ui/dist/assets/{prism-core-DmuST30i.js → prism-core-DYsiqJeR.js} +1 -1
  71. package/dist/ui/dist/assets/{quadrantDiagram-D57K4MC3-4oQMip2Q.js → quadrantDiagram-D57K4MC3-BKP75B-m.js} +1 -1
  72. package/dist/ui/dist/assets/{radar-ABXABTNO-DvGg96L7.js → radar-ABXABTNO-BrEk2mQ7.js} +1 -1
  73. package/dist/ui/dist/assets/{railroad-I3PHUGI6-8wdFKMZp.js → railroad-I3PHUGI6-wWO4Eapc.js} +1 -1
  74. package/dist/ui/dist/assets/{railroad-abnf-LNEFI6M7-DUtOO0gn.js → railroad-abnf-LNEFI6M7-DfY-IPvE.js} +1 -1
  75. package/dist/ui/dist/assets/{railroad-ebnf-DWYD2UWJ-5YCGY1_d.js → railroad-ebnf-DWYD2UWJ-19wedWGk.js} +1 -1
  76. package/dist/ui/dist/assets/{railroad-peg-JR7BVNR7-Bwywxjhs.js → railroad-peg-JR7BVNR7-9vIslnoJ.js} +1 -1
  77. package/dist/ui/dist/assets/{railroadDiagram-Y72NRBL7-CwO6HSNQ.js → railroadDiagram-Y72NRBL7-DVnmXs8w.js} +1 -1
  78. package/dist/ui/dist/assets/{requirementDiagram-EQYSW5RA-Cp0IbfNr.js → requirementDiagram-EQYSW5RA-BEoMdlzR.js} +1 -1
  79. package/dist/ui/dist/assets/{sankeyDiagram-SY24JHO5-DeQKsO68.js → sankeyDiagram-SY24JHO5-D7Tn2Gbs.js} +1 -1
  80. package/dist/ui/dist/assets/{sequenceDiagram-BLEHNHQU-DTDalCxF.js → sequenceDiagram-BLEHNHQU-CyjUDBaQ.js} +1 -1
  81. package/dist/ui/dist/assets/{src-XnrqevnL.js → src-BiC9XNcI.js} +1 -1
  82. package/dist/ui/dist/assets/{stateDiagram-v2-A6IESKWZ-2DZ-tKo9.js → stateDiagram-v2-A6IESKWZ-CMp1VCO9.js} +1 -1
  83. package/dist/ui/dist/assets/{swimlanes-B3ENRKFQ-66yZJeRU.js → swimlanes-B3ENRKFQ-KEciLd6r.js} +1 -1
  84. package/dist/ui/dist/assets/swimlanesDiagram-AYQRV4AK-DzestpDE.js +8 -0
  85. package/dist/ui/dist/assets/{timeline-definition-JXYKGLGB-Dm1xGU-d.js → timeline-definition-JXYKGLGB-C8YQtv7M.js} +1 -1
  86. package/dist/ui/dist/assets/{treeView-D4JQ5SDB-BvgsbSot.js → treeView-D4JQ5SDB-DgAzPaOO.js} +1 -1
  87. package/dist/ui/dist/assets/{treemap-SAJKECNS-C3lnbG1a.js → treemap-SAJKECNS-eCRqZMYc.js} +1 -1
  88. package/dist/ui/dist/assets/{usecaseDiagram-WPOLBNNX-B6iPNIhZ.js → usecaseDiagram-WPOLBNNX-Dirryw9G.js} +1 -1
  89. package/dist/ui/dist/assets/{vennDiagram-Q3Y3IDCW-D5dgflg9.js → vennDiagram-Q3Y3IDCW-BjxQbAUx.js} +1 -1
  90. package/dist/ui/dist/assets/{wardley-7MLQ67FV-Dn9mLIB4.js → wardley-7MLQ67FV-CqHX5RUz.js} +1 -1
  91. package/dist/ui/dist/assets/{wardleyDiagram-IVAIDKWA-W1EwzN_X.js → wardleyDiagram-IVAIDKWA-Ds1HoEUY.js} +1 -1
  92. package/dist/ui/dist/assets/{xychartDiagram-4URNHDU4-CKRzMUvp.js → xychartDiagram-4URNHDU4-kodzDb12.js} +1 -1
  93. package/dist/ui/dist/index.html +2 -2
  94. package/package.json +1 -1
  95. package/skills/follow-me/SKILL.md +2 -0
  96. package/dist/ui/dist/assets/channel-gz6cS9Z1.js +0 -1
  97. package/dist/ui/dist/assets/flowDiagram-IUGLLWBU-BqUgZSCz.js +0 -1
  98. package/dist/ui/dist/assets/index-BW95UA5q.css +0 -1
  99. package/dist/ui/dist/assets/index-DOGXgoHF.js +0 -134
  100. package/dist/ui/dist/assets/swimlanesDiagram-AYQRV4AK-rbX6bYG4.js +0 -8
package/README.md CHANGED
@@ -75,13 +75,16 @@ The red blob in the party hat is your branch tip. Nothing follow-me does moves i
75
75
  ![Cmd-clicking format in pow opens a peek of its definition, which lives outside this branch](assets/screenshots/definition.png)
76
76
  *Cmd-click a name to jump to where it is defined, even in a later commit, and Alt+Left to come back. A definition the branch does not change opens as a peek.*
77
77
 
78
+ ![Coverage while reading: gutter bars mark covered, partly covered and uncovered lines, with counts per commit, per file and for the branch, and a popover listing the test that ran a line](assets/screenshots/coverage.png)
79
+ *Coverage while reading. Your agent runs the tests with coverage and passes the lcov to `follow-me coverage`; follow-me never runs anything. Bars show covered, partly covered and uncovered lines, `x` jumps to the next uncovered line, and clicking a bar lists the tests that ran it.*
80
+
78
81
  ![A diagram opened in the lightbox at full size](assets/screenshots/diagram.png)
79
82
  *Diagrams sit full width under the commit or reply they explain, and open full size on click.*
80
83
 
81
84
  ![The hand-off page: every kept item with its original-branch path and line, an include checkbox, and copy buttons](assets/screenshots/handoff.png)
82
85
  *Submit review. Each item carries the original branch's path and line, so your agent can post it to the merge request as is.*
83
86
 
84
- Keys: `[` shows or hides the index, `j` and `k` move between commits, Cmd-click (Ctrl-click) goes to a definition and Alt+Left comes back, arrows and Shift+arrows pick rows inside a hunk, `c` comments on the pick, `v` marks a commit viewed, `?` lists the rest. An unfinished review autosaves and comes back when the page is opened again.
87
+ Keys: `[` shows or hides the index, `j` and `k` move between commits, Cmd-click (Ctrl-click) goes to a definition and Alt+Left comes back, arrows and Shift+arrows pick rows inside a hunk, `c` comments on the pick, `v` marks a commit viewed, `g` shows or hides coverage and `x` jumps to the next uncovered line, `?` lists the rest. An unfinished review autosaves and comes back when the page is opened again.
85
88
 
86
89
  ### Reviewing someone else's branch
87
90
 
package/dist/src/cli.js CHANGED
@@ -1,22 +1,27 @@
1
1
  import { execFileSync } from "node:child_process";
2
2
  import { readFileSync, writeFileSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
- import { dirname, join, relative } from "node:path";
4
+ import { dirname, join, relative, resolve } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
+ import { changedLines, LcovError, parseLcov, parseLocArg, recordRun, runOf, tipPlacer, totalOf, } from "./coverage.js";
6
7
  import { definitionIndex, isIdentifier } from "./definition.js";
7
8
  import { DiffParseError, formatUnitRef, isUnitId } from "./diff.js";
8
- import { BranchRefusedError, branchHead, GitError, isOwnWork, signing, treeOf, writeBranch, } from "./git.js";
9
- import { fetchCarried, fetchComments, fetchReport, findServer, followEvents, idleMs, listSessions, openBrowser, pollEvents, portFallbackMessage, postComment, postReply, probeHealth, readServerRecord, registerWith, sessionUrl, spawnDetached, startOrAttach, stateDir, stillHosted, stopServer, swapVersion, } from "./host.js";
9
+ import { BranchRefusedError, branchHead, GitError, isOwnWork, pathsAt, pathsChanged, signing, treeOf, writeBranch, } from "./git.js";
10
+ import { fetchCarried, fetchComments, fetchReport, findServer, followEvents, idleMs, listSessions, openBrowser, pollEvents, portFallbackMessage, postComment, postCoverage, postReply, probeHealth, readServerRecord, registerWith, sessionUrl, spawnDetached, startOrAttach, stateDir, stillHosted, stopServer, swapVersion, } from "./host.js";
10
11
  import { findSkillDir, InstallError, runInstall, runUninstall, syncSkills, } from "./install.js";
11
12
  import { naiveDraft } from "./naive.js";
12
13
  import { isCommentId, isConcernId, isFields, serializePlanFile, validatePlan, } from "./plan.js";
13
14
  import { renderEvents, renderPrompt, renderReport, renderShownComment, renderShownConcern, renderShownDefinitions, renderShownUnit, reportItems, } from "./prompt.js";
14
15
  import { RegistrationError, sessionIdOf } from "./server.js";
15
- import { BranchMovedError, cleanSessions, extractSession, MissingDraftError, NotARepositoryError, openSession, SessionFileError, SessionLockedError, sessionComments, sinceLastPlan, } from "./session.js";
16
+ import { BranchMovedError, cleanSessions, coverageAt, extractSession, MissingDraftError, NotARepositoryError, openSession, SessionFileError, SessionLockedError, sessionComments, sinceLastPlan, } from "./session.js";
16
17
  import { checkSyntax } from "./syntax.js";
17
18
  class UsageError extends Error {
18
19
  name = "UsageError";
19
20
  }
21
+ /** A test run follow-me will not record, with the reason the agent can act on. */
22
+ class CoverageError extends Error {
23
+ name = "CoverageError";
24
+ }
20
25
  const SHORT_USAGE = `usage: follow-me [--wait <seconds>] [--branch <name>]
21
26
 
22
27
  Run follow-me with no command. Each call does the next thing that is missing and returns: it
@@ -37,6 +42,8 @@ Other commands:
37
42
  is defined, instead of reading files
38
43
  report [--json] [--markdown] print the report of the latest review; --json lists
39
44
  the items to post to a merge request
45
+ coverage <lcov> [--test <id> [--at <path:line>]]
46
+ record a test run taken at the tip for the page
40
47
 
41
48
  help --all lists every command.`;
42
49
  const USAGE = `usage: follow-me <command> [options]
@@ -100,6 +107,11 @@ Interactive loop (while the review page is open):
100
107
  line in both voices, then questions with answers;
101
108
  --json lists the items to post to a merge request
102
109
  (path, lines, severity, body, thread, excluded)
110
+ coverage <lcov> [--test <id> [--at <path:line>]] [--branch <name>]
111
+ record the lcov of a test run at the branch tip;
112
+ refuses when a covered file in the working tree
113
+ differs from the tip. A plain run replaces the last;
114
+ --test runs add one test each, --at is where it is
103
115
  apply, while the page is open, swaps it to the new plan version in place, same URL.
104
116
 
105
117
  Old names, still working:
@@ -909,8 +921,8 @@ function reviewedPlan(session) {
909
921
  return null;
910
922
  }
911
923
  /** Prints the finished review's report, which is what the agent works from from here on. */
912
- function finished(session, stored) {
913
- const text = renderStored(session, stored);
924
+ async function finished(session, stored) {
925
+ const text = await renderStored(session, stored);
914
926
  if (text === null)
915
927
  return 1;
916
928
  // A review that swapped versions holds what its earlier versions said too.
@@ -1173,6 +1185,9 @@ async function entry(cwd, args) {
1173
1185
  const result = await hostReview(session, mode, detached, async () => prep, reportSaved);
1174
1186
  if (result.kind !== "elsewhere")
1175
1187
  return exitCode(result);
1188
+ if (session.coverage() === null) {
1189
+ next("if the project has tests, run them with coverage at the tip and pass the lcov to follow-me coverage");
1190
+ }
1176
1191
  say("follow-me is interactive: keep running it until it prints the report");
1177
1192
  return listen(session, result, args);
1178
1193
  }
@@ -1230,7 +1245,7 @@ async function walkPlan(session, args) {
1230
1245
  return alreadyReviewed(session, latest.version, args) ? null : latest;
1231
1246
  }
1232
1247
  /** The consolidated report of a saved review, or null after saying why it cannot be rendered. */
1233
- function renderStored(session, stored, format = "markdown") {
1248
+ async function renderStored(session, stored, format = "markdown") {
1234
1249
  const plan = session.plans().find((p) => p.version === stored.version);
1235
1250
  if (plan === undefined) {
1236
1251
  console.error(`follow-me: review v${stored.version} refers to a plan that is missing`);
@@ -1258,7 +1273,13 @@ function renderStored(session, stored, format = "markdown") {
1258
1273
  concerns: stored.review.carriedConcerns ?? [],
1259
1274
  },
1260
1275
  };
1261
- const opts = { branch: session.anchor.branch, responses, ...earlier };
1276
+ const coverage = await coverageAt(session.root, session.dir, session.anchor.tip);
1277
+ const opts = {
1278
+ branch: session.anchor.branch,
1279
+ responses,
1280
+ ...earlier,
1281
+ ...(coverage === null ? {} : { coverage }),
1282
+ };
1262
1283
  if (format === "json") {
1263
1284
  const items = reportItems(stored.review, verdict.plan.commits, session.diff.units, opts);
1264
1285
  return `${JSON.stringify(items, null, 2)}\n`;
@@ -1288,12 +1309,67 @@ async function report(cwd, args, walkOnly = false) {
1288
1309
  console.error("follow-me: no review is open and none has ended; run `follow-me review` first");
1289
1310
  return 1;
1290
1311
  }
1291
- const text = renderStored(session, stored, format);
1312
+ const text = await renderStored(session, stored, format);
1292
1313
  if (text === null)
1293
1314
  return 1;
1294
1315
  process.stdout.write(text);
1295
1316
  return 0;
1296
1317
  }
1318
+ const COVERAGE_USAGE = "usage: follow-me coverage <lcov> [--test <id> [--at <path:line>]]";
1319
+ /**
1320
+ * Records the lcov of a test run the agent made at the tip in the session's coverage.json and
1321
+ * prints how much of the branch's changed code it ran. follow-me never runs the tests itself.
1322
+ */
1323
+ async function coverage(cwd, args) {
1324
+ const [file, extra] = args.positionals;
1325
+ const id = args.values.get("test");
1326
+ const atArg = args.values.get("at");
1327
+ if (file === undefined || extra !== undefined)
1328
+ throw new UsageError(COVERAGE_USAGE);
1329
+ if (atArg !== undefined && id === undefined) {
1330
+ throw new UsageError(`--at needs --test\n${COVERAGE_USAGE}`);
1331
+ }
1332
+ let text;
1333
+ try {
1334
+ text = readFileSync(resolve(cwd, file), "utf8");
1335
+ }
1336
+ catch {
1337
+ throw new UsageError(`cannot read ${file}`);
1338
+ }
1339
+ const records = parseLcov(text);
1340
+ const session = await openFresh(cwd, args);
1341
+ const { tip } = session.anchor;
1342
+ const place = tipPlacer(session.root, cwd, await pathsAt(session.root, tip));
1343
+ const run = runOf(records, place);
1344
+ if (run.files.size === 0) {
1345
+ throw new CoverageError(`none of the ${plural(new Set(records.map((r) => r.path)).size, "file")} in ${file} is in ${session.anchor.branch} at the tip; run follow-me coverage from the directory the tests ran in`);
1346
+ }
1347
+ const changed = await pathsChanged(session.root, tip, null);
1348
+ const differ = [...run.files.keys()].filter((p) => changed.has(p));
1349
+ if (differ.length > 0) {
1350
+ throw new CoverageError(`coverage must be taken on the tip; these files differ: ${differ.map(show).join(", ")}`);
1351
+ }
1352
+ let test;
1353
+ if (id !== undefined) {
1354
+ const at = atArg === undefined ? undefined : parseLocArg(atArg, place);
1355
+ if (typeof at === "string")
1356
+ throw new UsageError(at);
1357
+ test = { id, ...(at === undefined ? {} : { at }) };
1358
+ }
1359
+ const takenAt = new Date().toISOString();
1360
+ const recorded = recordRun(session.coverage(), run, {
1361
+ tip,
1362
+ takenAt,
1363
+ ...(test === undefined ? {} : { test }),
1364
+ });
1365
+ session.writeCoverage(recorded);
1366
+ const open = await openOnServer(session, null);
1367
+ if (open !== null)
1368
+ await postCoverage(open.port, open.summary);
1369
+ const { covered, executable } = totalOf(changedLines(session.diff.units, recorded.files).values());
1370
+ say(`coverage for ${plural(recorded.files.size, "file")} at ${short(tip)}; ${covered} of ${executable} changed lines covered`);
1371
+ return 0;
1372
+ }
1297
1373
  /** The old name of report. */
1298
1374
  async function feedback(cwd, args) {
1299
1375
  console.error("follow-me: feedback is now report; printing the same");
@@ -1302,9 +1378,12 @@ async function feedback(cwd, args) {
1302
1378
  function isCommentEvent(event) {
1303
1379
  return event.type === "comment" || event.type === "comment-updated";
1304
1380
  }
1305
- /** Events the reviewer caused; the agent's own replies, explanations and version swaps are not news. */
1381
+ /**
1382
+ * Events the reviewer caused; the agent's own replies, explanations, version swaps and coverage
1383
+ * are not news.
1384
+ */
1306
1385
  function isNews(event) {
1307
- if (event.type === "version")
1386
+ if (event.type === "version" || event.type === "coverage")
1308
1387
  return false;
1309
1388
  if (event.type === "comment" || event.type === "comment-updated") {
1310
1389
  return event.comment.author !== "agent";
@@ -1878,6 +1957,8 @@ export async function main(argv, cwd) {
1878
1957
  }
1879
1958
  case "show":
1880
1959
  return await showRef(cwd, parseArgs(rest, { values: ["branch"] }));
1960
+ case "coverage":
1961
+ return await coverage(cwd, parseArgs(rest, { values: ["test", "at", "branch"] }));
1881
1962
  case "report":
1882
1963
  return await report(cwd, parseArgs(rest, { values: ["branch"], flags: ["json", "markdown"] }));
1883
1964
  case "clean": {
@@ -1918,6 +1999,8 @@ export async function main(argv, cwd) {
1918
1999
  catch (error) {
1919
2000
  const known = [
1920
2001
  UsageError,
2002
+ CoverageError,
2003
+ LcovError,
1921
2004
  BranchMovedError,
1922
2005
  BranchRefusedError,
1923
2006
  SessionFileError,
@@ -1,4 +1,4 @@
1
- import { isRepoPath, isUnitId } from "./diff.js";
1
+ import { isRepoPath, isSha, isUnitId } from "./diff.js";
2
2
  import { diagramProblem, isArray, isCommentId, isConcernId, isFields, isPlanVersion, isSeverity, parsePlacedConcern, } from "./plan.js";
3
3
  /**
4
4
  * The wire formats of review state, parsed once at the boundary: comments, replies, concern
@@ -452,6 +452,11 @@ export function parseEvent(v) {
452
452
  return carried;
453
453
  return { seq, type: v.type, version: v.version, hash: v.hash, carried };
454
454
  }
455
+ case "coverage":
456
+ if (typeof v.tip !== "string" || !isSha(v.tip) || typeof v.takenAt !== "string") {
457
+ return "malformed coverage event";
458
+ }
459
+ return { seq, type: v.type, tip: v.tip, takenAt: v.takenAt };
455
460
  case "submitted":
456
461
  return { seq, type: v.type };
457
462
  case "ended":
@@ -0,0 +1,397 @@
1
+ import { realpathSync } from "node:fs";
2
+ import { isAbsolute, relative, resolve, sep } from "node:path";
3
+ import { isRepoPath, isSha, tipStartOf } from "./diff.js";
4
+ import { GitError, pathsChanged } from "./git.js";
5
+ import { isFields } from "./plan.js";
6
+ /** lcov input that cannot be read, with the 1-based line it fails on. */
7
+ export class LcovError extends Error {
8
+ name = "LcovError";
9
+ line;
10
+ constructor(line, reason) {
11
+ super(`lcov line ${line}: ${reason}`);
12
+ this.line = line;
13
+ }
14
+ }
15
+ const RECORD = /^([A-Z][A-Z0-9_]*):(.*)$/;
16
+ const DA = /^([1-9][0-9]*),([0-9]+)(?:,.*)?$/;
17
+ const BRDA = /^([1-9][0-9]*),([^,]+),(.+),(-|[0-9]+)$/;
18
+ /**
19
+ * Reads lcov's tracefile format: TN names the test of the records after it, SF opens a file, DA
20
+ * and BRDA give line and branch hits, end_of_record closes it. Other records are skipped.
21
+ */
22
+ export function parseLcov(text) {
23
+ const records = [];
24
+ let test = null;
25
+ let open = null;
26
+ const close = () => {
27
+ if (open !== null)
28
+ records.push(open);
29
+ open = null;
30
+ };
31
+ const rows = text.split("\n");
32
+ for (const [i, raw] of rows.entries()) {
33
+ const n = i + 1;
34
+ const row = raw.endsWith("\r") ? raw.slice(0, -1) : raw;
35
+ if (row.trim() === "")
36
+ continue;
37
+ if (row === "end_of_record") {
38
+ close();
39
+ continue;
40
+ }
41
+ const m = RECORD.exec(row);
42
+ if (m === null)
43
+ throw new LcovError(n, `"${row.slice(0, 60)}" is not an lcov record`);
44
+ const [, key = "", value = ""] = m;
45
+ if (key === "TN") {
46
+ test = value.trim() === "" ? null : value.trim();
47
+ continue;
48
+ }
49
+ if (key === "SF") {
50
+ close();
51
+ if (value === "")
52
+ throw new LcovError(n, "SF needs a path");
53
+ open = { path: value, test, lines: new Map(), branches: new Map() };
54
+ continue;
55
+ }
56
+ if (key !== "DA" && key !== "BRDA")
57
+ continue;
58
+ if (open === null)
59
+ throw new LcovError(n, `${key} comes before any SF`);
60
+ if (key === "DA") {
61
+ const da = DA.exec(value);
62
+ if (da === null)
63
+ throw new LcovError(n, `DA needs <line>,<hits>, got "${value}"`);
64
+ const line = Number(da[1]);
65
+ open.lines.set(line, (open.lines.get(line) ?? 0) + Number(da[2]));
66
+ continue;
67
+ }
68
+ const br = BRDA.exec(value);
69
+ if (br === null) {
70
+ throw new LcovError(n, `BRDA needs <line>,<block>,<branch>,<taken>, got "${value}"`);
71
+ }
72
+ const line = Number(br[1]);
73
+ const id = `${br[2]},${br[3]}`;
74
+ const taken = br[4] !== "-" && Number(br[4]) > 0;
75
+ const branches = open.branches.get(line) ?? new Map();
76
+ branches.set(id, (branches.get(id) ?? false) || taken);
77
+ open.branches.set(line, branches);
78
+ }
79
+ close();
80
+ if (records.length === 0)
81
+ throw new LcovError(rows.length, "no SF record; is this lcov?");
82
+ return records;
83
+ }
84
+ /**
85
+ * Where an lcov path is at the tip: absolute, or relative to cwd and failing that to the root,
86
+ * symlinks resolved, then relative to the root. Null outside the repo or for a file the tip lacks.
87
+ */
88
+ export function tipPlacer(root, cwd, atTip) {
89
+ const real = (p) => {
90
+ try {
91
+ return realpathSync(p);
92
+ }
93
+ catch {
94
+ return p;
95
+ }
96
+ };
97
+ const tops = [root, real(root)];
98
+ return (raw) => {
99
+ const tried = isAbsolute(raw) ? [raw] : [resolve(cwd, raw), resolve(root, raw)];
100
+ for (const abs of tried.flatMap((p) => [p, real(p)])) {
101
+ for (const top of tops) {
102
+ const rel = relative(top, abs);
103
+ if (rel === "" || rel.startsWith("..") || isAbsolute(rel))
104
+ continue;
105
+ const path = Buffer.from(rel.split(sep).join("/"), "utf8").toString("latin1");
106
+ if (isRepoPath(path) && atTip.has(path))
107
+ return path;
108
+ }
109
+ }
110
+ return null;
111
+ };
112
+ }
113
+ /** Sums hits and joins branches over records of the same file; drops what `place` rejects. */
114
+ export function runOf(records, place) {
115
+ const lines = new Map();
116
+ const branches = new Map();
117
+ const tests = new Map();
118
+ for (const record of records) {
119
+ const path = place(record.path);
120
+ if (path === null)
121
+ continue;
122
+ const hits = lines.get(path) ?? new Map();
123
+ for (const [line, n] of record.lines)
124
+ hits.set(line, (hits.get(line) ?? 0) + n);
125
+ lines.set(path, hits);
126
+ const joined = branches.get(path) ?? new Map();
127
+ for (const [line, ids] of record.branches) {
128
+ const on = joined.get(line) ?? new Map();
129
+ for (const [id, taken] of ids)
130
+ on.set(id, (on.get(id) ?? false) || taken);
131
+ joined.set(line, on);
132
+ }
133
+ branches.set(path, joined);
134
+ if (record.test === null)
135
+ continue;
136
+ const ran = tests.get(record.test) ?? new Map();
137
+ const set = ran.get(path) ?? new Set();
138
+ for (const [line, n] of record.lines)
139
+ if (n > 0)
140
+ set.add(line);
141
+ ran.set(path, set);
142
+ tests.set(record.test, ran);
143
+ }
144
+ const files = new Map();
145
+ for (const [path, hits] of lines) {
146
+ const counted = new Map();
147
+ for (const [line, ids] of branches.get(path) ?? []) {
148
+ counted.set(line, [[...ids.values()].filter(Boolean).length, ids.size]);
149
+ }
150
+ files.set(path, { lines: sorted(hits), branches: sorted(counted) });
151
+ }
152
+ return {
153
+ files,
154
+ tests: new Map([...tests].map(([id, ran]) => [
155
+ id,
156
+ new Map([...ran].map(([path, set]) => [path, [...set].sort((a, b) => a - b)])),
157
+ ])),
158
+ };
159
+ }
160
+ function sorted(map) {
161
+ return new Map([...map].sort(([a], [b]) => a - b));
162
+ }
163
+ /** The lines of each file with at least one hit. */
164
+ function hitLines(files) {
165
+ return new Map([...files].map(([path, f]) => [path, [...f.lines].flatMap(([l, n]) => (n > 0 ? [l] : []))]));
166
+ }
167
+ /**
168
+ * Per-test runs only add: a line keeps the larger count, so they never inflate an aggregate run
169
+ * of the same suite, and running the same test twice changes nothing.
170
+ */
171
+ function mergeMax(a, b) {
172
+ const out = new Map(a);
173
+ for (const [path, f] of b) {
174
+ const had = out.get(path);
175
+ if (had === undefined) {
176
+ out.set(path, f);
177
+ continue;
178
+ }
179
+ const lines = new Map(had.lines);
180
+ for (const [l, n] of f.lines)
181
+ lines.set(l, Math.max(n, lines.get(l) ?? 0));
182
+ const branches = new Map(had.branches);
183
+ for (const [l, [taken, total]] of f.branches) {
184
+ const [t, n] = branches.get(l) ?? [0, 0];
185
+ branches.set(l, [Math.max(taken, t), Math.max(total, n)]);
186
+ }
187
+ out.set(path, { lines: sorted(lines), branches: sorted(branches) });
188
+ }
189
+ return out;
190
+ }
191
+ /**
192
+ * The coverage after this run. A plain run replaces everything, its TN blocks becoming its tests.
193
+ * A named test's run adds to what is already there for the same tip, and starts over otherwise.
194
+ */
195
+ export function recordRun(prior, run, at) {
196
+ const { tip, takenAt, test } = at;
197
+ if (test === undefined) {
198
+ const tests = new Map([...run.tests].map(([id, lines]) => [id, { lines }]));
199
+ return { tip, takenAt, files: run.files, tests };
200
+ }
201
+ const base = prior !== null && prior.tip === tip ? prior : null;
202
+ const tests = new Map(base?.tests ?? []);
203
+ const entry = {
204
+ ...(test.at === undefined ? {} : { at: test.at }),
205
+ lines: hitLines(run.files),
206
+ };
207
+ tests.set(test.id, entry);
208
+ const files = base === null ? run.files : mergeMax(base.files, run.files);
209
+ return { tip, takenAt, files, tests };
210
+ }
211
+ /**
212
+ * Per file, the branch's added rows that lcov calls executable, and how many ran. Every added
213
+ * row is a tip line, which is where the run was taken. Files without such rows are left out.
214
+ */
215
+ export function changedLines(units, files) {
216
+ const out = new Map();
217
+ for (const unit of units.values()) {
218
+ if (unit.kind !== "hunk")
219
+ continue;
220
+ const file = files.get(unit.path);
221
+ if (file === undefined)
222
+ continue;
223
+ let line = tipStartOf(unit, units);
224
+ let { covered, executable } = out.get(unit.path) ?? { covered: 0, executable: 0 };
225
+ for (const row of unit.body) {
226
+ const hits = row.op === "+" ? file.lines.get(line) : undefined;
227
+ if (hits !== undefined) {
228
+ executable += 1;
229
+ if (hits > 0)
230
+ covered += 1;
231
+ }
232
+ if (row.op !== "-")
233
+ line += 1;
234
+ }
235
+ if (executable > 0)
236
+ out.set(unit.path, { covered, executable });
237
+ }
238
+ return out;
239
+ }
240
+ export function totalOf(tallies) {
241
+ let covered = 0;
242
+ let executable = 0;
243
+ for (const t of tallies) {
244
+ covered += t.covered;
245
+ executable += t.executable;
246
+ }
247
+ return { covered, executable };
248
+ }
249
+ /**
250
+ * Covered files whose content at `tip` differs from where the run was taken, in file order.
251
+ * When the run's commit is gone from the repo, every file is stale.
252
+ */
253
+ export async function staleFiles(repo, coverage, tip) {
254
+ if (coverage.tip === tip)
255
+ return [];
256
+ let changed;
257
+ try {
258
+ changed = await pathsChanged(repo, coverage.tip, tip);
259
+ }
260
+ catch (error) {
261
+ if (!(error instanceof GitError))
262
+ throw error;
263
+ changed = new Set(coverage.files.keys());
264
+ }
265
+ return [...coverage.files.keys()].filter((p) => changed.has(p));
266
+ }
267
+ /** The files whose coverage still describes the tip. */
268
+ export function freshFiles(coverage, stale) {
269
+ const gone = new Set(stale);
270
+ return new Map([...coverage.files].filter(([path]) => !gone.has(path)));
271
+ }
272
+ export function serializeCoverage(c) {
273
+ const files = Object.fromEntries([...c.files].map(([path, f]) => [
274
+ path,
275
+ {
276
+ lines: Object.fromEntries(f.lines),
277
+ ...(f.branches.size === 0 ? {} : { branches: Object.fromEntries(f.branches) }),
278
+ },
279
+ ]));
280
+ const tests = Object.fromEntries([...c.tests].map(([id, t]) => [
281
+ id,
282
+ {
283
+ ...(t.at === undefined ? {} : { at: t.at }),
284
+ lines: Object.fromEntries([...t.lines].map(([p, ls]) => [p, [...ls]])),
285
+ },
286
+ ]));
287
+ return { tip: c.tip, takenAt: c.takenAt, files, ...(c.tests.size === 0 ? {} : { tests }) };
288
+ }
289
+ const LINE_KEY = /^[1-9][0-9]*$/;
290
+ function isCount(v) {
291
+ return typeof v === "number" && Number.isInteger(v) && v >= 0;
292
+ }
293
+ function isLine(v) {
294
+ return typeof v === "number" && Number.isInteger(v) && v >= 1;
295
+ }
296
+ function entries(v) {
297
+ return isFields(v) ? Object.entries(v) : null;
298
+ }
299
+ function parseLoc(v) {
300
+ if (!isFields(v))
301
+ return null;
302
+ const { path, start, end } = v;
303
+ if (typeof path !== "string" || !isRepoPath(path) || !isLine(start) || !isLine(end))
304
+ return null;
305
+ return end < start ? null : { path, start, end };
306
+ }
307
+ /** Like parseLoc, for argv: "path:line" or "path:start-end", the path placed like lcov's. */
308
+ export function parseLocArg(raw, place) {
309
+ const m = /^(.+):([1-9][0-9]*)(?:-([1-9][0-9]*))?$/.exec(raw);
310
+ if (m === null)
311
+ return `--at ${raw} is not <path>:<line> or <path>:<start>-<end>`;
312
+ const path = place(m[1] ?? "");
313
+ if (path === null)
314
+ return `--at ${raw} names no file of the branch tip`;
315
+ const start = Number(m[2]);
316
+ const end = m[3] === undefined ? start : Number(m[3]);
317
+ return end < start ? `--at ${raw} runs backwards` : { path, start, end };
318
+ }
319
+ function parseFile(v) {
320
+ if (!isFields(v))
321
+ return "a file is not an object";
322
+ const lines = entries(v.lines);
323
+ if (lines === null)
324
+ return "lines is not an object";
325
+ const hits = new Map();
326
+ for (const [key, n] of lines) {
327
+ if (!LINE_KEY.test(key) || !isCount(n))
328
+ return `line ${key} has no hit count`;
329
+ hits.set(Number(key), n);
330
+ }
331
+ const branchEntries = entries(v.branches ?? {});
332
+ if (branchEntries === null)
333
+ return "branches is not an object";
334
+ const branches = new Map();
335
+ for (const [key, pair] of branchEntries) {
336
+ if (!LINE_KEY.test(key) || !Array.isArray(pair) || pair.length !== 2) {
337
+ return `branches of line ${key} are not [taken, total]`;
338
+ }
339
+ const [taken, total] = pair;
340
+ if (!isCount(taken) || !isCount(total) || taken > total) {
341
+ return `branches of line ${key} are not [taken, total]`;
342
+ }
343
+ branches.set(Number(key), [taken, total]);
344
+ }
345
+ return { lines: sorted(hits), branches: sorted(branches) };
346
+ }
347
+ function parseTest(v) {
348
+ if (!isFields(v))
349
+ return "a test is not an object";
350
+ const at = v.at === undefined ? undefined : parseLoc(v.at);
351
+ if (at === null)
352
+ return "at is not a { path, start, end }";
353
+ const lineEntries = entries(v.lines);
354
+ if (lineEntries === null)
355
+ return "lines is not an object";
356
+ const lines = new Map();
357
+ for (const [path, list] of lineEntries) {
358
+ if (!isRepoPath(path) || !Array.isArray(list) || !list.every(isLine)) {
359
+ return `lines of ${path} are not line numbers`;
360
+ }
361
+ lines.set(path, list);
362
+ }
363
+ return { ...(at === undefined ? {} : { at }), lines };
364
+ }
365
+ /** The one place coverage.json becomes a Coverage. */
366
+ export function parseCoverage(v) {
367
+ if (!isFields(v))
368
+ return "not an object";
369
+ const { tip, takenAt } = v;
370
+ if (typeof tip !== "string" || !isSha(tip))
371
+ return "tip is not an object id";
372
+ if (typeof takenAt !== "string")
373
+ return "takenAt is not a time";
374
+ const fileEntries = entries(v.files);
375
+ if (fileEntries === null)
376
+ return "files is not an object";
377
+ const files = new Map();
378
+ for (const [path, raw] of fileEntries) {
379
+ if (!isRepoPath(path))
380
+ return "a file has no path";
381
+ const file = parseFile(raw);
382
+ if (typeof file === "string")
383
+ return `${path}: ${file}`;
384
+ files.set(path, file);
385
+ }
386
+ const testEntries = entries(v.tests ?? {});
387
+ if (testEntries === null)
388
+ return "tests is not an object";
389
+ const tests = new Map();
390
+ for (const [id, raw] of testEntries) {
391
+ const test = parseTest(raw);
392
+ if (typeof test === "string")
393
+ return `test ${id}: ${test}`;
394
+ tests.set(id, test);
395
+ }
396
+ return { tip, takenAt, files, tests };
397
+ }
package/dist/src/git.js CHANGED
@@ -132,6 +132,24 @@ export async function blobsAt(repo, commit, paths) {
132
132
  const blobs = await catBatch(repo, paths.map((p) => `${commit}:${p}`));
133
133
  return new Map(paths.map((p, i) => [p, blobs[i] ?? ""]));
134
134
  }
135
+ function pathList(out) {
136
+ return new Set(out
137
+ .toString("latin1")
138
+ .split("\0")
139
+ .filter((p) => isRepoPath(p)));
140
+ }
141
+ /** Every file path in the commit's tree. */
142
+ export async function pathsAt(repo, commit) {
143
+ return pathList(await git(repo, ["ls-tree", "-r", "-z", "--name-only", commit]));
144
+ }
145
+ /**
146
+ * Paths whose content differs between the commit and `to`: another commit, or with null the
147
+ * working tree and index. Untracked files are not listed.
148
+ */
149
+ export async function pathsChanged(repo, from, to) {
150
+ const target = to === null ? [] : [to];
151
+ return pathList(await git(repo, ["diff", "--name-only", "-z", ...DIFF_FLAGS, from, ...target, "--"]));
152
+ }
135
153
  /**
136
154
  * Lines of the commit's tree, not the working tree, that match any of the POSIX extended
137
155
  * patterns, in files matching the pathspecs. Binary files are skipped.
package/dist/src/host.js CHANGED
@@ -373,6 +373,10 @@ export async function postComment(port, summary, comment) {
373
373
  }
374
374
  return id;
375
375
  }
376
+ /** Tells the hosted page that coverage.json changed, so it reloads its marks. */
377
+ export async function postCoverage(port, summary) {
378
+ await call(port, "POST", `${summary.url}api/coverage`);
379
+ }
376
380
  /** The consolidated report of the hosted session as it stands. */
377
381
  export async function fetchReport(port, summary, format = "markdown") {
378
382
  const res = await fetch(`${sessionUrl(port, summary)}api/report?format=${format}`);