@shiyuphan/follow-me 0.6.0 → 0.8.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/README.md +7 -1
- package/dist/src/cli.js +210 -23
- package/dist/src/comments.js +152 -8
- package/dist/src/coverage.js +411 -0
- package/dist/src/git.js +18 -0
- package/dist/src/host.js +27 -0
- package/dist/src/plan.js +128 -6
- package/dist/src/prompt.js +113 -25
- package/dist/src/server.js +115 -11
- package/dist/src/session.js +34 -0
- package/dist/src/types.js +2 -0
- package/dist/ui/dist/assets/{abnfDiagram-WEYOIA3C-fYSYLdMl.js → abnfDiagram-WEYOIA3C-Bp14ygmz.js} +1 -1
- package/dist/ui/dist/assets/{arc-DGuGSnwa.js → arc-DUR6uzhI.js} +1 -1
- package/dist/ui/dist/assets/{architecture-WOLXFQ4H-VFox_hV1.js → architecture-WOLXFQ4H-CyfnP65O.js} +1 -1
- package/dist/ui/dist/assets/{architectureDiagram-ZHVYKT53-CBVK3nHZ.js → architectureDiagram-ZHVYKT53-dJTicIk2.js} +1 -1
- package/dist/ui/dist/assets/{blockDiagram-RJAE7QA3-BFdsIWEr.js → blockDiagram-RJAE7QA3-BYiqh5Ib.js} +1 -1
- package/dist/ui/dist/assets/{c4Diagram-XDPPPE54-BGntcPId.js → c4Diagram-XDPPPE54-DHHPQ7xE.js} +1 -1
- package/dist/ui/dist/assets/channel-CY2cQobm.js +1 -0
- package/dist/ui/dist/assets/{chunk-2E7WHVMD-CNMuK9zj.js → chunk-2E7WHVMD-BODm_awL.js} +1 -1
- package/dist/ui/dist/assets/{chunk-4EMNKR2T-ByeTTJWS.js → chunk-4EMNKR2T-CpZfjf2T.js} +1 -1
- package/dist/ui/dist/assets/{chunk-55XCU76K-99xE7cLH.js → chunk-55XCU76K-CoP2pJTQ.js} +1 -1
- package/dist/ui/dist/assets/{chunk-7S6XSTFO-ZVmT7eMZ.js → chunk-7S6XSTFO-CaQcUvXY.js} +1 -1
- package/dist/ui/dist/assets/{chunk-AQDTSKZB-B981JORL.js → chunk-AQDTSKZB-BZ9Czvbu.js} +1 -1
- package/dist/ui/dist/assets/{chunk-DSNLSHJZ-B2z7FX4h.js → chunk-DSNLSHJZ-CNjUo110.js} +1 -1
- package/dist/ui/dist/assets/{chunk-HAGFOTED-CHFbBS-4.js → chunk-HAGFOTED-BTmBE_D3.js} +1 -1
- package/dist/ui/dist/assets/{chunk-HNE4Q36P-rq11xv9X.js → chunk-HNE4Q36P-D2cDuT0C.js} +1 -1
- package/dist/ui/dist/assets/{chunk-NQOKRVIF-BYmg9kSX.js → chunk-NQOKRVIF-AZcemFgG.js} +1 -1
- package/dist/ui/dist/assets/{chunk-UHW7PFBP-JvL9IXWg.js → chunk-UHW7PFBP-BszwjUqG.js} +1 -1
- package/dist/ui/dist/assets/{chunk-VQ5W62QI-CYMQaAKn.js → chunk-VQ5W62QI-vFSenYSe.js} +1 -1
- package/dist/ui/dist/assets/{chunk-WQQL4CDK-CXnR0PHT.js → chunk-WQQL4CDK-DZR6is1Q.js} +1 -1
- package/dist/ui/dist/assets/{chunk-XXDRQBXY-CDUWJCXi.js → chunk-XXDRQBXY-CACFh-si.js} +1 -1
- package/dist/ui/dist/assets/{chunk-YZO67HFG-mZfXIx0h.js → chunk-YZO67HFG-DRRTjGuW.js} +1 -1
- package/dist/ui/dist/assets/{chunk-ZAUX2LTX-DBxVTCHX.js → chunk-ZAUX2LTX-Bxuy-Roa.js} +1 -1
- package/dist/ui/dist/assets/{chunk-ZCHMQSOR-DrWA4IBo.js → chunk-ZCHMQSOR-rYXk__1o.js} +1 -1
- package/dist/ui/dist/assets/{classDiagram-v2-4MYWR3UA-Beah5uxA.js → classDiagram-v2-4MYWR3UA-RiF44tTz.js} +1 -1
- package/dist/ui/dist/assets/{cose-bilkent-JH36ORCC-UaE-jnHk.js → cose-bilkent-JH36ORCC-0oiQoThS.js} +1 -1
- package/dist/ui/dist/assets/{cynefin-EF2NZ3EQ-DKfGaCR8.js → cynefin-EF2NZ3EQ-BAssFK5K.js} +1 -1
- package/dist/ui/dist/assets/{cynefinDiagram-WF24UDRP-tTUzTs3D.js → cynefinDiagram-WF24UDRP-Bmd05bak.js} +1 -1
- package/dist/ui/dist/assets/{dagre-NK4GIZVX-jSn35_T7.js → dagre-NK4GIZVX-QJkLjPxj.js} +1 -1
- package/dist/ui/dist/assets/{diagram-CBK3MWTC-7W2uw8Zc.js → diagram-CBK3MWTC-PSaSiYXz.js} +1 -1
- package/dist/ui/dist/assets/{diagram-I5XIIV2D-CcfyQVh9.js → diagram-I5XIIV2D-BmU5X_hr.js} +1 -1
- package/dist/ui/dist/assets/{diagram-ODIL2UCW-BjGvaLis.js → diagram-ODIL2UCW-AAV39kUu.js} +1 -1
- package/dist/ui/dist/assets/{diagram-PFBEJUXE-jvdCrt8B.js → diagram-PFBEJUXE-DWlV-oxd.js} +1 -1
- package/dist/ui/dist/assets/{diagram-RJA3I4VV-D65_Vpi_.js → diagram-RJA3I4VV-D8wHRCNX.js} +1 -1
- package/dist/ui/dist/assets/{diagram-TYUNFSNR-C52roQBE.js → diagram-TYUNFSNR-B_JQEsNY.js} +1 -1
- package/dist/ui/dist/assets/{dist-BJd4jp9Y.js → dist-DxQIK_Ay.js} +1 -1
- package/dist/ui/dist/assets/{ebnfDiagram-V4P3TTPT-DXysoIBq.js → ebnfDiagram-V4P3TTPT-D83qV8mv.js} +1 -1
- package/dist/ui/dist/assets/{elk-QONTBCTQ-BVZcsgV3.js → elk-QONTBCTQ-BKFAKVSD.js} +1 -1
- package/dist/ui/dist/assets/{erDiagram-KYQIGQAA-BJnT4fVU.js → erDiagram-KYQIGQAA-CHYRd4aM.js} +1 -1
- package/dist/ui/dist/assets/{eventmodeling-K75KTNOO-C5MzxCMY.js → eventmodeling-K75KTNOO-BDJDv3Zf.js} +1 -1
- package/dist/ui/dist/assets/flowDiagram-IUGLLWBU-Bke3CfUH.js +1 -0
- package/dist/ui/dist/assets/{ganttDiagram-R7GPNTJY-CZRrnuah.js → ganttDiagram-R7GPNTJY-gTHjYkFj.js} +1 -1
- package/dist/ui/dist/assets/{gitGraph-VSP46ZUC-l58aGSkh.js → gitGraph-VSP46ZUC-D4cWkxKB.js} +1 -1
- package/dist/ui/dist/assets/{gitGraphDiagram-PDYL7XHY-D4ks1WO-.js → gitGraphDiagram-PDYL7XHY-D3M9wYMr.js} +1 -1
- package/dist/ui/dist/assets/index-BUOFihod.js +134 -0
- package/dist/ui/dist/assets/index-DLNJT0VS.css +1 -0
- package/dist/ui/dist/assets/{info-OHQRW6UA-BSqfzkdk.js → info-OHQRW6UA-B9y0ksVq.js} +1 -1
- package/dist/ui/dist/assets/{infoDiagram-MVDRKAX5-B6NgVF_N.js → infoDiagram-MVDRKAX5-s4FMwb8r.js} +1 -1
- package/dist/ui/dist/assets/{ishikawaDiagram-HPEHV2GE-DvtfssEZ.js → ishikawaDiagram-HPEHV2GE-CnQdxybf.js} +1 -1
- package/dist/ui/dist/assets/{journeyDiagram-BOSHW7O7-DM64gT5v.js → journeyDiagram-BOSHW7O7-I-9OpDWY.js} +1 -1
- package/dist/ui/dist/assets/{kanban-definition-FNSA4HC5-AliyRJHy.js → kanban-definition-FNSA4HC5-DqUbIdH4.js} +1 -1
- package/dist/ui/dist/assets/{line-CHncXbKO.js → line-BELSui4d.js} +1 -1
- package/dist/ui/dist/assets/{linear-BWEWydI0.js → linear-77hmn2iQ.js} +1 -1
- package/dist/ui/dist/assets/{mermaid-parser.core-DGpOP8AM.js → mermaid-parser.core-Bxa71wW-.js} +3 -3
- package/dist/ui/dist/assets/{mermaid.core-BJtBFbJx.js → mermaid.core-DBI9F4bh.js} +4 -4
- package/dist/ui/dist/assets/{mindmap-definition-6OY57UG3-NjavnWBi.js → mindmap-definition-6OY57UG3-BxXMOapP.js} +1 -1
- package/dist/ui/dist/assets/{packet-JDAUHWVQ-zuhpVsME.js → packet-JDAUHWVQ-DTT77cQh.js} +1 -1
- package/dist/ui/dist/assets/{pegDiagram-ECIUJNBX-sL4grM97.js → pegDiagram-ECIUJNBX-HzrOQPT5.js} +1 -1
- package/dist/ui/dist/assets/{pie-XZMESJXO-C9l1Mp09.js → pie-XZMESJXO-iHwL1Qw-.js} +1 -1
- package/dist/ui/dist/assets/{pieDiagram-GNMPY74T-rlRBr2U4.js → pieDiagram-GNMPY74T-DZ1tLVjw.js} +1 -1
- package/dist/ui/dist/assets/{prism-core-DmuST30i.js → prism-core-NtO0YF9C.js} +1 -1
- package/dist/ui/dist/assets/{quadrantDiagram-D57K4MC3-4oQMip2Q.js → quadrantDiagram-D57K4MC3-BjTi9H5L.js} +1 -1
- package/dist/ui/dist/assets/{radar-ABXABTNO-DvGg96L7.js → radar-ABXABTNO-D2hYgXSB.js} +1 -1
- package/dist/ui/dist/assets/{railroad-I3PHUGI6-8wdFKMZp.js → railroad-I3PHUGI6-DpakXN74.js} +1 -1
- package/dist/ui/dist/assets/{railroad-abnf-LNEFI6M7-DUtOO0gn.js → railroad-abnf-LNEFI6M7-B8ZFR3iy.js} +1 -1
- package/dist/ui/dist/assets/{railroad-ebnf-DWYD2UWJ-5YCGY1_d.js → railroad-ebnf-DWYD2UWJ-S1XUyTsC.js} +1 -1
- package/dist/ui/dist/assets/{railroad-peg-JR7BVNR7-Bwywxjhs.js → railroad-peg-JR7BVNR7-BbeHC_6K.js} +1 -1
- package/dist/ui/dist/assets/{railroadDiagram-Y72NRBL7-CwO6HSNQ.js → railroadDiagram-Y72NRBL7-fd-2iyBo.js} +1 -1
- package/dist/ui/dist/assets/{requirementDiagram-EQYSW5RA-Cp0IbfNr.js → requirementDiagram-EQYSW5RA-B9-HHKH-.js} +1 -1
- package/dist/ui/dist/assets/{sankeyDiagram-SY24JHO5-DeQKsO68.js → sankeyDiagram-SY24JHO5-CeQN1ZEG.js} +1 -1
- package/dist/ui/dist/assets/{sequenceDiagram-BLEHNHQU-DTDalCxF.js → sequenceDiagram-BLEHNHQU-Vff7tx4t.js} +1 -1
- package/dist/ui/dist/assets/{src-XnrqevnL.js → src-D5-ZzLwt.js} +1 -1
- package/dist/ui/dist/assets/{stateDiagram-v2-A6IESKWZ-2DZ-tKo9.js → stateDiagram-v2-A6IESKWZ-Bzn_ma9c.js} +1 -1
- package/dist/ui/dist/assets/{swimlanes-B3ENRKFQ-66yZJeRU.js → swimlanes-B3ENRKFQ-CevqLchV.js} +1 -1
- package/dist/ui/dist/assets/swimlanesDiagram-AYQRV4AK-Bkv8hAmN.js +8 -0
- package/dist/ui/dist/assets/{timeline-definition-JXYKGLGB-Dm1xGU-d.js → timeline-definition-JXYKGLGB-D9jSDGJI.js} +1 -1
- package/dist/ui/dist/assets/{treeView-D4JQ5SDB-BvgsbSot.js → treeView-D4JQ5SDB-CoXKj11m.js} +1 -1
- package/dist/ui/dist/assets/{treemap-SAJKECNS-C3lnbG1a.js → treemap-SAJKECNS-cHsansGe.js} +1 -1
- package/dist/ui/dist/assets/{usecaseDiagram-WPOLBNNX-B6iPNIhZ.js → usecaseDiagram-WPOLBNNX-CW9i3bW6.js} +1 -1
- package/dist/ui/dist/assets/{vennDiagram-Q3Y3IDCW-D5dgflg9.js → vennDiagram-Q3Y3IDCW-DuU3qEKJ.js} +1 -1
- package/dist/ui/dist/assets/{wardley-7MLQ67FV-Dn9mLIB4.js → wardley-7MLQ67FV-dl9RgHUI.js} +1 -1
- package/dist/ui/dist/assets/{wardleyDiagram-IVAIDKWA-W1EwzN_X.js → wardleyDiagram-IVAIDKWA-BOXhrK-a.js} +1 -1
- package/dist/ui/dist/assets/{xychartDiagram-4URNHDU4-CKRzMUvp.js → xychartDiagram-4URNHDU4-CLQNGrh4.js} +1 -1
- package/dist/ui/dist/index.html +2 -2
- package/package.json +1 -1
- package/skills/follow-me/SKILL.md +4 -0
- package/dist/ui/dist/assets/channel-gz6cS9Z1.js +0 -1
- package/dist/ui/dist/assets/flowDiagram-IUGLLWBU-BqUgZSCz.js +0 -1
- package/dist/ui/dist/assets/index-BW95UA5q.css +0 -1
- package/dist/ui/dist/assets/index-DOGXgoHF.js +0 -134
- package/dist/ui/dist/assets/swimlanesDiagram-AYQRV4AK-rbX6bYG4.js +0 -8
package/README.md
CHANGED
|
@@ -75,13 +75,19 @@ The red blob in the party hat is your branch tip. Nothing follow-me does moves i
|
|
|
75
75
|

