@shiyuphan/follow-me 0.6.0 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -1
- package/dist/src/cli.js +94 -11
- package/dist/src/comments.js +6 -1
- package/dist/src/coverage.js +397 -0
- package/dist/src/git.js +18 -0
- package/dist/src/host.js +4 -0
- package/dist/src/prompt.js +58 -23
- package/dist/src/server.js +50 -8
- package/dist/src/session.js +33 -0
- package/dist/src/types.js +2 -0
- package/dist/ui/dist/assets/{abnfDiagram-WEYOIA3C-fYSYLdMl.js → abnfDiagram-WEYOIA3C-cHEJvcBn.js} +1 -1
- package/dist/ui/dist/assets/{arc-DGuGSnwa.js → arc-xolFyc0v.js} +1 -1
- package/dist/ui/dist/assets/{architecture-WOLXFQ4H-VFox_hV1.js → architecture-WOLXFQ4H-B3hvvNV2.js} +1 -1
- package/dist/ui/dist/assets/{architectureDiagram-ZHVYKT53-CBVK3nHZ.js → architectureDiagram-ZHVYKT53-BNTMbPJW.js} +1 -1
- package/dist/ui/dist/assets/{blockDiagram-RJAE7QA3-BFdsIWEr.js → blockDiagram-RJAE7QA3-NYY6qeuY.js} +1 -1
- package/dist/ui/dist/assets/{c4Diagram-XDPPPE54-BGntcPId.js → c4Diagram-XDPPPE54-B_bNpXHQ.js} +1 -1
- package/dist/ui/dist/assets/channel-DPDY0XJP.js +1 -0
- package/dist/ui/dist/assets/{chunk-2E7WHVMD-CNMuK9zj.js → chunk-2E7WHVMD-BTet9STn.js} +1 -1
- package/dist/ui/dist/assets/{chunk-4EMNKR2T-ByeTTJWS.js → chunk-4EMNKR2T-qoQt4Fpx.js} +1 -1
- package/dist/ui/dist/assets/{chunk-55XCU76K-99xE7cLH.js → chunk-55XCU76K-C3I6EB-H.js} +1 -1
- package/dist/ui/dist/assets/{chunk-7S6XSTFO-ZVmT7eMZ.js → chunk-7S6XSTFO-CL-9s_8p.js} +1 -1
- package/dist/ui/dist/assets/{chunk-AQDTSKZB-B981JORL.js → chunk-AQDTSKZB-DHoGnGyP.js} +1 -1
- package/dist/ui/dist/assets/{chunk-DSNLSHJZ-B2z7FX4h.js → chunk-DSNLSHJZ-Bk8WO5JF.js} +1 -1
- package/dist/ui/dist/assets/{chunk-HAGFOTED-CHFbBS-4.js → chunk-HAGFOTED-CwAC85lg.js} +1 -1
- package/dist/ui/dist/assets/{chunk-HNE4Q36P-rq11xv9X.js → chunk-HNE4Q36P-CIllywWr.js} +1 -1
- package/dist/ui/dist/assets/{chunk-NQOKRVIF-BYmg9kSX.js → chunk-NQOKRVIF-DDlpyYiH.js} +1 -1
- package/dist/ui/dist/assets/{chunk-UHW7PFBP-JvL9IXWg.js → chunk-UHW7PFBP-Cxdndcon.js} +1 -1
- package/dist/ui/dist/assets/{chunk-VQ5W62QI-CYMQaAKn.js → chunk-VQ5W62QI-CL9d9_iJ.js} +1 -1
- package/dist/ui/dist/assets/{chunk-WQQL4CDK-CXnR0PHT.js → chunk-WQQL4CDK-C4kzN8QU.js} +1 -1
- package/dist/ui/dist/assets/{chunk-XXDRQBXY-CDUWJCXi.js → chunk-XXDRQBXY-Cv_2FSiY.js} +1 -1
- package/dist/ui/dist/assets/{chunk-YZO67HFG-mZfXIx0h.js → chunk-YZO67HFG-oHiuYaCJ.js} +1 -1
- package/dist/ui/dist/assets/{chunk-ZAUX2LTX-DBxVTCHX.js → chunk-ZAUX2LTX-Cy_8d0XT.js} +1 -1
- package/dist/ui/dist/assets/{chunk-ZCHMQSOR-DrWA4IBo.js → chunk-ZCHMQSOR-piIpIcQi.js} +1 -1
- package/dist/ui/dist/assets/{classDiagram-v2-4MYWR3UA-Beah5uxA.js → classDiagram-v2-4MYWR3UA-DNKRIujO.js} +1 -1
- package/dist/ui/dist/assets/{cose-bilkent-JH36ORCC-UaE-jnHk.js → cose-bilkent-JH36ORCC-BWKTGCHY.js} +1 -1
- package/dist/ui/dist/assets/{cynefin-EF2NZ3EQ-DKfGaCR8.js → cynefin-EF2NZ3EQ-yx7KKkAk.js} +1 -1
- package/dist/ui/dist/assets/{cynefinDiagram-WF24UDRP-tTUzTs3D.js → cynefinDiagram-WF24UDRP-BTnZ7QVL.js} +1 -1
- package/dist/ui/dist/assets/{dagre-NK4GIZVX-jSn35_T7.js → dagre-NK4GIZVX-CPh5Zn5I.js} +1 -1
- package/dist/ui/dist/assets/{diagram-CBK3MWTC-7W2uw8Zc.js → diagram-CBK3MWTC-pI6NFdrD.js} +1 -1
- package/dist/ui/dist/assets/{diagram-I5XIIV2D-CcfyQVh9.js → diagram-I5XIIV2D-DUIfARoS.js} +1 -1
- package/dist/ui/dist/assets/{diagram-ODIL2UCW-BjGvaLis.js → diagram-ODIL2UCW-lafSPvAw.js} +1 -1
- package/dist/ui/dist/assets/{diagram-PFBEJUXE-jvdCrt8B.js → diagram-PFBEJUXE-u1lZas3a.js} +1 -1
- package/dist/ui/dist/assets/{diagram-RJA3I4VV-D65_Vpi_.js → diagram-RJA3I4VV-mc0GLMHo.js} +1 -1
- package/dist/ui/dist/assets/{diagram-TYUNFSNR-C52roQBE.js → diagram-TYUNFSNR-1NRbBrZW.js} +1 -1
- package/dist/ui/dist/assets/{dist-BJd4jp9Y.js → dist-CHZ9mrl_.js} +1 -1
- package/dist/ui/dist/assets/{ebnfDiagram-V4P3TTPT-DXysoIBq.js → ebnfDiagram-V4P3TTPT-DdStjTDc.js} +1 -1
- package/dist/ui/dist/assets/{elk-QONTBCTQ-BVZcsgV3.js → elk-QONTBCTQ-BzRoFO45.js} +1 -1
- package/dist/ui/dist/assets/{erDiagram-KYQIGQAA-BJnT4fVU.js → erDiagram-KYQIGQAA-DlMkgQxj.js} +1 -1
- package/dist/ui/dist/assets/{eventmodeling-K75KTNOO-C5MzxCMY.js → eventmodeling-K75KTNOO-BRYIOrod.js} +1 -1
- package/dist/ui/dist/assets/flowDiagram-IUGLLWBU-C1IBh_GZ.js +1 -0
- package/dist/ui/dist/assets/{ganttDiagram-R7GPNTJY-CZRrnuah.js → ganttDiagram-R7GPNTJY-D8fQZ7Ah.js} +1 -1
- package/dist/ui/dist/assets/{gitGraph-VSP46ZUC-l58aGSkh.js → gitGraph-VSP46ZUC-C7ZydyWX.js} +1 -1
- package/dist/ui/dist/assets/{gitGraphDiagram-PDYL7XHY-D4ks1WO-.js → gitGraphDiagram-PDYL7XHY-DCItam0v.js} +1 -1
- package/dist/ui/dist/assets/index-8BehwQ7E.js +134 -0
- package/dist/ui/dist/assets/index-mC2OW2Av.css +1 -0
- package/dist/ui/dist/assets/{info-OHQRW6UA-BSqfzkdk.js → info-OHQRW6UA-C3KneBKT.js} +1 -1
- package/dist/ui/dist/assets/{infoDiagram-MVDRKAX5-B6NgVF_N.js → infoDiagram-MVDRKAX5-6wK09D32.js} +1 -1
- package/dist/ui/dist/assets/{ishikawaDiagram-HPEHV2GE-DvtfssEZ.js → ishikawaDiagram-HPEHV2GE-DTP5C8VX.js} +1 -1
- package/dist/ui/dist/assets/{journeyDiagram-BOSHW7O7-DM64gT5v.js → journeyDiagram-BOSHW7O7-B--UIMtq.js} +1 -1
- package/dist/ui/dist/assets/{kanban-definition-FNSA4HC5-AliyRJHy.js → kanban-definition-FNSA4HC5-Ku4N9QGt.js} +1 -1
- package/dist/ui/dist/assets/{line-CHncXbKO.js → line-C4-H5h1-.js} +1 -1
- package/dist/ui/dist/assets/{linear-BWEWydI0.js → linear-DhAID3hm.js} +1 -1
- package/dist/ui/dist/assets/{mermaid-parser.core-DGpOP8AM.js → mermaid-parser.core-CMPNc-u6.js} +3 -3
- package/dist/ui/dist/assets/{mermaid.core-BJtBFbJx.js → mermaid.core-Nd_xOg2y.js} +4 -4
- package/dist/ui/dist/assets/{mindmap-definition-6OY57UG3-NjavnWBi.js → mindmap-definition-6OY57UG3-BlVsym5h.js} +1 -1
- package/dist/ui/dist/assets/{packet-JDAUHWVQ-zuhpVsME.js → packet-JDAUHWVQ-rsk7SM7S.js} +1 -1
- package/dist/ui/dist/assets/{pegDiagram-ECIUJNBX-sL4grM97.js → pegDiagram-ECIUJNBX-C0L20M8z.js} +1 -1
- package/dist/ui/dist/assets/{pie-XZMESJXO-C9l1Mp09.js → pie-XZMESJXO-B23-iZSN.js} +1 -1
- package/dist/ui/dist/assets/{pieDiagram-GNMPY74T-rlRBr2U4.js → pieDiagram-GNMPY74T-BqFhe4Oh.js} +1 -1
- package/dist/ui/dist/assets/{prism-core-DmuST30i.js → prism-core-DYsiqJeR.js} +1 -1
- package/dist/ui/dist/assets/{quadrantDiagram-D57K4MC3-4oQMip2Q.js → quadrantDiagram-D57K4MC3-BKP75B-m.js} +1 -1
- package/dist/ui/dist/assets/{radar-ABXABTNO-DvGg96L7.js → radar-ABXABTNO-BrEk2mQ7.js} +1 -1
- package/dist/ui/dist/assets/{railroad-I3PHUGI6-8wdFKMZp.js → railroad-I3PHUGI6-wWO4Eapc.js} +1 -1
- package/dist/ui/dist/assets/{railroad-abnf-LNEFI6M7-DUtOO0gn.js → railroad-abnf-LNEFI6M7-DfY-IPvE.js} +1 -1
- package/dist/ui/dist/assets/{railroad-ebnf-DWYD2UWJ-5YCGY1_d.js → railroad-ebnf-DWYD2UWJ-19wedWGk.js} +1 -1
- package/dist/ui/dist/assets/{railroad-peg-JR7BVNR7-Bwywxjhs.js → railroad-peg-JR7BVNR7-9vIslnoJ.js} +1 -1
- package/dist/ui/dist/assets/{railroadDiagram-Y72NRBL7-CwO6HSNQ.js → railroadDiagram-Y72NRBL7-DVnmXs8w.js} +1 -1
- package/dist/ui/dist/assets/{requirementDiagram-EQYSW5RA-Cp0IbfNr.js → requirementDiagram-EQYSW5RA-BEoMdlzR.js} +1 -1
- package/dist/ui/dist/assets/{sankeyDiagram-SY24JHO5-DeQKsO68.js → sankeyDiagram-SY24JHO5-D7Tn2Gbs.js} +1 -1
- package/dist/ui/dist/assets/{sequenceDiagram-BLEHNHQU-DTDalCxF.js → sequenceDiagram-BLEHNHQU-CyjUDBaQ.js} +1 -1
- package/dist/ui/dist/assets/{src-XnrqevnL.js → src-BiC9XNcI.js} +1 -1
- package/dist/ui/dist/assets/{stateDiagram-v2-A6IESKWZ-2DZ-tKo9.js → stateDiagram-v2-A6IESKWZ-CMp1VCO9.js} +1 -1
- package/dist/ui/dist/assets/{swimlanes-B3ENRKFQ-66yZJeRU.js → swimlanes-B3ENRKFQ-KEciLd6r.js} +1 -1
- package/dist/ui/dist/assets/swimlanesDiagram-AYQRV4AK-DzestpDE.js +8 -0
- package/dist/ui/dist/assets/{timeline-definition-JXYKGLGB-Dm1xGU-d.js → timeline-definition-JXYKGLGB-C8YQtv7M.js} +1 -1
- package/dist/ui/dist/assets/{treeView-D4JQ5SDB-BvgsbSot.js → treeView-D4JQ5SDB-DgAzPaOO.js} +1 -1
- package/dist/ui/dist/assets/{treemap-SAJKECNS-C3lnbG1a.js → treemap-SAJKECNS-eCRqZMYc.js} +1 -1
- package/dist/ui/dist/assets/{usecaseDiagram-WPOLBNNX-B6iPNIhZ.js → usecaseDiagram-WPOLBNNX-Dirryw9G.js} +1 -1
- package/dist/ui/dist/assets/{vennDiagram-Q3Y3IDCW-D5dgflg9.js → vennDiagram-Q3Y3IDCW-BjxQbAUx.js} +1 -1
- package/dist/ui/dist/assets/{wardley-7MLQ67FV-Dn9mLIB4.js → wardley-7MLQ67FV-CqHX5RUz.js} +1 -1
- package/dist/ui/dist/assets/{wardleyDiagram-IVAIDKWA-W1EwzN_X.js → wardleyDiagram-IVAIDKWA-Ds1HoEUY.js} +1 -1
- package/dist/ui/dist/assets/{xychartDiagram-4URNHDU4-CKRzMUvp.js → xychartDiagram-4URNHDU4-kodzDb12.js} +1 -1
- package/dist/ui/dist/index.html +2 -2
- package/package.json +1 -1
- package/skills/follow-me/SKILL.md +2 -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,16 @@ 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
|
+
|
|
78
81
|

