@shiyuphan/follow-me 0.8.0 → 0.9.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.
Files changed (95) hide show
  1. package/README.md +8 -3
  2. package/dist/src/cli.js +54 -19
  3. package/dist/src/comments.js +70 -3
  4. package/dist/src/coverage.js +147 -23
  5. package/dist/src/refs.js +239 -0
  6. package/dist/ui/dist/assets/{abnfDiagram-WEYOIA3C-Bp14ygmz.js → abnfDiagram-WEYOIA3C-DztIEwNE.js} +1 -1
  7. package/dist/ui/dist/assets/{arc-DUR6uzhI.js → arc-DUYh-EqZ.js} +1 -1
  8. package/dist/ui/dist/assets/{architecture-WOLXFQ4H-CyfnP65O.js → architecture-WOLXFQ4H-BQYTB92_.js} +1 -1
  9. package/dist/ui/dist/assets/{architectureDiagram-ZHVYKT53-dJTicIk2.js → architectureDiagram-ZHVYKT53-Dl7095A5.js} +1 -1
  10. package/dist/ui/dist/assets/{blockDiagram-RJAE7QA3-BYiqh5Ib.js → blockDiagram-RJAE7QA3-QDZ5kE1J.js} +1 -1
  11. package/dist/ui/dist/assets/{c4Diagram-XDPPPE54-DHHPQ7xE.js → c4Diagram-XDPPPE54-CKJZ1xOw.js} +1 -1
  12. package/dist/ui/dist/assets/channel-DF08Hd8f.js +1 -0
  13. package/dist/ui/dist/assets/{chunk-2E7WHVMD-BODm_awL.js → chunk-2E7WHVMD-BXBjaqd1.js} +1 -1
  14. package/dist/ui/dist/assets/{chunk-4EMNKR2T-CpZfjf2T.js → chunk-4EMNKR2T-DPzfVuke.js} +1 -1
  15. package/dist/ui/dist/assets/{chunk-55XCU76K-CoP2pJTQ.js → chunk-55XCU76K-B33Sj3w8.js} +1 -1
  16. package/dist/ui/dist/assets/{chunk-7S6XSTFO-CaQcUvXY.js → chunk-7S6XSTFO-B_u7dS03.js} +1 -1
  17. package/dist/ui/dist/assets/{chunk-AQDTSKZB-BZ9Czvbu.js → chunk-AQDTSKZB-B4EzH5gx.js} +1 -1
  18. package/dist/ui/dist/assets/{chunk-DSNLSHJZ-CNjUo110.js → chunk-DSNLSHJZ-CfWeHABr.js} +1 -1
  19. package/dist/ui/dist/assets/{chunk-HAGFOTED-BTmBE_D3.js → chunk-HAGFOTED-B8G8PjLZ.js} +1 -1
  20. package/dist/ui/dist/assets/{chunk-HNE4Q36P-D2cDuT0C.js → chunk-HNE4Q36P-BA-PqoTF.js} +1 -1
  21. package/dist/ui/dist/assets/{chunk-NQOKRVIF-AZcemFgG.js → chunk-NQOKRVIF-JvhuFcnR.js} +1 -1
  22. package/dist/ui/dist/assets/{chunk-UHW7PFBP-BszwjUqG.js → chunk-UHW7PFBP-BrfL5CGY.js} +1 -1
  23. package/dist/ui/dist/assets/{chunk-VQ5W62QI-vFSenYSe.js → chunk-VQ5W62QI-dBJqgdDy.js} +1 -1
  24. package/dist/ui/dist/assets/{chunk-WQQL4CDK-DZR6is1Q.js → chunk-WQQL4CDK-dfqd3k_P.js} +1 -1
  25. package/dist/ui/dist/assets/{chunk-XXDRQBXY-CACFh-si.js → chunk-XXDRQBXY-kViWdB9M.js} +1 -1
  26. package/dist/ui/dist/assets/{chunk-YZO67HFG-DRRTjGuW.js → chunk-YZO67HFG-ukfs4wT3.js} +1 -1
  27. package/dist/ui/dist/assets/{chunk-ZAUX2LTX-Bxuy-Roa.js → chunk-ZAUX2LTX-BxZHki81.js} +1 -1
  28. package/dist/ui/dist/assets/{chunk-ZCHMQSOR-rYXk__1o.js → chunk-ZCHMQSOR-PjluhR_E.js} +1 -1
  29. package/dist/ui/dist/assets/{classDiagram-v2-4MYWR3UA-RiF44tTz.js → classDiagram-v2-4MYWR3UA-Ct89LJec.js} +1 -1
  30. package/dist/ui/dist/assets/{cose-bilkent-JH36ORCC-0oiQoThS.js → cose-bilkent-JH36ORCC-BnQceGmB.js} +1 -1
  31. package/dist/ui/dist/assets/{cynefin-EF2NZ3EQ-BAssFK5K.js → cynefin-EF2NZ3EQ-D6WkCVE8.js} +1 -1
  32. package/dist/ui/dist/assets/{cynefinDiagram-WF24UDRP-Bmd05bak.js → cynefinDiagram-WF24UDRP-dn6DUPAb.js} +1 -1
  33. package/dist/ui/dist/assets/{dagre-NK4GIZVX-QJkLjPxj.js → dagre-NK4GIZVX-BtZSlLk7.js} +1 -1
  34. package/dist/ui/dist/assets/{diagram-CBK3MWTC-PSaSiYXz.js → diagram-CBK3MWTC-Ds4_tv6X.js} +1 -1
  35. package/dist/ui/dist/assets/{diagram-I5XIIV2D-BmU5X_hr.js → diagram-I5XIIV2D-DcoVkg45.js} +1 -1
  36. package/dist/ui/dist/assets/{diagram-ODIL2UCW-AAV39kUu.js → diagram-ODIL2UCW-T47FAA3k.js} +1 -1
  37. package/dist/ui/dist/assets/{diagram-PFBEJUXE-DWlV-oxd.js → diagram-PFBEJUXE-CRZcaVKP.js} +1 -1
  38. package/dist/ui/dist/assets/{diagram-RJA3I4VV-D8wHRCNX.js → diagram-RJA3I4VV-D6KXeUPg.js} +1 -1
  39. package/dist/ui/dist/assets/{diagram-TYUNFSNR-B_JQEsNY.js → diagram-TYUNFSNR-BHEIVyNN.js} +1 -1
  40. package/dist/ui/dist/assets/{dist-DxQIK_Ay.js → dist-DLlKIWBp.js} +1 -1
  41. package/dist/ui/dist/assets/{ebnfDiagram-V4P3TTPT-D83qV8mv.js → ebnfDiagram-V4P3TTPT-nUDvYRBV.js} +1 -1
  42. package/dist/ui/dist/assets/{elk-QONTBCTQ-BKFAKVSD.js → elk-QONTBCTQ-D21BcRzc.js} +1 -1
  43. package/dist/ui/dist/assets/{erDiagram-KYQIGQAA-CHYRd4aM.js → erDiagram-KYQIGQAA-CTpHuftU.js} +1 -1
  44. package/dist/ui/dist/assets/{eventmodeling-K75KTNOO-BDJDv3Zf.js → eventmodeling-K75KTNOO-B6JaKuDY.js} +1 -1
  45. package/dist/ui/dist/assets/flowDiagram-IUGLLWBU-BTeOUR-p.js +1 -0
  46. package/dist/ui/dist/assets/{ganttDiagram-R7GPNTJY-gTHjYkFj.js → ganttDiagram-R7GPNTJY-D2-pMcnC.js} +1 -1
  47. package/dist/ui/dist/assets/{gitGraph-VSP46ZUC-D4cWkxKB.js → gitGraph-VSP46ZUC-B2yxwhEo.js} +1 -1
  48. package/dist/ui/dist/assets/{gitGraphDiagram-PDYL7XHY-D3M9wYMr.js → gitGraphDiagram-PDYL7XHY-DnrhBMH1.js} +1 -1
  49. package/dist/ui/dist/assets/index-D74Skifa.css +1 -0
  50. package/dist/ui/dist/assets/index-Dnte9tPd.js +136 -0
  51. package/dist/ui/dist/assets/{info-OHQRW6UA-B9y0ksVq.js → info-OHQRW6UA-D8oh6gvs.js} +1 -1
  52. package/dist/ui/dist/assets/{infoDiagram-MVDRKAX5-s4FMwb8r.js → infoDiagram-MVDRKAX5-BLSZjjgB.js} +1 -1
  53. package/dist/ui/dist/assets/{ishikawaDiagram-HPEHV2GE-CnQdxybf.js → ishikawaDiagram-HPEHV2GE-BXiznLSn.js} +1 -1
  54. package/dist/ui/dist/assets/{journeyDiagram-BOSHW7O7-I-9OpDWY.js → journeyDiagram-BOSHW7O7-BSYmVcO_.js} +1 -1
  55. package/dist/ui/dist/assets/{kanban-definition-FNSA4HC5-DqUbIdH4.js → kanban-definition-FNSA4HC5-COthkkzK.js} +1 -1
  56. package/dist/ui/dist/assets/{line-BELSui4d.js → line-6ND8OaOL.js} +1 -1
  57. package/dist/ui/dist/assets/{linear-77hmn2iQ.js → linear-DbWEPFJX.js} +1 -1
  58. package/dist/ui/dist/assets/{mermaid-parser.core-Bxa71wW-.js → mermaid-parser.core-Btsk294z.js} +3 -3
  59. package/dist/ui/dist/assets/{mermaid.core-DBI9F4bh.js → mermaid.core-B4llDSFO.js} +4 -4
  60. package/dist/ui/dist/assets/{mindmap-definition-6OY57UG3-BxXMOapP.js → mindmap-definition-6OY57UG3-hv2PfX-x.js} +1 -1
  61. package/dist/ui/dist/assets/{packet-JDAUHWVQ-DTT77cQh.js → packet-JDAUHWVQ-Dr4vBgjt.js} +1 -1
  62. package/dist/ui/dist/assets/{pegDiagram-ECIUJNBX-HzrOQPT5.js → pegDiagram-ECIUJNBX-DvlFov3N.js} +1 -1
  63. package/dist/ui/dist/assets/{pie-XZMESJXO-iHwL1Qw-.js → pie-XZMESJXO-C9QsqC4n.js} +1 -1
  64. package/dist/ui/dist/assets/{pieDiagram-GNMPY74T-DZ1tLVjw.js → pieDiagram-GNMPY74T-CrrHOj67.js} +1 -1
  65. package/dist/ui/dist/assets/{prism-core-NtO0YF9C.js → prism-core-Cc8H7LPG.js} +1 -1
  66. package/dist/ui/dist/assets/{quadrantDiagram-D57K4MC3-BjTi9H5L.js → quadrantDiagram-D57K4MC3-DQ7J6-ti.js} +1 -1
  67. package/dist/ui/dist/assets/{radar-ABXABTNO-D2hYgXSB.js → radar-ABXABTNO-BBBPaujh.js} +1 -1
  68. package/dist/ui/dist/assets/{railroad-I3PHUGI6-DpakXN74.js → railroad-I3PHUGI6-C-P6Igaz.js} +1 -1
  69. package/dist/ui/dist/assets/{railroad-abnf-LNEFI6M7-B8ZFR3iy.js → railroad-abnf-LNEFI6M7-CAkorRLs.js} +1 -1
  70. package/dist/ui/dist/assets/{railroad-ebnf-DWYD2UWJ-S1XUyTsC.js → railroad-ebnf-DWYD2UWJ-D8Hz6gCf.js} +1 -1
  71. package/dist/ui/dist/assets/{railroad-peg-JR7BVNR7-BbeHC_6K.js → railroad-peg-JR7BVNR7-D-XNMkOs.js} +1 -1
  72. package/dist/ui/dist/assets/{railroadDiagram-Y72NRBL7-fd-2iyBo.js → railroadDiagram-Y72NRBL7-DE9LwxJQ.js} +1 -1
  73. package/dist/ui/dist/assets/{requirementDiagram-EQYSW5RA-B9-HHKH-.js → requirementDiagram-EQYSW5RA-FrKFY6NJ.js} +1 -1
  74. package/dist/ui/dist/assets/{sankeyDiagram-SY24JHO5-CeQN1ZEG.js → sankeyDiagram-SY24JHO5-BDxI6RDX.js} +1 -1
  75. package/dist/ui/dist/assets/{sequenceDiagram-BLEHNHQU-Vff7tx4t.js → sequenceDiagram-BLEHNHQU-pV1XcKX7.js} +1 -1
  76. package/dist/ui/dist/assets/{src-D5-ZzLwt.js → src-snNJTXXq.js} +1 -1
  77. package/dist/ui/dist/assets/{stateDiagram-v2-A6IESKWZ-Bzn_ma9c.js → stateDiagram-v2-A6IESKWZ-BSc_5aAf.js} +1 -1
  78. package/dist/ui/dist/assets/{swimlanes-B3ENRKFQ-CevqLchV.js → swimlanes-B3ENRKFQ-DC6qUXLh.js} +1 -1
  79. package/dist/ui/dist/assets/swimlanesDiagram-AYQRV4AK-F1HykV3o.js +8 -0
  80. package/dist/ui/dist/assets/{timeline-definition-JXYKGLGB-D9jSDGJI.js → timeline-definition-JXYKGLGB-C8L0Dxtu.js} +1 -1
  81. package/dist/ui/dist/assets/{treeView-D4JQ5SDB-CoXKj11m.js → treeView-D4JQ5SDB-DDyZIG5T.js} +1 -1
  82. package/dist/ui/dist/assets/{treemap-SAJKECNS-cHsansGe.js → treemap-SAJKECNS-CUaLgge0.js} +1 -1
  83. package/dist/ui/dist/assets/{usecaseDiagram-WPOLBNNX-CW9i3bW6.js → usecaseDiagram-WPOLBNNX-BHRBd5pV.js} +1 -1
  84. package/dist/ui/dist/assets/{vennDiagram-Q3Y3IDCW-DuU3qEKJ.js → vennDiagram-Q3Y3IDCW-Du4iwwkh.js} +1 -1
  85. package/dist/ui/dist/assets/{wardley-7MLQ67FV-dl9RgHUI.js → wardley-7MLQ67FV-DUgENI0s.js} +1 -1
  86. package/dist/ui/dist/assets/{wardleyDiagram-IVAIDKWA-BOXhrK-a.js → wardleyDiagram-IVAIDKWA-B86zOdke.js} +1 -1
  87. package/dist/ui/dist/assets/{xychartDiagram-4URNHDU4-CLQNGrh4.js → xychartDiagram-4URNHDU4-twq9irqO.js} +1 -1
  88. package/dist/ui/dist/index.html +2 -2
  89. package/package.json +1 -1
  90. package/skills/follow-me/SKILL.md +4 -4
  91. package/dist/ui/dist/assets/channel-CY2cQobm.js +0 -1
  92. package/dist/ui/dist/assets/flowDiagram-IUGLLWBU-Bke3CfUH.js +0 -1
  93. package/dist/ui/dist/assets/index-BUOFihod.js +0 -134
  94. package/dist/ui/dist/assets/index-DLNJT0VS.css +0 -1
  95. package/dist/ui/dist/assets/swimlanesDiagram-AYQRV4AK-Bkv8hAmN.js +0 -8
