@shiyuphan/follow-me 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/README.md +6 -3
  2. package/dist/src/cli.js +35 -11
  3. package/dist/src/comments.js +62 -9
  4. package/dist/src/definition.js +226 -0
  5. package/dist/src/evolve.js +145 -29
  6. package/dist/src/git.js +69 -13
  7. package/dist/src/host.js +12 -3
  8. package/dist/src/plan.js +26 -1
  9. package/dist/src/prompt.js +122 -12
  10. package/dist/src/server.js +120 -28
  11. package/dist/src/session.js +23 -0
  12. package/dist/src/types.js +2 -0
  13. package/dist/ui/dist/assets/{abnfDiagram-WEYOIA3C-BXkLs7mV.js → abnfDiagram-WEYOIA3C-fYSYLdMl.js} +1 -1
  14. package/dist/ui/dist/assets/{arc-DNzjw7Ie.js → arc-DGuGSnwa.js} +1 -1
  15. package/dist/ui/dist/assets/{architecture-WOLXFQ4H-D66kgJJt.js → architecture-WOLXFQ4H-VFox_hV1.js} +1 -1
  16. package/dist/ui/dist/assets/{architectureDiagram-ZHVYKT53-D8UWuhQr.js → architectureDiagram-ZHVYKT53-CBVK3nHZ.js} +1 -1
  17. package/dist/ui/dist/assets/{blockDiagram-RJAE7QA3-BLkmr62i.js → blockDiagram-RJAE7QA3-BFdsIWEr.js} +1 -1
  18. package/dist/ui/dist/assets/{c4Diagram-XDPPPE54-3rZPYwOc.js → c4Diagram-XDPPPE54-BGntcPId.js} +1 -1
  19. package/dist/ui/dist/assets/channel-gz6cS9Z1.js +1 -0
  20. package/dist/ui/dist/assets/{chunk-2E7WHVMD-MMnvAgpB.js → chunk-2E7WHVMD-CNMuK9zj.js} +1 -1
  21. package/dist/ui/dist/assets/{chunk-4EMNKR2T-D9FGWA3l.js → chunk-4EMNKR2T-ByeTTJWS.js} +1 -1
  22. package/dist/ui/dist/assets/{chunk-55XCU76K-DuOUaV8F.js → chunk-55XCU76K-99xE7cLH.js} +1 -1
  23. package/dist/ui/dist/assets/{chunk-7S6XSTFO-p3YWPv0m.js → chunk-7S6XSTFO-ZVmT7eMZ.js} +1 -1
  24. package/dist/ui/dist/assets/{chunk-AQDTSKZB-DWfKYjwk.js → chunk-AQDTSKZB-B981JORL.js} +1 -1
  25. package/dist/ui/dist/assets/{chunk-DSNLSHJZ-C3CIwpv0.js → chunk-DSNLSHJZ-B2z7FX4h.js} +1 -1
  26. package/dist/ui/dist/assets/{chunk-HAGFOTED-Bwfl9gme.js → chunk-HAGFOTED-CHFbBS-4.js} +1 -1
  27. package/dist/ui/dist/assets/{chunk-HNE4Q36P-0_oeU0le.js → chunk-HNE4Q36P-rq11xv9X.js} +1 -1
  28. package/dist/ui/dist/assets/{chunk-NQOKRVIF-z_LPPPx5.js → chunk-NQOKRVIF-BYmg9kSX.js} +1 -1
  29. package/dist/ui/dist/assets/{chunk-UHW7PFBP-BH28V2yu.js → chunk-UHW7PFBP-JvL9IXWg.js} +1 -1
  30. package/dist/ui/dist/assets/{chunk-VQ5W62QI-BXDajDf7.js → chunk-VQ5W62QI-CYMQaAKn.js} +1 -1
  31. package/dist/ui/dist/assets/{chunk-WQQL4CDK-BphBlfKj.js → chunk-WQQL4CDK-CXnR0PHT.js} +1 -1
  32. package/dist/ui/dist/assets/{chunk-XXDRQBXY-BiCT5NwV.js → chunk-XXDRQBXY-CDUWJCXi.js} +1 -1
  33. package/dist/ui/dist/assets/{chunk-YZO67HFG-Bg5Pu8BX.js → chunk-YZO67HFG-mZfXIx0h.js} +1 -1
  34. package/dist/ui/dist/assets/{chunk-ZAUX2LTX-C_Tgd8tB.js → chunk-ZAUX2LTX-DBxVTCHX.js} +1 -1
  35. package/dist/ui/dist/assets/{chunk-ZCHMQSOR-MBRcMi8-.js → chunk-ZCHMQSOR-DrWA4IBo.js} +1 -1
  36. package/dist/ui/dist/assets/{classDiagram-v2-4MYWR3UA-1wyeCe5y.js → classDiagram-v2-4MYWR3UA-Beah5uxA.js} +1 -1
  37. package/dist/ui/dist/assets/{cose-bilkent-JH36ORCC-BtOYclF6.js → cose-bilkent-JH36ORCC-UaE-jnHk.js} +1 -1
  38. package/dist/ui/dist/assets/{cynefin-EF2NZ3EQ-dJmoOG8g.js → cynefin-EF2NZ3EQ-DKfGaCR8.js} +1 -1
  39. package/dist/ui/dist/assets/{cynefinDiagram-WF24UDRP-AloyqbEN.js → cynefinDiagram-WF24UDRP-tTUzTs3D.js} +1 -1
  40. package/dist/ui/dist/assets/{dagre-NK4GIZVX-NCZhdnfb.js → dagre-NK4GIZVX-jSn35_T7.js} +1 -1
  41. package/dist/ui/dist/assets/{diagram-CBK3MWTC-D8w7x5Gb.js → diagram-CBK3MWTC-7W2uw8Zc.js} +1 -1
  42. package/dist/ui/dist/assets/{diagram-I5XIIV2D-BUFnCGlp.js → diagram-I5XIIV2D-CcfyQVh9.js} +1 -1
  43. package/dist/ui/dist/assets/{diagram-ODIL2UCW-BULmHKaW.js → diagram-ODIL2UCW-BjGvaLis.js} +1 -1
  44. package/dist/ui/dist/assets/{diagram-PFBEJUXE-B5MqFo0r.js → diagram-PFBEJUXE-jvdCrt8B.js} +1 -1
  45. package/dist/ui/dist/assets/{diagram-RJA3I4VV-BIkDLL8E.js → diagram-RJA3I4VV-D65_Vpi_.js} +1 -1
  46. package/dist/ui/dist/assets/{diagram-TYUNFSNR-Ca4gsQV8.js → diagram-TYUNFSNR-C52roQBE.js} +1 -1
  47. package/dist/ui/dist/assets/{dist-G85JKY5k.js → dist-BJd4jp9Y.js} +1 -1
  48. package/dist/ui/dist/assets/{ebnfDiagram-V4P3TTPT-rVjcFFtb.js → ebnfDiagram-V4P3TTPT-DXysoIBq.js} +1 -1
  49. package/dist/ui/dist/assets/{elk-QONTBCTQ-BWPdvULh.js → elk-QONTBCTQ-BVZcsgV3.js} +1 -1
  50. package/dist/ui/dist/assets/{erDiagram-KYQIGQAA-BPxJJkRl.js → erDiagram-KYQIGQAA-BJnT4fVU.js} +1 -1
  51. package/dist/ui/dist/assets/{eventmodeling-K75KTNOO-BSB9WYV3.js → eventmodeling-K75KTNOO-C5MzxCMY.js} +1 -1
  52. package/dist/ui/dist/assets/flowDiagram-IUGLLWBU-BqUgZSCz.js +1 -0
  53. package/dist/ui/dist/assets/{ganttDiagram-R7GPNTJY-DKps530K.js → ganttDiagram-R7GPNTJY-CZRrnuah.js} +1 -1
  54. package/dist/ui/dist/assets/{gitGraph-VSP46ZUC-D1d4UAFV.js → gitGraph-VSP46ZUC-l58aGSkh.js} +1 -1
  55. package/dist/ui/dist/assets/{gitGraphDiagram-PDYL7XHY-N55yl6hc.js → gitGraphDiagram-PDYL7XHY-D4ks1WO-.js} +1 -1
  56. package/dist/ui/dist/assets/index-BW95UA5q.css +1 -0
  57. package/dist/ui/dist/assets/index-DOGXgoHF.js +134 -0
  58. package/dist/ui/dist/assets/{info-OHQRW6UA-CgxE3gf9.js → info-OHQRW6UA-BSqfzkdk.js} +1 -1
  59. package/dist/ui/dist/assets/{infoDiagram-MVDRKAX5-DOnUrnMf.js → infoDiagram-MVDRKAX5-B6NgVF_N.js} +1 -1
  60. package/dist/ui/dist/assets/{ishikawaDiagram-HPEHV2GE-BIY0fFHB.js → ishikawaDiagram-HPEHV2GE-DvtfssEZ.js} +1 -1
  61. package/dist/ui/dist/assets/{journeyDiagram-BOSHW7O7-CqA2Ern0.js → journeyDiagram-BOSHW7O7-DM64gT5v.js} +1 -1
  62. package/dist/ui/dist/assets/{kanban-definition-FNSA4HC5-vGrpQKIF.js → kanban-definition-FNSA4HC5-AliyRJHy.js} +1 -1
  63. package/dist/ui/dist/assets/{line-Brzv3qA6.js → line-CHncXbKO.js} +1 -1
  64. package/dist/ui/dist/assets/{linear-D9uIlqyh.js → linear-BWEWydI0.js} +1 -1
  65. package/dist/ui/dist/assets/{mermaid-parser.core-Bz9b-Thu.js → mermaid-parser.core-DGpOP8AM.js} +3 -3
  66. package/dist/ui/dist/assets/{mermaid.core-BaZipu3b.js → mermaid.core-BJtBFbJx.js} +4 -4
  67. package/dist/ui/dist/assets/{mindmap-definition-6OY57UG3-D6aie7uH.js → mindmap-definition-6OY57UG3-NjavnWBi.js} +1 -1
  68. package/dist/ui/dist/assets/{packet-JDAUHWVQ-28r3jVFY.js → packet-JDAUHWVQ-zuhpVsME.js} +1 -1
  69. package/dist/ui/dist/assets/{pegDiagram-ECIUJNBX-CbGvD4qP.js → pegDiagram-ECIUJNBX-sL4grM97.js} +1 -1
  70. package/dist/ui/dist/assets/{pie-XZMESJXO-DHpcGnZd.js → pie-XZMESJXO-C9l1Mp09.js} +1 -1
  71. package/dist/ui/dist/assets/{pieDiagram-GNMPY74T-DuhauwnY.js → pieDiagram-GNMPY74T-rlRBr2U4.js} +1 -1
  72. package/dist/ui/dist/assets/{prism-core-CbZMOvrb.js → prism-core-DmuST30i.js} +1 -1
  73. package/dist/ui/dist/assets/{quadrantDiagram-D57K4MC3-BfyNmVca.js → quadrantDiagram-D57K4MC3-4oQMip2Q.js} +1 -1
  74. package/dist/ui/dist/assets/{radar-ABXABTNO-Xp1TYwaj.js → radar-ABXABTNO-DvGg96L7.js} +1 -1
  75. package/dist/ui/dist/assets/{railroad-I3PHUGI6-c5FY4wwa.js → railroad-I3PHUGI6-8wdFKMZp.js} +1 -1
  76. package/dist/ui/dist/assets/{railroad-abnf-LNEFI6M7-RDyWXqcL.js → railroad-abnf-LNEFI6M7-DUtOO0gn.js} +1 -1
  77. package/dist/ui/dist/assets/{railroad-ebnf-DWYD2UWJ-CLD4jVou.js → railroad-ebnf-DWYD2UWJ-5YCGY1_d.js} +1 -1
  78. package/dist/ui/dist/assets/{railroad-peg-JR7BVNR7-17EYTA-w.js → railroad-peg-JR7BVNR7-Bwywxjhs.js} +1 -1
  79. package/dist/ui/dist/assets/{railroadDiagram-Y72NRBL7-C4p2wtKe.js → railroadDiagram-Y72NRBL7-CwO6HSNQ.js} +1 -1
  80. package/dist/ui/dist/assets/{requirementDiagram-EQYSW5RA-DTYIFN8T.js → requirementDiagram-EQYSW5RA-Cp0IbfNr.js} +1 -1
  81. package/dist/ui/dist/assets/{sankeyDiagram-SY24JHO5-YDYhRyJU.js → sankeyDiagram-SY24JHO5-DeQKsO68.js} +1 -1
  82. package/dist/ui/dist/assets/{sequenceDiagram-BLEHNHQU-Crvy6IlU.js → sequenceDiagram-BLEHNHQU-DTDalCxF.js} +1 -1
  83. package/dist/ui/dist/assets/{src-BPod-Ype.js → src-XnrqevnL.js} +1 -1
  84. package/dist/ui/dist/assets/{stateDiagram-v2-A6IESKWZ-BWr70YOQ.js → stateDiagram-v2-A6IESKWZ-2DZ-tKo9.js} +1 -1
  85. package/dist/ui/dist/assets/{swimlanes-B3ENRKFQ-DvbG4I9a.js → swimlanes-B3ENRKFQ-66yZJeRU.js} +1 -1
  86. package/dist/ui/dist/assets/swimlanesDiagram-AYQRV4AK-rbX6bYG4.js +8 -0
  87. package/dist/ui/dist/assets/{timeline-definition-JXYKGLGB-CR950Bzb.js → timeline-definition-JXYKGLGB-Dm1xGU-d.js} +1 -1
  88. package/dist/ui/dist/assets/{treeView-D4JQ5SDB-DSDDXGrk.js → treeView-D4JQ5SDB-BvgsbSot.js} +1 -1
  89. package/dist/ui/dist/assets/{treemap-SAJKECNS-Ct5w2_RR.js → treemap-SAJKECNS-C3lnbG1a.js} +1 -1
  90. package/dist/ui/dist/assets/{usecaseDiagram-WPOLBNNX-_eP0371y.js → usecaseDiagram-WPOLBNNX-B6iPNIhZ.js} +1 -1
  91. package/dist/ui/dist/assets/{vennDiagram-Q3Y3IDCW-Cu-IA57L.js → vennDiagram-Q3Y3IDCW-D5dgflg9.js} +1 -1
  92. package/dist/ui/dist/assets/{wardley-7MLQ67FV-r8yd6frN.js → wardley-7MLQ67FV-Dn9mLIB4.js} +1 -1
  93. package/dist/ui/dist/assets/{wardleyDiagram-IVAIDKWA-CBUUiRQm.js → wardleyDiagram-IVAIDKWA-W1EwzN_X.js} +1 -1
  94. package/dist/ui/dist/assets/{xychartDiagram-4URNHDU4-DEVQjvgG.js → xychartDiagram-4URNHDU4-CKRzMUvp.js} +1 -1
  95. package/dist/ui/dist/index.html +2 -2
  96. package/package.json +1 -1
  97. package/skills/follow-me/SKILL.md +3 -1
  98. package/dist/ui/dist/assets/channel-B9JVdOBZ.js +0 -1
  99. package/dist/ui/dist/assets/flowDiagram-IUGLLWBU-N-Spyk2o.js +0 -1
  100. package/dist/ui/dist/assets/index-B8TYcxF8.css +0 -1
  101. package/dist/ui/dist/assets/index-BKtWBS7Z.js +0 -134
  102. package/dist/ui/dist/assets/swimlanesDiagram-AYQRV4AK-D2wU-jW6.js +0 -8