|
|
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
|
+

|
|
79
|
+
*Coverage while reading. Your agent runs the tests with coverage and passes the lcov to `follow-me coverage`; follow-me never runs anything. Bars show covered, partly covered and uncovered lines, `x` jumps to the next uncovered line, and clicking a bar lists the tests that ran it.*
|
|
80
|
+
|
|
81
|
+

|
|
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.*
|
|
83
|
+
|
|
78
84
|

|
|
79
85
|
*Diagrams sit full width under the commit or reply they explain, and open full size on click.*
|
|
80
86
|
|
|
81
87
|

|
|
82
88
|
*Submit review. Each item carries the original branch's path and line, so your agent can post it to the merge request as is.*
|
|
83
89
|
|
|
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.
|
|
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.
|
|
85
91
|
|
|
86
92
|
### Reviewing someone else's branch
|
|
87
93
|
|
package/dist/src/cli.js
CHANGED
|
@@ -1,22 +1,28 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
2
|
import { readFileSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
|
-
import { dirname, join, relative } from "node:path";
|
|
4
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { mapOffTip, parseStateMap } from "./comments.js";
|
|
7
|
+
import { changedLines, LcovError, PREVIEW_LINES, parseLcov, parseLocArg, recordRun, runOf, tipPlacer, totalOf, } from "./coverage.js";
|
|
6
8
|
import { definitionIndex, isIdentifier } from "./definition.js";
|
|
7
|
-
import { DiffParseError, formatUnitRef, isUnitId } from "./diff.js";
|
|
8
|
-
import { BranchRefusedError, branchHead, GitError, isOwnWork, signing, treeOf, writeBranch, } from "./git.js";
|
|
9
|
-
import { fetchCarried, fetchComments, fetchReport, findServer, followEvents, idleMs, listSessions, openBrowser, pollEvents, portFallbackMessage, postComment, postReply, probeHealth, readServerRecord, registerWith, sessionUrl, spawnDetached, startOrAttach, stateDir, stillHosted, stopServer, swapVersion, } from "./host.js";
|
|
9
|
+
import { DiffParseError, formatUnitRef, isRepoPath, isUnitId, splitLines } from "./diff.js";
|
|
10
|
+
import { BranchRefusedError, blobsAt, branchHead, GitError, isOwnWork, pathsAt, pathsChanged, signing, treeOf, writeBranch, } from "./git.js";
|
|
11
|
+
import { fetchCarried, fetchComments, fetchReport, fetchWaitingOffers, findServer, followEvents, idleMs, listSessions, openBrowser, pollEvents, portFallbackMessage, postComment, postCoverage, postReply, probeHealth, readServerRecord, registerWith, sessionUrl, spawnDetached, startOrAttach, stateDir, stillHosted, stopServer, swapVersion, } from "./host.js";
|
|
10
12
|
import { findSkillDir, InstallError, runInstall, runUninstall, syncSkills, } from "./install.js";
|
|
11
13
|
import { naiveDraft } from "./naive.js";
|
|
12
|
-
import { isCommentId, isConcernId, isFields, serializePlanFile, validatePlan, } from "./plan.js";
|
|
14
|
+
import { isCommentId, isConcernId, isFields, OFFERS_PER_COMMIT, OFFERS_PER_PLAN, serializePlanFile, validatePlan, } from "./plan.js";
|
|
13
15
|
import { renderEvents, renderPrompt, renderReport, renderShownComment, renderShownConcern, renderShownDefinitions, renderShownUnit, reportItems, } from "./prompt.js";
|
|
14
16
|
import { RegistrationError, sessionIdOf } from "./server.js";
|
|
15
|
-
import { BranchMovedError, cleanSessions, extractSession, MissingDraftError, NotARepositoryError, openSession, SessionFileError, SessionLockedError, sessionComments, sinceLastPlan, } from "./session.js";
|
|
17
|
+
import { BranchMovedError, cleanSessions, coverageAt, extractSession, MissingDraftError, NotARepositoryError, openSession, SessionFileError, SessionLockedError, sessionComments, sinceLastPlan, } from "./session.js";
|
|
16
18
|
import { checkSyntax } from "./syntax.js";
|
|
17
19
|
class UsageError extends Error {
|
|
18
20
|
name = "UsageError";
|
|
19
21
|
}
|
|
22
|
+
/** A test run follow-me will not record, with the reason the agent can act on. */
|
|
23
|
+
class CoverageError extends Error {
|
|
24
|
+
name = "CoverageError";
|
|
25
|
+
}
|
|
20
26
|
const SHORT_USAGE = `usage: follow-me [--wait <seconds>] [--branch <name>]
|
|
21
27
|
|
|
22
28
|
Run follow-me with no command. Each call does the next thing that is missing and returns: it
|
|
@@ -26,9 +32,10 @@ prints what they flagged or asked. It is interactive: do what its "next:" line s
|
|
|
26
32
|
again until it prints the report.
|
|
27
33
|
|
|
28
34
|
Other commands:
|
|
29
|
-
reply <commentId> --body <text> [--diagram <file>]
|
|
35
|
+
reply <commentId> --body <text> [--diagram <file> [--map <file>]]
|
|
30
36
|
answer under a comment on the page; the diagram is
|
|
31
|
-
Mermaid source
|
|
37
|
+
Mermaid source, the map JSON tying its states to
|
|
38
|
+
tip lines
|
|
32
39
|
explain [--commit <n>] [--unit <id>] [--lines <a-b>] --body <text> [--diagram <file>]
|
|
33
40
|
start a thread on the page without being asked
|
|
34
41
|
show <h_…|c_…|k_…|path:line[@n|@base]|name>
|
|
@@ -37,6 +44,9 @@ Other commands:
|
|
|
37
44
|
is defined, instead of reading files
|
|
38
45
|
report [--json] [--markdown] print the report of the latest review; --json lists
|
|
39
46
|
the items to post to a merge request
|
|
47
|
+
coverage <lcov> [--test <id> [--at <path:line>]] [--reset]
|
|
48
|
+
record a test run taken at the tip for the page;
|
|
49
|
+
--reset alone clears it before per-test runs
|
|
40
50
|
|
|
41
51
|
help --all lists every command.`;
|
|
42
52
|
const USAGE = `usage: follow-me <command> [options]
|
|
@@ -79,9 +89,10 @@ Interactive loop (while the review page is open):
|
|
|
79
89
|
reports the end of the review. Your own replies are
|
|
80
90
|
left out. --follow streams every event until the
|
|
81
91
|
review ends instead of stopping at the first
|
|
82
|
-
reply <commentId> --body <text> [--diagram <file>]
|
|
92
|
+
reply <commentId> --body <text> [--diagram <file> [--map <file>]]
|
|
83
93
|
answer under a comment on the page; the diagram is
|
|
84
|
-
Mermaid source
|
|
94
|
+
Mermaid source, the map JSON tying its states to
|
|
95
|
+
tip lines
|
|
85
96
|
explain [--commit <n>] [--unit <id>] [--lines <a-b>] --body <text> [--diagram <file>]
|
|
86
97
|
start a thread on the page without being asked;
|
|
87
98
|
--lines are change lines of the unit, commits count
|
|
@@ -100,6 +111,12 @@ Interactive loop (while the review page is open):
|
|
|
100
111
|
line in both voices, then questions with answers;
|
|
101
112
|
--json lists the items to post to a merge request
|
|
102
113
|
(path, lines, severity, body, thread, excluded)
|
|
114
|
+
coverage <lcov> [--test <id> [--at <path:line>]] [--reset] [--branch <name>]
|
|
115
|
+
record the lcov of a test run at the branch tip;
|
|
116
|
+
refuses when a covered file in the working tree
|
|
117
|
+
differs from the tip. A plain run replaces the last;
|
|
118
|
+
--test runs add one test each, --at is where it is.
|
|
119
|
+
--reset starts fresh; alone it clears the last run
|
|
103
120
|
apply, while the page is open, swaps it to the new plan version in place, same URL.
|
|
104
121
|
|
|
105
122
|
Old names, still working:
|
|
@@ -279,6 +296,12 @@ function formatIssue(issue) {
|
|
|
279
296
|
return `$.commits[${issue.commit}]: concern: ${issue.reason}`;
|
|
280
297
|
case "too-many-concerns":
|
|
281
298
|
return `${issue.unit} has more than one concern, in commits ${issue.commits.join(" and ")}; keep the one that matters`;
|
|
299
|
+
case "bad-offer":
|
|
300
|
+
return `$.commits[${issue.commit}]: offer: ${issue.reason}`;
|
|
301
|
+
case "too-many-offers":
|
|
302
|
+
return issue.commit === null
|
|
303
|
+
? `the plan has ${issue.count} offers; at most ${OFFERS_PER_PLAN}, and most plans need none`
|
|
304
|
+
: `$.commits[${issue.commit}] has ${issue.count} offers; at most ${OFFERS_PER_COMMIT} per commit`;
|
|
282
305
|
case "final-mismatch": {
|
|
283
306
|
const tip = issue.expected ? `${issue.expected.mode} ${issue.expected.blob}` : "absent";
|
|
284
307
|
return `${show(issue.path)}: the last commit has ${issue.got}, the tip has ${tip}`;
|
|
@@ -293,6 +316,9 @@ function formatWarning(w) {
|
|
|
293
316
|
if (w.kind === "long-title") {
|
|
294
317
|
return `warning: $.commits[${w.commit}]: title is ${w.length} characters; keep it to 50`;
|
|
295
318
|
}
|
|
319
|
+
if (w.kind === "section-order") {
|
|
320
|
+
return `warning: $.commits[${w.commit}]: section ${w.section} comes after ${w.after}; sections read story, steps, details, wiring`;
|
|
321
|
+
}
|
|
296
322
|
if (w.kind === "question-addressed") {
|
|
297
323
|
return `warning: question ${w.comment} is marked addressed; explain it with "answered" and "at" unless answering it changed the plan`;
|
|
298
324
|
}
|
|
@@ -909,8 +935,8 @@ function reviewedPlan(session) {
|
|
|
909
935
|
return null;
|
|
910
936
|
}
|
|
911
937
|
/** Prints the finished review's report, which is what the agent works from from here on. */
|
|
912
|
-
function finished(session, stored) {
|
|
913
|
-
const text = renderStored(session, stored);
|
|
938
|
+
async function finished(session, stored) {
|
|
939
|
+
const text = await renderStored(session, stored);
|
|
914
940
|
if (text === null)
|
|
915
941
|
return 1;
|
|
916
942
|
// A review that swapped versions holds what its earlier versions said too.
|
|
@@ -1041,6 +1067,13 @@ function waitOf(args) {
|
|
|
1041
1067
|
/** One call fits inside a harness's tool timeout, so the agent's loop is one command per turn. */
|
|
1042
1068
|
const ENTRY_WAIT_SECONDS = 90;
|
|
1043
1069
|
const MAX_ENTRY_WAIT_SECONDS = 300;
|
|
1070
|
+
/** The step that names the oldest accepted offer the agent has not answered, or null. */
|
|
1071
|
+
async function offerStep(port, summary) {
|
|
1072
|
+
const [first] = await fetchWaitingOffers(port, summary);
|
|
1073
|
+
if (first === undefined)
|
|
1074
|
+
return null;
|
|
1075
|
+
return `${STILL_WORKING}; offer ${first.offer} waits for your diagram: follow-me reply ${first.comment} --diagram <file> [--map <file>], then run follow-me again`;
|
|
1076
|
+
}
|
|
1044
1077
|
/**
|
|
1045
1078
|
* Listens to the hosted review for up to --wait seconds and prints what the reviewer did. It
|
|
1046
1079
|
* returns at once with news, and with the report when the reviewer submitted or ended the review.
|
|
@@ -1100,11 +1133,13 @@ async function listen(session, open, args) {
|
|
|
1100
1133
|
next("done; the user can port it with follow-me report --json");
|
|
1101
1134
|
return 0;
|
|
1102
1135
|
}
|
|
1103
|
-
next(
|
|
1136
|
+
next((await offerStep(port, summary)) ??
|
|
1137
|
+
`${STILL_WORKING}; answer with follow-me reply <id> --body "<markdown>" [--diagram file], then run follow-me again`);
|
|
1104
1138
|
return 0;
|
|
1105
1139
|
}
|
|
1106
1140
|
if (left <= 0) {
|
|
1107
|
-
next(
|
|
1141
|
+
next((await offerStep(port, summary)) ??
|
|
1142
|
+
`${STILL_WORKING}; run follow-me again to keep listening`);
|
|
1108
1143
|
return 0;
|
|
1109
1144
|
}
|
|
1110
1145
|
}
|
|
@@ -1173,6 +1208,9 @@ async function entry(cwd, args) {
|
|
|
1173
1208
|
const result = await hostReview(session, mode, detached, async () => prep, reportSaved);
|
|
1174
1209
|
if (result.kind !== "elsewhere")
|
|
1175
1210
|
return exitCode(result);
|
|
1211
|
+
if (session.coverage() === null) {
|
|
1212
|
+
next("if the project has tests, run them with coverage at the tip and pass the lcov to follow-me coverage");
|
|
1213
|
+
}
|
|
1176
1214
|
say("follow-me is interactive: keep running it until it prints the report");
|
|
1177
1215
|
return listen(session, result, args);
|
|
1178
1216
|
}
|
|
@@ -1230,7 +1268,7 @@ async function walkPlan(session, args) {
|
|
|
1230
1268
|
return alreadyReviewed(session, latest.version, args) ? null : latest;
|
|
1231
1269
|
}
|
|
1232
1270
|
/** The consolidated report of a saved review, or null after saying why it cannot be rendered. */
|
|
1233
|
-
function renderStored(session, stored, format = "markdown") {
|
|
1271
|
+
async function renderStored(session, stored, format = "markdown") {
|
|
1234
1272
|
const plan = session.plans().find((p) => p.version === stored.version);
|
|
1235
1273
|
if (plan === undefined) {
|
|
1236
1274
|
console.error(`follow-me: review v${stored.version} refers to a plan that is missing`);
|
|
@@ -1258,7 +1296,13 @@ function renderStored(session, stored, format = "markdown") {
|
|
|
1258
1296
|
concerns: stored.review.carriedConcerns ?? [],
|
|
1259
1297
|
},
|
|
1260
1298
|
};
|
|
1261
|
-
const
|
|
1299
|
+
const coverage = await coverageAt(session.root, session.dir, session.anchor.tip);
|
|
1300
|
+
const opts = {
|
|
1301
|
+
branch: session.anchor.branch,
|
|
1302
|
+
responses,
|
|
1303
|
+
...earlier,
|
|
1304
|
+
...(coverage === null ? {} : { coverage }),
|
|
1305
|
+
};
|
|
1262
1306
|
if (format === "json") {
|
|
1263
1307
|
const items = reportItems(stored.review, verdict.plan.commits, session.diff.units, opts);
|
|
1264
1308
|
return `${JSON.stringify(items, null, 2)}\n`;
|
|
@@ -1288,12 +1332,92 @@ async function report(cwd, args, walkOnly = false) {
|
|
|
1288
1332
|
console.error("follow-me: no review is open and none has ended; run `follow-me review` first");
|
|
1289
1333
|
return 1;
|
|
1290
1334
|
}
|
|
1291
|
-
const text = renderStored(session, stored, format);
|
|
1335
|
+
const text = await renderStored(session, stored, format);
|
|
1292
1336
|
if (text === null)
|
|
1293
1337
|
return 1;
|
|
1294
1338
|
process.stdout.write(text);
|
|
1295
1339
|
return 0;
|
|
1296
1340
|
}
|
|
1341
|
+
const COVERAGE_USAGE = "usage: follow-me coverage <lcov> [--test <id> [--at <path:line>]] [--reset]\n follow-me coverage --reset";
|
|
1342
|
+
/**
|
|
1343
|
+
* Records the lcov of a test run the agent made at the tip in the session's coverage.json and
|
|
1344
|
+
* prints how much of the branch's changed code it ran. follow-me never runs the tests itself.
|
|
1345
|
+
*/
|
|
1346
|
+
async function coverage(cwd, args) {
|
|
1347
|
+
const [file, extra] = args.positionals;
|
|
1348
|
+
const id = args.values.get("test");
|
|
1349
|
+
const atArg = args.values.get("at");
|
|
1350
|
+
const reset = args.flags.has("reset");
|
|
1351
|
+
if (file === undefined && reset && id === undefined && atArg === undefined) {
|
|
1352
|
+
const session = await openFresh(cwd, args);
|
|
1353
|
+
session.clearCoverage();
|
|
1354
|
+
say("coverage cleared; the next run starts fresh");
|
|
1355
|
+
return 0;
|
|
1356
|
+
}
|
|
1357
|
+
if (file === undefined || extra !== undefined)
|
|
1358
|
+
throw new UsageError(COVERAGE_USAGE);
|
|
1359
|
+
if (atArg !== undefined && id === undefined) {
|
|
1360
|
+
throw new UsageError(`--at needs --test\n${COVERAGE_USAGE}`);
|
|
1361
|
+
}
|
|
1362
|
+
let text;
|
|
1363
|
+
try {
|
|
1364
|
+
text = readFileSync(resolve(cwd, file), "utf8");
|
|
1365
|
+
}
|
|
1366
|
+
catch {
|
|
1367
|
+
throw new UsageError(`cannot read ${file}`);
|
|
1368
|
+
}
|
|
1369
|
+
const records = parseLcov(text);
|
|
1370
|
+
const session = await openFresh(cwd, args);
|
|
1371
|
+
const { tip } = session.anchor;
|
|
1372
|
+
const place = tipPlacer(session.root, cwd, await pathsAt(session.root, tip));
|
|
1373
|
+
const run = runOf(records, place);
|
|
1374
|
+
if (run.files.size === 0) {
|
|
1375
|
+
throw new CoverageError(`none of the ${plural(new Set(records.map((r) => r.path)).size, "file")} in ${file} is in ${session.anchor.branch} at the tip; run follow-me coverage from the directory the tests ran in`);
|
|
1376
|
+
}
|
|
1377
|
+
const changed = await pathsChanged(session.root, tip, null);
|
|
1378
|
+
const differ = [...run.files.keys()].filter((p) => changed.has(p));
|
|
1379
|
+
if (differ.length > 0) {
|
|
1380
|
+
throw new CoverageError(`coverage must be taken on the tip; these files differ: ${differ.map(show).join(", ")}`);
|
|
1381
|
+
}
|
|
1382
|
+
let test;
|
|
1383
|
+
if (id !== undefined) {
|
|
1384
|
+
const at = atArg === undefined ? undefined : parseLocArg(atArg, place);
|
|
1385
|
+
if (typeof at === "string")
|
|
1386
|
+
throw new UsageError(at);
|
|
1387
|
+
const preview = at === undefined ? undefined : await testPreview(session, at);
|
|
1388
|
+
test = {
|
|
1389
|
+
id,
|
|
1390
|
+
...(at === undefined ? {} : { at }),
|
|
1391
|
+
...(preview === undefined ? {} : { preview }),
|
|
1392
|
+
};
|
|
1393
|
+
}
|
|
1394
|
+
const takenAt = new Date().toISOString();
|
|
1395
|
+
const recorded = recordRun(reset ? null : session.coverage(), run, {
|
|
1396
|
+
tip,
|
|
1397
|
+
takenAt,
|
|
1398
|
+
...(test === undefined ? {} : { test }),
|
|
1399
|
+
});
|
|
1400
|
+
session.writeCoverage(recorded);
|
|
1401
|
+
const open = await openOnServer(session, null);
|
|
1402
|
+
if (open !== null)
|
|
1403
|
+
await postCoverage(open.port, open.summary);
|
|
1404
|
+
const { covered, executable } = totalOf(changedLines(session.diff.units, recorded.files).values());
|
|
1405
|
+
say(`coverage for ${plural(recorded.files.size, "file")} at ${short(tip)}; ${covered} of ${executable} changed lines covered`);
|
|
1406
|
+
return 0;
|
|
1407
|
+
}
|
|
1408
|
+
/**
|
|
1409
|
+
* Up to PREVIEW_LINES tip lines from where a test starts, when the branch leaves its file alone:
|
|
1410
|
+
* the page shows the branch's own files from the plan, and the others need this to peek.
|
|
1411
|
+
*/
|
|
1412
|
+
async function testPreview(session, at) {
|
|
1413
|
+
const changed = new Set([...session.diff.units.values()].map((u) => u.path));
|
|
1414
|
+
if (changed.has(at.path))
|
|
1415
|
+
return undefined;
|
|
1416
|
+
const bytes = (await blobsAt(session.root, session.anchor.tip, [at.path])).get(at.path) ?? "";
|
|
1417
|
+
return splitLines(bytes)
|
|
1418
|
+
.slice(at.start - 1, at.start - 1 + PREVIEW_LINES)
|
|
1419
|
+
.map((line) => Buffer.from(line.replace(/\r?\n$/, ""), "latin1").toString("utf8"));
|
|
1420
|
+
}
|
|
1297
1421
|
/** The old name of report. */
|
|
1298
1422
|
async function feedback(cwd, args) {
|
|
1299
1423
|
console.error("follow-me: feedback is now report; printing the same");
|
|
@@ -1302,9 +1426,14 @@ async function feedback(cwd, args) {
|
|
|
1302
1426
|
function isCommentEvent(event) {
|
|
1303
1427
|
return event.type === "comment" || event.type === "comment-updated";
|
|
1304
1428
|
}
|
|
1305
|
-
/**
|
|
1429
|
+
/**
|
|
1430
|
+
* Events the reviewer caused; the agent's own replies, explanations, version swaps and coverage
|
|
1431
|
+
* are not news, and neither is a dismissed offer, which asks nothing of the agent.
|
|
1432
|
+
*/
|
|
1306
1433
|
function isNews(event) {
|
|
1307
|
-
if (event.type === "version")
|
|
1434
|
+
if (event.type === "version" || event.type === "coverage")
|
|
1435
|
+
return false;
|
|
1436
|
+
if (event.type === "offer-dismissed")
|
|
1308
1437
|
return false;
|
|
1309
1438
|
if (event.type === "comment" || event.type === "comment-updated") {
|
|
1310
1439
|
return event.comment.author !== "agent";
|
|
@@ -1385,12 +1514,18 @@ async function watch(cwd, args) {
|
|
|
1385
1514
|
/** Posts the agent's answer under a comment on the hosted page. */
|
|
1386
1515
|
async function reply(cwd, args) {
|
|
1387
1516
|
const [commentId, extra] = args.positionals;
|
|
1388
|
-
const
|
|
1517
|
+
const diagram = diagramOf(args);
|
|
1518
|
+
// A diagram can be the whole answer, as when it answers an accepted offer.
|
|
1519
|
+
const body = args.values.get("body") ?? (diagram === undefined ? undefined : "");
|
|
1389
1520
|
if (commentId === undefined || extra !== undefined || body === undefined) {
|
|
1390
|
-
throw new UsageError(
|
|
1521
|
+
throw new UsageError(REPLY_USAGE);
|
|
1522
|
+
}
|
|
1523
|
+
const mapFile = args.values.get("map");
|
|
1524
|
+
if (mapFile !== undefined && diagram === undefined) {
|
|
1525
|
+
throw new UsageError(`--map needs --diagram, the state diagram it ties to code\n${REPLY_USAGE}`);
|
|
1391
1526
|
}
|
|
1392
|
-
const diagram = diagramOf(args);
|
|
1393
1527
|
const session = await openSession(cwd, sessionOpts(args));
|
|
1528
|
+
const map = mapFile === undefined ? undefined : await readMap(session, mapFile, diagram);
|
|
1394
1529
|
const open = await openOnServer(session, null);
|
|
1395
1530
|
if (open === null)
|
|
1396
1531
|
return notOpen(session);
|
|
@@ -1398,11 +1533,59 @@ async function reply(cwd, args) {
|
|
|
1398
1533
|
author: "agent",
|
|
1399
1534
|
body,
|
|
1400
1535
|
...(diagram === undefined ? {} : { diagram }),
|
|
1536
|
+
...(map === undefined ? {} : { map }),
|
|
1401
1537
|
});
|
|
1402
1538
|
say(`replied ${id} to ${commentId}`);
|
|
1403
1539
|
next(`run follow-me again; ${STILL_WORKING}`);
|
|
1404
1540
|
return 0;
|
|
1405
1541
|
}
|
|
1542
|
+
const REPLY_USAGE = `usage: follow-me reply <commentId> --body <text> [--diagram <file> [--map <file>]]
|
|
1543
|
+
follow-me reply <commentId> --diagram <file> [--map <file>] [--body <text>]`;
|
|
1544
|
+
/** A map path as git names it: the JSON's UTF-8 text as the bytes git prints, read as latin1. */
|
|
1545
|
+
function repoPathOf(typed) {
|
|
1546
|
+
const path = Buffer.from(typed, "utf8").toString("latin1");
|
|
1547
|
+
if (!isRepoPath(path))
|
|
1548
|
+
throw new UsageError(`${typed} is not a repo path`);
|
|
1549
|
+
return path;
|
|
1550
|
+
}
|
|
1551
|
+
/**
|
|
1552
|
+
* --map's JSON, checked against the tip: every place is a file there and within its lines. Every
|
|
1553
|
+
* bad entry is named at once, so one fix round is enough.
|
|
1554
|
+
*/
|
|
1555
|
+
async function readMap(session, file, diagram) {
|
|
1556
|
+
let raw;
|
|
1557
|
+
try {
|
|
1558
|
+
raw = JSON.parse(readFileSync(file, "utf8"));
|
|
1559
|
+
}
|
|
1560
|
+
catch (error) {
|
|
1561
|
+
throw new UsageError(`cannot read ${file} as JSON: ${error instanceof Error ? error.message : error}`);
|
|
1562
|
+
}
|
|
1563
|
+
const typed = parseStateMap(raw, diagram);
|
|
1564
|
+
if (typeof typed === "string")
|
|
1565
|
+
throw new UsageError(`${file}: ${typed}`);
|
|
1566
|
+
const at = (loc) => ({ ...loc, path: repoPathOf(loc.path) });
|
|
1567
|
+
const map = {
|
|
1568
|
+
states: Object.fromEntries(Object.entries(typed.states).map(([name, locs]) => [name, locs.map(at)])),
|
|
1569
|
+
...(typed.transitions === undefined
|
|
1570
|
+
? {}
|
|
1571
|
+
: { transitions: typed.transitions.map((t) => ({ ...t, at: t.at.map(at) })) }),
|
|
1572
|
+
};
|
|
1573
|
+
const { root, anchor } = session;
|
|
1574
|
+
const named = [
|
|
1575
|
+
...Object.values(map.states).flat(),
|
|
1576
|
+
...(map.transitions ?? []).flatMap((t) => t.at),
|
|
1577
|
+
].map((loc) => loc.path);
|
|
1578
|
+
const tip = await pathsAt(root, anchor.tip);
|
|
1579
|
+
const present = [...new Set(named)].filter((p) => tip.has(p));
|
|
1580
|
+
const lines = new Map([...(await blobsAt(root, anchor.tip, present))].map(([p, bytes]) => [
|
|
1581
|
+
p,
|
|
1582
|
+
splitLines(bytes).length,
|
|
1583
|
+
]));
|
|
1584
|
+
const problems = mapOffTip(map, lines, (p) => Buffer.from(p, "latin1").toString("utf8"));
|
|
1585
|
+
if (problems.length > 0)
|
|
1586
|
+
throw new UsageError(`${file}:\n ${problems.join("\n ")}`);
|
|
1587
|
+
return map;
|
|
1588
|
+
}
|
|
1406
1589
|
/** The Mermaid source in --diagram's file, or undefined without the flag. */
|
|
1407
1590
|
function diagramOf(args) {
|
|
1408
1591
|
const file = args.values.get("diagram");
|
|
@@ -1871,13 +2054,15 @@ export async function main(argv, cwd) {
|
|
|
1871
2054
|
return await watch(cwd, parseArgs(rest, spec));
|
|
1872
2055
|
}
|
|
1873
2056
|
case "reply":
|
|
1874
|
-
return await reply(cwd, parseArgs(rest, { values: ["body", "diagram", "branch"] }));
|
|
2057
|
+
return await reply(cwd, parseArgs(rest, { values: ["body", "diagram", "map", "branch"] }));
|
|
1875
2058
|
case "explain": {
|
|
1876
2059
|
const spec = { values: ["commit", "unit", "lines", "body", "diagram", "branch"] };
|
|
1877
2060
|
return await explain(cwd, parseArgs(rest, spec));
|
|
1878
2061
|
}
|
|
1879
2062
|
case "show":
|
|
1880
2063
|
return await showRef(cwd, parseArgs(rest, { values: ["branch"] }));
|
|
2064
|
+
case "coverage":
|
|
2065
|
+
return await coverage(cwd, parseArgs(rest, { values: ["test", "at", "branch"], flags: ["reset"] }));
|
|
1881
2066
|
case "report":
|
|
1882
2067
|
return await report(cwd, parseArgs(rest, { values: ["branch"], flags: ["json", "markdown"] }));
|
|
1883
2068
|
case "clean": {
|
|
@@ -1918,6 +2103,8 @@ export async function main(argv, cwd) {
|
|
|
1918
2103
|
catch (error) {
|
|
1919
2104
|
const known = [
|
|
1920
2105
|
UsageError,
|
|
2106
|
+
CoverageError,
|
|
2107
|
+
LcovError,
|
|
1921
2108
|
BranchMovedError,
|
|
1922
2109
|
BranchRefusedError,
|
|
1923
2110
|
SessionFileError,
|
package/dist/src/comments.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { parseLoc } from "./coverage.js";
|
|
2
|
+
import { isRepoPath, isSha, isUnitId } from "./diff.js";
|
|
3
|
+
import { diagramProblem, isArray, isCommentId, isConcernId, isFields, isOfferId, isPlanVersion, isSeverity, parsePlacedConcern, } from "./plan.js";
|
|
3
4
|
/**
|
|
4
5
|
* The wire formats of review state, parsed once at the boundary: comments, replies, concern
|
|
5
6
|
* decisions, the review file, its autosaved draft and the events a hosted session logs. Every
|
|
@@ -48,10 +49,110 @@ function parseAnchor(v) {
|
|
|
48
49
|
}
|
|
49
50
|
return { kind: "hunk", unit: v.unit, commit, changes, offsets };
|
|
50
51
|
}
|
|
52
|
+
function parseLocs(v, at, needed) {
|
|
53
|
+
const what = `${at}: expected a list of { path, start, end }${needed ? " with at least one place" : ""}`;
|
|
54
|
+
if (!isArray(v) || (needed && v.length === 0))
|
|
55
|
+
return what;
|
|
56
|
+
const out = [];
|
|
57
|
+
for (const [i, raw] of v.entries()) {
|
|
58
|
+
const loc = parseLoc(raw);
|
|
59
|
+
if (loc === null)
|
|
60
|
+
return `${at}[${i}]: expected { path, start, end } with 1 <= start <= end`;
|
|
61
|
+
out.push(loc);
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
const TRANSITION_ENDS = ["from", "to"];
|
|
66
|
+
function escapeRegExp(text) {
|
|
67
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Boundary parse of a reply's state map. Every state has at least one place, every transition
|
|
71
|
+
* names states of the map, and with a stateDiagram every state name appears in it. The reason
|
|
72
|
+
* names the bad entry, as map.states.<name>[i] or map.transitions[i].<field>.
|
|
73
|
+
*/
|
|
74
|
+
export function parseStateMap(v, diagram) {
|
|
75
|
+
if (!isFields(v))
|
|
76
|
+
return "map: expected { states, transitions }";
|
|
77
|
+
if (!isFields(v.states) || Object.keys(v.states).length === 0) {
|
|
78
|
+
return "map.states: expected an object of state name to places";
|
|
79
|
+
}
|
|
80
|
+
const states = [];
|
|
81
|
+
for (const [name, raw] of Object.entries(v.states)) {
|
|
82
|
+
if (name.trim() === "")
|
|
83
|
+
return "map.states: a state has an empty name";
|
|
84
|
+
const locs = parseLocs(raw, `map.states.${name}`, true);
|
|
85
|
+
if (typeof locs === "string")
|
|
86
|
+
return locs;
|
|
87
|
+
states.push([name, locs]);
|
|
88
|
+
}
|
|
89
|
+
const named = new Set(states.map(([name]) => name));
|
|
90
|
+
const transitions = [];
|
|
91
|
+
const rawTransitions = v.transitions ?? [];
|
|
92
|
+
if (!isArray(rawTransitions))
|
|
93
|
+
return "map.transitions: expected a list";
|
|
94
|
+
for (const [i, raw] of rawTransitions.entries()) {
|
|
95
|
+
const at = `map.transitions[${i}]`;
|
|
96
|
+
if (!isFields(raw))
|
|
97
|
+
return `${at}: expected { from, to, event, at }`;
|
|
98
|
+
for (const end of TRANSITION_ENDS) {
|
|
99
|
+
const state = raw[end];
|
|
100
|
+
if (typeof state !== "string" || !named.has(state)) {
|
|
101
|
+
return `${at}.${end}: ${JSON.stringify(state)} is not a state in map.states`;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
const { from, to, event } = raw;
|
|
105
|
+
if (typeof from !== "string" || typeof to !== "string")
|
|
106
|
+
return `${at}: expected from and to`;
|
|
107
|
+
if (event !== undefined && typeof event !== "string")
|
|
108
|
+
return `${at}.event: expected a string`;
|
|
109
|
+
const locs = parseLocs(raw.at, `${at}.at`, false);
|
|
110
|
+
if (typeof locs === "string")
|
|
111
|
+
return locs;
|
|
112
|
+
transitions.push({ from, to, ...(event === undefined ? {} : { event }), at: locs });
|
|
113
|
+
}
|
|
114
|
+
const first = (diagram ?? "")
|
|
115
|
+
.split("\n")
|
|
116
|
+
.find((line) => line.trim() !== "")
|
|
117
|
+
?.trim() ?? "";
|
|
118
|
+
if (first.startsWith("stateDiagram")) {
|
|
119
|
+
for (const name of named) {
|
|
120
|
+
if (!new RegExp(`(^|[^\\w])${escapeRegExp(name)}([^\\w]|$)`, "m").test(diagram ?? "")) {
|
|
121
|
+
return `map.states.${name}: not a state of the stateDiagram`;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return {
|
|
126
|
+
states: Object.fromEntries(states),
|
|
127
|
+
...(transitions.length === 0 ? {} : { transitions }),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Where a map points past the tip: a path the tip has no file at, or lines beyond its end. lines
|
|
132
|
+
* holds the line count of every tip file the map names that exists; show prints a path.
|
|
133
|
+
*/
|
|
134
|
+
export function mapOffTip(map, lines, show = (path) => path) {
|
|
135
|
+
const check = (loc, at) => {
|
|
136
|
+
const count = lines.get(loc.path);
|
|
137
|
+
if (count === undefined)
|
|
138
|
+
return [`${at}: ${show(loc.path)} is not a file at the tip`];
|
|
139
|
+
if (loc.end > count) {
|
|
140
|
+
return [
|
|
141
|
+
`${at}: lines ${loc.start}-${loc.end} are past the end of ${show(loc.path)}, which has ${count} lines at the tip`,
|
|
142
|
+
];
|
|
143
|
+
}
|
|
144
|
+
return [];
|
|
145
|
+
};
|
|
146
|
+
return [
|
|
147
|
+
...Object.entries(map.states).flatMap(([name, locs]) => locs.flatMap((loc, i) => check(loc, `map.states.${name}[${i}]`))),
|
|
148
|
+
...(map.transitions ?? []).flatMap((t, i) => t.at.flatMap((loc, j) => check(loc, `map.transitions[${i}].at[${j}]`))),
|
|
149
|
+
];
|
|
150
|
+
}
|
|
51
151
|
/** A stored reply. Replies a client posts go through parseNewReply, which mints id and time. */
|
|
52
152
|
export function parseReply(v) {
|
|
53
|
-
if (!isFields(v))
|
|
153
|
+
if (!isFields(v)) {
|
|
54
154
|
return "not an object";
|
|
155
|
+
}
|
|
55
156
|
if (!isReplyId(v.id))
|
|
56
157
|
return "id is not a reply id";
|
|
57
158
|
if (v.author !== "agent" && v.author !== "reviewer")
|
|
@@ -61,30 +162,50 @@ export function parseReply(v) {
|
|
|
61
162
|
}
|
|
62
163
|
if (v.diagram !== undefined && typeof v.diagram !== "string")
|
|
63
164
|
return "diagram is not a string";
|
|
165
|
+
const map = v.map === undefined ? undefined : parseStateMap(v.map, v.diagram);
|
|
166
|
+
if (typeof map === "string")
|
|
167
|
+
return map;
|
|
64
168
|
return {
|
|
65
169
|
id: v.id,
|
|
66
170
|
author: v.author,
|
|
67
171
|
body: v.body,
|
|
68
172
|
...(v.diagram === undefined ? {} : { diagram: v.diagram }),
|
|
173
|
+
...(map === undefined ? {} : { map }),
|
|
69
174
|
createdAt: v.createdAt,
|
|
70
175
|
};
|
|
71
176
|
}
|
|
72
|
-
/**
|
|
177
|
+
/**
|
|
178
|
+
* 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.
|
|
180
|
+
*/
|
|
73
181
|
export function parseNewReply(v) {
|
|
74
182
|
if (!isFields(v))
|
|
75
183
|
return "the reply is not an object";
|
|
76
184
|
if (v.author !== "agent" && v.author !== "reviewer")
|
|
77
185
|
return 'author is not "agent" or "reviewer"';
|
|
78
|
-
if (typeof v.body !== "string"
|
|
79
|
-
return "body is not a
|
|
80
|
-
if (v.diagram === undefined)
|
|
186
|
+
if (typeof v.body !== "string")
|
|
187
|
+
return "body is not a string";
|
|
188
|
+
if (v.diagram === undefined) {
|
|
189
|
+
if (v.map !== undefined)
|
|
190
|
+
return "map needs the diagram it ties to code";
|
|
191
|
+
if (v.body.trim() === "")
|
|
192
|
+
return "body is empty; a reply needs words or a diagram";
|
|
81
193
|
return { author: v.author, body: v.body };
|
|
194
|
+
}
|
|
82
195
|
if (typeof v.diagram !== "string")
|
|
83
196
|
return "diagram is not a string";
|
|
84
197
|
const problem = diagramProblem(v.diagram);
|
|
85
198
|
if (problem !== null)
|
|
86
199
|
return `diagram: ${problem}`;
|
|
87
|
-
|
|
200
|
+
const map = v.map === undefined ? undefined : parseStateMap(v.map, v.diagram);
|
|
201
|
+
if (typeof map === "string")
|
|
202
|
+
return map;
|
|
203
|
+
return {
|
|
204
|
+
author: v.author,
|
|
205
|
+
body: v.body,
|
|
206
|
+
diagram: v.diagram,
|
|
207
|
+
...(map === undefined ? {} : { map }),
|
|
208
|
+
};
|
|
88
209
|
}
|
|
89
210
|
function isString(v) {
|
|
90
211
|
return typeof v === "string";
|
|
@@ -133,6 +254,14 @@ export function parseComment(v) {
|
|
|
133
254
|
const severity = v.severity;
|
|
134
255
|
if (severity !== undefined && !isSeverity(severity))
|
|
135
256
|
return "severity is unknown";
|
|
257
|
+
const offer = v.offer;
|
|
258
|
+
if (offer !== undefined) {
|
|
259
|
+
if (!isOfferId(offer))
|
|
260
|
+
return "offer is not an offer id";
|
|
261
|
+
if (v.kind !== "question" || v.author !== undefined) {
|
|
262
|
+
return "offer is only for a reviewer's question";
|
|
263
|
+
}
|
|
264
|
+
}
|
|
136
265
|
const replies = parseList(v.replies, parseReply, "replies");
|
|
137
266
|
if (typeof replies === "string")
|
|
138
267
|
return replies;
|
|
@@ -152,6 +281,7 @@ export function parseComment(v) {
|
|
|
152
281
|
...(replies.length === 0 ? {} : { replies }),
|
|
153
282
|
...(v.author === "agent" ? { author: "agent" } : {}),
|
|
154
283
|
...(v.diagram === undefined ? {} : { diagram: v.diagram }),
|
|
284
|
+
...(offer === undefined ? {} : { offer }),
|
|
155
285
|
};
|
|
156
286
|
}
|
|
157
287
|
function withDecision(concern, v) {
|
|
@@ -363,6 +493,9 @@ export function parseReview(v) {
|
|
|
363
493
|
const concernsCarried = parseList(v.carriedConcerns, parseCarriedConcern, "carriedConcerns");
|
|
364
494
|
if (typeof concernsCarried === "string")
|
|
365
495
|
return concernsCarried;
|
|
496
|
+
const dismissed = parseIds(v.dismissedOffers, isOfferId, "dismissedOffers", "offer");
|
|
497
|
+
if (typeof dismissed === "string")
|
|
498
|
+
return dismissed;
|
|
366
499
|
return {
|
|
367
500
|
schema: v.schema,
|
|
368
501
|
mode,
|
|
@@ -376,6 +509,7 @@ export function parseReview(v) {
|
|
|
376
509
|
...(earlier.length === 0 ? {} : { earlierReplies: earlier }),
|
|
377
510
|
...(carried === null ? {} : { carried }),
|
|
378
511
|
...(concernsCarried.length === 0 ? {} : { carriedConcerns: concernsCarried }),
|
|
512
|
+
...(dismissed.length === 0 ? {} : { dismissedOffers: dismissed }),
|
|
379
513
|
};
|
|
380
514
|
}
|
|
381
515
|
function lenient(items, parse) {
|
|
@@ -403,6 +537,7 @@ export function parseDraft(v) {
|
|
|
403
537
|
earlierReplies: lenient(v.earlierReplies, parseEarlierReplies),
|
|
404
538
|
carried: v.carried === undefined ? null : lenient(v.carried, parseCarriedRef),
|
|
405
539
|
carriedConcerns: lenient(v.carriedConcerns, parseCarriedConcern),
|
|
540
|
+
dismissedOffers: lenientIds(v.dismissedOffers, isOfferId),
|
|
406
541
|
};
|
|
407
542
|
}
|
|
408
543
|
export function parseEvent(v) {
|
|
@@ -444,6 +579,10 @@ export function parseEvent(v) {
|
|
|
444
579
|
}
|
|
445
580
|
return { seq, type: v.type, commentId: id, resolved: v.resolved };
|
|
446
581
|
}
|
|
582
|
+
case "offer-dismissed":
|
|
583
|
+
return isOfferId(v.offer)
|
|
584
|
+
? { seq, type: v.type, offer: v.offer }
|
|
585
|
+
: "offer is not an offer id";
|
|
447
586
|
case "version": {
|
|
448
587
|
if (!isPlanVersion(v.version) || typeof v.hash !== "string")
|
|
449
588
|
return "malformed version event";
|
|
@@ -452,6 +591,11 @@ export function parseEvent(v) {
|
|
|
452
591
|
return carried;
|
|
453
592
|
return { seq, type: v.type, version: v.version, hash: v.hash, carried };
|
|
454
593
|
}
|
|
594
|
+
case "coverage":
|
|
595
|
+
if (typeof v.tip !== "string" || !isSha(v.tip) || typeof v.takenAt !== "string") {
|
|
596
|
+
return "malformed coverage event";
|
|
597
|
+
}
|
|
598
|
+
return { seq, type: v.type, tip: v.tip, takenAt: v.takenAt };
|
|
455
599
|
case "submitted":
|
|
456
600
|
return { seq, type: v.type };
|
|
457
601
|
case "ended":
|