package/README.md CHANGED
@@ -75,11 +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
+ When your agent's answer names code, like `src/lib.js:4-5`, each place becomes a numbered chip. Hover the answer to tint those lines, click a chip to jump there and Alt+Left to come back. An answer that names several places is a walkthrough: press Enter on it, then `.` and `,` to step.
79
+
80
+ ![An agent reply naming three places as numbered chips, one in a later commit, with those lines tinted and numbered in the code and the walkthrough bar at step 1 of 3](assets/screenshots/refs.png)
81
+ *A walkthrough. Each place the agent names is tinted and numbered on the code, the current step strongest, and the bar at the bottom says where you are.*
82
+
78
83
  ![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
84
  *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
85
 
81
- ![A state machine reply: each state is marked by whether a test reached it, and clicking a state jumps to its code](assets/screenshots/state-machine.png)
82
- *Your agent offers what it can draw, like "a state machine for this guard", as chips under the commit. Tap one and it answers with the diagram, each state marked by whether a test reached it. Click a state to jump to its code.*
86
+ ![A checkout state machine reply: each state marked by whether a test reached it, tested transitions green with a count of their tests, one untested transition red and dashed, the sequences no one test drives, and a table naming the tests of each state and move](assets/screenshots/state-machine.png)
87
+ *Your agent offers what it can draw, like "a state machine for this guard", as chips under the commit. Tap one and it answers with the diagram, each state marked by whether a test reached it. A transition is green with the number of tests that take it, or red and dashed when none does. When the tests run one at a time, the table names them, and a list names the sequences each step of which has a test but no one test drives. Click a state or a transition to jump to its code.*
83
88
 
84
89
  ![A diagram opened in the lightbox at full size](assets/screenshots/diagram.png)
85
90
  *Diagrams sit full width under the commit or reply they explain, and open full size on click.*
@@ -87,7 +92,7 @@ The red blob in the party hat is your branch tip. Nothing follow-me does moves i
87
92
  ![The hand-off page: every kept item with its original-branch path and line, an include checkbox, and copy buttons](assets/screenshots/handoff.png)
88
93
  *Submit review. Each item carries the original branch's path and line, so your agent can post it to the merge request as is.*
89
94
 
90
- 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.
95
+ 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, `.` and `,` step through the agent's walkthrough, `?` lists the rest. An unfinished review autosaves and comes back when the page is opened again.
91
96
 
92
97
  ### Reviewing someone else's branch
93
98
 
package/dist/src/cli.js CHANGED
@@ -13,6 +13,7 @@ import { findSkillDir, InstallError, runInstall, runUninstall, syncSkills, } fro
13
13
  import { naiveDraft } from "./naive.js";
14
14
  import { isCommentId, isConcernId, isFields, OFFERS_PER_COMMIT, OFFERS_PER_PLAN, serializePlanFile, validatePlan, } from "./plan.js";
15
15
  import { renderEvents, renderPrompt, renderReport, renderShownComment, renderShownConcern, renderShownDefinitions, renderShownUnit, reportItems, } from "./prompt.js";
16
+ import { parseRefText, refsIn, repoPathOfTyped, resolveRefs } from "./refs.js";
16
17
  import { RegistrationError, sessionIdOf } from "./server.js";
17
18
  import { BranchMovedError, cleanSessions, coverageAt, extractSession, MissingDraftError, NotARepositoryError, openSession, SessionFileError, SessionLockedError, sessionComments, sinceLastPlan, } from "./session.js";
18
19
  import { checkSyntax } from "./syntax.js";
@@ -38,9 +39,9 @@ Other commands:
38
39
  tip lines
39
40
  explain [--commit <n>] [--unit <id>] [--lines <a-b>] --body <text> [--diagram <file>]
40
41
  start a thread on the page without being asked
41
- show <h_…|c_…|k_…|path:line[@n|@base]|name>
42
+ show <h_…|c_…|k_…|path:a[-b][@n|@base]|name>
42
43
  print a unit, a comment's thread, a concern, the
43
- lines around path:line in commit n, or where a name
44
+ lines around path:a-b in commit n, or where a name
44
45
  is defined, instead of reading files
45
46
  report [--json] [--markdown] print the report of the latest review; --json lists
46
47
  the items to post to a merge request
@@ -92,16 +93,17 @@ Interactive loop (while the review page is open):
92
93
  reply <commentId> --body <text> [--diagram <file> [--map <file>]]
93
94
  answer under a comment on the page; the diagram is
94
95
  Mermaid source, the map JSON tying its states to
95
- tip lines
96
+ tip lines. \`path:a-b[@n]\` in backticks in the body
97
+ points at code, and is refused if it does not exist
96
98
  explain [--commit <n>] [--unit <id>] [--lines <a-b>] --body <text> [--diagram <file>]
97
99
  start a thread on the page without being asked;
98
100
  --lines are change lines of the unit, commits count
99
101
  from 1; with no target it is about the whole plan.
100
102
  watch leaves these out like your replies
101
- show <h_…|c_…|k_…|path:line[@n|@base]|name> [--branch <name>]
103
+ show <h_…|c_…|k_…|path:a[-b][@n|@base]|name> [--branch <name>]
102
104
  print a unit with the commits that take it, a
103
105
  comment with its rows and thread, a concern with its
104
- lines, the lines around path:line at the tip, in
106
+ lines, the lines around path:a-b at the tip, in
105
107
  commit n of the plan, or in the base, or each place
106
108
  a name is defined at the tip with the commit it
107
109
  lands in
@@ -1529,11 +1531,13 @@ async function reply(cwd, args) {
1529
1531
  const open = await openOnServer(session, null);
1530
1532
  if (open === null)
1531
1533
  return notOpen(session);
1534
+ const refs = await bodyRefs(session, open.summary.version, body);
1532
1535
  const id = await postReply(open.port, open.summary, commentId, {
1533
1536
  author: "agent",
1534
1537
  body,
1535
1538
  ...(diagram === undefined ? {} : { diagram }),
1536
1539
  ...(map === undefined ? {} : { map }),
1540
+ ...(refs.length === 0 ? {} : { refs }),
1537
1541
  });
1538
1542
  say(`replied ${id} to ${commentId}`);
1539
1543
  next(`run follow-me again; ${STILL_WORKING}`);
@@ -1586,6 +1590,33 @@ async function readMap(session, file, diagram) {
1586
1590
  throw new UsageError(`${file}:\n ${problems.join("\n ")}`);
1587
1591
  return map;
1588
1592
  }
1593
+ /**
1594
+ * The refs --body writes, resolved against plan `version` and the tip. Every one that does not
1595
+ * resolve is named at once, as --map's are.
1596
+ */
1597
+ async function bodyRefs(session, version, body) {
1598
+ const specs = refsIn(body);
1599
+ if (specs.length === 0)
1600
+ return [];
1601
+ const { root, diff } = session;
1602
+ const otherChanged = new Set([...diff.units.values()].flatMap((u) => (diff.textFiles.has(u.path) ? [] : [u.path])));
1603
+ const asked = [...new Set(specs.map((s) => repoPathOfTyped(s.path)))].filter((p) => isRepoPath(p) && !diff.textFiles.has(p) && !otherChanged.has(p));
1604
+ const tip = asked.length === 0 ? new Set() : await pathsAt(root, diff.tip);
1605
+ const present = asked.filter((p) => tip.has(p));
1606
+ const unchanged = new Map([...(await blobsAt(root, diff.tip, present))].map(([p, bytes]) => [p, splitLines(bytes)]));
1607
+ const { refs, problems } = resolveRefs(specs, {
1608
+ tip: diff.tip,
1609
+ textFiles: diff.textFiles,
1610
+ otherChanged,
1611
+ unchanged,
1612
+ commits: projectedCommits(session, version),
1613
+ version,
1614
+ });
1615
+ if (problems.length > 0) {
1616
+ throw new UsageError(`--body points at code that does not resolve:\n ${problems.join("\n ")}`);
1617
+ }
1618
+ return refs;
1619
+ }
1589
1620
  /** The Mermaid source in --diagram's file, or undefined without the flag. */
1590
1621
  function diagramOf(args) {
1591
1622
  const file = args.values.get("diagram");
@@ -1693,17 +1724,19 @@ async function explain(cwd, args) {
1693
1724
  if (open === null)
1694
1725
  return notOpen(session);
1695
1726
  const target = explainTarget(session, open.summary.version, args);
1727
+ const refs = await bodyRefs(session, open.summary.version, body);
1696
1728
  const id = await postComment(open.port, open.summary, {
1697
1729
  ...target,
1698
1730
  body,
1699
1731
  author: "agent",
1700
1732
  ...(diagram === undefined ? {} : { diagram }),
1733
+ ...(refs.length === 0 ? {} : { refs }),
1701
1734
  });
1702
1735
  say(`explained ${id}`);
1703
1736
  next(`run follow-me again; ${STILL_WORKING}`);
1704
1737
  return 0;
1705
1738
  }
1706
- const SHOW_USAGE = "usage: follow-me show <h_…|c_…|k_…|path:line[@<commit>|@base]|name>";
1739
+ const SHOW_USAGE = "usage: follow-me show <h_…|c_…|k_…|path:a[-b][@<commit>|@base]|name>";
1707
1740
  /** Every comment the session has, with the plan version it was made on: live ones first. */
1708
1741
  async function knownComments(session) {
1709
1742
  const open = await openOnServer(session, null);
@@ -1717,14 +1750,16 @@ async function knownComments(session) {
1717
1750
  }
1718
1751
  const SHOW_MARGIN = 3;
1719
1752
  /**
1720
- * The lines around path:line as commit `at` of the latest plan writes the file (1-based), the
1721
- * base, or the tip when `at` is undefined. Read from the plan, so it works before any apply.
1753
+ * The lines around path:a-b as commit `at` of the latest plan writes the file (1-based), the
1754
+ * base, or the tip when `at` is null. Read from the plan, so it works before any apply.
1722
1755
  */
1723
1756
  function showLines(session, raw) {
1724
- const m = /^(.+):(\d+)(?:@(\d+|base))?$/.exec(raw);
1725
- const [, typed, lineRaw, at] = m ?? [];
1726
- if (typed === undefined || lineRaw === undefined)
1757
+ const spec = parseRefText(raw);
1758
+ if (spec === null)
1727
1759
  throw new UsageError(SHOW_USAGE);
1760
+ const { path: typed, start, end, at } = spec;
1761
+ if (end < start)
1762
+ throw new UsageError(`${raw} ends before it starts`);
1728
1763
  const path = Buffer.from(typed, "utf8").toString("latin1");
1729
1764
  const file = [...session.diff.textFiles.values()].find((f) => f.path === path);
1730
1765
  if (file === undefined) {
@@ -1732,7 +1767,7 @@ function showLines(session, raw) {
1732
1767
  }
1733
1768
  let text;
1734
1769
  let label;
1735
- if (at === undefined) {
1770
+ if (at === null) {
1736
1771
  text = file.tip?.bytes ?? null;
1737
1772
  label = "the tip";
1738
1773
  }
@@ -1743,7 +1778,7 @@ function showLines(session, raw) {
1743
1778
  else {
1744
1779
  const latest = session.plans().at(-1);
1745
1780
  const commits = latest === undefined ? [] : projectedCommits(session, latest.version);
1746
- const n = Number(at);
1781
+ const n = at;
1747
1782
  if (n < 1 || n > commits.length) {
1748
1783
  throw new UsageError(`@${at} is not one of the ${commits.length} commits of the plan`);
1749
1784
  }
@@ -1762,18 +1797,18 @@ function showLines(session, raw) {
1762
1797
  const lines = Buffer.from(text, "latin1").toString("utf8").split("\n");
1763
1798
  if (lines.at(-1) === "")
1764
1799
  lines.pop();
1765
- const line = Number(lineRaw);
1766
- if (line < 1 || line > lines.length) {
1800
+ if (start < 1 || end > lines.length) {
1767
1801
  return `${typed} has ${plural(lines.length, "line")} in ${label}\n`;
1768
1802
  }
1769
- const from = Math.max(1, line - SHOW_MARGIN);
1770
- const to = Math.min(lines.length, line + SHOW_MARGIN);
1803
+ const from = Math.max(1, start - SHOW_MARGIN);
1804
+ const to = Math.min(lines.length, end + SHOW_MARGIN);
1771
1805
  const width = String(to).length;
1772
1806
  const rows = lines.slice(from - 1, to).map((l, i) => {
1773
1807
  const n = from + i;
1774
- return `${n === line ? ">" : " "} ${String(n).padStart(width)} ${l}`;
1808
+ return `${n >= start && n <= end ? ">" : " "} ${String(n).padStart(width)} ${l}`;
1775
1809
  });
1776
- return `${typed}:${line} in ${label}\n${rows.join("\n")}\n`;
1810
+ const span = end > start ? `${start}-${end}` : `${start}`;
1811
+ return `${typed}:${span} in ${label}\n${rows.join("\n")}\n`;
1777
1812
  }
1778
1813
  /** Expands a ref the agent was given to the lines and thread it names, so no file read is needed. */
1779
1814
  async function showRef(cwd, args) {
@@ -148,6 +148,55 @@ export function mapOffTip(map, lines, show = (path) => path) {
148
148
  ...(map.transitions ?? []).flatMap((t, i) => t.at.flatMap((loc, j) => check(loc, `map.transitions[${i}].at[${j}]`))),
149
149
  ];
150
150
  }
151
+ function parseSpan(v) {
152
+ if (v === null)
153
+ return null;
154
+ if (!isFields(v) || !isLine(v.start) || !isLine(v.end) || v.end < v.start) {
155
+ return "expected null or { start, end } with 1 <= start <= end";
156
+ }
157
+ return { start: v.start, end: v.end };
158
+ }
159
+ function parseCodeRef(v) {
160
+ if (!isFields(v))
161
+ return "expected an object";
162
+ const { n, path, start, end, at, quote, tip } = v;
163
+ if (!isLine(n))
164
+ return "n is not a number from 1";
165
+ if (typeof path !== "string" || !isRepoPath(path))
166
+ return "path is not a repo path";
167
+ if (!isLine(start) || !isLine(end) || end < start)
168
+ return "expected 1 <= start <= end";
169
+ if (at !== null && at !== "base" && !isLine(at))
170
+ return 'at is not a commit, "base" or null';
171
+ if (!isArray(quote) || !quote.every(isString))
172
+ return "quote is not a list of strings";
173
+ if (typeof tip !== "string" || !isSha(tip))
174
+ return "tip is not a commit sha";
175
+ const lines = parseSpan(v.lines);
176
+ const old = parseSpan(v.old);
177
+ if (typeof lines === "string")
178
+ return `lines: ${lines}`;
179
+ if (typeof old === "string")
180
+ return `old: ${old}`;
181
+ return { n, path, start, end, at, quote: quote.filter(isString), tip, lines, old };
182
+ }
183
+ /** refs as stored beside an agent's words: absent, or a list numbered 1, 2, 3 in order. */
184
+ function parseCodeRefs(v) {
185
+ if (v === undefined)
186
+ return [];
187
+ if (!isArray(v))
188
+ return "refs is not a list";
189
+ const refs = [];
190
+ for (const [i, raw] of v.entries()) {
191
+ const ref = parseCodeRef(raw);
192
+ if (typeof ref === "string")
193
+ return `refs[${i}]: ${ref}`;
194
+ if (ref.n !== i + 1)
195
+ return `refs[${i}]: n is not ${i + 1}`;
196
+ refs.push(ref);
197
+ }
198
+ return refs;
199
+ }
151
200
  /** A stored reply. Replies a client posts go through parseNewReply, which mints id and time. */
152
201
  export function parseReply(v) {
153
202
  if (!isFields(v)) {
@@ -165,32 +214,43 @@ export function parseReply(v) {
165
214
  const map = v.map === undefined ? undefined : parseStateMap(v.map, v.diagram);
166
215
  if (typeof map === "string")
167
216
  return map;
217
+ const refs = parseCodeRefs(v.refs);
218
+ if (typeof refs === "string")
219
+ return refs;
168
220
  return {
169
221
  id: v.id,
170
222
  author: v.author,
171
223
  body: v.body,
172
224
  ...(v.diagram === undefined ? {} : { diagram: v.diagram }),
173
225
  ...(map === undefined ? {} : { map }),
226
+ ...(refs.length === 0 ? {} : { refs }),
174
227
  createdAt: v.createdAt,
175
228
  };
176
229
  }
177
230
  /**
178
231
  * The body of POST .../replies: who speaks, what, and an optional Mermaid diagram. The body may be
179
- * empty only when the diagram is the answer.
232
+ * empty only when the diagram is the answer. Only the agent's words carry refs.
180
233
  */
181
234
  export function parseNewReply(v) {
182
- if (!isFields(v))
235
+ if (!isFields(v)) {
183
236
  return "the reply is not an object";
237
+ }
184
238
  if (v.author !== "agent" && v.author !== "reviewer")
185
239
  return 'author is not "agent" or "reviewer"';
186
240
  if (typeof v.body !== "string")
187
241
  return "body is not a string";
242
+ const refs = parseCodeRefs(v.refs);
243
+ if (typeof refs === "string")
244
+ return refs;
245
+ if (refs.length > 0 && v.author !== "agent")
246
+ return "refs are only for the agent's words";
247
+ const withRefs = refs.length === 0 ? {} : { refs };
188
248
  if (v.diagram === undefined) {
189
249
  if (v.map !== undefined)
190
250
  return "map needs the diagram it ties to code";
191
251
  if (v.body.trim() === "")
192
252
  return "body is empty; a reply needs words or a diagram";
193
- return { author: v.author, body: v.body };
253
+ return { author: v.author, body: v.body, ...withRefs };
194
254
  }
195
255
  if (typeof v.diagram !== "string")
196
256
  return "diagram is not a string";
@@ -205,6 +265,7 @@ export function parseNewReply(v) {
205
265
  body: v.body,
206
266
  diagram: v.diagram,
207
267
  ...(map === undefined ? {} : { map }),
268
+ ...withRefs,
208
269
  };
209
270
  }
210
271
  function isString(v) {
@@ -262,6 +323,11 @@ export function parseComment(v) {
262
323
  return "offer is only for a reviewer's question";
263
324
  }
264
325
  }
326
+ const refs = parseCodeRefs(v.refs);
327
+ if (typeof refs === "string")
328
+ return refs;
329
+ if (refs.length > 0 && v.author !== "agent")
330
+ return "refs are only for an agent's comment";
265
331
  const replies = parseList(v.replies, parseReply, "replies");
266
332
  if (typeof replies === "string")
267
333
  return replies;
@@ -281,6 +347,7 @@ export function parseComment(v) {
281
347
  ...(replies.length === 0 ? {} : { replies }),
282
348
  ...(v.author === "agent" ? { author: "agent" } : {}),
283
349
  ...(v.diagram === undefined ? {} : { diagram: v.diagram }),
350
+ ...(refs.length === 0 ? {} : { refs }),
284
351
  ...(offer === undefined ? {} : { offer }),
285
352
  };
286
353
  }
@@ -15,9 +15,12 @@ export class LcovError extends Error {
15
15
  const RECORD = /^([A-Z][A-Z0-9_]*):(.*)$/;
16
16
  const DA = /^([1-9][0-9]*),([0-9]+)(?:,.*)?$/;
17
17
  const BRDA = /^([1-9][0-9]*),([^,]+),(.+),(-|[0-9]+)$/;
18
+ /** FN:<start>,<name>, or lcov 2's FN:<start>,<end>,<name>. */
19
+ const FN = /^([1-9][0-9]*),(?:([1-9][0-9]*),)?(.+)$/;
18
20
  /**
19
21
  * 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.
22
+ * and BRDA give line and branch hits, FN a function's place, end_of_record closes it. Other
23
+ * records are skipped.
21
24
  */
22
25
  export function parseLcov(text) {
23
26
  const records = [];
@@ -50,10 +53,10 @@ export function parseLcov(text) {
50
53
  close();
51
54
  if (value === "")
52
55
  throw new LcovError(n, "SF needs a path");
53
- open = { path: value, test, lines: new Map(), branches: new Map() };
56
+ open = { path: value, test, lines: new Map(), branches: new Map(), functions: [] };
54
57
  continue;
55
58
  }
56
- if (key !== "DA" && key !== "BRDA")
59
+ if (key !== "DA" && key !== "BRDA" && key !== "FN")
57
60
  continue;
58
61
  if (open === null)
59
62
  throw new LcovError(n, `${key} comes before any SF`);
@@ -65,15 +68,27 @@ export function parseLcov(text) {
65
68
  open.lines.set(line, (open.lines.get(line) ?? 0) + Number(da[2]));
66
69
  continue;
67
70
  }
71
+ if (key === "FN") {
72
+ const fn = FN.exec(value);
73
+ if (fn === null)
74
+ throw new LcovError(n, `FN needs <line>,<name>, got "${value}"`);
75
+ const start = Number(fn[1]);
76
+ const end = fn[2] === undefined ? undefined : Number(fn[2]);
77
+ const name = fn[3] ?? "";
78
+ if (end !== undefined && end < start)
79
+ throw new LcovError(n, "FN ends before it starts");
80
+ open.functions.push(end === undefined ? { name, start } : { name, start, end });
81
+ continue;
82
+ }
68
83
  const br = BRDA.exec(value);
69
84
  if (br === null) {
70
85
  throw new LcovError(n, `BRDA needs <line>,<block>,<branch>,<taken>, got "${value}"`);
71
86
  }
72
87
  const line = Number(br[1]);
73
88
  const id = `${br[2]},${br[3]}`;
74
- const taken = br[4] !== "-" && Number(br[4]) > 0;
89
+ const taken = br[4] === "-" ? 0 : Number(br[4]);
75
90
  const branches = open.branches.get(line) ?? new Map();
76
- branches.set(id, (branches.get(id) ?? false) || taken);
91
+ branches.set(id, (branches.get(id) ?? 0) + taken);
77
92
  open.branches.set(line, branches);
78
93
  }
79
94
  close();
@@ -110,10 +125,42 @@ export function tipPlacer(root, cwd, atTip) {
110
125
  return null;
111
126
  };
112
127
  }
128
+ /** Adds a record's branch counts into `into`, keeping lcov's branch order per line. */
129
+ function countInto(into, path, record) {
130
+ const joined = into.get(path) ?? new Map();
131
+ for (const [line, ids] of record.branches) {
132
+ const on = joined.get(line) ?? new Map();
133
+ for (const [id, n] of ids)
134
+ on.set(id, (on.get(id) ?? 0) + n);
135
+ joined.set(line, on);
136
+ }
137
+ into.set(path, joined);
138
+ }
139
+ function countsOf(counting) {
140
+ const out = new Map();
141
+ for (const [path, byLine] of counting) {
142
+ if (byLine.size === 0)
143
+ continue;
144
+ out.set(path, sorted(new Map([...byLine].map(([line, ids]) => [line, [...ids.values()]]))));
145
+ }
146
+ return out;
147
+ }
148
+ /** Functions of both lists, once each by start and name, in line order. */
149
+ function joinFunctions(a, b) {
150
+ const seen = new Map();
151
+ for (const f of [...a, ...b]) {
152
+ const key = `${f.start}|${f.name}`;
153
+ const had = seen.get(key);
154
+ if (had === undefined || (had.end === undefined && f.end !== undefined))
155
+ seen.set(key, f);
156
+ }
157
+ return [...seen.values()].sort((x, y) => x.start - y.start);
158
+ }
113
159
  /** Sums hits and joins branches over records of the same file; drops what `place` rejects. */
114
160
  export function runOf(records, place) {
115
161
  const lines = new Map();
116
162
  const branches = new Map();
163
+ const functions = new Map();
117
164
  const tests = new Map();
118
165
  for (const record of records) {
119
166
  const path = place(record.path);
@@ -123,37 +170,42 @@ export function runOf(records, place) {
123
170
  for (const [line, n] of record.lines)
124
171
  hits.set(line, (hits.get(line) ?? 0) + n);
125
172
  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);
173
+ countInto(branches, path, record);
174
+ functions.set(path, joinFunctions(functions.get(path) ?? [], record.functions));
134
175
  if (record.test === null)
135
176
  continue;
136
- const ran = tests.get(record.test) ?? new Map();
137
- const set = ran.get(path) ?? new Set();
177
+ const ran = tests.get(record.test) ?? { lines: new Map(), branches: new Map() };
178
+ const set = ran.lines.get(path) ?? new Set();
138
179
  for (const [line, n] of record.lines)
139
180
  if (n > 0)
140
181
  set.add(line);
141
- ran.set(path, set);
182
+ ran.lines.set(path, set);
183
+ countInto(ran.branches, path, record);
142
184
  tests.set(record.test, ran);
143
185
  }
144
186
  const files = new Map();
145
187
  for (const [path, hits] of lines) {
146
188
  const counted = new Map();
147
189
  for (const [line, ids] of branches.get(path) ?? []) {
148
- counted.set(line, [[...ids.values()].filter(Boolean).length, ids.size]);
190
+ const n = [...ids.values()];
191
+ counted.set(line, [n.filter((c) => c > 0).length, n.length]);
149
192
  }
150
- files.set(path, { lines: sorted(hits), branches: sorted(counted) });
193
+ const fns = functions.get(path) ?? [];
194
+ files.set(path, {
195
+ lines: sorted(hits),
196
+ branches: sorted(counted),
197
+ ...(fns.length === 0 ? {} : { functions: fns }),
198
+ });
151
199
  }
152
200
  return {
153
201
  files,
202
+ counts: countsOf(branches),
154
203
  tests: new Map([...tests].map(([id, ran]) => [
155
204
  id,
156
- new Map([...ran].map(([path, set]) => [path, [...set].sort((a, b) => a - b)])),
205
+ {
206
+ lines: new Map([...ran.lines].map(([path, set]) => [path, [...set].sort((a, b) => a - b)])),
207
+ branches: countsOf(ran.branches),
208
+ },
157
209
  ])),
158
210
  };
159
211
  }
@@ -184,7 +236,12 @@ function mergeMax(a, b) {
184
236
  const [t, n] = branches.get(l) ?? [0, 0];
185
237
  branches.set(l, [Math.max(taken, t), Math.max(total, n)]);
186
238
  }
187
- out.set(path, { lines: sorted(lines), branches: sorted(branches) });
239
+ const functions = joinFunctions(had.functions ?? [], f.functions ?? []);
240
+ out.set(path, {
241
+ lines: sorted(lines),
242
+ branches: sorted(branches),
243
+ ...(functions.length === 0 ? {} : { functions }),
244
+ });
188
245
  }
189
246
  return out;
190
247
  }
@@ -197,7 +254,7 @@ export const PREVIEW_LINES = 8;
197
254
  export function recordRun(prior, run, at) {
198
255
  const { tip, takenAt, test } = at;
199
256
  if (test === undefined) {
200
- const tests = new Map([...run.tests].map(([id, lines]) => [id, { lines }]));
257
+ const tests = new Map([...run.tests].map(([id, ran]) => [id, testEntry(ran)]));
201
258
  return { tip, takenAt, files: run.files, tests };
202
259
  }
203
260
  const base = prior !== null && prior.tip === tip ? prior : null;
@@ -205,12 +262,16 @@ export function recordRun(prior, run, at) {
205
262
  const entry = {
206
263
  ...(test.at === undefined ? {} : { at: test.at }),
207
264
  ...(test.preview === undefined ? {} : { preview: test.preview }),
208
- lines: hitLines(run.files),
265
+ ...testEntry({ lines: hitLines(run.files), branches: run.counts }),
209
266
  };
210
267
  tests.set(test.id, entry);
211
268
  const files = base === null ? run.files : mergeMax(base.files, run.files);
212
269
  return { tip, takenAt, files, tests };
213
270
  }
271
+ /** A test's lines, and its branch counts when the run had any. */
272
+ function testEntry(ran) {
273
+ return { lines: ran.lines, ...(ran.branches.size === 0 ? {} : { branches: ran.branches }) };
274
+ }
214
275
  /**
215
276
  * Per file, the branch's added rows that lcov calls executable, and how many ran. Every added
216
277
  * row is a tip line, which is where the run was taken. Files without such rows are left out.
@@ -278,6 +339,7 @@ export function serializeCoverage(c) {
278
339
  {
279
340
  lines: Object.fromEntries(f.lines),
280
341
  ...(f.branches.size === 0 ? {} : { branches: Object.fromEntries(f.branches) }),
342
+ ...(f.functions === undefined ? {} : { functions: f.functions }),
281
343
  },
282
344
  ]));
283
345
  const tests = Object.fromEntries([...c.tests].map(([id, t]) => [
@@ -286,6 +348,11 @@ export function serializeCoverage(c) {
286
348
  ...(t.at === undefined ? {} : { at: t.at }),
287
349
  ...(t.preview === undefined ? {} : { preview: [...t.preview] }),
288
350
  lines: Object.fromEntries([...t.lines].map(([p, ls]) => [p, [...ls]])),
351
+ ...(t.branches === undefined
352
+ ? {}
353
+ : {
354
+ branches: Object.fromEntries([...t.branches].map(([p, byLine]) => [p, Object.fromEntries(byLine)])),
355
+ }),
289
356
  },
290
357
  ]));
291
358
  return { tip: c.tip, takenAt: c.takenAt, files, ...(c.tests.size === 0 ? {} : { tests }) };
@@ -321,6 +388,52 @@ export function parseLocArg(raw, place) {
321
388
  const end = m[3] === undefined ? start : Number(m[3]);
322
389
  return end < start ? `--at ${raw} runs backwards` : { path, start, end };
323
390
  }
391
+ function parseFunction(v) {
392
+ if (!isFields(v))
393
+ return null;
394
+ const { name, start, end } = v;
395
+ if (typeof name !== "string" || !isLine(start))
396
+ return null;
397
+ if (end === undefined)
398
+ return { name, start };
399
+ return isLine(end) && end >= start ? { name, start, end } : null;
400
+ }
401
+ function parseFunctions(v) {
402
+ if (v === undefined)
403
+ return undefined;
404
+ if (!Array.isArray(v))
405
+ return "functions is not a list";
406
+ const out = [];
407
+ for (const raw of v) {
408
+ const f = parseFunction(raw);
409
+ if (f === null)
410
+ return "a function is not a { name, start, end? }";
411
+ out.push(f);
412
+ }
413
+ return out;
414
+ }
415
+ function parseCounts(v) {
416
+ if (v === undefined)
417
+ return undefined;
418
+ const files = entries(v);
419
+ if (files === null)
420
+ return "branches is not an object";
421
+ const out = new Map();
422
+ for (const [path, raw] of files) {
423
+ const lines = entries(raw);
424
+ if (!isRepoPath(path) || lines === null)
425
+ return `branches of ${path} are not by line`;
426
+ const byLine = new Map();
427
+ for (const [key, counts] of lines) {
428
+ if (!LINE_KEY.test(key) || !Array.isArray(counts) || !counts.every(isCount)) {
429
+ return `branches of ${path}:${key} are not counts`;
430
+ }
431
+ byLine.set(Number(key), counts.filter(isCount));
432
+ }
433
+ out.set(path, sorted(byLine));
434
+ }
435
+ return out;
436
+ }
324
437
  function parseFile(v) {
325
438
  if (!isFields(v))
326
439
  return "a file is not an object";
@@ -347,7 +460,14 @@ function parseFile(v) {
347
460
  }
348
461
  branches.set(Number(key), [taken, total]);
349
462
  }
350
- return { lines: sorted(hits), branches: sorted(branches) };
463
+ const functions = parseFunctions(v.functions);
464
+ if (typeof functions === "string")
465
+ return functions;
466
+ return {
467
+ lines: sorted(hits),
468
+ branches: sorted(branches),
469
+ ...(functions === undefined ? {} : { functions }),
470
+ };
351
471
  }
352
472
  function parseTest(v) {
353
473
  if (!isFields(v))
@@ -370,10 +490,14 @@ function parseTest(v) {
370
490
  }
371
491
  lines.set(path, list);
372
492
  }
493
+ const branches = parseCounts(v.branches);
494
+ if (typeof branches === "string")
495
+ return branches;
373
496
  return {
374
497
  ...(at === undefined ? {} : { at }),
375
498
  ...(preview === undefined ? {} : { preview: preview.filter((l) => typeof l === "string") }),
376
499
  lines,
500
+ ...(branches === undefined ? {} : { branches }),
377
501
  };
378
502
  }
379
503
  /** The one place coverage.json becomes a Coverage. */