@shiyuphan/follow-me 0.9.0 → 0.9.2
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 +161 -35
- package/dist/src/comments.js +25 -4
- package/dist/src/coverage.js +26 -15
- package/dist/src/host.js +15 -5
- package/dist/src/install.js +11 -1
- package/dist/src/plain.js +20 -0
- package/dist/src/plan.js +44 -22
- package/dist/src/prompt.js +58 -17
- package/dist/src/session.js +8 -3
- package/dist/ui/dist/assets/{abnfDiagram-WEYOIA3C-DztIEwNE.js → abnfDiagram-WEYOIA3C-BWgk01ve.js} +1 -1
- package/dist/ui/dist/assets/{arc-DUYh-EqZ.js → arc-Cg_Qb5c4.js} +1 -1
- package/dist/ui/dist/assets/{architecture-WOLXFQ4H-BQYTB92_.js → architecture-WOLXFQ4H-EdfV1etv.js} +1 -1
- package/dist/ui/dist/assets/{architectureDiagram-ZHVYKT53-Dl7095A5.js → architectureDiagram-ZHVYKT53-Dz-gytQU.js} +1 -1
- package/dist/ui/dist/assets/{blockDiagram-RJAE7QA3-QDZ5kE1J.js → blockDiagram-RJAE7QA3-B7Q5KhYC.js} +1 -1
- package/dist/ui/dist/assets/{c4Diagram-XDPPPE54-CKJZ1xOw.js → c4Diagram-XDPPPE54-Cq9IvGR1.js} +1 -1
- package/dist/ui/dist/assets/channel-Da0YvuQF.js +1 -0
- package/dist/ui/dist/assets/{chunk-2E7WHVMD-BXBjaqd1.js → chunk-2E7WHVMD-BTJB-4C8.js} +1 -1
- package/dist/ui/dist/assets/{chunk-4EMNKR2T-DPzfVuke.js → chunk-4EMNKR2T-BILthc2j.js} +1 -1
- package/dist/ui/dist/assets/{chunk-55XCU76K-B33Sj3w8.js → chunk-55XCU76K-DH3fYrJH.js} +1 -1
- package/dist/ui/dist/assets/{chunk-7S6XSTFO-B_u7dS03.js → chunk-7S6XSTFO-DDIiBvc2.js} +1 -1
- package/dist/ui/dist/assets/{chunk-AQDTSKZB-B4EzH5gx.js → chunk-AQDTSKZB-b1EeJ3gb.js} +1 -1
- package/dist/ui/dist/assets/{chunk-DSNLSHJZ-CfWeHABr.js → chunk-DSNLSHJZ-DZZ635s5.js} +1 -1
- package/dist/ui/dist/assets/{chunk-HAGFOTED-B8G8PjLZ.js → chunk-HAGFOTED-DhhrHTIh.js} +1 -1
- package/dist/ui/dist/assets/{chunk-HNE4Q36P-BA-PqoTF.js → chunk-HNE4Q36P-B8wvhc2e.js} +1 -1
- package/dist/ui/dist/assets/{chunk-NQOKRVIF-JvhuFcnR.js → chunk-NQOKRVIF-CLtGMwDx.js} +1 -1
- package/dist/ui/dist/assets/{chunk-UHW7PFBP-BrfL5CGY.js → chunk-UHW7PFBP-B9fVLGl0.js} +1 -1
- package/dist/ui/dist/assets/{chunk-VQ5W62QI-dBJqgdDy.js → chunk-VQ5W62QI-CgPvvMAv.js} +1 -1
- package/dist/ui/dist/assets/{chunk-WQQL4CDK-dfqd3k_P.js → chunk-WQQL4CDK-DdqKu6R9.js} +1 -1
- package/dist/ui/dist/assets/{chunk-XXDRQBXY-kViWdB9M.js → chunk-XXDRQBXY-GlM07vbp.js} +1 -1
- package/dist/ui/dist/assets/{chunk-YZO67HFG-ukfs4wT3.js → chunk-YZO67HFG-YqT6M0W9.js} +1 -1
- package/dist/ui/dist/assets/{chunk-ZAUX2LTX-BxZHki81.js → chunk-ZAUX2LTX-DqHK51Gc.js} +1 -1
- package/dist/ui/dist/assets/{chunk-ZCHMQSOR-PjluhR_E.js → chunk-ZCHMQSOR-DEeXkjWz.js} +1 -1
- package/dist/ui/dist/assets/{classDiagram-v2-4MYWR3UA-Ct89LJec.js → classDiagram-v2-4MYWR3UA-BcKsX4up.js} +1 -1
- package/dist/ui/dist/assets/{cose-bilkent-JH36ORCC-BnQceGmB.js → cose-bilkent-JH36ORCC-fu9djgYc.js} +1 -1
- package/dist/ui/dist/assets/{cynefin-EF2NZ3EQ-D6WkCVE8.js → cynefin-EF2NZ3EQ-CFJASBsJ.js} +1 -1
- package/dist/ui/dist/assets/{cynefinDiagram-WF24UDRP-dn6DUPAb.js → cynefinDiagram-WF24UDRP-BVJD_n0g.js} +1 -1
- package/dist/ui/dist/assets/{dagre-NK4GIZVX-BtZSlLk7.js → dagre-NK4GIZVX-Cx8pjz9O.js} +1 -1
- package/dist/ui/dist/assets/{diagram-CBK3MWTC-Ds4_tv6X.js → diagram-CBK3MWTC-CVIkEZ1X.js} +1 -1
- package/dist/ui/dist/assets/{diagram-I5XIIV2D-DcoVkg45.js → diagram-I5XIIV2D-2rxpF8BZ.js} +1 -1
- package/dist/ui/dist/assets/{diagram-ODIL2UCW-T47FAA3k.js → diagram-ODIL2UCW-NJfGHnLk.js} +1 -1
- package/dist/ui/dist/assets/{diagram-PFBEJUXE-CRZcaVKP.js → diagram-PFBEJUXE-DZHVJSeM.js} +1 -1
- package/dist/ui/dist/assets/{diagram-RJA3I4VV-D6KXeUPg.js → diagram-RJA3I4VV-CQAfOObI.js} +1 -1
- package/dist/ui/dist/assets/{diagram-TYUNFSNR-BHEIVyNN.js → diagram-TYUNFSNR-DGGaMOl6.js} +1 -1
- package/dist/ui/dist/assets/{dist-DLlKIWBp.js → dist-CW-4NrUq.js} +1 -1
- package/dist/ui/dist/assets/{ebnfDiagram-V4P3TTPT-nUDvYRBV.js → ebnfDiagram-V4P3TTPT-92pb2Fcg.js} +1 -1
- package/dist/ui/dist/assets/{elk-QONTBCTQ-D21BcRzc.js → elk-QONTBCTQ-vu0Z4rYG.js} +1 -1
- package/dist/ui/dist/assets/{erDiagram-KYQIGQAA-CTpHuftU.js → erDiagram-KYQIGQAA-BXnsRzCQ.js} +1 -1
- package/dist/ui/dist/assets/{eventmodeling-K75KTNOO-B6JaKuDY.js → eventmodeling-K75KTNOO-bSr88MT1.js} +1 -1
- package/dist/ui/dist/assets/flowDiagram-IUGLLWBU-CUH45H9n.js +1 -0
- package/dist/ui/dist/assets/{ganttDiagram-R7GPNTJY-D2-pMcnC.js → ganttDiagram-R7GPNTJY-BQzVkGbj.js} +1 -1
- package/dist/ui/dist/assets/{gitGraph-VSP46ZUC-B2yxwhEo.js → gitGraph-VSP46ZUC-BegblPQi.js} +1 -1
- package/dist/ui/dist/assets/{gitGraphDiagram-PDYL7XHY-DnrhBMH1.js → gitGraphDiagram-PDYL7XHY-BaGlGpEX.js} +1 -1
- package/dist/ui/dist/assets/{index-Dnte9tPd.js → index-D8ErIacA.js} +4 -4
- package/dist/ui/dist/assets/{info-OHQRW6UA-D8oh6gvs.js → info-OHQRW6UA-D3HVGJDc.js} +1 -1
- package/dist/ui/dist/assets/{infoDiagram-MVDRKAX5-BLSZjjgB.js → infoDiagram-MVDRKAX5-Ckcp-D0C.js} +1 -1
- package/dist/ui/dist/assets/{ishikawaDiagram-HPEHV2GE-BXiznLSn.js → ishikawaDiagram-HPEHV2GE-CWhd0CTS.js} +1 -1
- package/dist/ui/dist/assets/{journeyDiagram-BOSHW7O7-BSYmVcO_.js → journeyDiagram-BOSHW7O7-CrH5cFXY.js} +1 -1
- package/dist/ui/dist/assets/{kanban-definition-FNSA4HC5-COthkkzK.js → kanban-definition-FNSA4HC5-CpORMTDZ.js} +1 -1
- package/dist/ui/dist/assets/{line-6ND8OaOL.js → line-B3El9R0Z.js} +1 -1
- package/dist/ui/dist/assets/{linear-DbWEPFJX.js → linear-DLfthkbB.js} +1 -1
- package/dist/ui/dist/assets/{mermaid-parser.core-Btsk294z.js → mermaid-parser.core-QtQjLaoK.js} +3 -3
- package/dist/ui/dist/assets/{mermaid.core-B4llDSFO.js → mermaid.core-C2DGgMSc.js} +4 -4
- package/dist/ui/dist/assets/{mindmap-definition-6OY57UG3-hv2PfX-x.js → mindmap-definition-6OY57UG3-CW1lSXnX.js} +1 -1
- package/dist/ui/dist/assets/{packet-JDAUHWVQ-Dr4vBgjt.js → packet-JDAUHWVQ-Dhu_m_17.js} +1 -1
- package/dist/ui/dist/assets/{pegDiagram-ECIUJNBX-DvlFov3N.js → pegDiagram-ECIUJNBX-BGOhbzN4.js} +1 -1
- package/dist/ui/dist/assets/{pie-XZMESJXO-C9QsqC4n.js → pie-XZMESJXO-Citt3cMX.js} +1 -1
- package/dist/ui/dist/assets/{pieDiagram-GNMPY74T-CrrHOj67.js → pieDiagram-GNMPY74T-DPVZikvz.js} +1 -1
- package/dist/ui/dist/assets/{prism-core-Cc8H7LPG.js → prism-core-YgwsYh9d.js} +1 -1
- package/dist/ui/dist/assets/{quadrantDiagram-D57K4MC3-DQ7J6-ti.js → quadrantDiagram-D57K4MC3-Bwye1B9e.js} +1 -1
- package/dist/ui/dist/assets/{radar-ABXABTNO-BBBPaujh.js → radar-ABXABTNO-DRUb4G6V.js} +1 -1
- package/dist/ui/dist/assets/{railroad-I3PHUGI6-C-P6Igaz.js → railroad-I3PHUGI6-x3Ug2mxX.js} +1 -1
- package/dist/ui/dist/assets/{railroad-abnf-LNEFI6M7-CAkorRLs.js → railroad-abnf-LNEFI6M7-Dw14XhkZ.js} +1 -1
- package/dist/ui/dist/assets/{railroad-ebnf-DWYD2UWJ-D8Hz6gCf.js → railroad-ebnf-DWYD2UWJ-AwoWTWgL.js} +1 -1
- package/dist/ui/dist/assets/{railroad-peg-JR7BVNR7-D-XNMkOs.js → railroad-peg-JR7BVNR7-U7MXfciE.js} +1 -1
- package/dist/ui/dist/assets/{railroadDiagram-Y72NRBL7-DE9LwxJQ.js → railroadDiagram-Y72NRBL7-BxWlCZYs.js} +1 -1
- package/dist/ui/dist/assets/{requirementDiagram-EQYSW5RA-FrKFY6NJ.js → requirementDiagram-EQYSW5RA-TPmnZguV.js} +1 -1
- package/dist/ui/dist/assets/{sankeyDiagram-SY24JHO5-BDxI6RDX.js → sankeyDiagram-SY24JHO5-nPGftpl5.js} +1 -1
- package/dist/ui/dist/assets/{sequenceDiagram-BLEHNHQU-pV1XcKX7.js → sequenceDiagram-BLEHNHQU-BdctgaWa.js} +1 -1
- package/dist/ui/dist/assets/{src-snNJTXXq.js → src-zq235FjH.js} +1 -1
- package/dist/ui/dist/assets/{stateDiagram-v2-A6IESKWZ-BSc_5aAf.js → stateDiagram-v2-A6IESKWZ-BK_E6kPg.js} +1 -1
- package/dist/ui/dist/assets/{swimlanes-B3ENRKFQ-DC6qUXLh.js → swimlanes-B3ENRKFQ-BY50Ne4l.js} +1 -1
- package/dist/ui/dist/assets/swimlanesDiagram-AYQRV4AK-Ctw23I3i.js +8 -0
- package/dist/ui/dist/assets/{timeline-definition-JXYKGLGB-C8L0Dxtu.js → timeline-definition-JXYKGLGB-Bg8AXJ2P.js} +1 -1
- package/dist/ui/dist/assets/{treeView-D4JQ5SDB-DDyZIG5T.js → treeView-D4JQ5SDB-B4hz6M4l.js} +1 -1
- package/dist/ui/dist/assets/{treemap-SAJKECNS-CUaLgge0.js → treemap-SAJKECNS-e_Vc97Ox.js} +1 -1
- package/dist/ui/dist/assets/{usecaseDiagram-WPOLBNNX-BHRBd5pV.js → usecaseDiagram-WPOLBNNX-Butu_LCX.js} +1 -1
- package/dist/ui/dist/assets/{vennDiagram-Q3Y3IDCW-Du4iwwkh.js → vennDiagram-Q3Y3IDCW-BGIyxlbf.js} +1 -1
- package/dist/ui/dist/assets/{wardley-7MLQ67FV-DUgENI0s.js → wardley-7MLQ67FV-Btv95mqq.js} +1 -1
- package/dist/ui/dist/assets/{wardleyDiagram-IVAIDKWA-B86zOdke.js → wardleyDiagram-IVAIDKWA-DwTKExJ4.js} +1 -1
- package/dist/ui/dist/assets/{xychartDiagram-4URNHDU4-twq9irqO.js → xychartDiagram-4URNHDU4-UmlA5uxc.js} +1 -1
- package/dist/ui/dist/index.html +1 -1
- package/package.json +1 -1
- package/skills/follow-me/SKILL.md +7 -7
- package/dist/ui/dist/assets/channel-DF08Hd8f.js +0 -1
- package/dist/ui/dist/assets/flowDiagram-IUGLLWBU-BTeOUR-p.js +0 -1
- package/dist/ui/dist/assets/swimlanesDiagram-AYQRV4AK-F1HykV3o.js +0 -8
package/dist/src/cli.js
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
|
-
import { readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { dirname, join, relative, resolve } from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import { mapOffTip, parseStateMap } from "./comments.js";
|
|
7
|
-
import { changedLines, LcovError, PREVIEW_LINES, parseLcov, parseLocArg, recordRun, runOf, tipPlacer, totalOf, } from "./coverage.js";
|
|
7
|
+
import { changedBranchesTaken, changedLines, LcovError, PREVIEW_LINES, parseLcov, parseLocArg, recordRun, runOf, tipPlacer, totalOf, } from "./coverage.js";
|
|
8
8
|
import { definitionIndex, isIdentifier } from "./definition.js";
|
|
9
9
|
import { DiffParseError, formatUnitRef, isRepoPath, isUnitId, splitLines } from "./diff.js";
|
|
10
10
|
import { BranchRefusedError, blobsAt, branchHead, GitError, isOwnWork, pathsAt, pathsChanged, signing, treeOf, writeBranch, } from "./git.js";
|
|
11
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";
|
|
12
12
|
import { findSkillDir, InstallError, runInstall, runUninstall, syncSkills, } from "./install.js";
|
|
13
13
|
import { naiveDraft } from "./naive.js";
|
|
14
|
+
import { plainNumber, plainText } from "./plain.js";
|
|
14
15
|
import { isCommentId, isConcernId, isFields, OFFERS_PER_COMMIT, OFFERS_PER_PLAN, serializePlanFile, validatePlan, } from "./plan.js";
|
|
15
16
|
import { renderEvents, renderPrompt, renderReport, renderShownComment, renderShownConcern, renderShownDefinitions, renderShownUnit, reportItems, } from "./prompt.js";
|
|
16
17
|
import { parseRefText, refsIn, repoPathOfTyped, resolveRefs } from "./refs.js";
|
|
@@ -20,6 +21,10 @@ import { checkSyntax } from "./syntax.js";
|
|
|
20
21
|
class UsageError extends Error {
|
|
21
22
|
name = "UsageError";
|
|
22
23
|
}
|
|
24
|
+
/** `--help` or `-h` anywhere a command reads its options; main prints that command's usage. */
|
|
25
|
+
class HelpRequested extends Error {
|
|
26
|
+
name = "HelpRequested";
|
|
27
|
+
}
|
|
23
28
|
/** A test run follow-me will not record, with the reason the agent can act on. */
|
|
24
29
|
class CoverageError extends Error {
|
|
25
30
|
name = "CoverageError";
|
|
@@ -47,7 +52,8 @@ Other commands:
|
|
|
47
52
|
the items to post to a merge request
|
|
48
53
|
coverage <lcov> [--test <id> [--at <path:line>]] [--reset]
|
|
49
54
|
record a test run taken at the tip for the page;
|
|
50
|
-
--
|
|
55
|
+
--at is where the test is declared; --reset alone
|
|
56
|
+
clears it before per-test runs
|
|
51
57
|
|
|
52
58
|
help --all lists every command.`;
|
|
53
59
|
const USAGE = `usage: follow-me <command> [options]
|
|
@@ -117,7 +123,8 @@ Interactive loop (while the review page is open):
|
|
|
117
123
|
record the lcov of a test run at the branch tip;
|
|
118
124
|
refuses when a covered file in the working tree
|
|
119
125
|
differs from the tip. A plain run replaces the last;
|
|
120
|
-
--test runs add one test each, --at is
|
|
126
|
+
--test runs add one test each, --at is the line
|
|
127
|
+
where that test is declared.
|
|
121
128
|
--reset starts fresh; alone it clears the last run
|
|
122
129
|
apply, while the page is open, swaps it to the new plan version in place, same URL.
|
|
123
130
|
|
|
@@ -157,6 +164,8 @@ function parseArgs(argv, spec) {
|
|
|
157
164
|
const flags = new Set();
|
|
158
165
|
for (let i = 0; i < argv.length; i++) {
|
|
159
166
|
const arg = argv[i] ?? "";
|
|
167
|
+
if (arg === "--help" || arg === "-h")
|
|
168
|
+
throw new HelpRequested();
|
|
160
169
|
if (!arg.startsWith("--")) {
|
|
161
170
|
positionals.push(arg);
|
|
162
171
|
continue;
|
|
@@ -176,6 +185,43 @@ function parseArgs(argv, spec) {
|
|
|
176
185
|
}
|
|
177
186
|
return { positionals, values, flags };
|
|
178
187
|
}
|
|
188
|
+
/**
|
|
189
|
+
* The usage of one command, read from its entries in USAGE: the synopsis, then what it does.
|
|
190
|
+
* Null for a command USAGE does not list.
|
|
191
|
+
*/
|
|
192
|
+
function usageOf(command) {
|
|
193
|
+
const blocks = [];
|
|
194
|
+
let block = null;
|
|
195
|
+
for (const line of USAGE.split("\n")) {
|
|
196
|
+
if (line.startsWith(` ${command} `) || line === ` ${command}`) {
|
|
197
|
+
block = [line];
|
|
198
|
+
blocks.push(block);
|
|
199
|
+
}
|
|
200
|
+
else if (block !== null && line.startsWith(" "))
|
|
201
|
+
block.push(line);
|
|
202
|
+
else
|
|
203
|
+
block = null;
|
|
204
|
+
}
|
|
205
|
+
if (blocks.length === 0)
|
|
206
|
+
return null;
|
|
207
|
+
return blocks
|
|
208
|
+
.map((lines) => {
|
|
209
|
+
const synopsis = [];
|
|
210
|
+
const what = [];
|
|
211
|
+
for (const line of lines) {
|
|
212
|
+
const [left = "", right = ""] = line.trim().split(/\s{3,}/, 2);
|
|
213
|
+
if (/^\s{20,}/.test(line))
|
|
214
|
+
what.push(left);
|
|
215
|
+
else {
|
|
216
|
+
synopsis.push(left);
|
|
217
|
+
if (right !== "")
|
|
218
|
+
what.push(right);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return [`usage: follow-me ${synopsis.join(" ")}`, ...what.map((w) => ` ${w}`)].join("\n");
|
|
222
|
+
})
|
|
223
|
+
.join("\n");
|
|
224
|
+
}
|
|
179
225
|
function say(line = "") {
|
|
180
226
|
process.stdout.write(`${line}\n`);
|
|
181
227
|
}
|
|
@@ -265,45 +311,59 @@ function summarize(session, cwd) {
|
|
|
265
311
|
}
|
|
266
312
|
say(`session ${relative(cwd, session.dir) || "."}`);
|
|
267
313
|
}
|
|
314
|
+
/** Commits are numbered from 1 in everything the agent reads; issues carry 0-based indices. */
|
|
315
|
+
function commitName(k) {
|
|
316
|
+
return `commit ${k + 1}`;
|
|
317
|
+
}
|
|
318
|
+
/** A JSON path into the draft with its commit named from 1: `$.commits[2].units[0]` reads `commit 3, units[0]`. */
|
|
319
|
+
function jsonPlace(at) {
|
|
320
|
+
const m = /^\$\.commits\[(\d+)\]\.?(.*)$/.exec(at);
|
|
321
|
+
if (m === null)
|
|
322
|
+
return at;
|
|
323
|
+
const [, k = "0", rest = ""] = m;
|
|
324
|
+
return rest === "" ? commitName(Number(k)) : `${commitName(Number(k))}, ${rest}`;
|
|
325
|
+
}
|
|
268
326
|
function formatIssue(issue) {
|
|
269
327
|
switch (issue.kind) {
|
|
270
328
|
case "malformed":
|
|
271
|
-
return `malformed ${issue.at}: ${issue.reason}`;
|
|
329
|
+
return `malformed ${jsonPlace(issue.at)}: ${issue.reason}`;
|
|
272
330
|
case "stale":
|
|
273
331
|
return `stale: the plan is for tip ${short(issue.planTip)} but the session tip is ${short(issue.sessionTip)}`;
|
|
274
332
|
case "empty-message":
|
|
275
|
-
return
|
|
333
|
+
return `${commitName(issue.commit)}: empty message`;
|
|
276
334
|
case "empty-commit":
|
|
277
|
-
return
|
|
335
|
+
return `${commitName(issue.commit)}: no units`;
|
|
278
336
|
case "unknown-unit":
|
|
279
|
-
return
|
|
337
|
+
return `${commitName(issue.commit)}: unknown unit ${issue.ref}`;
|
|
280
338
|
case "bad-selection":
|
|
281
|
-
return
|
|
339
|
+
return `${commitName(issue.commit)}: ${issue.unit}: ${issue.reason}`;
|
|
282
340
|
case "duplicate": {
|
|
283
341
|
const [a, b] = issue.commits;
|
|
284
342
|
const what = issue.changes.length > 0 ? lines(issue.unit, issue.changes) : issue.unit;
|
|
285
|
-
return `${what} is claimed by commits ${a} and ${b}`;
|
|
343
|
+
return `${what} is claimed by commits ${a + 1} and ${b + 1}`;
|
|
286
344
|
}
|
|
287
345
|
case "unassigned": {
|
|
288
346
|
const what = issue.changes.length > 0 ? lines(issue.unit, issue.changes) : issue.unit;
|
|
289
347
|
return `${what} (${show(issue.path)}) is in no commit`;
|
|
290
348
|
}
|
|
291
349
|
case "path-conflict":
|
|
292
|
-
return
|
|
350
|
+
return `${commitName(issue.commit)}: ${show(issue.file)} is a file while ${show(issue.under)} exists`;
|
|
293
351
|
case "unknown-response":
|
|
294
352
|
return `response to unknown comment ${issue.comment}`;
|
|
295
353
|
case "bad-answer":
|
|
296
354
|
return `response to ${issue.comment}: ${issue.reason}`;
|
|
355
|
+
case "section-order":
|
|
356
|
+
return `${commitName(issue.commit)}: section ${issue.section} comes after ${commitName(issue.before)} in ${issue.beforeSection}; sections read story, steps, details, wiring, so move ${commitName(issue.commit)} before ${commitName(issue.before)}, or change its section`;
|
|
297
357
|
case "bad-concern":
|
|
298
|
-
return
|
|
358
|
+
return `${commitName(issue.commit)}: concern: ${issue.reason}`;
|
|
299
359
|
case "too-many-concerns":
|
|
300
|
-
return `${issue.unit} has
|
|
360
|
+
return `${commitName(issue.commit)}: ${issue.unit} has ${issue.count} concerns; a unit takes one per commit (each slice of a split hunk may carry its own), so keep the one that matters`;
|
|
301
361
|
case "bad-offer":
|
|
302
|
-
return
|
|
362
|
+
return `${commitName(issue.commit)}: offer: ${issue.reason}`;
|
|
303
363
|
case "too-many-offers":
|
|
304
364
|
return issue.commit === null
|
|
305
365
|
? `the plan has ${issue.count} offers; at most ${OFFERS_PER_PLAN}, and most plans need none`
|
|
306
|
-
:
|
|
366
|
+
: `${commitName(issue.commit)} has ${issue.count} offers; at most ${OFFERS_PER_COMMIT} per commit`;
|
|
307
367
|
case "final-mismatch": {
|
|
308
368
|
const tip = issue.expected ? `${issue.expected.mode} ${issue.expected.blob}` : "absent";
|
|
309
369
|
return `${show(issue.path)}: the last commit has ${issue.got}, the tip has ${tip}`;
|
|
@@ -316,10 +376,7 @@ function formatIssue(issue) {
|
|
|
316
376
|
}
|
|
317
377
|
function formatWarning(w) {
|
|
318
378
|
if (w.kind === "long-title") {
|
|
319
|
-
return `warning:
|
|
320
|
-
}
|
|
321
|
-
if (w.kind === "section-order") {
|
|
322
|
-
return `warning: $.commits[${w.commit}]: section ${w.section} comes after ${w.after}; sections read story, steps, details, wiring`;
|
|
379
|
+
return `warning: ${commitName(w.commit)}: title is ${w.length} characters; keep it to 50`;
|
|
323
380
|
}
|
|
324
381
|
if (w.kind === "question-addressed") {
|
|
325
382
|
return `warning: question ${w.comment} is marked addressed; explain it with "answered" and "at" unless answering it changed the plan`;
|
|
@@ -403,7 +460,7 @@ function syntaxOverride(args) {
|
|
|
403
460
|
return { override };
|
|
404
461
|
}
|
|
405
462
|
function formatSyntax(report, mark = "") {
|
|
406
|
-
const out = report.issues.map((i) => `${mark}
|
|
463
|
+
const out = report.issues.map((i) => `${mark}${commitName(i.commit)}: ${show(i.path)}: ${i.message}`);
|
|
407
464
|
for (const i of report.tip) {
|
|
408
465
|
out.push(`${mark}${show(i.path)}: ${i.message} (the tip itself does not parse here, so earlier commits are not checked for it)`);
|
|
409
466
|
}
|
|
@@ -559,7 +616,7 @@ function projectedCommits(session, version) {
|
|
|
559
616
|
const plan = session.plans().find((p) => p.version === version);
|
|
560
617
|
if (plan === undefined)
|
|
561
618
|
return [];
|
|
562
|
-
const verdict = validatePlan(session.diff, serializePlanFile(plan.file));
|
|
619
|
+
const verdict = validatePlan(session.diff, serializePlanFile(plan.file), { frozen: true });
|
|
563
620
|
return verdict.ok ? verdict.plan.commits : [];
|
|
564
621
|
}
|
|
565
622
|
/** Writes the one-commit-per-file draft with no agent. */
|
|
@@ -930,7 +987,8 @@ function reviewedPlan(session) {
|
|
|
930
987
|
const reviews = reviewsOf(session);
|
|
931
988
|
for (const stored of reviews.toReversed()) {
|
|
932
989
|
const plan = session.plans().find((p) => p.version === stored.version);
|
|
933
|
-
if (plan !== undefined &&
|
|
990
|
+
if (plan !== undefined &&
|
|
991
|
+
validatePlan(session.diff, serializePlanFile(plan.file), { frozen: true }).ok) {
|
|
934
992
|
return stored;
|
|
935
993
|
}
|
|
936
994
|
}
|
|
@@ -1046,9 +1104,18 @@ function readCursor(session, summary) {
|
|
|
1046
1104
|
const counts = log === logOf(summary) && typeof seq === "number" && Number.isInteger(seq) && seq >= 0;
|
|
1047
1105
|
return { session: logOf(summary), seq: counts ? seq : 0, seen };
|
|
1048
1106
|
}
|
|
1107
|
+
const ID_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789_";
|
|
1108
|
+
const LOG_CHARS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-@.:+";
|
|
1109
|
+
const CURSOR_ID = /^[a-z]_[a-z0-9]+$/;
|
|
1110
|
+
const CURSOR_LOG = /^[\w-]+@[\w.:+-]+$/;
|
|
1111
|
+
/** The cursor holds data the server sent, so only ids and a log name of the expected shape are written. */
|
|
1049
1112
|
function writeCursor(session, cursor) {
|
|
1050
1113
|
const { session: log, seq, seen } = cursor;
|
|
1051
|
-
|
|
1114
|
+
if (!CURSOR_LOG.test(log) || !Number.isInteger(seq))
|
|
1115
|
+
throw new Error(`refusing to save the cursor for ${log}`);
|
|
1116
|
+
const ids = [...seen].filter((id) => CURSOR_ID.test(id)).map((id) => plainText(id, ID_CHARS));
|
|
1117
|
+
const saved = { session: plainText(log, LOG_CHARS), seq: plainNumber(seq), seen: ids };
|
|
1118
|
+
writeFileSync(cursorPath(session), `${JSON.stringify(saved)}\n`);
|
|
1052
1119
|
}
|
|
1053
1120
|
/** The comment or reply id an event first carries, or null for events without one. */
|
|
1054
1121
|
function saidId(event) {
|
|
@@ -1076,11 +1143,42 @@ async function offerStep(port, summary) {
|
|
|
1076
1143
|
return null;
|
|
1077
1144
|
return `${STILL_WORKING}; offer ${first.offer} waits for your diagram: follow-me reply ${first.comment} --diagram <file> [--map <file>], then run follow-me again`;
|
|
1078
1145
|
}
|
|
1146
|
+
/** The per-test step while a state map waits for it, said as a `next:` of the listening loop. */
|
|
1147
|
+
async function testsStep(session, open, cwd) {
|
|
1148
|
+
if (!(await mapWantsTests(session, open)))
|
|
1149
|
+
return null;
|
|
1150
|
+
return `${STILL_WORKING}; ${perTestStep(session, cwd)}; then run follow-me again`;
|
|
1151
|
+
}
|
|
1152
|
+
/**
|
|
1153
|
+
* Where the agent writes lcov files, relative to cwd: inside the session, which `.follow-me/`'s
|
|
1154
|
+
* own .gitignore hides and which goes when the session goes, so no run leaves a file in the repo.
|
|
1155
|
+
*/
|
|
1156
|
+
function lcovDir(session, cwd) {
|
|
1157
|
+
const dir = join(session.dir, "lcov");
|
|
1158
|
+
mkdirSync(dir, { recursive: true });
|
|
1159
|
+
return relative(cwd, dir);
|
|
1160
|
+
}
|
|
1161
|
+
/** The aggregate run to take when a review opens. */
|
|
1162
|
+
function coverageStep(session, cwd) {
|
|
1163
|
+
return `if the project has tests, run them with coverage at the tip, lcov to ${lcovDir(session, cwd)}/all.info, and pass it to follow-me coverage`;
|
|
1164
|
+
}
|
|
1165
|
+
/** The per-test runs a state map needs for its badges and its "Not driven" list. */
|
|
1166
|
+
function perTestStep(session, cwd) {
|
|
1167
|
+
const dir = lcovDir(session, cwd);
|
|
1168
|
+
return `the state machine needs one coverage run per test: follow-me coverage --reset, then for each test run it alone with lcov to ${dir}/<n>.info and follow-me coverage ${dir}/<n>.info --test "<test name>" --at <path:line where it is declared>`;
|
|
1169
|
+
}
|
|
1170
|
+
/** Whether a reply of the hosted review carries a state map while no run has per-test detail. */
|
|
1171
|
+
async function mapWantsTests(session, open) {
|
|
1172
|
+
if ((session.coverage()?.tests.size ?? 0) > 0)
|
|
1173
|
+
return false;
|
|
1174
|
+
const said = await fetchComments(open.port, open.summary);
|
|
1175
|
+
return said.some(({ comments }) => comments.some((c) => (c.replies ?? []).some((r) => r.map !== undefined)));
|
|
1176
|
+
}
|
|
1079
1177
|
/**
|
|
1080
1178
|
* Listens to the hosted review for up to --wait seconds and prints what the reviewer did. It
|
|
1081
1179
|
* returns at once with news, and with the report when the reviewer submitted or ended the review.
|
|
1082
1180
|
*/
|
|
1083
|
-
async function listen(session, open, args) {
|
|
1181
|
+
async function listen(session, open, args, cwd) {
|
|
1084
1182
|
const { port, summary } = open;
|
|
1085
1183
|
const waitMs = waitOf(args) * 1000;
|
|
1086
1184
|
const deadline = Date.now() + waitMs;
|
|
@@ -1136,11 +1234,13 @@ async function listen(session, open, args) {
|
|
|
1136
1234
|
return 0;
|
|
1137
1235
|
}
|
|
1138
1236
|
next((await offerStep(port, summary)) ??
|
|
1237
|
+
(await testsStep(session, open, cwd)) ??
|
|
1139
1238
|
`${STILL_WORKING}; answer with follow-me reply <id> --body "<markdown>" [--diagram file], then run follow-me again`);
|
|
1140
1239
|
return 0;
|
|
1141
1240
|
}
|
|
1142
1241
|
if (left <= 0) {
|
|
1143
1242
|
next((await offerStep(port, summary)) ??
|
|
1243
|
+
(await testsStep(session, open, cwd)) ??
|
|
1144
1244
|
`${STILL_WORKING}; run follow-me again to keep listening`);
|
|
1145
1245
|
return 0;
|
|
1146
1246
|
}
|
|
@@ -1165,7 +1265,7 @@ async function entry(cwd, args) {
|
|
|
1165
1265
|
return swapOpenPage(session, hosted, args);
|
|
1166
1266
|
}
|
|
1167
1267
|
if (hosted !== null)
|
|
1168
|
-
return listen(session, hosted, args);
|
|
1268
|
+
return listen(session, hosted, args, cwd);
|
|
1169
1269
|
const state = draftState(session);
|
|
1170
1270
|
if (state.kind === "invalid") {
|
|
1171
1271
|
next("fix the draft, then run follow-me again");
|
|
@@ -1210,11 +1310,10 @@ async function entry(cwd, args) {
|
|
|
1210
1310
|
const result = await hostReview(session, mode, detached, async () => prep, reportSaved);
|
|
1211
1311
|
if (result.kind !== "elsewhere")
|
|
1212
1312
|
return exitCode(result);
|
|
1213
|
-
if (session.coverage() === null)
|
|
1214
|
-
next(
|
|
1215
|
-
}
|
|
1313
|
+
if (session.coverage() === null)
|
|
1314
|
+
next(coverageStep(session, cwd));
|
|
1216
1315
|
say("follow-me is interactive: keep running it until it prints the report");
|
|
1217
|
-
return listen(session, result, args);
|
|
1316
|
+
return listen(session, result, args, cwd);
|
|
1218
1317
|
}
|
|
1219
1318
|
/** Opens the session, re-extracting instead of failing when the branch moved or --base changed. */
|
|
1220
1319
|
async function openFresh(cwd, args) {
|
|
@@ -1237,7 +1336,10 @@ function walkablePlan(session) {
|
|
|
1237
1336
|
const lastReview = session.lastReview();
|
|
1238
1337
|
const stored = session.plans().at(-1);
|
|
1239
1338
|
if (stored !== undefined) {
|
|
1240
|
-
const verdict = validatePlan(session.diff, serializePlanFile(stored.file), {
|
|
1339
|
+
const verdict = validatePlan(session.diff, serializePlanFile(stored.file), {
|
|
1340
|
+
lastReview,
|
|
1341
|
+
frozen: true,
|
|
1342
|
+
});
|
|
1241
1343
|
if (verdict.ok)
|
|
1242
1344
|
return { version: stored.version, plan: verdict.plan };
|
|
1243
1345
|
}
|
|
@@ -1276,7 +1378,7 @@ async function renderStored(session, stored, format = "markdown") {
|
|
|
1276
1378
|
console.error(`follow-me: review v${stored.version} refers to a plan that is missing`);
|
|
1277
1379
|
return null;
|
|
1278
1380
|
}
|
|
1279
|
-
const verdict = validatePlan(session.diff, serializePlanFile(plan.file));
|
|
1381
|
+
const verdict = validatePlan(session.diff, serializePlanFile(plan.file), { frozen: true });
|
|
1280
1382
|
if (!verdict.ok) {
|
|
1281
1383
|
console.error(`follow-me: plan v${plan.version} no longer fits ${session.anchor.branch}`);
|
|
1282
1384
|
return null;
|
|
@@ -1340,7 +1442,7 @@ async function report(cwd, args, walkOnly = false) {
|
|
|
1340
1442
|
process.stdout.write(text);
|
|
1341
1443
|
return 0;
|
|
1342
1444
|
}
|
|
1343
|
-
const COVERAGE_USAGE = "usage: follow-me coverage <lcov> [--test <id> [--at <path:line>]] [--reset]\n follow-me coverage --reset";
|
|
1445
|
+
const COVERAGE_USAGE = "usage: follow-me coverage <lcov> [--test <id> [--at <path:line>]] [--reset]\n follow-me coverage --reset\n--at is the path:line where the test is declared";
|
|
1344
1446
|
/**
|
|
1345
1447
|
* Records the lcov of a test run the agent made at the tip in the session's coverage.json and
|
|
1346
1448
|
* prints how much of the branch's changed code it ran. follow-me never runs the tests itself.
|
|
@@ -1404,7 +1506,19 @@ async function coverage(cwd, args) {
|
|
|
1404
1506
|
if (open !== null)
|
|
1405
1507
|
await postCoverage(open.port, open.summary);
|
|
1406
1508
|
const { covered, executable } = totalOf(changedLines(session.diff.units, recorded.files).values());
|
|
1407
|
-
|
|
1509
|
+
const total = `coverage for ${plural(recorded.files.size, "file")} at ${short(tip)}; ${covered} of ${executable} changed lines covered`;
|
|
1510
|
+
if (test === undefined)
|
|
1511
|
+
say(total);
|
|
1512
|
+
else {
|
|
1513
|
+
// A per-test run adds to what is on record, so its own share is said apart from the total.
|
|
1514
|
+
const own = totalOf(changedLines(session.diff.units, run.files).values()).covered;
|
|
1515
|
+
const branches = changedBranchesTaken(session.diff.units, run.files);
|
|
1516
|
+
say(`recorded test ${JSON.stringify(test.id)}: ${plural(own, "changed line")}, ${branches === 1 ? "1 branch" : `${branches} branches`}`);
|
|
1517
|
+
say(`running total of ${plural(recorded.tests.size, "test")}: ${total}`);
|
|
1518
|
+
}
|
|
1519
|
+
if (test === undefined && open !== null && (await mapWantsTests(session, open))) {
|
|
1520
|
+
next(`${perTestStep(session, cwd)}; then run follow-me again`);
|
|
1521
|
+
}
|
|
1408
1522
|
return 0;
|
|
1409
1523
|
}
|
|
1410
1524
|
/**
|
|
@@ -1540,7 +1654,11 @@ async function reply(cwd, args) {
|
|
|
1540
1654
|
...(refs.length === 0 ? {} : { refs }),
|
|
1541
1655
|
});
|
|
1542
1656
|
say(`replied ${id} to ${commentId}`);
|
|
1543
|
-
|
|
1657
|
+
if (map !== undefined && (session.coverage()?.tests.size ?? 0) === 0) {
|
|
1658
|
+
next(`${perTestStep(session, cwd)}; then run follow-me again; ${STILL_WORKING}`);
|
|
1659
|
+
}
|
|
1660
|
+
else
|
|
1661
|
+
next(`run follow-me again; ${STILL_WORKING}`);
|
|
1544
1662
|
return 0;
|
|
1545
1663
|
}
|
|
1546
1664
|
const REPLY_USAGE = `usage: follow-me reply <commentId> --body <text> [--diagram <file> [--map <file>]]
|
|
@@ -1824,7 +1942,7 @@ async function showRef(cwd, args) {
|
|
|
1824
1942
|
throw new UsageError(`${ref} is not a unit of the branch now; its hunk changed`);
|
|
1825
1943
|
}
|
|
1826
1944
|
const commits = latest === undefined ? [] : projectedCommits(session, latest.version);
|
|
1827
|
-
process.stdout.write(renderShownUnit(unit, commits));
|
|
1945
|
+
process.stdout.write(renderShownUnit(unit, units, commits));
|
|
1828
1946
|
return 0;
|
|
1829
1947
|
}
|
|
1830
1948
|
if (/^c_[a-z2-7]{6}$/.test(ref) && isCommentId(ref)) {
|
|
@@ -1915,6 +2033,9 @@ async function serverCommand(argv) {
|
|
|
1915
2033
|
await started.host.closed;
|
|
1916
2034
|
return 0;
|
|
1917
2035
|
}
|
|
2036
|
+
case "--help":
|
|
2037
|
+
case "-h":
|
|
2038
|
+
throw new HelpRequested();
|
|
1918
2039
|
default:
|
|
1919
2040
|
throw new UsageError("usage: follow-me server status|stop");
|
|
1920
2041
|
}
|
|
@@ -2136,6 +2257,11 @@ export async function main(argv, cwd) {
|
|
|
2136
2257
|
}
|
|
2137
2258
|
}
|
|
2138
2259
|
catch (error) {
|
|
2260
|
+
if (error instanceof HelpRequested) {
|
|
2261
|
+
const asked = command === undefined || command === "help" ? null : usageOf(command);
|
|
2262
|
+
say(asked ?? SHORT_USAGE);
|
|
2263
|
+
return 0;
|
|
2264
|
+
}
|
|
2139
2265
|
const known = [
|
|
2140
2266
|
UsageError,
|
|
2141
2267
|
CoverageError,
|
package/dist/src/comments.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { parseLoc } from "./coverage.js";
|
|
2
2
|
import { isRepoPath, isSha, isUnitId } from "./diff.js";
|
|
3
3
|
import { diagramProblem, isArray, isCommentId, isConcernId, isFields, isOfferId, isPlanVersion, isSeverity, parsePlacedConcern, } from "./plan.js";
|
|
4
|
+
import { parseRefText } from "./refs.js";
|
|
4
5
|
/**
|
|
5
6
|
* The wire formats of review state, parsed once at the boundary: comments, replies, concern
|
|
6
7
|
* decisions, the review file, its autosaved draft and the events a hosted session logs. Every
|
|
@@ -49,26 +50,42 @@ function parseAnchor(v) {
|
|
|
49
50
|
}
|
|
50
51
|
return { kind: "hunk", unit: v.unit, commit, changes, offsets };
|
|
51
52
|
}
|
|
53
|
+
/** A place written as a ref string (`path:a-b`, tip lines) or as { path, start, end }. */
|
|
54
|
+
function parsePlace(raw) {
|
|
55
|
+
if (typeof raw !== "string")
|
|
56
|
+
return parseLoc(raw);
|
|
57
|
+
const spec = parseRefText(raw);
|
|
58
|
+
if (spec === null)
|
|
59
|
+
return null;
|
|
60
|
+
if (spec.at !== null)
|
|
61
|
+
return `${JSON.stringify(raw)}: a map is in tip lines; drop the @`;
|
|
62
|
+
return parseLoc({ path: spec.path, start: spec.start, end: spec.end });
|
|
63
|
+
}
|
|
52
64
|
function parseLocs(v, at, needed) {
|
|
53
|
-
const what = `${at}: expected a list of { path, start, end }${needed ? " with at least one place" : ""}`;
|
|
65
|
+
const what = `${at}: expected a list of "path:a-b" or { path, start, end }${needed ? " with at least one place" : ""}`;
|
|
54
66
|
if (!isArray(v) || (needed && v.length === 0))
|
|
55
67
|
return what;
|
|
56
68
|
const out = [];
|
|
57
69
|
for (const [i, raw] of v.entries()) {
|
|
58
|
-
const loc =
|
|
70
|
+
const loc = parsePlace(raw);
|
|
59
71
|
if (loc === null)
|
|
60
|
-
return `${at}[${i}]: expected { path, start, end } with 1 <= start <= end`;
|
|
72
|
+
return `${at}[${i}]: expected "path:a-b" or { path, start, end } with 1 <= start <= end`;
|
|
73
|
+
if (typeof loc === "string")
|
|
74
|
+
return `${at}[${i}]: ${loc}`;
|
|
61
75
|
out.push(loc);
|
|
62
76
|
}
|
|
63
77
|
return out;
|
|
64
78
|
}
|
|
65
79
|
const TRANSITION_ENDS = ["from", "to"];
|
|
80
|
+
/** A stateDiagram's start and end marker. */
|
|
81
|
+
const START_END = "[*]";
|
|
66
82
|
function escapeRegExp(text) {
|
|
67
83
|
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
68
84
|
}
|
|
69
85
|
/**
|
|
70
86
|
* 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.
|
|
87
|
+
* names states of the map, and with a stateDiagram every state name appears in it. A transition
|
|
88
|
+
* to or from `[*]` is dropped: the diagram marks the start and end. The reason
|
|
72
89
|
* names the bad entry, as map.states.<name>[i] or map.transitions[i].<field>.
|
|
73
90
|
*/
|
|
74
91
|
export function parseStateMap(v, diagram) {
|
|
@@ -95,6 +112,10 @@ export function parseStateMap(v, diagram) {
|
|
|
95
112
|
const at = `map.transitions[${i}]`;
|
|
96
113
|
if (!isFields(raw))
|
|
97
114
|
return `${at}: expected { from, to, event, at }`;
|
|
115
|
+
// The diagram's [*] already marks where the machine starts and ends; a move to or from it
|
|
116
|
+
// has no code of its own, so it is dropped rather than refused.
|
|
117
|
+
if (raw.from === START_END || raw.to === START_END)
|
|
118
|
+
continue;
|
|
98
119
|
for (const end of TRANSITION_ENDS) {
|
|
99
120
|
const state = raw[end];
|
|
100
121
|
if (typeof state !== "string" || !named.has(state)) {
|
package/dist/src/coverage.js
CHANGED
|
@@ -272,12 +272,8 @@ export function recordRun(prior, run, at) {
|
|
|
272
272
|
function testEntry(ran) {
|
|
273
273
|
return { lines: ran.lines, ...(ran.branches.size === 0 ? {} : { branches: ran.branches }) };
|
|
274
274
|
}
|
|
275
|
-
/**
|
|
276
|
-
|
|
277
|
-
* row is a tip line, which is where the run was taken. Files without such rows are left out.
|
|
278
|
-
*/
|
|
279
|
-
export function changedLines(units, files) {
|
|
280
|
-
const out = new Map();
|
|
275
|
+
/** Calls visit with each added row's tip line, per hunk, for files the run covers. */
|
|
276
|
+
function eachAddedLine(units, files, visit) {
|
|
281
277
|
for (const unit of units.values()) {
|
|
282
278
|
if (unit.kind !== "hunk")
|
|
283
279
|
continue;
|
|
@@ -285,22 +281,37 @@ export function changedLines(units, files) {
|
|
|
285
281
|
if (file === undefined)
|
|
286
282
|
continue;
|
|
287
283
|
let line = tipStartOf(unit, units);
|
|
288
|
-
let { covered, executable } = out.get(unit.path) ?? { covered: 0, executable: 0 };
|
|
289
284
|
for (const row of unit.body) {
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
executable += 1;
|
|
293
|
-
if (hits > 0)
|
|
294
|
-
covered += 1;
|
|
295
|
-
}
|
|
285
|
+
if (row.op === "+")
|
|
286
|
+
visit(unit.path, file, line);
|
|
296
287
|
if (row.op !== "-")
|
|
297
288
|
line += 1;
|
|
298
289
|
}
|
|
299
|
-
if (executable > 0)
|
|
300
|
-
out.set(unit.path, { covered, executable });
|
|
301
290
|
}
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* Per file, the branch's added rows that lcov calls executable, and how many ran. Every added
|
|
294
|
+
* row is a tip line, which is where the run was taken. Files without such rows are left out.
|
|
295
|
+
*/
|
|
296
|
+
export function changedLines(units, files) {
|
|
297
|
+
const out = new Map();
|
|
298
|
+
eachAddedLine(units, files, (path, file, line) => {
|
|
299
|
+
const hits = file.lines.get(line);
|
|
300
|
+
if (hits === undefined)
|
|
301
|
+
return;
|
|
302
|
+
const { covered, executable } = out.get(path) ?? { covered: 0, executable: 0 };
|
|
303
|
+
out.set(path, { covered: covered + (hits > 0 ? 1 : 0), executable: executable + 1 });
|
|
304
|
+
});
|
|
302
305
|
return out;
|
|
303
306
|
}
|
|
307
|
+
/** How many branches the run took on the branch's added rows, summed over every decision. */
|
|
308
|
+
export function changedBranchesTaken(units, files) {
|
|
309
|
+
let taken = 0;
|
|
310
|
+
eachAddedLine(units, files, (_, file, line) => {
|
|
311
|
+
taken += file.branches.get(line)?.[0] ?? 0;
|
|
312
|
+
});
|
|
313
|
+
return taken;
|
|
314
|
+
}
|
|
304
315
|
export function totalOf(tallies) {
|
|
305
316
|
let covered = 0;
|
|
306
317
|
let executable = 0;
|
package/dist/src/host.js
CHANGED
|
@@ -5,10 +5,12 @@ import { homedir } from "node:os";
|
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import { parseCarried, parseComment, parseEvent, parseEventBatch, } from "./comments.js";
|
|
7
7
|
import { carryComments } from "./evolve.js";
|
|
8
|
+
import { plainNumber, plainText } from "./plain.js";
|
|
8
9
|
import { isPlanVersion, serializePlanFile, validatePlan } from "./plan.js";
|
|
9
10
|
import { HEALTH_APP, isSessionId, loadDraft, PortInUseError, RegistrationError, startServer, } from "./server.js";
|
|
10
11
|
import { BranchMovedError, diffOfPlan, isAlive, openSessionAt, SessionFileError, SessionLockedError, sessionComments, } from "./session.js";
|
|
11
12
|
const RECORD = "server.json";
|
|
13
|
+
const PATH_CHARS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789/._~-?=&";
|
|
12
14
|
const DEFAULT_IDLE_MS = 30_000;
|
|
13
15
|
const START_TIMEOUT_MS = 10_000;
|
|
14
16
|
const STOP_TIMEOUT_MS = 5_000;
|
|
@@ -28,6 +30,13 @@ export function idleMs(env = process.env) {
|
|
|
28
30
|
function isPort(value) {
|
|
29
31
|
return typeof value === "number" && Number.isInteger(value) && value > 0 && value <= 65535;
|
|
30
32
|
}
|
|
33
|
+
/** Every request this client makes goes to the loopback server, on a port and a path that are plain. */
|
|
34
|
+
function loopbackUrl(port, path) {
|
|
35
|
+
if (!isPort(port) || !/^\/[\w./~-]*(\?[\w=&.-]*)?$/.test(path)) {
|
|
36
|
+
throw new RegistrationError("refusing a request to an unexpected address");
|
|
37
|
+
}
|
|
38
|
+
return `http://127.0.0.1:${plainNumber(port)}${plainText(path, PATH_CHARS)}`;
|
|
39
|
+
}
|
|
31
40
|
function isFields(value) {
|
|
32
41
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
33
42
|
}
|
|
@@ -52,7 +61,7 @@ export function readServerRecord(dir) {
|
|
|
52
61
|
/** The pid of the follow-me server answering on this port, or null for anything else. */
|
|
53
62
|
export async function probeHealth(port) {
|
|
54
63
|
try {
|
|
55
|
-
const res = await fetch(
|
|
64
|
+
const res = await fetch(loopbackUrl(port, "/api/health"), {
|
|
56
65
|
signal: AbortSignal.timeout(1_000),
|
|
57
66
|
});
|
|
58
67
|
const body = await res.json();
|
|
@@ -122,6 +131,7 @@ async function loadHosted(registration, held) {
|
|
|
122
131
|
}
|
|
123
132
|
const verdict = validatePlan(session.diff, serializePlanFile(stored.file), {
|
|
124
133
|
lastReview: session.lastReview(),
|
|
134
|
+
frozen: true,
|
|
125
135
|
});
|
|
126
136
|
if (!verdict.ok) {
|
|
127
137
|
throw new RegistrationError(`plan v${version} no longer fits ${branch}; check it again`);
|
|
@@ -251,7 +261,7 @@ export async function spawnDetached(dir, port) {
|
|
|
251
261
|
throw new Error(`the review server did not start; see ${logPath}`);
|
|
252
262
|
}
|
|
253
263
|
async function call(port, method, path, body) {
|
|
254
|
-
const res = await fetch(
|
|
264
|
+
const res = await fetch(loopbackUrl(port, path), {
|
|
255
265
|
method,
|
|
256
266
|
headers: { "content-type": "application/json" },
|
|
257
267
|
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
@@ -356,7 +366,7 @@ export async function pollEvents(port, summary, since, timeoutMs) {
|
|
|
356
366
|
* the server closes the stream, which it does after the `ended` event.
|
|
357
367
|
*/
|
|
358
368
|
export async function followEvents(port, summary, since, onEvent) {
|
|
359
|
-
const res = await fetch(
|
|
369
|
+
const res = await fetch(loopbackUrl(port, `${summary.url}api/events/stream?since=${Number(since)}`));
|
|
360
370
|
if (!res.ok || res.body === null) {
|
|
361
371
|
throw new RegistrationError(`GET api/events/stream failed with ${res.status}`);
|
|
362
372
|
}
|
|
@@ -402,7 +412,7 @@ export async function postCoverage(port, summary) {
|
|
|
402
412
|
}
|
|
403
413
|
/** The consolidated report of the hosted session as it stands. */
|
|
404
414
|
export async function fetchReport(port, summary, format = "markdown") {
|
|
405
|
-
const res = await fetch(
|
|
415
|
+
const res = await fetch(loopbackUrl(port, `${summary.url}api/report?format=${format}`));
|
|
406
416
|
if (!res.ok)
|
|
407
417
|
throw new RegistrationError(`GET api/report failed with ${res.status}`);
|
|
408
418
|
return res.text();
|
|
@@ -422,7 +432,7 @@ export function sessionUrl(port, summary) {
|
|
|
422
432
|
/** Whether the server on this port still serves the session's state; a gone server serves none. */
|
|
423
433
|
export async function stillHosted(port, summary) {
|
|
424
434
|
try {
|
|
425
|
-
const res = await fetch(
|
|
435
|
+
const res = await fetch(loopbackUrl(port, `${summary.url}api/state`), {
|
|
426
436
|
signal: AbortSignal.timeout(2_000),
|
|
427
437
|
});
|
|
428
438
|
await res.body?.cancel();
|
package/dist/src/install.js
CHANGED
|
@@ -60,9 +60,19 @@ export function planSkillFiles(options, home, repoRoot) {
|
|
|
60
60
|
}
|
|
61
61
|
return [...byPath.values()];
|
|
62
62
|
}
|
|
63
|
+
function readIfPresent(path) {
|
|
64
|
+
try {
|
|
65
|
+
return readFileSync(path);
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT")
|
|
69
|
+
return null;
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
63
73
|
function writeSkill(skillDir, file, dryRun) {
|
|
64
74
|
const content = readFileSync(join(skillDir, file.source));
|
|
65
|
-
const current =
|
|
75
|
+
const current = readIfPresent(file.path);
|
|
66
76
|
if (current?.equals(content))
|
|
67
77
|
return `unchanged ${file.path}`;
|
|
68
78
|
if (dryRun)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
const DIGITS = "0123456789";
|
|
2
|
+
/**
|
|
3
|
+
* Rebuilds `text` one character at a time from `alphabet`, and throws on a character outside it. What comes
|
|
4
|
+
* back is made only of characters that were checked, so a port read from a file or an id sent by the server
|
|
5
|
+
* cannot carry anything else into a request address or a file.
|
|
6
|
+
*/
|
|
7
|
+
export function plainText(text, alphabet) {
|
|
8
|
+
let out = "";
|
|
9
|
+
for (const char of text) {
|
|
10
|
+
const at = alphabet.indexOf(char);
|
|
11
|
+
if (at < 0)
|
|
12
|
+
throw new Error(`unexpected character ${JSON.stringify(char)}`);
|
|
13
|
+
out += alphabet.charAt(at);
|
|
14
|
+
}
|
|
15
|
+
return out;
|
|
16
|
+
}
|
|
17
|
+
/** A whole number, rebuilt from its digits; throws on anything else. */
|
|
18
|
+
export function plainNumber(value) {
|
|
19
|
+
return Number(plainText(String(value), DIGITS));
|
|
20
|
+
}
|