package/README.md CHANGED
@@ -69,8 +69,11 @@ If the hashes differ, `apply` exits with code 1. The commits are signed when you
69
69
 
70
70
  The red blob in the party hat is your branch tip. Nothing follow-me does moves it.
71
71
 
72
- ![The review page: one scrolling document of commits with the stack dropdown in the sticky header, a picked line with its Ask box, and an inline thread where the agent answers in Markdown with a diagram](assets/screenshots/review.png)
73
- *The review page. Pick any line, changed or not, or a range; Ask or Flag opens right under it. The agent answers inline in Markdown.*
72
+ ![The review page: the index on the left groups commits by section in top-down order, and commit 2 shows a question on a line with the agent's answer inline](assets/screenshots/review.png)
73
+ *The review page. The index groups the commits the way they read: the story first, then the steps it calls, then the details. Pick any line, changed or not, and Ask or Flag right under it; the agent answers inline.*
74
+
75
+ ![Cmd-clicking format in pow opens a peek of its definition, which lives outside this branch](assets/screenshots/definition.png)
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.*
74
77
 
75
78
  ![A diagram opened in the lightbox at full size](assets/screenshots/diagram.png)
76
79
  *Diagrams sit full width under the commit or reply they explain, and open full size on click.*
@@ -78,7 +81,7 @@ The red blob in the party hat is your branch tip. Nothing follow-me does moves i
78
81
  ![The hand-off page: every kept item with its original-branch path and line, an include checkbox, and copy buttons](assets/screenshots/handoff.png)
79
82
  *Submit review. Each item carries the original branch's path and line, so your agent can post it to the merge request as is.*
80
83
 
81
- Keys: `j` and `k` move between commits, 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.
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.
82
85
 
83
86
  ### Reviewing someone else's branch
84
87
 
package/dist/src/cli.js CHANGED
@@ -3,15 +3,16 @@ import { readFileSync, writeFileSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
4
  import { dirname, join, relative } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
+ import { definitionIndex, isIdentifier } from "./definition.js";
6
7
  import { DiffParseError, formatUnitRef, isUnitId } from "./diff.js";
7
8
  import { BranchRefusedError, branchHead, GitError, isOwnWork, signing, treeOf, writeBranch, } from "./git.js";
8
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
10
  import { findSkillDir, InstallError, runInstall, runUninstall, syncSkills, } from "./install.js";
10
11
  import { naiveDraft } from "./naive.js";
11
12
  import { isCommentId, isConcernId, isFields, serializePlanFile, validatePlan, } from "./plan.js";
12
- import { renderEvents, renderPrompt, renderReport, renderShownComment, renderShownConcern, renderShownUnit, reportItems, } from "./prompt.js";
13
+ import { renderEvents, renderPrompt, renderReport, renderShownComment, renderShownConcern, renderShownDefinitions, renderShownUnit, reportItems, } from "./prompt.js";
13
14
  import { RegistrationError, sessionIdOf } from "./server.js";
14
- import { BranchMovedError, cleanSessions, extractSession, MissingDraftError, NotARepositoryError, openSession, SessionFileError, SessionLockedError, sinceLastPlan, } from "./session.js";
15
+ import { BranchMovedError, cleanSessions, extractSession, MissingDraftError, NotARepositoryError, openSession, SessionFileError, SessionLockedError, sessionComments, sinceLastPlan, } from "./session.js";
15
16
  import { checkSyntax } from "./syntax.js";
16
17
  class UsageError extends Error {
17
18
  name = "UsageError";
@@ -30,9 +31,10 @@ Other commands:
30
31
  Mermaid source
31
32
  explain [--commit <n>] [--unit <id>] [--lines <a-b>] --body <text> [--diagram <file>]
32
33
  start a thread on the page without being asked
33
- show <h_…|c_…|k_…|path:line[@n|@base]> print a unit, a comment's thread, a concern, or the
34
- lines around path:line in commit n, instead of
35
- reading files
34
+ show <h_…|c_…|k_…|path:line[@n|@base]|name>
35
+ print a unit, a comment's thread, a concern, the
36
+ lines around path:line in commit n, or where a name
37
+ is defined, instead of reading files
36
38
  report [--json] [--markdown] print the report of the latest review; --json lists
37
39
  the items to post to a merge request
38
40
 
@@ -85,11 +87,13 @@ Interactive loop (while the review page is open):
85
87
  --lines are change lines of the unit, commits count
86
88
  from 1; with no target it is about the whole plan.
87
89
  watch leaves these out like your replies
88
- show <h_…|c_…|k_…|path:line[@n|@base]> [--branch <name>]
90
+ show <h_…|c_…|k_…|path:line[@n|@base]|name> [--branch <name>]
89
91
  print a unit with the commits that take it, a
90
92
  comment with its rows and thread, a concern with its
91
- lines, or the lines around path:line at the tip, in
92
- commit n of the plan, or in the base
93
+ lines, the lines around path:line at the tip, in
94
+ commit n of the plan, or in the base, or each place
95
+ a name is defined at the tip with the commit it
96
+ lands in
93
97
  report [--json] [--markdown] [--branch <name>]
94
98
  print the report of the open review, or of the last
95
99
  one that ended. Markdown lists issues by file and
@@ -909,7 +913,9 @@ function finished(session, stored) {
909
913
  const text = renderStored(session, stored);
910
914
  if (text === null)
911
915
  return 1;
912
- say(`review v${stored.version} ended with ${plural(stored.review.comments.length, "comment")}`);
916
+ // A review that swapped versions holds what its earlier versions said too.
917
+ const said = stored.review.comments.length + (stored.review.carried?.length ?? 0);
918
+ say(`review v${stored.version} ended with ${plural(said, "comment")}`);
913
919
  process.stdout.write(text);
914
920
  say();
915
921
  next("done; the user can port it with follow-me report --json");
@@ -1240,7 +1246,19 @@ function renderStored(session, stored, format = "markdown") {
1240
1246
  .plans()
1241
1247
  .filter((p) => p.version > stored.version)
1242
1248
  .flatMap((p) => p.file.responses);
1243
- const opts = { branch: session.anchor.branch, responses };
1249
+ const { carried } = stored.review;
1250
+ const said = reviewsOf(session)
1251
+ .filter((r) => r.version <= stored.version)
1252
+ .map((r) => r.review);
1253
+ const earlier = carried === undefined
1254
+ ? {}
1255
+ : {
1256
+ earlier: {
1257
+ comments: sessionComments(carried, said),
1258
+ concerns: stored.review.carriedConcerns ?? [],
1259
+ },
1260
+ };
1261
+ const opts = { branch: session.anchor.branch, responses, ...earlier };
1244
1262
  if (format === "json") {
1245
1263
  const items = reportItems(stored.review, verdict.plan.commits, session.diff.units, opts);
1246
1264
  return `${JSON.stringify(items, null, 2)}\n`;
@@ -1502,7 +1520,7 @@ async function explain(cwd, args) {
1502
1520
  next(`run follow-me again; ${STILL_WORKING}`);
1503
1521
  return 0;
1504
1522
  }
1505
- const SHOW_USAGE = "usage: follow-me show <h_…|c_…|k_…|path:line[@<commit>|@base]>";
1523
+ const SHOW_USAGE = "usage: follow-me show <h_…|c_…|k_…|path:line[@<commit>|@base]|name>";
1506
1524
  /** Every comment the session has, with the plan version it was made on: live ones first. */
1507
1525
  async function knownComments(session) {
1508
1526
  const open = await openOnServer(session, null);
@@ -1615,6 +1633,12 @@ async function showRef(cwd, args) {
1615
1633
  }
1616
1634
  throw new UsageError(`no concern ${ref} in a plan that fits the branch`);
1617
1635
  }
1636
+ if (isIdentifier(ref)) {
1637
+ const commits = latest === undefined ? [] : projectedCommits(session, latest.version);
1638
+ const index = definitionIndex(session.root, session.diff.tip, units, commits);
1639
+ process.stdout.write(renderShownDefinitions(ref, await index.lookup(ref, null)));
1640
+ return 0;
1641
+ }
1618
1642
  process.stdout.write(showLines(session, ref));
1619
1643
  return 0;
1620
1644
  }
@@ -1,5 +1,5 @@
1
1
  import { isRepoPath, isUnitId } from "./diff.js";
2
- import { diagramProblem, isArray, isCommentId, isConcernId, isFields, isPlanVersion, isSeverity, } from "./plan.js";
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
5
5
  * decisions, the review file, its autosaved draft and the events a hosted session logs. Every
@@ -253,13 +253,7 @@ function parseEarlierReplies(v) {
253
253
  function isLine(v) {
254
254
  return typeof v === "number" && Number.isInteger(v) && v >= 1;
255
255
  }
256
- export function parseCarried(v) {
257
- if (!isFields(v)) {
258
- return "not an object";
259
- }
260
- const comment = parseComment(v.comment);
261
- if (typeof comment === "string")
262
- return comment;
256
+ function parsePlacement(v) {
263
257
  const { unit, offsets, changed, path, newLine } = v;
264
258
  if (unit !== null && !(typeof unit === "string" && isUnitId(unit)))
265
259
  return "unit is not a unit id";
@@ -273,7 +267,6 @@ export function parseCarried(v) {
273
267
  if (newLine !== null && !isLine(newLine))
274
268
  return "newLine is not null or a line";
275
269
  return {
276
- comment,
277
270
  unit,
278
271
  offsets: offsets === null ? null : offsets.filter(isIndex),
279
272
  changed,
@@ -281,6 +274,56 @@ export function parseCarried(v) {
281
274
  newLine,
282
275
  };
283
276
  }
277
+ export function parseCarried(v) {
278
+ if (!isFields(v))
279
+ return "not an object";
280
+ const comment = parseComment(v.comment);
281
+ if (typeof comment === "string")
282
+ return comment;
283
+ const placement = parsePlacement(v);
284
+ return typeof placement === "string" ? placement : { comment, ...placement };
285
+ }
286
+ function parseSince(v) {
287
+ if (v === null)
288
+ return null;
289
+ return isPlanVersion(v) ? v : "since is not null or a plan version";
290
+ }
291
+ function parseCarriedRef(v) {
292
+ if (!isFields(v))
293
+ return "not an object";
294
+ const comment = asCommentId(v.comment);
295
+ if (comment === null)
296
+ return "comment is not a comment id";
297
+ if (!isPlanVersion(v.version))
298
+ return "version is not a plan version";
299
+ const since = parseSince(v.since);
300
+ if (typeof since === "string")
301
+ return since;
302
+ const placement = parsePlacement(v);
303
+ if (typeof placement === "string")
304
+ return placement;
305
+ return { ...placement, comment, version: v.version, since };
306
+ }
307
+ function parseCarriedConcern(v) {
308
+ if (!isFields(v)) {
309
+ return "not an object";
310
+ }
311
+ const concern = parsePlacedConcern(v.concern);
312
+ if (typeof concern === "string")
313
+ return `concern: ${concern}`;
314
+ if (!isIndex(v.commit))
315
+ return "commit is not a commit index";
316
+ const decision = parseDecision(v.decision);
317
+ if (typeof decision === "string")
318
+ return `decision: ${decision}`;
319
+ const since = parseSince(v.since);
320
+ if (typeof since === "string")
321
+ return since;
322
+ const placement = parsePlacement(v);
323
+ if (typeof placement === "string")
324
+ return placement;
325
+ return { ...placement, concern, commit: v.commit, decision, since };
326
+ }
284
327
  /** Boundary parse of reviews/vN.json. Files written before walk mode existed have no mode. */
285
328
  export function parseReview(v) {
286
329
  if (!isFields(v)) {
@@ -314,6 +357,12 @@ export function parseReview(v) {
314
357
  const earlier = parseList(v.earlierReplies, parseEarlierReplies, "earlierReplies");
315
358
  if (typeof earlier === "string")
316
359
  return earlier;
360
+ const carried = v.carried === undefined ? null : parseList(v.carried, parseCarriedRef, "carried");
361
+ if (typeof carried === "string")
362
+ return carried;
363
+ const concernsCarried = parseList(v.carriedConcerns, parseCarriedConcern, "carriedConcerns");
364
+ if (typeof concernsCarried === "string")
365
+ return concernsCarried;
317
366
  return {
318
367
  schema: v.schema,
319
368
  mode,
@@ -325,6 +374,8 @@ export function parseReview(v) {
325
374
  ...(resolved.length === 0 ? {} : { resolved }),
326
375
  ...(anyExcluded ? { excluded } : {}),
327
376
  ...(earlier.length === 0 ? {} : { earlierReplies: earlier }),
377
+ ...(carried === null ? {} : { carried }),
378
+ ...(concernsCarried.length === 0 ? {} : { carriedConcerns: concernsCarried }),
328
379
  };
329
380
  }
330
381
  function lenient(items, parse) {
@@ -350,6 +401,8 @@ export function parseDraft(v) {
350
401
  resolved: lenientIds(v.resolved, isCommentIdValue),
351
402
  excluded: typeof excluded === "string" ? { comments: [], concerns: [] } : excluded,
352
403
  earlierReplies: lenient(v.earlierReplies, parseEarlierReplies),
404
+ carried: v.carried === undefined ? null : lenient(v.carried, parseCarriedRef),
405
+ carriedConcerns: lenient(v.carriedConcerns, parseCarriedConcern),
353
406
  };
354
407
  }
355
408
  export function parseEvent(v) {
@@ -0,0 +1,226 @@
1
+ import { posix } from "node:path";
2
+ import { splitLines, tipStartOf } from "./diff.js";
3
+ import { blobsAt, grepTip } from "./git.js";
4
+ export const MAX_CANDIDATES = 10;
5
+ const PREVIEW_LINES = 8;
6
+ /** An ASCII identifier as JS, Python, Go, Rust, JVM languages, Ruby and C spell one. */
7
+ export function isIdentifier(s) {
8
+ return /^[A-Za-z_$][A-Za-z0-9_$]{0,127}$/.test(s);
9
+ }
10
+ const JS_MODS = "((static|async|public|private|protected|readonly|override|get|set)\\s+)*";
11
+ const RUST_VIS = "(pub([(][^)]*[)])?\\s+)?";
12
+ const JAVA_MODS = "((public|private|protected|static|final|abstract|synchronized|native|default|strictfp)\\s+)*";
13
+ const JVM_CLASS_MODS = "((public|private|protected|static|final|abstract|sealed|data|open|internal|inner|enum|annotation)\\s+)*";
14
+ const KOTLIN_MODS = "((public|private|protected|internal|override|open|abstract|suspend|inline|operator|infix|tailrec|external|actual|expect)\\s+)*";
15
+ const STATEMENT = /^\s*(return|new|throw|else|case|yield|await|typedef|delete|goto)\b/;
16
+ const LANGUAGES = [
17
+ {
18
+ extensions: [".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx", ".mts", ".cts"],
19
+ patterns: [
20
+ {
21
+ kind: "function",
22
+ source: "^\\s*(export\\s+)?(default\\s+)?(async\\s+)?function\\s*[*]?\\s*NAME\\s*[(<]",
23
+ },
24
+ {
25
+ kind: "const",
26
+ source: "^\\s*(export\\s+)?(declare\\s+)?(const|let|var)\\s+NAME\\s*(:[^=]*)?=",
27
+ },
28
+ {
29
+ kind: "class",
30
+ source: "^\\s*(export\\s+)?(default\\s+)?(declare\\s+)?(abstract\\s+)?class\\s+NAMEEND",
31
+ },
32
+ {
33
+ kind: "type",
34
+ source: "^\\s*(export\\s+)?(declare\\s+)?(interface|type|(const\\s+)?enum)\\s+NAMEEND",
35
+ },
36
+ {
37
+ kind: "method",
38
+ source: `^\\s+${JS_MODS}[*]?NAME\\s*(<[^>]*>)?[(].*[)]\\s*(:[^={;]+)?[{]\\s*$`,
39
+ reject: /^\s*(if|for|while|switch|catch|return)\b/,
40
+ },
41
+ ],
42
+ },
43
+ {
44
+ extensions: [".py", ".pyi"],
45
+ patterns: [
46
+ { kind: "function", source: "^(async\\s+)?def\\s+NAME\\s*[(]" },
47
+ { kind: "method", source: "^\\s+(async\\s+)?def\\s+NAME\\s*[(]" },
48
+ { kind: "class", source: "^\\s*class\\s+NAME\\s*[(:]" },
49
+ ],
50
+ },
51
+ {
52
+ extensions: [".go"],
53
+ patterns: [
54
+ { kind: "function", source: "^func\\s+NAME\\s*[[(]" },
55
+ { kind: "method", source: "^func\\s*[(][^)]*[)]\\s*NAME\\s*[[(]" },
56
+ { kind: "type", source: "^\\s*type\\s+NAMEEND" },
57
+ ],
58
+ },
59
+ {
60
+ extensions: [".rs"],
61
+ patterns: [
62
+ {
63
+ kind: "function",
64
+ source: `^${RUST_VIS}((const|async|unsafe|extern)\\s+("[^"]*"\\s+)?)*fn\\s+NAME\\s*[<(]`,
65
+ },
66
+ {
67
+ kind: "method",
68
+ source: `^\\s+${RUST_VIS}((const|async|unsafe|extern)\\s+("[^"]*"\\s+)?)*fn\\s+NAME\\s*[<(]`,
69
+ },
70
+ { kind: "class", source: `^\\s*${RUST_VIS}struct\\s+NAMEEND` },
71
+ { kind: "type", source: `^\\s*${RUST_VIS}(enum|trait|type)\\s+NAMEEND` },
72
+ { kind: "other", source: "^\\s*impl(<[^>]*>)?\\s+([A-Za-z0-9_:<>, ]+\\s+for\\s+)?NAMEEND" },
73
+ ],
74
+ },
75
+ {
76
+ extensions: [".java", ".kt", ".kts"],
77
+ patterns: [
78
+ { kind: "class", source: `^\\s*${JVM_CLASS_MODS}(class|object|record)\\s+NAMEEND` },
79
+ { kind: "type", source: `^\\s*${JVM_CLASS_MODS}(interface|enum|@interface)\\s+NAMEEND` },
80
+ {
81
+ kind: "function",
82
+ source: `^\\s*${KOTLIN_MODS}fun\\s+(<[^>]*>\\s*)?([A-Za-z0-9_.<>]+[.])?NAME\\s*[(]`,
83
+ },
84
+ {
85
+ kind: "method",
86
+ source: `^\\s*${JAVA_MODS}(<[^>]*>\\s+)?[A-Za-z_][A-Za-z0-9_<>,.?]*(\\[\\])*\\s+NAME\\s*[(][^;]*$`,
87
+ reject: STATEMENT,
88
+ },
89
+ ],
90
+ },
91
+ {
92
+ extensions: [".rb"],
93
+ patterns: [
94
+ { kind: "method", source: "^\\s*def\\s+(self[.])?NAMEEND" },
95
+ { kind: "class", source: "^\\s*class\\s+NAMEEND" },
96
+ { kind: "other", source: "^\\s*module\\s+NAMEEND" },
97
+ ],
98
+ },
99
+ {
100
+ extensions: [".c", ".h", ".cc", ".cpp", ".cxx", ".hh", ".hpp", ".hxx"],
101
+ patterns: [
102
+ {
103
+ kind: "function",
104
+ source: "^[A-Za-z_][A-Za-z0-9_ *&<>,:~]*[ *&]([A-Za-z_][A-Za-z0-9_]*::)*NAME\\s*[(][^;]*$",
105
+ reject: STATEMENT,
106
+ },
107
+ { kind: "function", source: "^NAME\\s*[(][^;]*$" },
108
+ { kind: "class", source: "^\\s*(typedef\\s+)?(struct|class|union)\\s+NAME\\s*([{:]|$)" },
109
+ { kind: "type", source: "^\\s*(typedef\\s+)?enum\\s+(class\\s+)?NAME\\s*([{:]|$)" },
110
+ ],
111
+ },
112
+ ];
113
+ const END = "([^A-Za-z0-9_$]|$)";
114
+ function spell(source, name) {
115
+ return source
116
+ .replaceAll("NAMEEND", `${name.replaceAll("$", "[$]")}${END}`)
117
+ .replaceAll("NAME", name.replaceAll("$", "[$]"));
118
+ }
119
+ function toEre(source) {
120
+ return source.replaceAll("\\s", "[[:space:]]");
121
+ }
122
+ function languageOf(path) {
123
+ const ext = posix.extname(path).toLowerCase();
124
+ return LANGUAGES.find((l) => l.extensions.includes(ext)) ?? null;
125
+ }
126
+ /** What the match defines by the patterns of its file's language, or null when it is no definition. */
127
+ function kindOf(match, name) {
128
+ const language = languageOf(match.path);
129
+ if (language === null)
130
+ return null;
131
+ const hit = language.patterns.find((p) => new RegExp(spell(p.source, name)).test(match.text) && !p.reject?.test(match.text));
132
+ return hit?.kind ?? null;
133
+ }
134
+ /** Same file as `from` first, then the same directory, then the rest, each in path and line order. */
135
+ export function rank(found, from) {
136
+ const tier = (path) => {
137
+ if (from === null)
138
+ return 2;
139
+ if (path === from)
140
+ return 0;
141
+ return posix.dirname(path) === posix.dirname(from) ? 1 : 2;
142
+ };
143
+ return found
144
+ .map((f, i) => ({ f, i, t: tier(f.path) }))
145
+ .sort((a, b) => a.t - b.t || a.i - b.i)
146
+ .map(({ f }) => f);
147
+ }
148
+ const NOWHERE = { unit: null, row: null, commit: null };
149
+ /**
150
+ * The hunk row that holds tip line `line` of `path`, and the commit that writes it: the owner of a
151
+ * change row, or the first commit taking the unit for a context row.
152
+ */
153
+ export function place(units, commits, path, line) {
154
+ for (const unit of units.values()) {
155
+ if (unit.kind !== "hunk" || unit.path !== path)
156
+ continue;
157
+ const row = rowAt(unit, tipStartOf(unit, units), line);
158
+ if (row === null)
159
+ continue;
160
+ const body = unit.body[row];
161
+ const change = body !== undefined && body.op !== " " ? body.change : null;
162
+ const takes = (c) => c.refs.some((r) => r.unit === unit.id &&
163
+ (change === null || r.select.kind === "rest" || r.select.changes.includes(change)));
164
+ const k = commits.findIndex(takes);
165
+ return { unit: unit.id, row, commit: k === -1 ? null : k };
166
+ }
167
+ return NOWHERE;
168
+ }
169
+ function rowAt(hunk, tipStart, line) {
170
+ let tipLine = tipStart;
171
+ for (const [row, body] of hunk.body.entries()) {
172
+ if (body.op === "-")
173
+ continue;
174
+ if (tipLine === line)
175
+ return row;
176
+ tipLine += 1;
177
+ }
178
+ return null;
179
+ }
180
+ function stripEol(line) {
181
+ return line.replace(/\r?\n$/, "");
182
+ }
183
+ /**
184
+ * Where names are defined at one branch tip and where each lands in the plan. Built per hosted
185
+ * version, so a swap starts a fresh one; matches are cached per name and file lines per path.
186
+ */
187
+ export function definitionIndex(repo, tip, units, commits) {
188
+ const foundByName = new Map();
189
+ const linesByPath = new Map();
190
+ const find = (name) => {
191
+ const cached = foundByName.get(name);
192
+ if (cached !== undefined)
193
+ return cached;
194
+ const patterns = [
195
+ ...new Set(LANGUAGES.flatMap((l) => l.patterns.map((p) => toEre(spell(p.source, name))))),
196
+ ];
197
+ const pathspecs = LANGUAGES.flatMap((l) => l.extensions.map((e) => `*${e}`));
198
+ const found = grepTip(repo, tip, patterns, pathspecs).then((matches) => matches.flatMap((m) => {
199
+ const kind = kindOf(m, name);
200
+ return kind === null ? [] : [{ path: m.path, newLine: m.line, kind }];
201
+ }));
202
+ foundByName.set(name, found);
203
+ // A failed grep is not an answer; the next request tries again.
204
+ found.catch(() => foundByName.delete(name));
205
+ return found;
206
+ };
207
+ const load = async (paths) => {
208
+ const missing = [...new Set(paths)].filter((p) => !linesByPath.has(p));
209
+ if (missing.length === 0)
210
+ return;
211
+ for (const [path, bytes] of await blobsAt(repo, tip, missing)) {
212
+ linesByPath.set(path, splitLines(bytes).map(stripEol));
213
+ }
214
+ };
215
+ return {
216
+ lookup: async (name, from) => {
217
+ const ranked = rank(await find(name), from).slice(0, MAX_CANDIDATES);
218
+ await load(ranked.map((f) => f.path));
219
+ return ranked.map((f) => ({
220
+ ...f,
221
+ ...place(units, commits, f.path, f.newLine),
222
+ preview: (linesByPath.get(f.path) ?? []).slice(f.newLine - 1, f.newLine - 1 + PREVIEW_LINES),
223
+ }));
224
+ },
225
+ };
226
+ }
@@ -78,50 +78,166 @@ function distance(hunk, units, line) {
78
78
  const [from, to] = span(hunk, units);
79
79
  return line < from ? from - line : line > to ? line - to : 0;
80
80
  }
81
- /**
82
- * Pure. Places each comment of the previous version on the new diff: on its own unit when that
83
- * hunk is unchanged, else on the hunk of the same path nearest its old tip line.
84
- */
85
- export function carryComments(comments, oldUnits, newUnits) {
86
- return comments.map((comment) => carry(comment, oldUnits, newUnits));
81
+ /** The body row whose tip line is `line`; a kept row before a removal at the same line. */
82
+ function rowAt(hunk, units, line) {
83
+ if (line === null)
84
+ return 0;
85
+ const lines = tipLines(hunk, units);
86
+ const row = hunk.body.findIndex((r, i) => r.op !== "-" && lines[i] === line);
87
+ return row === -1 ? 0 : row;
87
88
  }
88
- function carry(comment, oldUnits, newUnits) {
89
+ function pinnedComment(comment) {
89
90
  const { anchor } = comment;
90
- const nowhere = { comment, unit: null, offsets: null, newLine: null };
91
- if (anchor.kind !== "hunk")
92
- return { ...nowhere, changed: false, path: comment.context.path };
93
91
  // A comment on a whole hunk has no line; inventing one moves its card off the hunk's end.
94
- const whole = anchor.changes === null && anchor.offsets === undefined;
95
- const kept = newUnits.get(anchor.unit);
92
+ const whole = anchor.kind === "hunk" && anchor.changes === null && anchor.offsets === undefined;
93
+ return { whole, row: (hunk) => firstRow(comment, hunk) };
94
+ }
95
+ function pinnedConcern(concern) {
96
+ const { lines } = concern;
97
+ if (lines === undefined)
98
+ return { whole: true, row: () => 0 };
99
+ return {
100
+ whole: false,
101
+ row: (hunk) => {
102
+ const row = hunk.body.findIndex((r) => r.op !== " " && lines.includes(r.change));
103
+ return row === -1 ? 0 : row;
104
+ },
105
+ };
106
+ }
107
+ /** Where a comment points on the version it was made on. */
108
+ function placedOwn(comment) {
109
+ const { anchor } = comment;
110
+ return {
111
+ unit: anchor.kind === "hunk" ? anchor.unit : null,
112
+ offsets: anchor.kind === "hunk" ? (anchor.offsets ?? null) : null,
113
+ changed: false,
114
+ path: comment.context.path,
115
+ newLine: null,
116
+ };
117
+ }
118
+ /**
119
+ * Pure. Moves a placement on the old diff to the new one: a unit the new diff still has keeps its
120
+ * place, else the hunk of the same path nearest its old tip line takes it, marked changed. A
121
+ * placement on no unit stays as it is.
122
+ */
123
+ function move(at, pinned, oldUnits, newUnits) {
124
+ if (at.unit === null)
125
+ return at;
126
+ const kept = newUnits.get(at.unit);
127
+ const old = oldUnits.get(at.unit);
96
128
  if (kept !== undefined) {
97
- const newLine = kept.kind === "hunk" && !whole
98
- ? (tipLines(kept, newUnits)[firstRow(comment, kept)] ?? null)
99
- : null;
100
- return {
101
- comment,
102
- unit: kept.id,
103
- offsets: anchor.offsets ?? null,
104
- changed: false,
105
- path: kept.path,
106
- newLine,
107
- };
129
+ let newLine = null;
130
+ if (kept.kind === "hunk" && !pinned.whole) {
131
+ if (!at.changed)
132
+ newLine = tipLines(kept, newUnits)[pinned.row(kept)] ?? null;
133
+ else if (at.newLine !== null && old?.kind === "hunk") {
134
+ newLine = at.newLine - tipStartOf(old, oldUnits) + tipStartOf(kept, newUnits);
135
+ }
136
+ else
137
+ newLine = at.newLine;
138
+ }
139
+ return { ...at, path: kept.path, newLine };
108
140
  }
109
- const old = oldUnits.get(anchor.unit);
110
- const path = old?.path ?? comment.context.path;
141
+ const path = old?.path ?? at.path;
111
142
  const onPath = [...newUnits.values()].filter((u) => u.path === path);
112
143
  const first = onPath[0];
144
+ const moved = { offsets: null, changed: true, path };
113
145
  if (first === undefined)
114
- return { ...nowhere, changed: true, path };
115
- const moved = { comment, offsets: null, changed: true, path };
146
+ return { ...moved, unit: null, newLine: null };
116
147
  const hunks = onPath.filter((u) => u.kind === "hunk");
117
148
  if (old?.kind !== "hunk" || hunks.length === 0)
118
149
  return { ...moved, unit: first.id, newLine: null };
119
- const row = firstRow(comment, old);
150
+ const row = at.changed ? rowAt(old, oldUnits, at.newLine) : pinned.row(old);
120
151
  const oldLine = tipLines(old, oldUnits)[row] ?? tipStartOf(old, oldUnits);
121
152
  const nearest = hunks.reduce((a, b) => distance(b, newUnits, oldLine) < distance(a, newUnits, oldLine) ? b : a);
122
153
  return {
123
154
  ...moved,
124
155
  unit: nearest.id,
125
- newLine: whole ? null : landing(nearest, newUnits, oldLine, old.body[row]?.text),
156
+ newLine: pinned.whole ? null : landing(nearest, newUnits, oldLine, old.body[row]?.text),
126
157
  };
127
158
  }
159
+ /**
160
+ * Pure. Places each comment of the previous version on the new diff: on its own unit when that
161
+ * hunk is unchanged, else on the hunk of the same path nearest its old tip line.
162
+ */
163
+ export function carryComments(comments, oldUnits, newUnits) {
164
+ return comments.map((comment) => ({
165
+ comment,
166
+ ...move(placedOwn(comment), pinnedComment(comment), oldUnits, newUnits),
167
+ }));
168
+ }
169
+ /** since stays once set; a first change records the version the code still matched. */
170
+ function sinceOf(before, after, since, at) {
171
+ return since ?? (after.changed && !before.changed ? at : null);
172
+ }
173
+ /**
174
+ * Pure. What an open review carries from version `at` into the next: what it carried already,
175
+ * moved again, then the comments said on `at`. Nothing is dropped, so a comment of v1 is still
176
+ * there after the second swap.
177
+ */
178
+ export function carrySession(carried, said, at, oldUnits, newUnits) {
179
+ const again = carried.map((c) => {
180
+ const moved = move(c, pinnedComment(c.comment), oldUnits, newUnits);
181
+ return { ...c, ...moved, since: sinceOf(c, moved, c.since, at) };
182
+ });
183
+ const fresh = said.map((comment) => {
184
+ const own = placedOwn(comment);
185
+ const moved = move(own, pinnedComment(comment), oldUnits, newUnits);
186
+ return { comment, version: at, ...moved, since: sinceOf(own, moved, null, at) };
187
+ });
188
+ return [...again, ...fresh];
189
+ }
190
+ /**
191
+ * Pure. Kept concerns of version `at` that the next plan no longer has, plus those carried before,
192
+ * placed on the new diff. A concern the next plan has again keeps its decision there instead, so
193
+ * it is left out here.
194
+ */
195
+ export function carryConcerns(carried, kept, inNext, at, oldUnits, newUnits) {
196
+ const fresh = kept.map((k) => {
197
+ const unit = k.concern.unit ?? null;
198
+ const path = unit === null ? null : (oldUnits.get(unit)?.path ?? null);
199
+ return { ...k, unit, offsets: null, changed: false, path, newLine: null, since: null };
200
+ });
201
+ return [...carried, ...fresh]
202
+ .filter((c) => !inNext.has(c.concern.id))
203
+ .map((c) => {
204
+ const moved = move(c, pinnedConcern(c.concern), oldUnits, newUnits);
205
+ return { ...c, ...moved, since: sinceOf(c, moved, c.since, at) };
206
+ });
207
+ }
208
+ /**
209
+ * Pure. Where a carried hunk comment points on the diff it was carried to, as an anchor of that
210
+ * diff: its own rows when the hunk is unchanged, the row at its new line when it changed, the
211
+ * whole hunk when it has no line. Null when the code is gone or the comment is not on a hunk.
212
+ */
213
+ export function anchorOn(carried, units, commit) {
214
+ const { anchor } = carried.comment;
215
+ if (anchor.kind !== "hunk" || carried.unit === null)
216
+ return null;
217
+ if (!carried.changed)
218
+ return { ...anchor, commit };
219
+ const hunk = units.get(carried.unit);
220
+ if (hunk?.kind !== "hunk" || carried.newLine === null) {
221
+ return { kind: "hunk", unit: carried.unit, commit, changes: null };
222
+ }
223
+ const row = rowAt(hunk, units, carried.newLine);
224
+ const at = hunk.body[row];
225
+ const changes = at === undefined || at.op === " " ? [] : [at.change];
226
+ return { kind: "hunk", unit: carried.unit, commit, changes, offsets: [row] };
227
+ }
228
+ /** Pure. A carried concern as a concern of the diff it was carried to; see anchorOn. */
229
+ export function concernOn(carried, units) {
230
+ const { concern } = carried;
231
+ if (carried.unit === null)
232
+ return concern;
233
+ if (!carried.changed)
234
+ return { ...concern, unit: carried.unit };
235
+ const { lines: _old, ...rest } = concern;
236
+ const hunk = units.get(carried.unit);
237
+ if (hunk?.kind !== "hunk" || carried.newLine === null)
238
+ return { ...rest, unit: carried.unit };
239
+ const at = hunk.body[rowAt(hunk, units, carried.newLine)];
240
+ return at === undefined || at.op === " "
241
+ ? { ...rest, unit: carried.unit }
242
+ : { ...rest, unit: carried.unit, lines: [at.change] };
243
+ }