@shiyuphan/follow-me 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/cli.js +202 -34
- package/dist/src/comments.js +49 -7
- package/dist/src/diff.js +11 -0
- package/dist/src/evolve.js +123 -0
- package/dist/src/host.js +24 -4
- package/dist/src/prompt.js +43 -14
- package/dist/src/server.js +91 -72
- package/dist/src/session.js +40 -1
- package/dist/ui/dist/assets/{abnfDiagram-WEYOIA3C-DcmzRkVZ.js → abnfDiagram-WEYOIA3C-byISxePP.js} +1 -1
- package/dist/ui/dist/assets/{arc-T5QB0T1C.js → arc-CBzsAsP2.js} +1 -1
- package/dist/ui/dist/assets/{architecture-WOLXFQ4H-DUIkQTeE.js → architecture-WOLXFQ4H-0OSkIwdB.js} +1 -1
- package/dist/ui/dist/assets/{architectureDiagram-ZHVYKT53-BUYi4-bA.js → architectureDiagram-ZHVYKT53-ixHybNqO.js} +1 -1
- package/dist/ui/dist/assets/{blockDiagram-RJAE7QA3-dyJxtT9e.js → blockDiagram-RJAE7QA3-1F8XL_Ou.js} +1 -1
- package/dist/ui/dist/assets/{c4Diagram-XDPPPE54-CrAgiaZR.js → c4Diagram-XDPPPE54-D9PFBXnc.js} +1 -1
- package/dist/ui/dist/assets/channel-B-fwsHf1.js +1 -0
- package/dist/ui/dist/assets/{chunk-2E7WHVMD-C6fAf4Vj.js → chunk-2E7WHVMD-zZdNfMkj.js} +1 -1
- package/dist/ui/dist/assets/{chunk-4EMNKR2T-BLZjwZ4Q.js → chunk-4EMNKR2T-CTKoxSSj.js} +1 -1
- package/dist/ui/dist/assets/{chunk-55XCU76K-DGLK2Q-L.js → chunk-55XCU76K-ifFnDlcS.js} +1 -1
- package/dist/ui/dist/assets/{chunk-7S6XSTFO-BablszTX.js → chunk-7S6XSTFO-BCMVdi_1.js} +1 -1
- package/dist/ui/dist/assets/{chunk-AQDTSKZB-Cuk3B_vb.js → chunk-AQDTSKZB-DC-5Whju.js} +1 -1
- package/dist/ui/dist/assets/{chunk-DSNLSHJZ-DiC78RqE.js → chunk-DSNLSHJZ-DASbe_gb.js} +1 -1
- package/dist/ui/dist/assets/{chunk-HAGFOTED-X_RbvixX.js → chunk-HAGFOTED-CArytDjj.js} +1 -1
- package/dist/ui/dist/assets/{chunk-HNE4Q36P-C14_x5ck.js → chunk-HNE4Q36P-Dnl-Wtud.js} +1 -1
- package/dist/ui/dist/assets/{chunk-NQOKRVIF-Dxd0dtYR.js → chunk-NQOKRVIF-wvSLaeaK.js} +1 -1
- package/dist/ui/dist/assets/{chunk-UHW7PFBP-BIxePNUS.js → chunk-UHW7PFBP-CckaOGiQ.js} +1 -1
- package/dist/ui/dist/assets/{chunk-VQ5W62QI-CKSKLbj4.js → chunk-VQ5W62QI-kQBMDJ7V.js} +1 -1
- package/dist/ui/dist/assets/{chunk-WQQL4CDK-CWNmZGlR.js → chunk-WQQL4CDK-CMg6bbLU.js} +1 -1
- package/dist/ui/dist/assets/{chunk-XXDRQBXY-D-I5QHgG.js → chunk-XXDRQBXY-CYlFr19K.js} +1 -1
- package/dist/ui/dist/assets/{chunk-YZO67HFG-DFP68CFd.js → chunk-YZO67HFG-CqkG1vVk.js} +1 -1
- package/dist/ui/dist/assets/{chunk-ZAUX2LTX-DY90d4QG.js → chunk-ZAUX2LTX-BUNEdx-e.js} +1 -1
- package/dist/ui/dist/assets/{chunk-ZCHMQSOR-Dlpr-5UC.js → chunk-ZCHMQSOR-CFTbvcrm.js} +1 -1
- package/dist/ui/dist/assets/{classDiagram-v2-4MYWR3UA-CH9uhoLJ.js → classDiagram-v2-4MYWR3UA-t2OdJ0LA.js} +1 -1
- package/dist/ui/dist/assets/{cose-bilkent-JH36ORCC-DtYHrp8D.js → cose-bilkent-JH36ORCC-FGm030gc.js} +1 -1
- package/dist/ui/dist/assets/{cynefin-EF2NZ3EQ-BtslbtrY.js → cynefin-EF2NZ3EQ-BtU33Lsu.js} +1 -1
- package/dist/ui/dist/assets/{cynefinDiagram-WF24UDRP-DUU1Jcc7.js → cynefinDiagram-WF24UDRP-DUr-I7hS.js} +1 -1
- package/dist/ui/dist/assets/{dagre-NK4GIZVX-CDlP-dRW.js → dagre-NK4GIZVX-BWTHwJKt.js} +1 -1
- package/dist/ui/dist/assets/{diagram-CBK3MWTC-DiV2fn-r.js → diagram-CBK3MWTC-PcHrsEBn.js} +1 -1
- package/dist/ui/dist/assets/{diagram-I5XIIV2D-Bg5RehGi.js → diagram-I5XIIV2D-CkGtKjYj.js} +1 -1
- package/dist/ui/dist/assets/{diagram-ODIL2UCW-CFmazz3f.js → diagram-ODIL2UCW-B_HdBB1w.js} +1 -1
- package/dist/ui/dist/assets/{diagram-PFBEJUXE-uw3ZBqDp.js → diagram-PFBEJUXE-DL9zayE6.js} +1 -1
- package/dist/ui/dist/assets/{diagram-RJA3I4VV-uEOYYRYa.js → diagram-RJA3I4VV-By5wl8nK.js} +1 -1
- package/dist/ui/dist/assets/{diagram-TYUNFSNR-BN58LF_3.js → diagram-TYUNFSNR-ziWPVayL.js} +1 -1
- package/dist/ui/dist/assets/{dist-tWS_lXDS.js → dist-NDVqCM7k.js} +1 -1
- package/dist/ui/dist/assets/{ebnfDiagram-V4P3TTPT-B68Tz-w8.js → ebnfDiagram-V4P3TTPT-DH4YGkPc.js} +1 -1
- package/dist/ui/dist/assets/{elk-QONTBCTQ-DZZohNea.js → elk-QONTBCTQ-BL2RlCEv.js} +1 -1
- package/dist/ui/dist/assets/{erDiagram-KYQIGQAA-BH_m74pv.js → erDiagram-KYQIGQAA-BKbTMaRp.js} +1 -1
- package/dist/ui/dist/assets/{eventmodeling-K75KTNOO-CTp5FHos.js → eventmodeling-K75KTNOO-Dk5RCWc-.js} +1 -1
- package/dist/ui/dist/assets/flowDiagram-IUGLLWBU-Dkjb3PCK.js +1 -0
- package/dist/ui/dist/assets/{ganttDiagram-R7GPNTJY-BDau7_0f.js → ganttDiagram-R7GPNTJY-3vM9gxeT.js} +1 -1
- package/dist/ui/dist/assets/{gitGraph-VSP46ZUC-Bv84IoCm.js → gitGraph-VSP46ZUC-CXGFhz54.js} +1 -1
- package/dist/ui/dist/assets/{gitGraphDiagram-PDYL7XHY-DzXcMlm8.js → gitGraphDiagram-PDYL7XHY-Cl4fwUuA.js} +1 -1
- package/dist/ui/dist/assets/index-B8TYcxF8.css +1 -0
- package/dist/ui/dist/assets/index-CBbx_pJk.js +134 -0
- package/dist/ui/dist/assets/{info-OHQRW6UA-222J0O_j.js → info-OHQRW6UA-DfSsZ8ib.js} +1 -1
- package/dist/ui/dist/assets/{infoDiagram-MVDRKAX5-DE2sa3Vz.js → infoDiagram-MVDRKAX5-B_7tyrL5.js} +1 -1
- package/dist/ui/dist/assets/{ishikawaDiagram-HPEHV2GE-BJDk0Zcj.js → ishikawaDiagram-HPEHV2GE-DUvHljpg.js} +1 -1
- package/dist/ui/dist/assets/{journeyDiagram-BOSHW7O7-JSUXX5Hb.js → journeyDiagram-BOSHW7O7-BCgqzrb8.js} +1 -1
- package/dist/ui/dist/assets/{kanban-definition-FNSA4HC5-Bg79b49M.js → kanban-definition-FNSA4HC5-DOzZ3NmE.js} +1 -1
- package/dist/ui/dist/assets/{line-Bk5qQSIa.js → line-B-mPFVll.js} +1 -1
- package/dist/ui/dist/assets/{linear-PFnVMru5.js → linear-k7o0jout.js} +1 -1
- package/dist/ui/dist/assets/{mermaid-parser.core-Dcxlzc-S.js → mermaid-parser.core-CAyLSQpH.js} +3 -3
- package/dist/ui/dist/assets/{mermaid.core-TeOHQJ00.js → mermaid.core-vfVMjgx2.js} +4 -4
- package/dist/ui/dist/assets/{mindmap-definition-6OY57UG3-IaNk8NnW.js → mindmap-definition-6OY57UG3-B6y-B_r4.js} +1 -1
- package/dist/ui/dist/assets/{packet-JDAUHWVQ-DMff5cVK.js → packet-JDAUHWVQ-C_r4ed30.js} +1 -1
- package/dist/ui/dist/assets/{pegDiagram-ECIUJNBX-66Xu4YRc.js → pegDiagram-ECIUJNBX-B8VUm3Bh.js} +1 -1
- package/dist/ui/dist/assets/{pie-XZMESJXO-GEtzLCIM.js → pie-XZMESJXO-DcwtbH9y.js} +1 -1
- package/dist/ui/dist/assets/{pieDiagram-GNMPY74T-7jMBi-kU.js → pieDiagram-GNMPY74T-CvUNBi2F.js} +1 -1
- package/dist/ui/dist/assets/{prism-core-FDHlWQ43.js → prism-core-_2ju85Ya.js} +1 -1
- package/dist/ui/dist/assets/{quadrantDiagram-D57K4MC3-8hIWayC2.js → quadrantDiagram-D57K4MC3-Dg2eVS3j.js} +1 -1
- package/dist/ui/dist/assets/{radar-ABXABTNO-BTdwUOOF.js → radar-ABXABTNO-xCNTcZE9.js} +1 -1
- package/dist/ui/dist/assets/{railroad-I3PHUGI6-DcMbDqBL.js → railroad-I3PHUGI6-BI-sIL2Q.js} +1 -1
- package/dist/ui/dist/assets/{railroad-abnf-LNEFI6M7-gTzUTWeI.js → railroad-abnf-LNEFI6M7-Ddvk40Wx.js} +1 -1
- package/dist/ui/dist/assets/{railroad-ebnf-DWYD2UWJ-CNtdUPtk.js → railroad-ebnf-DWYD2UWJ-h35gE183.js} +1 -1
- package/dist/ui/dist/assets/{railroad-peg-JR7BVNR7-DOiExAtE.js → railroad-peg-JR7BVNR7-Zim9iPdp.js} +1 -1
- package/dist/ui/dist/assets/{railroadDiagram-Y72NRBL7-2kE_ygvM.js → railroadDiagram-Y72NRBL7-Cz6fdmOQ.js} +1 -1
- package/dist/ui/dist/assets/{requirementDiagram-EQYSW5RA-CoIAHRq2.js → requirementDiagram-EQYSW5RA-DTJkCrcD.js} +1 -1
- package/dist/ui/dist/assets/{sankeyDiagram-SY24JHO5-7p8D3wim.js → sankeyDiagram-SY24JHO5-Bg1iiVla.js} +1 -1
- package/dist/ui/dist/assets/{sequenceDiagram-BLEHNHQU-C6QFnMvn.js → sequenceDiagram-BLEHNHQU-9XfetsRA.js} +1 -1
- package/dist/ui/dist/assets/{src-FgijoBVs.js → src-Dym5afQ5.js} +1 -1
- package/dist/ui/dist/assets/{stateDiagram-v2-A6IESKWZ-n9eAb_bT.js → stateDiagram-v2-A6IESKWZ-CMmcMV8z.js} +1 -1
- package/dist/ui/dist/assets/{swimlanes-B3ENRKFQ-U18mtUzX.js → swimlanes-B3ENRKFQ-B2PcshTY.js} +1 -1
- package/dist/ui/dist/assets/swimlanesDiagram-AYQRV4AK-DfB3XTTn.js +8 -0
- package/dist/ui/dist/assets/{timeline-definition-JXYKGLGB-CD2Z8QGP.js → timeline-definition-JXYKGLGB-Cr1hCNtW.js} +1 -1
- package/dist/ui/dist/assets/{treeView-D4JQ5SDB-sqUjH5gB.js → treeView-D4JQ5SDB-jH5ksKnh.js} +1 -1
- package/dist/ui/dist/assets/{treemap-SAJKECNS-DvZ38FP7.js → treemap-SAJKECNS-BV90e8QL.js} +1 -1
- package/dist/ui/dist/assets/{usecaseDiagram-WPOLBNNX-CqOGidaZ.js → usecaseDiagram-WPOLBNNX-BNwq5aJV.js} +1 -1
- package/dist/ui/dist/assets/{vennDiagram-Q3Y3IDCW-D1SXSBQ5.js → vennDiagram-Q3Y3IDCW-D-lLyZCL.js} +1 -1
- package/dist/ui/dist/assets/{wardley-7MLQ67FV-Bpwio-Dx.js → wardley-7MLQ67FV-CWfHQUGt.js} +1 -1
- package/dist/ui/dist/assets/{wardleyDiagram-IVAIDKWA-JSUXHMnk.js → wardleyDiagram-IVAIDKWA-BL_qNKVd.js} +1 -1
- package/dist/ui/dist/assets/{xychartDiagram-4URNHDU4-B4IFqQTN.js → xychartDiagram-4URNHDU4-BHPK5Snp.js} +1 -1
- package/dist/ui/dist/index.html +2 -2
- package/package.json +1 -1
- package/skills/follow-me/SKILL.md +8 -4
- package/dist/ui/dist/assets/channel-wW7ebm0i.js +0 -1
- package/dist/ui/dist/assets/flowDiagram-IUGLLWBU-CsCvexry.js +0 -1
- package/dist/ui/dist/assets/index-BTJoqVVN.js +0 -133
- package/dist/ui/dist/assets/index-D60U3smc.css +0 -1
- package/dist/ui/dist/assets/swimlanesDiagram-AYQRV4AK-DX07QVy9.js +0 -8
package/dist/src/cli.js
CHANGED
|
@@ -5,13 +5,13 @@ import { dirname, join, relative } from "node:path";
|
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import { DiffParseError, formatUnitRef, isUnitId } from "./diff.js";
|
|
7
7
|
import { BranchRefusedError, branchHead, GitError, isOwnWork, signing, treeOf, writeBranch, } from "./git.js";
|
|
8
|
-
import { fetchReport, findServer, followEvents, idleMs, listSessions, openBrowser, pollEvents, portFallbackMessage, postComment, postReply, probeHealth, registerWith, sessionUrl, spawnDetached, startOrAttach, stateDir, stillHosted, stopServer, swapVersion, } from "./host.js";
|
|
8
|
+
import { fetchCarried, fetchReport, findServer, followEvents, idleMs, listSessions, openBrowser, pollEvents, portFallbackMessage, postComment, postReply, probeHealth, readServerRecord, registerWith, sessionUrl, spawnDetached, startOrAttach, stateDir, stillHosted, stopServer, swapVersion, } from "./host.js";
|
|
9
9
|
import { runInstall, runUninstall, syncSkills } from "./install.js";
|
|
10
10
|
import { naiveDraft } from "./naive.js";
|
|
11
|
-
import { serializePlanFile, validatePlan } from "./plan.js";
|
|
11
|
+
import { isFields, serializePlanFile, validatePlan } from "./plan.js";
|
|
12
12
|
import { renderEvents, renderPrompt, renderReport, reportItems, } from "./prompt.js";
|
|
13
13
|
import { RegistrationError, sessionIdOf } from "./server.js";
|
|
14
|
-
import { BranchMovedError, cleanSessions, extractSession, MissingDraftError, NotARepositoryError, openSession, SessionFileError, SessionLockedError, } from "./session.js";
|
|
14
|
+
import { BranchMovedError, cleanSessions, extractSession, MissingDraftError, NotARepositoryError, openSession, SessionFileError, SessionLockedError, sinceLastPlan, } from "./session.js";
|
|
15
15
|
import { checkSyntax } from "./syntax.js";
|
|
16
16
|
class UsageError extends Error {
|
|
17
17
|
name = "UsageError";
|
|
@@ -20,8 +20,9 @@ const SHORT_USAGE = `usage: follow-me [--wait <seconds>] [--branch <name>]
|
|
|
20
20
|
|
|
21
21
|
Run follow-me with no command. Each call does the next thing that is missing and returns: it
|
|
22
22
|
writes the prompt for you to answer, reports what is wrong with your draft, opens the review for
|
|
23
|
-
the user, then listens to them for up to --wait seconds (default
|
|
24
|
-
they flagged or asked.
|
|
23
|
+
the user, then listens to them for up to --wait seconds (default 90, at most 300, 0 for none) and
|
|
24
|
+
prints what they flagged or asked. It is interactive: do what its "next:" line says and run it
|
|
25
|
+
again until it prints the report.
|
|
25
26
|
|
|
26
27
|
Other commands:
|
|
27
28
|
reply <commentId> --body <text> [--diagram <file>]
|
|
@@ -411,6 +412,7 @@ async function apply(cwd, args) {
|
|
|
411
412
|
* write runs under it, and the page is then swapped to the new plan version in place.
|
|
412
413
|
*/
|
|
413
414
|
async function applyHosted(session, args, open) {
|
|
415
|
+
rememberPort(session, open.port);
|
|
414
416
|
const code = await applyLocked(session, args);
|
|
415
417
|
const latest = session.plans().at(-1);
|
|
416
418
|
if (code !== 0 || latest === undefined)
|
|
@@ -482,11 +484,12 @@ async function applyLocked(session, args) {
|
|
|
482
484
|
say(`proof: ${result.branch}^{tree} ${tree} ${same ? "equals" : "DIFFERS FROM"} ${session.anchor.branch}^{tree} ${session.anchor.tipTree}`);
|
|
483
485
|
return same ? 0 : 1;
|
|
484
486
|
}
|
|
485
|
-
function promptInput(session, mode, args) {
|
|
487
|
+
function promptInput(session, mode, args, changes) {
|
|
486
488
|
const common = {
|
|
487
489
|
diff: session.diff,
|
|
488
490
|
draftPath: session.draftPath,
|
|
489
491
|
checkCommand: `follow-me check${branchFlag(args)}`,
|
|
492
|
+
...(changes === undefined ? {} : { changes }),
|
|
490
493
|
};
|
|
491
494
|
if (mode === "walk")
|
|
492
495
|
return { ...common, mode };
|
|
@@ -564,6 +567,7 @@ async function hostReview(session, mode, args, prepare, report) {
|
|
|
564
567
|
const home = stateDir();
|
|
565
568
|
const open = await openOnServer(session, mode);
|
|
566
569
|
if (open !== null) {
|
|
570
|
+
rememberPort(session, open.port);
|
|
567
571
|
announce(sessionUrl(open.port, open.summary), args);
|
|
568
572
|
return { kind: "elsewhere", ...open };
|
|
569
573
|
}
|
|
@@ -578,7 +582,7 @@ async function hostReview(session, mode, args, prepare, report) {
|
|
|
578
582
|
hash: prepared.plan.hash,
|
|
579
583
|
lockToken: held.token,
|
|
580
584
|
};
|
|
581
|
-
const port = portOf(args);
|
|
585
|
+
const port = portOf(args) ?? rememberedPort(session);
|
|
582
586
|
let started;
|
|
583
587
|
const running = await runningServer(home, port);
|
|
584
588
|
if (running !== null)
|
|
@@ -591,6 +595,7 @@ async function hostReview(session, mode, args, prepare, report) {
|
|
|
591
595
|
}
|
|
592
596
|
else
|
|
593
597
|
started = await startOrAttach(home, port, say);
|
|
598
|
+
rememberPort(session, started.kind === "attached" ? started.port : started.host.port);
|
|
594
599
|
if (started.kind === "attached") {
|
|
595
600
|
const summary = await registerWith(started.port, registration);
|
|
596
601
|
announce(sessionUrl(started.port, summary), args);
|
|
@@ -631,7 +636,52 @@ async function openOnServer(session, mode) {
|
|
|
631
636
|
return null;
|
|
632
637
|
const id = sessionIdOf(session.dir);
|
|
633
638
|
const summary = (await listSessions(live.port)).find((s) => s.id === id && (mode === null || s.mode === mode));
|
|
634
|
-
|
|
639
|
+
if (summary === undefined)
|
|
640
|
+
return null;
|
|
641
|
+
rememberPort(session, live.port);
|
|
642
|
+
return { port: live.port, summary };
|
|
643
|
+
}
|
|
644
|
+
/**
|
|
645
|
+
* The session's review after its server died: the lock still names the dead server. A fresh
|
|
646
|
+
* server takes the port the old one had, so the open page's origin stays valid and it reconnects;
|
|
647
|
+
* the session registers again from disk, where the page autosaved everything. Null when the
|
|
648
|
+
* session was not hosted by a dead server or its review already ended.
|
|
649
|
+
*/
|
|
650
|
+
async function revive(session) {
|
|
651
|
+
const home = stateDir();
|
|
652
|
+
const record = readServerRecord(home);
|
|
653
|
+
const lock = session.lockState();
|
|
654
|
+
if (record === null || lock.kind !== "stale" || lock.pid !== record.pid)
|
|
655
|
+
return null;
|
|
656
|
+
const latest = session.plans().at(-1);
|
|
657
|
+
if (latest === undefined || reviewsOf(session).some((r) => r.version === latest.version)) {
|
|
658
|
+
return null;
|
|
659
|
+
}
|
|
660
|
+
const server = (await findServer(home)) ?? (await spawnDetached(home, record.port));
|
|
661
|
+
const registration = {
|
|
662
|
+
dir: session.dir,
|
|
663
|
+
mode: reviewMode(session),
|
|
664
|
+
version: latest.version,
|
|
665
|
+
hash: latest.hash,
|
|
666
|
+
lockToken: null,
|
|
667
|
+
};
|
|
668
|
+
let summary;
|
|
669
|
+
try {
|
|
670
|
+
summary = await registerWith(server.port, registration);
|
|
671
|
+
}
|
|
672
|
+
catch (error) {
|
|
673
|
+
if (error instanceof RegistrationError)
|
|
674
|
+
return null;
|
|
675
|
+
throw error;
|
|
676
|
+
}
|
|
677
|
+
rememberPort(session, server.port);
|
|
678
|
+
say("review server restarted, nothing lost");
|
|
679
|
+
if (server.port !== record.port) {
|
|
680
|
+
say(portFallbackMessage(record.port, server.port));
|
|
681
|
+
say(`follow-me review: ${sessionUrl(server.port, summary)}`);
|
|
682
|
+
say("the open page cannot follow it to the new port; reopen it at that URL");
|
|
683
|
+
}
|
|
684
|
+
return { port: server.port, summary };
|
|
635
685
|
}
|
|
636
686
|
function timeoutOf(args) {
|
|
637
687
|
const raw = args.values.get("timeout");
|
|
@@ -767,6 +817,7 @@ async function split(cwd, args) {
|
|
|
767
817
|
say(headline(session));
|
|
768
818
|
const open = await openOnServer(session, "split");
|
|
769
819
|
if (open !== null) {
|
|
820
|
+
rememberPort(session, open.port);
|
|
770
821
|
announce(sessionUrl(open.port, open.summary), args);
|
|
771
822
|
next("end the review on the page, then follow-me report");
|
|
772
823
|
return 0;
|
|
@@ -844,6 +895,76 @@ function finished(session, stored) {
|
|
|
844
895
|
next("done; the user can port it with follow-me report --json");
|
|
845
896
|
return 0;
|
|
846
897
|
}
|
|
898
|
+
/** Whether draft.json was written for the branch's current tip; a missing draft was not. */
|
|
899
|
+
function draftForTip(session) {
|
|
900
|
+
try {
|
|
901
|
+
const raw = session.readDraft();
|
|
902
|
+
return (typeof raw === "object" && raw !== null && "tip" in raw && raw.tip === session.anchor.tip);
|
|
903
|
+
}
|
|
904
|
+
catch (error) {
|
|
905
|
+
if (error instanceof MissingDraftError)
|
|
906
|
+
return false;
|
|
907
|
+
throw error;
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
/**
|
|
911
|
+
* The branch moved after the latest plan. The first call writes that plan carried over to the new
|
|
912
|
+
* tip as draft.json; while the draft does not validate, each call writes the prompt with what
|
|
913
|
+
* changed and hands off. Null when the draft is ready, so the entry applies it as usual.
|
|
914
|
+
*/
|
|
915
|
+
function evolveHandoff(session, cwd, mode, args, moved, hosted) {
|
|
916
|
+
if (!draftForTip(session))
|
|
917
|
+
session.writeDraft(serializePlanFile(moved.draft));
|
|
918
|
+
if (draftState(session).kind === "valid")
|
|
919
|
+
return null;
|
|
920
|
+
const { since, vanished, added } = moved;
|
|
921
|
+
session.writePrompt(renderPrompt(promptInput(session, mode, args, { since, vanished, added })));
|
|
922
|
+
say(`wrote ${relative(cwd, session.promptPath)}`);
|
|
923
|
+
const step = `assign the new units in ${relative(cwd, session.draftPath)}, then run follow-me again`;
|
|
924
|
+
next(hosted ? `${step}; ${STILL_WORKING}` : step);
|
|
925
|
+
return 0;
|
|
926
|
+
}
|
|
927
|
+
const STILL_WORKING = "the reviewer is still working";
|
|
928
|
+
/**
|
|
929
|
+
* Applies the draft carried over to the moved tip under the server's lock (only freezes it for a
|
|
930
|
+
* reviewer's walk) and moves the open page onto it, threads and all.
|
|
931
|
+
*/
|
|
932
|
+
async function swapOpenPage(session, open, args) {
|
|
933
|
+
const state = draftState(session);
|
|
934
|
+
if (state.kind !== "valid")
|
|
935
|
+
return 1;
|
|
936
|
+
const syntax = await checkSyntax(state.prepared, syntaxOverride(args));
|
|
937
|
+
if (syntax.issues.length > 0) {
|
|
938
|
+
for (const line of formatSyntax(syntax))
|
|
939
|
+
say(line);
|
|
940
|
+
next(`fix the draft, then run follow-me again; ${STILL_WORKING}`);
|
|
941
|
+
return 1;
|
|
942
|
+
}
|
|
943
|
+
if (open.summary.mode === "split") {
|
|
944
|
+
const code = await applyLocked(session, args);
|
|
945
|
+
if (code !== 0) {
|
|
946
|
+
next(`fix the draft, then run follow-me again; ${STILL_WORKING}`);
|
|
947
|
+
return code;
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
else
|
|
951
|
+
session.freeze(state.prepared);
|
|
952
|
+
const latest = session.plans().at(-1);
|
|
953
|
+
if (latest === undefined)
|
|
954
|
+
return 1;
|
|
955
|
+
const swapped = latest.version === open.summary.version
|
|
956
|
+
? open.summary
|
|
957
|
+
: await swapVersion(open.port, open.summary, latest.version, latest.hash);
|
|
958
|
+
const carried = await fetchCarried(open.port, swapped);
|
|
959
|
+
const changed = carried.filter((c) => c.changed);
|
|
960
|
+
say(`swapped the open page to plan v${latest.version}; carried ${plural(carried.length, "comment")}, ${changed.length} changed`);
|
|
961
|
+
for (const { comment, path, newLine } of changed) {
|
|
962
|
+
const where = path === null ? "no file" : newLine === null ? path : `${path}:${newLine}`;
|
|
963
|
+
say(` ${comment.id} now on ${where}`);
|
|
964
|
+
}
|
|
965
|
+
next(`${STILL_WORKING}; reply on each changed thread with follow-me reply <id> --body "<what changed and where>", then run follow-me again`);
|
|
966
|
+
return 0;
|
|
967
|
+
}
|
|
847
968
|
/** Writes the prompt for the agent and says where; used whenever there is no plan to review. */
|
|
848
969
|
function promptHandoff(session, cwd, mode, args) {
|
|
849
970
|
session.writePrompt(renderPrompt(promptInput(session, mode, args)));
|
|
@@ -856,25 +977,31 @@ function promptHandoff(session, cwd, mode, args) {
|
|
|
856
977
|
function cursorPath(session) {
|
|
857
978
|
return join(session.dir, "cursor.json");
|
|
858
979
|
}
|
|
859
|
-
|
|
980
|
+
function logOf(summary) {
|
|
981
|
+
return `${summary.id}@${summary.openedAt}`;
|
|
982
|
+
}
|
|
860
983
|
function readCursor(session, summary) {
|
|
861
984
|
let raw;
|
|
862
985
|
try {
|
|
863
986
|
raw = JSON.parse(readFileSync(cursorPath(session), "utf8"));
|
|
864
987
|
}
|
|
865
988
|
catch {
|
|
866
|
-
|
|
989
|
+
raw = null;
|
|
867
990
|
}
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
const
|
|
871
|
-
|
|
872
|
-
const same = seen === `${summary.id}@${summary.openedAt}`;
|
|
873
|
-
return same && typeof seq === "number" && Number.isInteger(seq) && seq >= 0 ? seq : 0;
|
|
991
|
+
const { session: log, seq, seen: ids } = isFields(raw) ? raw : {};
|
|
992
|
+
const seen = new Set(Array.isArray(ids) ? ids.filter((id) => typeof id === "string") : []);
|
|
993
|
+
const counts = log === logOf(summary) && typeof seq === "number" && Number.isInteger(seq) && seq >= 0;
|
|
994
|
+
return { session: logOf(summary), seq: counts ? seq : 0, seen };
|
|
874
995
|
}
|
|
875
|
-
function writeCursor(session,
|
|
876
|
-
const
|
|
877
|
-
writeFileSync(cursorPath(session), `${JSON.stringify(
|
|
996
|
+
function writeCursor(session, cursor) {
|
|
997
|
+
const { session: log, seq, seen } = cursor;
|
|
998
|
+
writeFileSync(cursorPath(session), `${JSON.stringify({ session: log, seq, seen: [...seen] })}\n`);
|
|
999
|
+
}
|
|
1000
|
+
/** The comment or reply id an event first carries, or null for events without one. */
|
|
1001
|
+
function saidId(event) {
|
|
1002
|
+
if (event.type === "comment")
|
|
1003
|
+
return event.comment.id;
|
|
1004
|
+
return event.type === "reply" ? event.reply.id : null;
|
|
878
1005
|
}
|
|
879
1006
|
function waitOf(args) {
|
|
880
1007
|
const raw = args.values.get("wait");
|
|
@@ -884,9 +1011,11 @@ function waitOf(args) {
|
|
|
884
1011
|
if (!Number.isFinite(seconds) || seconds < 0) {
|
|
885
1012
|
throw new UsageError(`--wait ${raw} is not a number of seconds`);
|
|
886
1013
|
}
|
|
887
|
-
return seconds;
|
|
1014
|
+
return Math.min(seconds, MAX_ENTRY_WAIT_SECONDS);
|
|
888
1015
|
}
|
|
889
|
-
|
|
1016
|
+
/** One call fits inside a harness's tool timeout, so the agent's loop is one command per turn. */
|
|
1017
|
+
const ENTRY_WAIT_SECONDS = 90;
|
|
1018
|
+
const MAX_ENTRY_WAIT_SECONDS = 300;
|
|
890
1019
|
/**
|
|
891
1020
|
* Listens to the hosted review for up to --wait seconds and prints what the reviewer did. It
|
|
892
1021
|
* returns at once with news, and with the report when the reviewer submitted or ended the review.
|
|
@@ -895,28 +1024,38 @@ async function listen(session, open, args) {
|
|
|
895
1024
|
const { port, summary } = open;
|
|
896
1025
|
const waitMs = waitOf(args) * 1000;
|
|
897
1026
|
const deadline = Date.now() + waitMs;
|
|
898
|
-
let
|
|
1027
|
+
let cursor = readCursor(session, summary);
|
|
899
1028
|
let version = summary.version;
|
|
900
1029
|
for (;;) {
|
|
901
1030
|
const left = Math.max(0, deadline - Date.now());
|
|
902
1031
|
let batch;
|
|
903
1032
|
try {
|
|
904
|
-
batch = await pollEvents(port, summary,
|
|
1033
|
+
batch = await pollEvents(port, summary, cursor.seq, Math.min(left, POLL_MS));
|
|
905
1034
|
}
|
|
906
1035
|
catch (error) {
|
|
907
1036
|
if (!(error instanceof RegistrationError))
|
|
908
1037
|
throw error;
|
|
909
1038
|
// The log restarted under us (a new server); read it from the start.
|
|
910
|
-
|
|
911
|
-
writeCursor(session,
|
|
1039
|
+
cursor = { ...cursor, seq: 0 };
|
|
1040
|
+
writeCursor(session, cursor);
|
|
912
1041
|
continue;
|
|
913
1042
|
}
|
|
914
|
-
|
|
915
|
-
|
|
1043
|
+
const fresh = batch.events.filter((e) => {
|
|
1044
|
+
const id = saidId(e);
|
|
1045
|
+
return id === null || !cursor.seen.has(id);
|
|
1046
|
+
});
|
|
1047
|
+
const seen = new Set(cursor.seen);
|
|
1048
|
+
for (const event of batch.events) {
|
|
1049
|
+
const id = saidId(event);
|
|
1050
|
+
if (id !== null)
|
|
1051
|
+
seen.add(id);
|
|
1052
|
+
}
|
|
1053
|
+
cursor = { ...cursor, seq: batch.seq, seen };
|
|
1054
|
+
writeCursor(session, cursor);
|
|
916
1055
|
for (const event of batch.events)
|
|
917
1056
|
if (event.type === "version")
|
|
918
1057
|
version = event.version;
|
|
919
|
-
const news =
|
|
1058
|
+
const news = fresh.filter(isNews);
|
|
920
1059
|
if (news.length > 0) {
|
|
921
1060
|
const ctx = { commits: projectedCommits(session, version), units: session.diff.units };
|
|
922
1061
|
process.stdout.write(renderEvents(news, ctx));
|
|
@@ -933,12 +1072,11 @@ async function listen(session, open, args) {
|
|
|
933
1072
|
next("done; the user can port it with follow-me report --json");
|
|
934
1073
|
return 0;
|
|
935
1074
|
}
|
|
936
|
-
next(
|
|
1075
|
+
next(`${STILL_WORKING}; answer with follow-me reply <id> --body "<markdown>" [--diagram file], then run follow-me again`);
|
|
937
1076
|
return 0;
|
|
938
1077
|
}
|
|
939
1078
|
if (left <= 0) {
|
|
940
|
-
|
|
941
|
-
next("run follow-me again to keep listening");
|
|
1079
|
+
next(`${STILL_WORKING}; run follow-me again to keep listening`);
|
|
942
1080
|
return 0;
|
|
943
1081
|
}
|
|
944
1082
|
}
|
|
@@ -951,10 +1089,18 @@ async function listen(session, open, args) {
|
|
|
951
1089
|
async function entry(cwd, args) {
|
|
952
1090
|
syncSkills(homedir(), say);
|
|
953
1091
|
const session = await openFresh(cwd, args);
|
|
954
|
-
const hosted = await openOnServer(session, null);
|
|
1092
|
+
const hosted = (await openOnServer(session, null)) ?? (await revive(session));
|
|
1093
|
+
const mode = hosted?.summary.mode ?? (await entryMode(session, args));
|
|
1094
|
+
const moved = await sinceLastPlan(session);
|
|
1095
|
+
if (moved !== null) {
|
|
1096
|
+
const handed = evolveHandoff(session, cwd, mode, args, moved, hosted !== null);
|
|
1097
|
+
if (handed !== null)
|
|
1098
|
+
return handed;
|
|
1099
|
+
if (hosted !== null)
|
|
1100
|
+
return swapOpenPage(session, hosted, args);
|
|
1101
|
+
}
|
|
955
1102
|
if (hosted !== null)
|
|
956
1103
|
return listen(session, hosted, args);
|
|
957
|
-
const mode = await entryMode(session, args);
|
|
958
1104
|
const state = draftState(session);
|
|
959
1105
|
if (state.kind === "invalid") {
|
|
960
1106
|
next("fix the draft, then run follow-me again");
|
|
@@ -999,6 +1145,7 @@ async function entry(cwd, args) {
|
|
|
999
1145
|
const result = await hostReview(session, mode, detached, async () => prep, reportSaved);
|
|
1000
1146
|
if (result.kind !== "elsewhere")
|
|
1001
1147
|
return exitCode(result);
|
|
1148
|
+
say("follow-me is interactive: keep running it until it prints the report");
|
|
1002
1149
|
return listen(session, result, args);
|
|
1003
1150
|
}
|
|
1004
1151
|
/** Opens the session, re-extracting instead of failing when the branch moved or --base changed. */
|
|
@@ -1133,7 +1280,7 @@ function notOpen(session) {
|
|
|
1133
1280
|
*/
|
|
1134
1281
|
async function watch(cwd, args) {
|
|
1135
1282
|
const session = await openSession(cwd, sessionOpts(args));
|
|
1136
|
-
const open = await openOnServer(session, null);
|
|
1283
|
+
const open = (await openOnServer(session, null)) ?? (await revive(session));
|
|
1137
1284
|
if (open === null)
|
|
1138
1285
|
return notOpen(session);
|
|
1139
1286
|
const sinceRaw = args.values.get("since");
|
|
@@ -1475,6 +1622,27 @@ function upgrade(targets, cwd) {
|
|
|
1475
1622
|
return code;
|
|
1476
1623
|
}
|
|
1477
1624
|
const HELP_OR_VERSION = new Set(["--help", "--version"]);
|
|
1625
|
+
/**
|
|
1626
|
+
* The port the page was last served on for this session. A server that died or exited idle
|
|
1627
|
+
* takes its record with it; reusing the port keeps an open page on a live origin.
|
|
1628
|
+
*/
|
|
1629
|
+
function rememberedPort(session) {
|
|
1630
|
+
try {
|
|
1631
|
+
const raw = JSON.parse(readFileSync(join(session.dir, "page.json"), "utf8"));
|
|
1632
|
+
if (typeof raw === "object" && raw !== null && "port" in raw) {
|
|
1633
|
+
const port = raw.port;
|
|
1634
|
+
if (typeof port === "number" && Number.isInteger(port) && port > 0)
|
|
1635
|
+
return port;
|
|
1636
|
+
}
|
|
1637
|
+
}
|
|
1638
|
+
catch {
|
|
1639
|
+
return undefined;
|
|
1640
|
+
}
|
|
1641
|
+
return undefined;
|
|
1642
|
+
}
|
|
1643
|
+
function rememberPort(session, port) {
|
|
1644
|
+
writeFileSync(join(session.dir, "page.json"), `${JSON.stringify({ port })}\n`);
|
|
1645
|
+
}
|
|
1478
1646
|
export async function main(argv, cwd) {
|
|
1479
1647
|
const [command, ...rest] = argv;
|
|
1480
1648
|
try {
|
package/dist/src/comments.js
CHANGED
|
@@ -133,11 +133,6 @@ export function parseComment(v) {
|
|
|
133
133
|
const severity = v.severity;
|
|
134
134
|
if (severity !== undefined && !isSeverity(severity))
|
|
135
135
|
return "severity is unknown";
|
|
136
|
-
if (v.pending !== undefined && v.pending !== true)
|
|
137
|
-
return "pending is not true";
|
|
138
|
-
if (v.pending === true && (v.kind === "question" || v.author === "agent")) {
|
|
139
|
-
return "only a reviewer's flag can be pending";
|
|
140
|
-
}
|
|
141
136
|
const replies = parseList(v.replies, parseReply, "replies");
|
|
142
137
|
if (typeof replies === "string")
|
|
143
138
|
return replies;
|
|
@@ -154,7 +149,6 @@ export function parseComment(v) {
|
|
|
154
149
|
...(v.kind === "question" ? { kind: "question" } : {}),
|
|
155
150
|
...(follows === undefined ? {} : { follows }),
|
|
156
151
|
...(severity === undefined ? {} : { severity }),
|
|
157
|
-
...(v.pending === true ? { pending: true } : {}),
|
|
158
152
|
...(replies.length === 0 ? {} : { replies }),
|
|
159
153
|
...(v.author === "agent" ? { author: "agent" } : {}),
|
|
160
154
|
...(v.diagram === undefined ? {} : { diagram: v.diagram }),
|
|
@@ -229,6 +223,46 @@ function asCommentId(item) {
|
|
|
229
223
|
function parseCommentId(item) {
|
|
230
224
|
return asCommentId(item) ?? "not a comment id";
|
|
231
225
|
}
|
|
226
|
+
function parseEarlierReplies(v) {
|
|
227
|
+
if (!isFields(v))
|
|
228
|
+
return "not an object";
|
|
229
|
+
const comment = asCommentId(v.comment);
|
|
230
|
+
if (comment === null)
|
|
231
|
+
return "comment is not a comment id";
|
|
232
|
+
const replies = parseList(v.replies, parseReply, "replies");
|
|
233
|
+
return typeof replies === "string" ? replies : { comment, replies };
|
|
234
|
+
}
|
|
235
|
+
function isLine(v) {
|
|
236
|
+
return typeof v === "number" && Number.isInteger(v) && v >= 1;
|
|
237
|
+
}
|
|
238
|
+
export function parseCarried(v) {
|
|
239
|
+
if (!isFields(v)) {
|
|
240
|
+
return "not an object";
|
|
241
|
+
}
|
|
242
|
+
const comment = parseComment(v.comment);
|
|
243
|
+
if (typeof comment === "string")
|
|
244
|
+
return comment;
|
|
245
|
+
const { unit, offsets, changed, path, newLine } = v;
|
|
246
|
+
if (unit !== null && !(typeof unit === "string" && isUnitId(unit)))
|
|
247
|
+
return "unit is not a unit id";
|
|
248
|
+
if (offsets !== null && !(isArray(offsets) && offsets.every(isIndex))) {
|
|
249
|
+
return "offsets is not null or a list of rows";
|
|
250
|
+
}
|
|
251
|
+
if (typeof changed !== "boolean")
|
|
252
|
+
return "changed is not a boolean";
|
|
253
|
+
if (path !== null && !(typeof path === "string" && isRepoPath(path)))
|
|
254
|
+
return "path is not a path";
|
|
255
|
+
if (newLine !== null && !isLine(newLine))
|
|
256
|
+
return "newLine is not null or a line";
|
|
257
|
+
return {
|
|
258
|
+
comment,
|
|
259
|
+
unit,
|
|
260
|
+
offsets: offsets === null ? null : offsets.filter(isIndex),
|
|
261
|
+
changed,
|
|
262
|
+
path,
|
|
263
|
+
newLine,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
232
266
|
/** Boundary parse of reviews/vN.json. Files written before walk mode existed have no mode. */
|
|
233
267
|
export function parseReview(v) {
|
|
234
268
|
if (!isFields(v)) {
|
|
@@ -259,6 +293,9 @@ export function parseReview(v) {
|
|
|
259
293
|
if (typeof excluded === "string")
|
|
260
294
|
return excluded;
|
|
261
295
|
const anyExcluded = excluded.comments.length + excluded.concerns.length > 0;
|
|
296
|
+
const earlier = parseList(v.earlierReplies, parseEarlierReplies, "earlierReplies");
|
|
297
|
+
if (typeof earlier === "string")
|
|
298
|
+
return earlier;
|
|
262
299
|
return {
|
|
263
300
|
schema: v.schema,
|
|
264
301
|
mode,
|
|
@@ -269,6 +306,7 @@ export function parseReview(v) {
|
|
|
269
306
|
...(concerns.length === 0 ? {} : { concerns }),
|
|
270
307
|
...(resolved.length === 0 ? {} : { resolved }),
|
|
271
308
|
...(anyExcluded ? { excluded } : {}),
|
|
309
|
+
...(earlier.length === 0 ? {} : { earlierReplies: earlier }),
|
|
272
310
|
};
|
|
273
311
|
}
|
|
274
312
|
function lenient(items, parse) {
|
|
@@ -293,6 +331,7 @@ export function parseDraft(v) {
|
|
|
293
331
|
concerns: lenient(v.concerns, parseDecision),
|
|
294
332
|
resolved: lenient(v.resolved, parseCommentId),
|
|
295
333
|
excluded: typeof excluded === "string" ? { comments: [], concerns: [] } : excluded,
|
|
334
|
+
earlierReplies: lenient(v.earlierReplies, parseEarlierReplies),
|
|
296
335
|
};
|
|
297
336
|
}
|
|
298
337
|
export function parseEvent(v) {
|
|
@@ -337,7 +376,10 @@ export function parseEvent(v) {
|
|
|
337
376
|
case "version": {
|
|
338
377
|
if (!isPlanVersion(v.version) || typeof v.hash !== "string")
|
|
339
378
|
return "malformed version event";
|
|
340
|
-
|
|
379
|
+
const carried = parseList(v.carried, parseCarried, "carried");
|
|
380
|
+
if (typeof carried === "string")
|
|
381
|
+
return carried;
|
|
382
|
+
return { seq, type: v.type, version: v.version, hash: v.hash, carried };
|
|
341
383
|
}
|
|
342
384
|
case "submitted":
|
|
343
385
|
return { seq, type: v.type };
|
package/dist/src/diff.js
CHANGED
|
@@ -357,3 +357,14 @@ export function parseUnitRef(s) {
|
|
|
357
357
|
}
|
|
358
358
|
return { unit, select: { kind: "lines", changes } };
|
|
359
359
|
}
|
|
360
|
+
/** Where the hunk starts in the tip: its base position shifted by earlier hunks of the file. */
|
|
361
|
+
export function tipStartOf(hunk, units) {
|
|
362
|
+
let shift = 0;
|
|
363
|
+
for (const u of units.values()) {
|
|
364
|
+
if (u.kind !== "hunk" || u.path !== hunk.path || u.oldStart >= hunk.oldStart)
|
|
365
|
+
continue;
|
|
366
|
+
for (const line of u.body)
|
|
367
|
+
shift += line.op === "+" ? 1 : line.op === "-" ? -1 : 0;
|
|
368
|
+
}
|
|
369
|
+
return hunk.oldStart + shift + 1;
|
|
370
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How a plan follows its branch when the tip moves. A unit id hashes its path and hunk body, so
|
|
3
|
+
* an id that survives names the same change and keeps its place in the plan; everything else is
|
|
4
|
+
* either gone or new, and the agent decides where the new units go.
|
|
5
|
+
*/
|
|
6
|
+
import { tipStartOf } from "./diff.js";
|
|
7
|
+
/** Pure. Responses start empty: they answer the review of the old version, not this one. */
|
|
8
|
+
export function evolvePlan(oldPlan, oldDiff, newDiff) {
|
|
9
|
+
const gone = new Map();
|
|
10
|
+
const commits = [];
|
|
11
|
+
oldPlan.commits.forEach((commit, index) => {
|
|
12
|
+
const units = commit.units.filter((ref) => newDiff.units.has(ref.unit));
|
|
13
|
+
for (const ref of commit.units) {
|
|
14
|
+
const unit = oldDiff.units.get(ref.unit);
|
|
15
|
+
if (newDiff.units.has(ref.unit) || unit === undefined)
|
|
16
|
+
continue;
|
|
17
|
+
const seen = gone.get(ref.unit)?.commits ?? [];
|
|
18
|
+
gone.set(ref.unit, { unit, commits: [...seen, { index, message: commit.message }] });
|
|
19
|
+
}
|
|
20
|
+
if (units.length > 0)
|
|
21
|
+
commits.push({ ...commit, units });
|
|
22
|
+
});
|
|
23
|
+
const taken = new Set(oldPlan.commits.flatMap((c) => c.units.map((ref) => ref.unit)));
|
|
24
|
+
return {
|
|
25
|
+
draft: {
|
|
26
|
+
schema: oldPlan.schema,
|
|
27
|
+
base: newDiff.base,
|
|
28
|
+
tip: newDiff.tip,
|
|
29
|
+
commits,
|
|
30
|
+
responses: [],
|
|
31
|
+
},
|
|
32
|
+
vanished: [...gone.values()],
|
|
33
|
+
added: [...newDiff.units.values()].filter((unit) => !taken.has(unit.id)),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
/** The tip line of each body row; a removed row gets the line that follows it. */
|
|
37
|
+
function tipLines(hunk, units) {
|
|
38
|
+
let line = tipStartOf(hunk, units);
|
|
39
|
+
return hunk.body.map((row) => {
|
|
40
|
+
const at = line;
|
|
41
|
+
if (row.op !== "-")
|
|
42
|
+
line += 1;
|
|
43
|
+
return at;
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
/** The body row a hunk comment points at first: its first picked row, else its first change. */
|
|
47
|
+
function firstRow(comment, hunk) {
|
|
48
|
+
const { anchor } = comment;
|
|
49
|
+
if (anchor.kind !== "hunk")
|
|
50
|
+
return 0;
|
|
51
|
+
const picked = anchor.offsets?.[0];
|
|
52
|
+
if (picked !== undefined)
|
|
53
|
+
return picked;
|
|
54
|
+
const change = anchor.changes?.[0];
|
|
55
|
+
const row = hunk.body.findIndex((r) => r.op !== " " && r.change === change);
|
|
56
|
+
return change === undefined || row === -1 ? 0 : row;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Where an old line lands in a replacement hunk: the nearest row with the same text the tip
|
|
60
|
+
* keeps, else the old line clamped into the hunk's tip lines.
|
|
61
|
+
*/
|
|
62
|
+
function landing(hunk, units, oldLine, text) {
|
|
63
|
+
const lines = tipLines(hunk, units);
|
|
64
|
+
const same = hunk.body.flatMap((row, i) => row.op !== "-" && row.text === text ? [lines[i] ?? oldLine] : []);
|
|
65
|
+
if (same.length > 0) {
|
|
66
|
+
return same.reduce((a, b) => (Math.abs(b - oldLine) < Math.abs(a - oldLine) ? b : a));
|
|
67
|
+
}
|
|
68
|
+
const [from, to] = span(hunk, units);
|
|
69
|
+
return Math.min(Math.max(oldLine, from), to);
|
|
70
|
+
}
|
|
71
|
+
/** First and last tip line of a hunk; a pure removal spans the one line after it. */
|
|
72
|
+
function span(hunk, units) {
|
|
73
|
+
const from = tipStartOf(hunk, units);
|
|
74
|
+
const kept = hunk.body.filter((row) => row.op !== "-").length;
|
|
75
|
+
return [from, from + Math.max(kept, 1) - 1];
|
|
76
|
+
}
|
|
77
|
+
function distance(hunk, units, line) {
|
|
78
|
+
const [from, to] = span(hunk, units);
|
|
79
|
+
return line < from ? from - line : line > to ? line - to : 0;
|
|
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));
|
|
87
|
+
}
|
|
88
|
+
function carry(comment, oldUnits, newUnits) {
|
|
89
|
+
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
|
+
const kept = newUnits.get(anchor.unit);
|
|
94
|
+
if (kept !== undefined) {
|
|
95
|
+
const newLine = kept.kind === "hunk" ? (tipLines(kept, newUnits)[firstRow(comment, kept)] ?? null) : null;
|
|
96
|
+
return {
|
|
97
|
+
comment,
|
|
98
|
+
unit: kept.id,
|
|
99
|
+
offsets: anchor.offsets ?? null,
|
|
100
|
+
changed: false,
|
|
101
|
+
path: kept.path,
|
|
102
|
+
newLine,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
const old = oldUnits.get(anchor.unit);
|
|
106
|
+
const path = old?.path ?? comment.context.path;
|
|
107
|
+
const onPath = [...newUnits.values()].filter((u) => u.path === path);
|
|
108
|
+
const first = onPath[0];
|
|
109
|
+
if (first === undefined)
|
|
110
|
+
return { ...nowhere, changed: true, path };
|
|
111
|
+
const moved = { comment, offsets: null, changed: true, path };
|
|
112
|
+
const hunks = onPath.filter((u) => u.kind === "hunk");
|
|
113
|
+
if (old?.kind !== "hunk" || hunks.length === 0)
|
|
114
|
+
return { ...moved, unit: first.id, newLine: null };
|
|
115
|
+
const row = firstRow(comment, old);
|
|
116
|
+
const oldLine = tipLines(old, oldUnits)[row] ?? tipStartOf(old, oldUnits);
|
|
117
|
+
const nearest = hunks.reduce((a, b) => distance(b, newUnits, oldLine) < distance(a, newUnits, oldLine) ? b : a);
|
|
118
|
+
return {
|
|
119
|
+
...moved,
|
|
120
|
+
unit: nearest.id,
|
|
121
|
+
newLine: landing(nearest, newUnits, oldLine, old.body[row]?.text),
|
|
122
|
+
};
|
|
123
|
+
}
|