|
|
79
82
|
*Diagrams sit full width under the commit or reply they explain, and open full size on click.*
|
|
80
83
|
|
|
81
84
|

|
|
82
85
|
*Submit review. Each item carries the original branch's path and line, so your agent can post it to the merge request as is.*
|
|
83
86
|
|
|
84
|
-
Keys: `[` shows or hides the index, `j` and `k` move between commits, Cmd-click (Ctrl-click) goes to a definition and Alt+Left comes back, arrows and Shift+arrows pick rows inside a hunk, `c` comments on the pick, `v` marks a commit viewed, `?` lists the rest. An unfinished review autosaves and comes back when the page is opened again.
|
|
87
|
+
Keys: `[` shows or hides the index, `j` and `k` move between commits, Cmd-click (Ctrl-click) goes to a definition and Alt+Left comes back, arrows and Shift+arrows pick rows inside a hunk, `c` comments on the pick, `v` marks a commit viewed, `g` shows or hides coverage and `x` jumps to the next uncovered line, `?` lists the rest. An unfinished review autosaves and comes back when the page is opened again.
|
|
85
88
|
|
|
86
89
|
### Reviewing someone else's branch
|
|
87
90
|
|
package/dist/src/cli.js
CHANGED
|
@@ -1,22 +1,27 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
2
|
import { readFileSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
|
-
import { dirname, join, relative } from "node:path";
|
|
4
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { changedLines, LcovError, parseLcov, parseLocArg, recordRun, runOf, tipPlacer, totalOf, } from "./coverage.js";
|
|
6
7
|
import { definitionIndex, isIdentifier } from "./definition.js";
|
|
7
8
|
import { DiffParseError, formatUnitRef, isUnitId } from "./diff.js";
|
|
8
|
-
import { BranchRefusedError, branchHead, GitError, isOwnWork, signing, treeOf, writeBranch, } from "./git.js";
|
|
9
|
-
import { fetchCarried, fetchComments, fetchReport, findServer, followEvents, idleMs, listSessions, openBrowser, pollEvents, portFallbackMessage, postComment, postReply, probeHealth, readServerRecord, registerWith, sessionUrl, spawnDetached, startOrAttach, stateDir, stillHosted, stopServer, swapVersion, } from "./host.js";
|
|
9
|
+
import { BranchRefusedError, branchHead, GitError, isOwnWork, pathsAt, pathsChanged, signing, treeOf, writeBranch, } from "./git.js";
|
|
10
|
+
import { fetchCarried, fetchComments, fetchReport, findServer, followEvents, idleMs, listSessions, openBrowser, pollEvents, portFallbackMessage, postComment, postCoverage, postReply, probeHealth, readServerRecord, registerWith, sessionUrl, spawnDetached, startOrAttach, stateDir, stillHosted, stopServer, swapVersion, } from "./host.js";
|
|
10
11
|
import { findSkillDir, InstallError, runInstall, runUninstall, syncSkills, } from "./install.js";
|
|
11
12
|
import { naiveDraft } from "./naive.js";
|
|
12
13
|
import { isCommentId, isConcernId, isFields, serializePlanFile, validatePlan, } from "./plan.js";
|
|
13
14
|
import { renderEvents, renderPrompt, renderReport, renderShownComment, renderShownConcern, renderShownDefinitions, renderShownUnit, reportItems, } from "./prompt.js";
|
|
14
15
|
import { RegistrationError, sessionIdOf } from "./server.js";
|
|
15
|
-
import { BranchMovedError, cleanSessions, extractSession, MissingDraftError, NotARepositoryError, openSession, SessionFileError, SessionLockedError, sessionComments, sinceLastPlan, } from "./session.js";
|
|
16
|
+
import { BranchMovedError, cleanSessions, coverageAt, extractSession, MissingDraftError, NotARepositoryError, openSession, SessionFileError, SessionLockedError, sessionComments, sinceLastPlan, } from "./session.js";
|
|
16
17
|
import { checkSyntax } from "./syntax.js";
|
|
17
18
|
class UsageError extends Error {
|
|
18
19
|
name = "UsageError";
|
|
19
20
|
}
|
|
21
|
+
/** A test run follow-me will not record, with the reason the agent can act on. */
|
|
22
|
+
class CoverageError extends Error {
|
|
23
|
+
name = "CoverageError";
|
|
24
|
+
}
|
|
20
25
|
const SHORT_USAGE = `usage: follow-me [--wait <seconds>] [--branch <name>]
|
|
21
26
|
|
|
22
27
|
Run follow-me with no command. Each call does the next thing that is missing and returns: it
|
|
@@ -37,6 +42,8 @@ Other commands:
|
|
|
37
42
|
is defined, instead of reading files
|
|
38
43
|
report [--json] [--markdown] print the report of the latest review; --json lists
|
|
39
44
|
the items to post to a merge request
|
|
45
|
+
coverage <lcov> [--test <id> [--at <path:line>]]
|
|
46
|
+
record a test run taken at the tip for the page
|
|
40
47
|
|
|
41
48
|
help --all lists every command.`;
|
|
42
49
|
const USAGE = `usage: follow-me <command> [options]
|
|
@@ -100,6 +107,11 @@ Interactive loop (while the review page is open):
|
|
|
100
107
|
line in both voices, then questions with answers;
|
|
101
108
|
--json lists the items to post to a merge request
|
|
102
109
|
(path, lines, severity, body, thread, excluded)
|
|
110
|
+
coverage <lcov> [--test <id> [--at <path:line>]] [--branch <name>]
|
|
111
|
+
record the lcov of a test run at the branch tip;
|
|
112
|
+
refuses when a covered file in the working tree
|
|
113
|
+
differs from the tip. A plain run replaces the last;
|
|
114
|
+
--test runs add one test each, --at is where it is
|
|
103
115
|
apply, while the page is open, swaps it to the new plan version in place, same URL.
|
|
104
116
|
|
|
105
117
|
Old names, still working:
|
|
@@ -909,8 +921,8 @@ function reviewedPlan(session) {
|
|
|
909
921
|
return null;
|
|
910
922
|
}
|
|
911
923
|
/** Prints the finished review's report, which is what the agent works from from here on. */
|
|
912
|
-
function finished(session, stored) {
|
|
913
|
-
const text = renderStored(session, stored);
|
|
924
|
+
async function finished(session, stored) {
|
|
925
|
+
const text = await renderStored(session, stored);
|
|
914
926
|
if (text === null)
|
|
915
927
|
return 1;
|
|
916
928
|
// A review that swapped versions holds what its earlier versions said too.
|
|
@@ -1173,6 +1185,9 @@ async function entry(cwd, args) {
|
|
|
1173
1185
|
const result = await hostReview(session, mode, detached, async () => prep, reportSaved);
|
|
1174
1186
|
if (result.kind !== "elsewhere")
|
|
1175
1187
|
return exitCode(result);
|
|
1188
|
+
if (session.coverage() === null) {
|
|
1189
|
+
next("if the project has tests, run them with coverage at the tip and pass the lcov to follow-me coverage");
|
|
1190
|
+
}
|
|
1176
1191
|
say("follow-me is interactive: keep running it until it prints the report");
|
|
1177
1192
|
return listen(session, result, args);
|
|
1178
1193
|
}
|
|
@@ -1230,7 +1245,7 @@ async function walkPlan(session, args) {
|
|
|
1230
1245
|
return alreadyReviewed(session, latest.version, args) ? null : latest;
|
|
1231
1246
|
}
|
|
1232
1247
|
/** The consolidated report of a saved review, or null after saying why it cannot be rendered. */
|
|
1233
|
-
function renderStored(session, stored, format = "markdown") {
|
|
1248
|
+
async function renderStored(session, stored, format = "markdown") {
|
|
1234
1249
|
const plan = session.plans().find((p) => p.version === stored.version);
|
|
1235
1250
|
if (plan === undefined) {
|
|
1236
1251
|
console.error(`follow-me: review v${stored.version} refers to a plan that is missing`);
|
|
@@ -1258,7 +1273,13 @@ function renderStored(session, stored, format = "markdown") {
|
|
|
1258
1273
|
concerns: stored.review.carriedConcerns ?? [],
|
|
1259
1274
|
},
|
|
1260
1275
|
};
|
|
1261
|
-
const
|
|
1276
|
+
const coverage = await coverageAt(session.root, session.dir, session.anchor.tip);
|
|
1277
|
+
const opts = {
|
|
1278
|
+
branch: session.anchor.branch,
|
|
1279
|
+
responses,
|
|
1280
|
+
...earlier,
|
|
1281
|
+
...(coverage === null ? {} : { coverage }),
|
|
1282
|
+
};
|
|
1262
1283
|
if (format === "json") {
|
|
1263
1284
|
const items = reportItems(stored.review, verdict.plan.commits, session.diff.units, opts);
|
|
1264
1285
|
return `${JSON.stringify(items, null, 2)}\n`;
|
|
@@ -1288,12 +1309,67 @@ async function report(cwd, args, walkOnly = false) {
|
|
|
1288
1309
|
console.error("follow-me: no review is open and none has ended; run `follow-me review` first");
|
|
1289
1310
|
return 1;
|
|
1290
1311
|
}
|
|
1291
|
-
const text = renderStored(session, stored, format);
|
|
1312
|
+
const text = await renderStored(session, stored, format);
|
|
1292
1313
|
if (text === null)
|
|
1293
1314
|
return 1;
|
|
1294
1315
|
process.stdout.write(text);
|
|
1295
1316
|
return 0;
|
|
1296
1317
|
}
|
|
1318
|
+
const COVERAGE_USAGE = "usage: follow-me coverage <lcov> [--test <id> [--at <path:line>]]";
|
|
1319
|
+
/**
|
|
1320
|
+
* Records the lcov of a test run the agent made at the tip in the session's coverage.json and
|
|
1321
|
+
* prints how much of the branch's changed code it ran. follow-me never runs the tests itself.
|
|
1322
|
+
*/
|
|
1323
|
+
async function coverage(cwd, args) {
|
|
1324
|
+
const [file, extra] = args.positionals;
|
|
1325
|
+
const id = args.values.get("test");
|
|
1326
|
+
const atArg = args.values.get("at");
|
|
1327
|
+
if (file === undefined || extra !== undefined)
|
|
1328
|
+
throw new UsageError(COVERAGE_USAGE);
|
|
1329
|
+
if (atArg !== undefined && id === undefined) {
|
|
1330
|
+
throw new UsageError(`--at needs --test\n${COVERAGE_USAGE}`);
|
|
1331
|
+
}
|
|
1332
|
+
let text;
|
|
1333
|
+
try {
|
|
1334
|
+
text = readFileSync(resolve(cwd, file), "utf8");
|
|
1335
|
+
}
|
|
1336
|
+
catch {
|
|
1337
|
+
throw new UsageError(`cannot read ${file}`);
|
|
1338
|
+
}
|
|
1339
|
+
const records = parseLcov(text);
|
|
1340
|
+
const session = await openFresh(cwd, args);
|
|
1341
|
+
const { tip } = session.anchor;
|
|
1342
|
+
const place = tipPlacer(session.root, cwd, await pathsAt(session.root, tip));
|
|
1343
|
+
const run = runOf(records, place);
|
|
1344
|
+
if (run.files.size === 0) {
|
|
1345
|
+
throw new CoverageError(`none of the ${plural(new Set(records.map((r) => r.path)).size, "file")} in ${file} is in ${session.anchor.branch} at the tip; run follow-me coverage from the directory the tests ran in`);
|
|
1346
|
+
}
|
|
1347
|
+
const changed = await pathsChanged(session.root, tip, null);
|
|
1348
|
+
const differ = [...run.files.keys()].filter((p) => changed.has(p));
|
|
1349
|
+
if (differ.length > 0) {
|
|
1350
|
+
throw new CoverageError(`coverage must be taken on the tip; these files differ: ${differ.map(show).join(", ")}`);
|
|
1351
|
+
}
|
|
1352
|
+
let test;
|
|
1353
|
+
if (id !== undefined) {
|
|
1354
|
+
const at = atArg === undefined ? undefined : parseLocArg(atArg, place);
|
|
1355
|
+
if (typeof at === "string")
|
|
1356
|
+
throw new UsageError(at);
|
|
1357
|
+
test = { id, ...(at === undefined ? {} : { at }) };
|
|
1358
|
+
}
|
|
1359
|
+
const takenAt = new Date().toISOString();
|
|
1360
|
+
const recorded = recordRun(session.coverage(), run, {
|
|
1361
|
+
tip,
|
|
1362
|
+
takenAt,
|
|
1363
|
+
...(test === undefined ? {} : { test }),
|
|
1364
|
+
});
|
|
1365
|
+
session.writeCoverage(recorded);
|
|
1366
|
+
const open = await openOnServer(session, null);
|
|
1367
|
+
if (open !== null)
|
|
1368
|
+
await postCoverage(open.port, open.summary);
|
|
1369
|
+
const { covered, executable } = totalOf(changedLines(session.diff.units, recorded.files).values());
|
|
1370
|
+
say(`coverage for ${plural(recorded.files.size, "file")} at ${short(tip)}; ${covered} of ${executable} changed lines covered`);
|
|
1371
|
+
return 0;
|
|
1372
|
+
}
|
|
1297
1373
|
/** The old name of report. */
|
|
1298
1374
|
async function feedback(cwd, args) {
|
|
1299
1375
|
console.error("follow-me: feedback is now report; printing the same");
|
|
@@ -1302,9 +1378,12 @@ async function feedback(cwd, args) {
|
|
|
1302
1378
|
function isCommentEvent(event) {
|
|
1303
1379
|
return event.type === "comment" || event.type === "comment-updated";
|
|
1304
1380
|
}
|
|
1305
|
-
/**
|
|
1381
|
+
/**
|
|
1382
|
+
* Events the reviewer caused; the agent's own replies, explanations, version swaps and coverage
|
|
1383
|
+
* are not news.
|
|
1384
|
+
*/
|
|
1306
1385
|
function isNews(event) {
|
|
1307
|
-
if (event.type === "version")
|
|
1386
|
+
if (event.type === "version" || event.type === "coverage")
|
|
1308
1387
|
return false;
|
|
1309
1388
|
if (event.type === "comment" || event.type === "comment-updated") {
|
|
1310
1389
|
return event.comment.author !== "agent";
|
|
@@ -1878,6 +1957,8 @@ export async function main(argv, cwd) {
|
|
|
1878
1957
|
}
|
|
1879
1958
|
case "show":
|
|
1880
1959
|
return await showRef(cwd, parseArgs(rest, { values: ["branch"] }));
|
|
1960
|
+
case "coverage":
|
|
1961
|
+
return await coverage(cwd, parseArgs(rest, { values: ["test", "at", "branch"] }));
|
|
1881
1962
|
case "report":
|
|
1882
1963
|
return await report(cwd, parseArgs(rest, { values: ["branch"], flags: ["json", "markdown"] }));
|
|
1883
1964
|
case "clean": {
|
|
@@ -1918,6 +1999,8 @@ export async function main(argv, cwd) {
|
|
|
1918
1999
|
catch (error) {
|
|
1919
2000
|
const known = [
|
|
1920
2001
|
UsageError,
|
|
2002
|
+
CoverageError,
|
|
2003
|
+
LcovError,
|
|
1921
2004
|
BranchMovedError,
|
|
1922
2005
|
BranchRefusedError,
|
|
1923
2006
|
SessionFileError,
|
package/dist/src/comments.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { isRepoPath, isUnitId } from "./diff.js";
|
|
1
|
+
import { isRepoPath, isSha, isUnitId } from "./diff.js";
|
|
2
2
|
import { diagramProblem, isArray, isCommentId, isConcernId, isFields, isPlanVersion, isSeverity, parsePlacedConcern, } from "./plan.js";
|
|
3
3
|
/**
|
|
4
4
|
* The wire formats of review state, parsed once at the boundary: comments, replies, concern
|
|
@@ -452,6 +452,11 @@ export function parseEvent(v) {
|
|
|
452
452
|
return carried;
|
|
453
453
|
return { seq, type: v.type, version: v.version, hash: v.hash, carried };
|
|
454
454
|
}
|
|
455
|
+
case "coverage":
|
|
456
|
+
if (typeof v.tip !== "string" || !isSha(v.tip) || typeof v.takenAt !== "string") {
|
|
457
|
+
return "malformed coverage event";
|
|
458
|
+
}
|
|
459
|
+
return { seq, type: v.type, tip: v.tip, takenAt: v.takenAt };
|
|
455
460
|
case "submitted":
|
|
456
461
|
return { seq, type: v.type };
|
|
457
462
|
case "ended":
|
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
import { realpathSync } from "node:fs";
|
|
2
|
+
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
3
|
+
import { isRepoPath, isSha, tipStartOf } from "./diff.js";
|
|
4
|
+
import { GitError, pathsChanged } from "./git.js";
|
|
5
|
+
import { isFields } from "./plan.js";
|
|
6
|
+
/** lcov input that cannot be read, with the 1-based line it fails on. */
|
|
7
|
+
export class LcovError extends Error {
|
|
8
|
+
name = "LcovError";
|
|
9
|
+
line;
|
|
10
|
+
constructor(line, reason) {
|
|
11
|
+
super(`lcov line ${line}: ${reason}`);
|
|
12
|
+
this.line = line;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
const RECORD = /^([A-Z][A-Z0-9_]*):(.*)$/;
|
|
16
|
+
const DA = /^([1-9][0-9]*),([0-9]+)(?:,.*)?$/;
|
|
17
|
+
const BRDA = /^([1-9][0-9]*),([^,]+),(.+),(-|[0-9]+)$/;
|
|
18
|
+
/**
|
|
19
|
+
* Reads lcov's tracefile format: TN names the test of the records after it, SF opens a file, DA
|
|
20
|
+
* and BRDA give line and branch hits, end_of_record closes it. Other records are skipped.
|
|
21
|
+
*/
|
|
22
|
+
export function parseLcov(text) {
|
|
23
|
+
const records = [];
|
|
24
|
+
let test = null;
|
|
25
|
+
let open = null;
|
|
26
|
+
const close = () => {
|
|
27
|
+
if (open !== null)
|
|
28
|
+
records.push(open);
|
|
29
|
+
open = null;
|
|
30
|
+
};
|
|
31
|
+
const rows = text.split("\n");
|
|
32
|
+
for (const [i, raw] of rows.entries()) {
|
|
33
|
+
const n = i + 1;
|
|
34
|
+
const row = raw.endsWith("\r") ? raw.slice(0, -1) : raw;
|
|
35
|
+
if (row.trim() === "")
|
|
36
|
+
continue;
|
|
37
|
+
if (row === "end_of_record") {
|
|
38
|
+
close();
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
const m = RECORD.exec(row);
|
|
42
|
+
if (m === null)
|
|
43
|
+
throw new LcovError(n, `"${row.slice(0, 60)}" is not an lcov record`);
|
|
44
|
+
const [, key = "", value = ""] = m;
|
|
45
|
+
if (key === "TN") {
|
|
46
|
+
test = value.trim() === "" ? null : value.trim();
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (key === "SF") {
|
|
50
|
+
close();
|
|
51
|
+
if (value === "")
|
|
52
|
+
throw new LcovError(n, "SF needs a path");
|
|
53
|
+
open = { path: value, test, lines: new Map(), branches: new Map() };
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (key !== "DA" && key !== "BRDA")
|
|
57
|
+
continue;
|
|
58
|
+
if (open === null)
|
|
59
|
+
throw new LcovError(n, `${key} comes before any SF`);
|
|
60
|
+
if (key === "DA") {
|
|
61
|
+
const da = DA.exec(value);
|
|
62
|
+
if (da === null)
|
|
63
|
+
throw new LcovError(n, `DA needs <line>,<hits>, got "${value}"`);
|
|
64
|
+
const line = Number(da[1]);
|
|
65
|
+
open.lines.set(line, (open.lines.get(line) ?? 0) + Number(da[2]));
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
const br = BRDA.exec(value);
|
|
69
|
+
if (br === null) {
|
|
70
|
+
throw new LcovError(n, `BRDA needs <line>,<block>,<branch>,<taken>, got "${value}"`);
|
|
71
|
+
}
|
|
72
|
+
const line = Number(br[1]);
|
|
73
|
+
const id = `${br[2]},${br[3]}`;
|
|
74
|
+
const taken = br[4] !== "-" && Number(br[4]) > 0;
|
|
75
|
+
const branches = open.branches.get(line) ?? new Map();
|
|
76
|
+
branches.set(id, (branches.get(id) ?? false) || taken);
|
|
77
|
+
open.branches.set(line, branches);
|
|
78
|
+
}
|
|
79
|
+
close();
|
|
80
|
+
if (records.length === 0)
|
|
81
|
+
throw new LcovError(rows.length, "no SF record; is this lcov?");
|
|
82
|
+
return records;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Where an lcov path is at the tip: absolute, or relative to cwd and failing that to the root,
|
|
86
|
+
* symlinks resolved, then relative to the root. Null outside the repo or for a file the tip lacks.
|
|
87
|
+
*/
|
|
88
|
+
export function tipPlacer(root, cwd, atTip) {
|
|
89
|
+
const real = (p) => {
|
|
90
|
+
try {
|
|
91
|
+
return realpathSync(p);
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return p;
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
const tops = [root, real(root)];
|
|
98
|
+
return (raw) => {
|
|
99
|
+
const tried = isAbsolute(raw) ? [raw] : [resolve(cwd, raw), resolve(root, raw)];
|
|
100
|
+
for (const abs of tried.flatMap((p) => [p, real(p)])) {
|
|
101
|
+
for (const top of tops) {
|
|
102
|
+
const rel = relative(top, abs);
|
|
103
|
+
if (rel === "" || rel.startsWith("..") || isAbsolute(rel))
|
|
104
|
+
continue;
|
|
105
|
+
const path = Buffer.from(rel.split(sep).join("/"), "utf8").toString("latin1");
|
|
106
|
+
if (isRepoPath(path) && atTip.has(path))
|
|
107
|
+
return path;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return null;
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
/** Sums hits and joins branches over records of the same file; drops what `place` rejects. */
|
|
114
|
+
export function runOf(records, place) {
|
|
115
|
+
const lines = new Map();
|
|
116
|
+
const branches = new Map();
|
|
117
|
+
const tests = new Map();
|
|
118
|
+
for (const record of records) {
|
|
119
|
+
const path = place(record.path);
|
|
120
|
+
if (path === null)
|
|
121
|
+
continue;
|
|
122
|
+
const hits = lines.get(path) ?? new Map();
|
|
123
|
+
for (const [line, n] of record.lines)
|
|
124
|
+
hits.set(line, (hits.get(line) ?? 0) + n);
|
|
125
|
+
lines.set(path, hits);
|
|
126
|
+
const joined = branches.get(path) ?? new Map();
|
|
127
|
+
for (const [line, ids] of record.branches) {
|
|
128
|
+
const on = joined.get(line) ?? new Map();
|
|
129
|
+
for (const [id, taken] of ids)
|
|
130
|
+
on.set(id, (on.get(id) ?? false) || taken);
|
|
131
|
+
joined.set(line, on);
|
|
132
|
+
}
|
|
133
|
+
branches.set(path, joined);
|
|
134
|
+
if (record.test === null)
|
|
135
|
+
continue;
|
|
136
|
+
const ran = tests.get(record.test) ?? new Map();
|
|
137
|
+
const set = ran.get(path) ?? new Set();
|
|
138
|
+
for (const [line, n] of record.lines)
|
|
139
|
+
if (n > 0)
|
|
140
|
+
set.add(line);
|
|
141
|
+
ran.set(path, set);
|
|
142
|
+
tests.set(record.test, ran);
|
|
143
|
+
}
|
|
144
|
+
const files = new Map();
|
|
145
|
+
for (const [path, hits] of lines) {
|
|
146
|
+
const counted = new Map();
|
|
147
|
+
for (const [line, ids] of branches.get(path) ?? []) {
|
|
148
|
+
counted.set(line, [[...ids.values()].filter(Boolean).length, ids.size]);
|
|
149
|
+
}
|
|
150
|
+
files.set(path, { lines: sorted(hits), branches: sorted(counted) });
|
|
151
|
+
}
|
|
152
|
+
return {
|
|
153
|
+
files,
|
|
154
|
+
tests: new Map([...tests].map(([id, ran]) => [
|
|
155
|
+
id,
|
|
156
|
+
new Map([...ran].map(([path, set]) => [path, [...set].sort((a, b) => a - b)])),
|
|
157
|
+
])),
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
function sorted(map) {
|
|
161
|
+
return new Map([...map].sort(([a], [b]) => a - b));
|
|
162
|
+
}
|
|
163
|
+
/** The lines of each file with at least one hit. */
|
|
164
|
+
function hitLines(files) {
|
|
165
|
+
return new Map([...files].map(([path, f]) => [path, [...f.lines].flatMap(([l, n]) => (n > 0 ? [l] : []))]));
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Per-test runs only add: a line keeps the larger count, so they never inflate an aggregate run
|
|
169
|
+
* of the same suite, and running the same test twice changes nothing.
|
|
170
|
+
*/
|
|
171
|
+
function mergeMax(a, b) {
|
|
172
|
+
const out = new Map(a);
|
|
173
|
+
for (const [path, f] of b) {
|
|
174
|
+
const had = out.get(path);
|
|
175
|
+
if (had === undefined) {
|
|
176
|
+
out.set(path, f);
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
const lines = new Map(had.lines);
|
|
180
|
+
for (const [l, n] of f.lines)
|
|
181
|
+
lines.set(l, Math.max(n, lines.get(l) ?? 0));
|
|
182
|
+
const branches = new Map(had.branches);
|
|
183
|
+
for (const [l, [taken, total]] of f.branches) {
|
|
184
|
+
const [t, n] = branches.get(l) ?? [0, 0];
|
|
185
|
+
branches.set(l, [Math.max(taken, t), Math.max(total, n)]);
|
|
186
|
+
}
|
|
187
|
+
out.set(path, { lines: sorted(lines), branches: sorted(branches) });
|
|
188
|
+
}
|
|
189
|
+
return out;
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* The coverage after this run. A plain run replaces everything, its TN blocks becoming its tests.
|
|
193
|
+
* A named test's run adds to what is already there for the same tip, and starts over otherwise.
|
|
194
|
+
*/
|
|
195
|
+
export function recordRun(prior, run, at) {
|
|
196
|
+
const { tip, takenAt, test } = at;
|
|
197
|
+
if (test === undefined) {
|
|
198
|
+
const tests = new Map([...run.tests].map(([id, lines]) => [id, { lines }]));
|
|
199
|
+
return { tip, takenAt, files: run.files, tests };
|
|
200
|
+
}
|
|
201
|
+
const base = prior !== null && prior.tip === tip ? prior : null;
|
|
202
|
+
const tests = new Map(base?.tests ?? []);
|
|
203
|
+
const entry = {
|
|
204
|
+
...(test.at === undefined ? {} : { at: test.at }),
|
|
205
|
+
lines: hitLines(run.files),
|
|
206
|
+
};
|
|
207
|
+
tests.set(test.id, entry);
|
|
208
|
+
const files = base === null ? run.files : mergeMax(base.files, run.files);
|
|
209
|
+
return { tip, takenAt, files, tests };
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Per file, the branch's added rows that lcov calls executable, and how many ran. Every added
|
|
213
|
+
* row is a tip line, which is where the run was taken. Files without such rows are left out.
|
|
214
|
+
*/
|
|
215
|
+
export function changedLines(units, files) {
|
|
216
|
+
const out = new Map();
|
|
217
|
+
for (const unit of units.values()) {
|
|
218
|
+
if (unit.kind !== "hunk")
|
|
219
|
+
continue;
|
|
220
|
+
const file = files.get(unit.path);
|
|
221
|
+
if (file === undefined)
|
|
222
|
+
continue;
|
|
223
|
+
let line = tipStartOf(unit, units);
|
|
224
|
+
let { covered, executable } = out.get(unit.path) ?? { covered: 0, executable: 0 };
|
|
225
|
+
for (const row of unit.body) {
|
|
226
|
+
const hits = row.op === "+" ? file.lines.get(line) : undefined;
|
|
227
|
+
if (hits !== undefined) {
|
|
228
|
+
executable += 1;
|
|
229
|
+
if (hits > 0)
|
|
230
|
+
covered += 1;
|
|
231
|
+
}
|
|
232
|
+
if (row.op !== "-")
|
|
233
|
+
line += 1;
|
|
234
|
+
}
|
|
235
|
+
if (executable > 0)
|
|
236
|
+
out.set(unit.path, { covered, executable });
|
|
237
|
+
}
|
|
238
|
+
return out;
|
|
239
|
+
}
|
|
240
|
+
export function totalOf(tallies) {
|
|
241
|
+
let covered = 0;
|
|
242
|
+
let executable = 0;
|
|
243
|
+
for (const t of tallies) {
|
|
244
|
+
covered += t.covered;
|
|
245
|
+
executable += t.executable;
|
|
246
|
+
}
|
|
247
|
+
return { covered, executable };
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Covered files whose content at `tip` differs from where the run was taken, in file order.
|
|
251
|
+
* When the run's commit is gone from the repo, every file is stale.
|
|
252
|
+
*/
|
|
253
|
+
export async function staleFiles(repo, coverage, tip) {
|
|
254
|
+
if (coverage.tip === tip)
|
|
255
|
+
return [];
|
|
256
|
+
let changed;
|
|
257
|
+
try {
|
|
258
|
+
changed = await pathsChanged(repo, coverage.tip, tip);
|
|
259
|
+
}
|
|
260
|
+
catch (error) {
|
|
261
|
+
if (!(error instanceof GitError))
|
|
262
|
+
throw error;
|
|
263
|
+
changed = new Set(coverage.files.keys());
|
|
264
|
+
}
|
|
265
|
+
return [...coverage.files.keys()].filter((p) => changed.has(p));
|
|
266
|
+
}
|
|
267
|
+
/** The files whose coverage still describes the tip. */
|
|
268
|
+
export function freshFiles(coverage, stale) {
|
|
269
|
+
const gone = new Set(stale);
|
|
270
|
+
return new Map([...coverage.files].filter(([path]) => !gone.has(path)));
|
|
271
|
+
}
|
|
272
|
+
export function serializeCoverage(c) {
|
|
273
|
+
const files = Object.fromEntries([...c.files].map(([path, f]) => [
|
|
274
|
+
path,
|
|
275
|
+
{
|
|
276
|
+
lines: Object.fromEntries(f.lines),
|
|
277
|
+
...(f.branches.size === 0 ? {} : { branches: Object.fromEntries(f.branches) }),
|
|
278
|
+
},
|
|
279
|
+
]));
|
|
280
|
+
const tests = Object.fromEntries([...c.tests].map(([id, t]) => [
|
|
281
|
+
id,
|
|
282
|
+
{
|
|
283
|
+
...(t.at === undefined ? {} : { at: t.at }),
|
|
284
|
+
lines: Object.fromEntries([...t.lines].map(([p, ls]) => [p, [...ls]])),
|
|
285
|
+
},
|
|
286
|
+
]));
|
|
287
|
+
return { tip: c.tip, takenAt: c.takenAt, files, ...(c.tests.size === 0 ? {} : { tests }) };
|
|
288
|
+
}
|
|
289
|
+
const LINE_KEY = /^[1-9][0-9]*$/;
|
|
290
|
+
function isCount(v) {
|
|
291
|
+
return typeof v === "number" && Number.isInteger(v) && v >= 0;
|
|
292
|
+
}
|
|
293
|
+
function isLine(v) {
|
|
294
|
+
return typeof v === "number" && Number.isInteger(v) && v >= 1;
|
|
295
|
+
}
|
|
296
|
+
function entries(v) {
|
|
297
|
+
return isFields(v) ? Object.entries(v) : null;
|
|
298
|
+
}
|
|
299
|
+
function parseLoc(v) {
|
|
300
|
+
if (!isFields(v))
|
|
301
|
+
return null;
|
|
302
|
+
const { path, start, end } = v;
|
|
303
|
+
if (typeof path !== "string" || !isRepoPath(path) || !isLine(start) || !isLine(end))
|
|
304
|
+
return null;
|
|
305
|
+
return end < start ? null : { path, start, end };
|
|
306
|
+
}
|
|
307
|
+
/** Like parseLoc, for argv: "path:line" or "path:start-end", the path placed like lcov's. */
|
|
308
|
+
export function parseLocArg(raw, place) {
|
|
309
|
+
const m = /^(.+):([1-9][0-9]*)(?:-([1-9][0-9]*))?$/.exec(raw);
|
|
310
|
+
if (m === null)
|
|
311
|
+
return `--at ${raw} is not <path>:<line> or <path>:<start>-<end>`;
|
|
312
|
+
const path = place(m[1] ?? "");
|
|
313
|
+
if (path === null)
|
|
314
|
+
return `--at ${raw} names no file of the branch tip`;
|
|
315
|
+
const start = Number(m[2]);
|
|
316
|
+
const end = m[3] === undefined ? start : Number(m[3]);
|
|
317
|
+
return end < start ? `--at ${raw} runs backwards` : { path, start, end };
|
|
318
|
+
}
|
|
319
|
+
function parseFile(v) {
|
|
320
|
+
if (!isFields(v))
|
|
321
|
+
return "a file is not an object";
|
|
322
|
+
const lines = entries(v.lines);
|
|
323
|
+
if (lines === null)
|
|
324
|
+
return "lines is not an object";
|
|
325
|
+
const hits = new Map();
|
|
326
|
+
for (const [key, n] of lines) {
|
|
327
|
+
if (!LINE_KEY.test(key) || !isCount(n))
|
|
328
|
+
return `line ${key} has no hit count`;
|
|
329
|
+
hits.set(Number(key), n);
|
|
330
|
+
}
|
|
331
|
+
const branchEntries = entries(v.branches ?? {});
|
|
332
|
+
if (branchEntries === null)
|
|
333
|
+
return "branches is not an object";
|
|
334
|
+
const branches = new Map();
|
|
335
|
+
for (const [key, pair] of branchEntries) {
|
|
336
|
+
if (!LINE_KEY.test(key) || !Array.isArray(pair) || pair.length !== 2) {
|
|
337
|
+
return `branches of line ${key} are not [taken, total]`;
|
|
338
|
+
}
|
|
339
|
+
const [taken, total] = pair;
|
|
340
|
+
if (!isCount(taken) || !isCount(total) || taken > total) {
|
|
341
|
+
return `branches of line ${key} are not [taken, total]`;
|
|
342
|
+
}
|
|
343
|
+
branches.set(Number(key), [taken, total]);
|
|
344
|
+
}
|
|
345
|
+
return { lines: sorted(hits), branches: sorted(branches) };
|
|
346
|
+
}
|
|
347
|
+
function parseTest(v) {
|
|
348
|
+
if (!isFields(v))
|
|
349
|
+
return "a test is not an object";
|
|
350
|
+
const at = v.at === undefined ? undefined : parseLoc(v.at);
|
|
351
|
+
if (at === null)
|
|
352
|
+
return "at is not a { path, start, end }";
|
|
353
|
+
const lineEntries = entries(v.lines);
|
|
354
|
+
if (lineEntries === null)
|
|
355
|
+
return "lines is not an object";
|
|
356
|
+
const lines = new Map();
|
|
357
|
+
for (const [path, list] of lineEntries) {
|
|
358
|
+
if (!isRepoPath(path) || !Array.isArray(list) || !list.every(isLine)) {
|
|
359
|
+
return `lines of ${path} are not line numbers`;
|
|
360
|
+
}
|
|
361
|
+
lines.set(path, list);
|
|
362
|
+
}
|
|
363
|
+
return { ...(at === undefined ? {} : { at }), lines };
|
|
364
|
+
}
|
|
365
|
+
/** The one place coverage.json becomes a Coverage. */
|
|
366
|
+
export function parseCoverage(v) {
|
|
367
|
+
if (!isFields(v))
|
|
368
|
+
return "not an object";
|
|
369
|
+
const { tip, takenAt } = v;
|
|
370
|
+
if (typeof tip !== "string" || !isSha(tip))
|
|
371
|
+
return "tip is not an object id";
|
|
372
|
+
if (typeof takenAt !== "string")
|
|
373
|
+
return "takenAt is not a time";
|
|
374
|
+
const fileEntries = entries(v.files);
|
|
375
|
+
if (fileEntries === null)
|
|
376
|
+
return "files is not an object";
|
|
377
|
+
const files = new Map();
|
|
378
|
+
for (const [path, raw] of fileEntries) {
|
|
379
|
+
if (!isRepoPath(path))
|
|
380
|
+
return "a file has no path";
|
|
381
|
+
const file = parseFile(raw);
|
|
382
|
+
if (typeof file === "string")
|
|
383
|
+
return `${path}: ${file}`;
|
|
384
|
+
files.set(path, file);
|
|
385
|
+
}
|
|
386
|
+
const testEntries = entries(v.tests ?? {});
|
|
387
|
+
if (testEntries === null)
|
|
388
|
+
return "tests is not an object";
|
|
389
|
+
const tests = new Map();
|
|
390
|
+
for (const [id, raw] of testEntries) {
|
|
391
|
+
const test = parseTest(raw);
|
|
392
|
+
if (typeof test === "string")
|
|
393
|
+
return `test ${id}: ${test}`;
|
|
394
|
+
tests.set(id, test);
|
|
395
|
+
}
|
|
396
|
+
return { tip, takenAt, files, tests };
|
|
397
|
+
}
|
package/dist/src/git.js
CHANGED
|
@@ -132,6 +132,24 @@ export async function blobsAt(repo, commit, paths) {
|
|
|
132
132
|
const blobs = await catBatch(repo, paths.map((p) => `${commit}:${p}`));
|
|
133
133
|
return new Map(paths.map((p, i) => [p, blobs[i] ?? ""]));
|
|
134
134
|
}
|
|
135
|
+
function pathList(out) {
|
|
136
|
+
return new Set(out
|
|
137
|
+
.toString("latin1")
|
|
138
|
+
.split("\0")
|
|
139
|
+
.filter((p) => isRepoPath(p)));
|
|
140
|
+
}
|
|
141
|
+
/** Every file path in the commit's tree. */
|
|
142
|
+
export async function pathsAt(repo, commit) {
|
|
143
|
+
return pathList(await git(repo, ["ls-tree", "-r", "-z", "--name-only", commit]));
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Paths whose content differs between the commit and `to`: another commit, or with null the
|
|
147
|
+
* working tree and index. Untracked files are not listed.
|
|
148
|
+
*/
|
|
149
|
+
export async function pathsChanged(repo, from, to) {
|
|
150
|
+
const target = to === null ? [] : [to];
|
|
151
|
+
return pathList(await git(repo, ["diff", "--name-only", "-z", ...DIFF_FLAGS, from, ...target, "--"]));
|
|
152
|
+
}
|
|
135
153
|
/**
|
|
136
154
|
* Lines of the commit's tree, not the working tree, that match any of the POSIX extended
|
|
137
155
|
* patterns, in files matching the pathspecs. Binary files are skipped.
|
package/dist/src/host.js
CHANGED
|
@@ -373,6 +373,10 @@ export async function postComment(port, summary, comment) {
|
|
|
373
373
|
}
|
|
374
374
|
return id;
|
|
375
375
|
}
|
|
376
|
+
/** Tells the hosted page that coverage.json changed, so it reloads its marks. */
|
|
377
|
+
export async function postCoverage(port, summary) {
|
|
378
|
+
await call(port, "POST", `${summary.url}api/coverage`);
|
|
379
|
+
}
|
|
376
380
|
/** The consolidated report of the hosted session as it stands. */
|
|
377
381
|
export async function fetchReport(port, summary, format = "markdown") {
|
|
378
382
|
const res = await fetch(`${sessionUrl(port, summary)}api/report?format=${format}`);
|