@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.
Files changed (101) hide show
  1. package/README.md +7 -1
  2. package/dist/src/cli.js +210 -23
  3. package/dist/src/comments.js +152 -8
  4. package/dist/src/coverage.js +411 -0
  5. package/dist/src/git.js +18 -0
  6. package/dist/src/host.js +27 -0
  7. package/dist/src/plan.js +128 -6
  8. package/dist/src/prompt.js +113 -25
  9. package/dist/src/server.js +115 -11
  10. package/dist/src/session.js +34 -0
  11. package/dist/src/types.js +2 -0
  12. package/dist/ui/dist/assets/{abnfDiagram-WEYOIA3C-fYSYLdMl.js → abnfDiagram-WEYOIA3C-Bp14ygmz.js} +1 -1
  13. package/dist/ui/dist/assets/{arc-DGuGSnwa.js → arc-DUR6uzhI.js} +1 -1
  14. package/dist/ui/dist/assets/{architecture-WOLXFQ4H-VFox_hV1.js → architecture-WOLXFQ4H-CyfnP65O.js} +1 -1
  15. package/dist/ui/dist/assets/{architectureDiagram-ZHVYKT53-CBVK3nHZ.js → architectureDiagram-ZHVYKT53-dJTicIk2.js} +1 -1
  16. package/dist/ui/dist/assets/{blockDiagram-RJAE7QA3-BFdsIWEr.js → blockDiagram-RJAE7QA3-BYiqh5Ib.js} +1 -1
  17. package/dist/ui/dist/assets/{c4Diagram-XDPPPE54-BGntcPId.js → c4Diagram-XDPPPE54-DHHPQ7xE.js} +1 -1
  18. package/dist/ui/dist/assets/channel-CY2cQobm.js +1 -0
  19. package/dist/ui/dist/assets/{chunk-2E7WHVMD-CNMuK9zj.js → chunk-2E7WHVMD-BODm_awL.js} +1 -1
  20. package/dist/ui/dist/assets/{chunk-4EMNKR2T-ByeTTJWS.js → chunk-4EMNKR2T-CpZfjf2T.js} +1 -1
  21. package/dist/ui/dist/assets/{chunk-55XCU76K-99xE7cLH.js → chunk-55XCU76K-CoP2pJTQ.js} +1 -1
  22. package/dist/ui/dist/assets/{chunk-7S6XSTFO-ZVmT7eMZ.js → chunk-7S6XSTFO-CaQcUvXY.js} +1 -1
  23. package/dist/ui/dist/assets/{chunk-AQDTSKZB-B981JORL.js → chunk-AQDTSKZB-BZ9Czvbu.js} +1 -1
  24. package/dist/ui/dist/assets/{chunk-DSNLSHJZ-B2z7FX4h.js → chunk-DSNLSHJZ-CNjUo110.js} +1 -1
  25. package/dist/ui/dist/assets/{chunk-HAGFOTED-CHFbBS-4.js → chunk-HAGFOTED-BTmBE_D3.js} +1 -1
  26. package/dist/ui/dist/assets/{chunk-HNE4Q36P-rq11xv9X.js → chunk-HNE4Q36P-D2cDuT0C.js} +1 -1
  27. package/dist/ui/dist/assets/{chunk-NQOKRVIF-BYmg9kSX.js → chunk-NQOKRVIF-AZcemFgG.js} +1 -1
  28. package/dist/ui/dist/assets/{chunk-UHW7PFBP-JvL9IXWg.js → chunk-UHW7PFBP-BszwjUqG.js} +1 -1
  29. package/dist/ui/dist/assets/{chunk-VQ5W62QI-CYMQaAKn.js → chunk-VQ5W62QI-vFSenYSe.js} +1 -1
  30. package/dist/ui/dist/assets/{chunk-WQQL4CDK-CXnR0PHT.js → chunk-WQQL4CDK-DZR6is1Q.js} +1 -1
  31. package/dist/ui/dist/assets/{chunk-XXDRQBXY-CDUWJCXi.js → chunk-XXDRQBXY-CACFh-si.js} +1 -1
  32. package/dist/ui/dist/assets/{chunk-YZO67HFG-mZfXIx0h.js → chunk-YZO67HFG-DRRTjGuW.js} +1 -1
  33. package/dist/ui/dist/assets/{chunk-ZAUX2LTX-DBxVTCHX.js → chunk-ZAUX2LTX-Bxuy-Roa.js} +1 -1
  34. package/dist/ui/dist/assets/{chunk-ZCHMQSOR-DrWA4IBo.js → chunk-ZCHMQSOR-rYXk__1o.js} +1 -1
  35. package/dist/ui/dist/assets/{classDiagram-v2-4MYWR3UA-Beah5uxA.js → classDiagram-v2-4MYWR3UA-RiF44tTz.js} +1 -1
  36. package/dist/ui/dist/assets/{cose-bilkent-JH36ORCC-UaE-jnHk.js → cose-bilkent-JH36ORCC-0oiQoThS.js} +1 -1
  37. package/dist/ui/dist/assets/{cynefin-EF2NZ3EQ-DKfGaCR8.js → cynefin-EF2NZ3EQ-BAssFK5K.js} +1 -1
  38. package/dist/ui/dist/assets/{cynefinDiagram-WF24UDRP-tTUzTs3D.js → cynefinDiagram-WF24UDRP-Bmd05bak.js} +1 -1
  39. package/dist/ui/dist/assets/{dagre-NK4GIZVX-jSn35_T7.js → dagre-NK4GIZVX-QJkLjPxj.js} +1 -1
  40. package/dist/ui/dist/assets/{diagram-CBK3MWTC-7W2uw8Zc.js → diagram-CBK3MWTC-PSaSiYXz.js} +1 -1
  41. package/dist/ui/dist/assets/{diagram-I5XIIV2D-CcfyQVh9.js → diagram-I5XIIV2D-BmU5X_hr.js} +1 -1
  42. package/dist/ui/dist/assets/{diagram-ODIL2UCW-BjGvaLis.js → diagram-ODIL2UCW-AAV39kUu.js} +1 -1
  43. package/dist/ui/dist/assets/{diagram-PFBEJUXE-jvdCrt8B.js → diagram-PFBEJUXE-DWlV-oxd.js} +1 -1
  44. package/dist/ui/dist/assets/{diagram-RJA3I4VV-D65_Vpi_.js → diagram-RJA3I4VV-D8wHRCNX.js} +1 -1
  45. package/dist/ui/dist/assets/{diagram-TYUNFSNR-C52roQBE.js → diagram-TYUNFSNR-B_JQEsNY.js} +1 -1
  46. package/dist/ui/dist/assets/{dist-BJd4jp9Y.js → dist-DxQIK_Ay.js} +1 -1
  47. package/dist/ui/dist/assets/{ebnfDiagram-V4P3TTPT-DXysoIBq.js → ebnfDiagram-V4P3TTPT-D83qV8mv.js} +1 -1
  48. package/dist/ui/dist/assets/{elk-QONTBCTQ-BVZcsgV3.js → elk-QONTBCTQ-BKFAKVSD.js} +1 -1
  49. package/dist/ui/dist/assets/{erDiagram-KYQIGQAA-BJnT4fVU.js → erDiagram-KYQIGQAA-CHYRd4aM.js} +1 -1
  50. package/dist/ui/dist/assets/{eventmodeling-K75KTNOO-C5MzxCMY.js → eventmodeling-K75KTNOO-BDJDv3Zf.js} +1 -1
  51. package/dist/ui/dist/assets/flowDiagram-IUGLLWBU-Bke3CfUH.js +1 -0
  52. package/dist/ui/dist/assets/{ganttDiagram-R7GPNTJY-CZRrnuah.js → ganttDiagram-R7GPNTJY-gTHjYkFj.js} +1 -1
  53. package/dist/ui/dist/assets/{gitGraph-VSP46ZUC-l58aGSkh.js → gitGraph-VSP46ZUC-D4cWkxKB.js} +1 -1
  54. package/dist/ui/dist/assets/{gitGraphDiagram-PDYL7XHY-D4ks1WO-.js → gitGraphDiagram-PDYL7XHY-D3M9wYMr.js} +1 -1
  55. package/dist/ui/dist/assets/index-BUOFihod.js +134 -0
  56. package/dist/ui/dist/assets/index-DLNJT0VS.css +1 -0
  57. package/dist/ui/dist/assets/{info-OHQRW6UA-BSqfzkdk.js → info-OHQRW6UA-B9y0ksVq.js} +1 -1
  58. package/dist/ui/dist/assets/{infoDiagram-MVDRKAX5-B6NgVF_N.js → infoDiagram-MVDRKAX5-s4FMwb8r.js} +1 -1
  59. package/dist/ui/dist/assets/{ishikawaDiagram-HPEHV2GE-DvtfssEZ.js → ishikawaDiagram-HPEHV2GE-CnQdxybf.js} +1 -1
  60. package/dist/ui/dist/assets/{journeyDiagram-BOSHW7O7-DM64gT5v.js → journeyDiagram-BOSHW7O7-I-9OpDWY.js} +1 -1
  61. package/dist/ui/dist/assets/{kanban-definition-FNSA4HC5-AliyRJHy.js → kanban-definition-FNSA4HC5-DqUbIdH4.js} +1 -1
  62. package/dist/ui/dist/assets/{line-CHncXbKO.js → line-BELSui4d.js} +1 -1
  63. package/dist/ui/dist/assets/{linear-BWEWydI0.js → linear-77hmn2iQ.js} +1 -1
  64. package/dist/ui/dist/assets/{mermaid-parser.core-DGpOP8AM.js → mermaid-parser.core-Bxa71wW-.js} +3 -3
  65. package/dist/ui/dist/assets/{mermaid.core-BJtBFbJx.js → mermaid.core-DBI9F4bh.js} +4 -4
  66. package/dist/ui/dist/assets/{mindmap-definition-6OY57UG3-NjavnWBi.js → mindmap-definition-6OY57UG3-BxXMOapP.js} +1 -1
  67. package/dist/ui/dist/assets/{packet-JDAUHWVQ-zuhpVsME.js → packet-JDAUHWVQ-DTT77cQh.js} +1 -1
  68. package/dist/ui/dist/assets/{pegDiagram-ECIUJNBX-sL4grM97.js → pegDiagram-ECIUJNBX-HzrOQPT5.js} +1 -1
  69. package/dist/ui/dist/assets/{pie-XZMESJXO-C9l1Mp09.js → pie-XZMESJXO-iHwL1Qw-.js} +1 -1
  70. package/dist/ui/dist/assets/{pieDiagram-GNMPY74T-rlRBr2U4.js → pieDiagram-GNMPY74T-DZ1tLVjw.js} +1 -1
  71. package/dist/ui/dist/assets/{prism-core-DmuST30i.js → prism-core-NtO0YF9C.js} +1 -1
  72. package/dist/ui/dist/assets/{quadrantDiagram-D57K4MC3-4oQMip2Q.js → quadrantDiagram-D57K4MC3-BjTi9H5L.js} +1 -1
  73. package/dist/ui/dist/assets/{radar-ABXABTNO-DvGg96L7.js → radar-ABXABTNO-D2hYgXSB.js} +1 -1
  74. package/dist/ui/dist/assets/{railroad-I3PHUGI6-8wdFKMZp.js → railroad-I3PHUGI6-DpakXN74.js} +1 -1
  75. package/dist/ui/dist/assets/{railroad-abnf-LNEFI6M7-DUtOO0gn.js → railroad-abnf-LNEFI6M7-B8ZFR3iy.js} +1 -1
  76. package/dist/ui/dist/assets/{railroad-ebnf-DWYD2UWJ-5YCGY1_d.js → railroad-ebnf-DWYD2UWJ-S1XUyTsC.js} +1 -1
  77. package/dist/ui/dist/assets/{railroad-peg-JR7BVNR7-Bwywxjhs.js → railroad-peg-JR7BVNR7-BbeHC_6K.js} +1 -1
  78. package/dist/ui/dist/assets/{railroadDiagram-Y72NRBL7-CwO6HSNQ.js → railroadDiagram-Y72NRBL7-fd-2iyBo.js} +1 -1
  79. package/dist/ui/dist/assets/{requirementDiagram-EQYSW5RA-Cp0IbfNr.js → requirementDiagram-EQYSW5RA-B9-HHKH-.js} +1 -1
  80. package/dist/ui/dist/assets/{sankeyDiagram-SY24JHO5-DeQKsO68.js → sankeyDiagram-SY24JHO5-CeQN1ZEG.js} +1 -1
  81. package/dist/ui/dist/assets/{sequenceDiagram-BLEHNHQU-DTDalCxF.js → sequenceDiagram-BLEHNHQU-Vff7tx4t.js} +1 -1
  82. package/dist/ui/dist/assets/{src-XnrqevnL.js → src-D5-ZzLwt.js} +1 -1
  83. package/dist/ui/dist/assets/{stateDiagram-v2-A6IESKWZ-2DZ-tKo9.js → stateDiagram-v2-A6IESKWZ-Bzn_ma9c.js} +1 -1
  84. package/dist/ui/dist/assets/{swimlanes-B3ENRKFQ-66yZJeRU.js → swimlanes-B3ENRKFQ-CevqLchV.js} +1 -1
  85. package/dist/ui/dist/assets/swimlanesDiagram-AYQRV4AK-Bkv8hAmN.js +8 -0
  86. package/dist/ui/dist/assets/{timeline-definition-JXYKGLGB-Dm1xGU-d.js → timeline-definition-JXYKGLGB-D9jSDGJI.js} +1 -1
  87. package/dist/ui/dist/assets/{treeView-D4JQ5SDB-BvgsbSot.js → treeView-D4JQ5SDB-CoXKj11m.js} +1 -1
  88. package/dist/ui/dist/assets/{treemap-SAJKECNS-C3lnbG1a.js → treemap-SAJKECNS-cHsansGe.js} +1 -1
  89. package/dist/ui/dist/assets/{usecaseDiagram-WPOLBNNX-B6iPNIhZ.js → usecaseDiagram-WPOLBNNX-CW9i3bW6.js} +1 -1
  90. package/dist/ui/dist/assets/{vennDiagram-Q3Y3IDCW-D5dgflg9.js → vennDiagram-Q3Y3IDCW-DuU3qEKJ.js} +1 -1
  91. package/dist/ui/dist/assets/{wardley-7MLQ67FV-Dn9mLIB4.js → wardley-7MLQ67FV-dl9RgHUI.js} +1 -1
  92. package/dist/ui/dist/assets/{wardleyDiagram-IVAIDKWA-W1EwzN_X.js → wardleyDiagram-IVAIDKWA-BOXhrK-a.js} +1 -1
  93. package/dist/ui/dist/assets/{xychartDiagram-4URNHDU4-CKRzMUvp.js → xychartDiagram-4URNHDU4-CLQNGrh4.js} +1 -1
  94. package/dist/ui/dist/index.html +2 -2
  95. package/package.json +1 -1
  96. package/skills/follow-me/SKILL.md +4 -0
  97. package/dist/ui/dist/assets/channel-gz6cS9Z1.js +0 -1
  98. package/dist/ui/dist/assets/flowDiagram-IUGLLWBU-BqUgZSCz.js +0 -1
  99. package/dist/ui/dist/assets/index-BW95UA5q.css +0 -1
  100. package/dist/ui/dist/assets/index-DOGXgoHF.js +0 -134
  101. package/dist/ui/dist/assets/swimlanesDiagram-AYQRV4AK-rbX6bYG4.js +0 -8
@@ -0,0 +1,411 @@
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
+ /** How many tip lines a test outside the branch's changes carries for the page's peek. */
192
+ export const PREVIEW_LINES = 8;
193
+ /**
194
+ * The coverage after this run. A plain run replaces everything, its TN blocks becoming its tests.
195
+ * A named test's run adds to what is already there for the same tip, and starts over otherwise.
196
+ */
197
+ export function recordRun(prior, run, at) {
198
+ const { tip, takenAt, test } = at;
199
+ if (test === undefined) {
200
+ const tests = new Map([...run.tests].map(([id, lines]) => [id, { lines }]));
201
+ return { tip, takenAt, files: run.files, tests };
202
+ }
203
+ const base = prior !== null && prior.tip === tip ? prior : null;
204
+ const tests = new Map(base?.tests ?? []);
205
+ const entry = {
206
+ ...(test.at === undefined ? {} : { at: test.at }),
207
+ ...(test.preview === undefined ? {} : { preview: test.preview }),
208
+ lines: hitLines(run.files),
209
+ };
210
+ tests.set(test.id, entry);
211
+ const files = base === null ? run.files : mergeMax(base.files, run.files);
212
+ return { tip, takenAt, files, tests };
213
+ }
214
+ /**
215
+ * Per file, the branch's added rows that lcov calls executable, and how many ran. Every added
216
+ * row is a tip line, which is where the run was taken. Files without such rows are left out.
217
+ */
218
+ export function changedLines(units, files) {
219
+ const out = new Map();
220
+ for (const unit of units.values()) {
221
+ if (unit.kind !== "hunk")
222
+ continue;
223
+ const file = files.get(unit.path);
224
+ if (file === undefined)
225
+ continue;
226
+ let line = tipStartOf(unit, units);
227
+ let { covered, executable } = out.get(unit.path) ?? { covered: 0, executable: 0 };
228
+ for (const row of unit.body) {
229
+ const hits = row.op === "+" ? file.lines.get(line) : undefined;
230
+ if (hits !== undefined) {
231
+ executable += 1;
232
+ if (hits > 0)
233
+ covered += 1;
234
+ }
235
+ if (row.op !== "-")
236
+ line += 1;
237
+ }
238
+ if (executable > 0)
239
+ out.set(unit.path, { covered, executable });
240
+ }
241
+ return out;
242
+ }
243
+ export function totalOf(tallies) {
244
+ let covered = 0;
245
+ let executable = 0;
246
+ for (const t of tallies) {
247
+ covered += t.covered;
248
+ executable += t.executable;
249
+ }
250
+ return { covered, executable };
251
+ }
252
+ /**
253
+ * Covered files whose content at `tip` differs from where the run was taken, in file order.
254
+ * When the run's commit is gone from the repo, every file is stale.
255
+ */
256
+ export async function staleFiles(repo, coverage, tip) {
257
+ if (coverage.tip === tip)
258
+ return [];
259
+ let changed;
260
+ try {
261
+ changed = await pathsChanged(repo, coverage.tip, tip);
262
+ }
263
+ catch (error) {
264
+ if (!(error instanceof GitError))
265
+ throw error;
266
+ changed = new Set(coverage.files.keys());
267
+ }
268
+ return [...coverage.files.keys()].filter((p) => changed.has(p));
269
+ }
270
+ /** The files whose coverage still describes the tip. */
271
+ export function freshFiles(coverage, stale) {
272
+ const gone = new Set(stale);
273
+ return new Map([...coverage.files].filter(([path]) => !gone.has(path)));
274
+ }
275
+ export function serializeCoverage(c) {
276
+ const files = Object.fromEntries([...c.files].map(([path, f]) => [
277
+ path,
278
+ {
279
+ lines: Object.fromEntries(f.lines),
280
+ ...(f.branches.size === 0 ? {} : { branches: Object.fromEntries(f.branches) }),
281
+ },
282
+ ]));
283
+ const tests = Object.fromEntries([...c.tests].map(([id, t]) => [
284
+ id,
285
+ {
286
+ ...(t.at === undefined ? {} : { at: t.at }),
287
+ ...(t.preview === undefined ? {} : { preview: [...t.preview] }),
288
+ lines: Object.fromEntries([...t.lines].map(([p, ls]) => [p, [...ls]])),
289
+ },
290
+ ]));
291
+ return { tip: c.tip, takenAt: c.takenAt, files, ...(c.tests.size === 0 ? {} : { tests }) };
292
+ }
293
+ const LINE_KEY = /^[1-9][0-9]*$/;
294
+ function isCount(v) {
295
+ return typeof v === "number" && Number.isInteger(v) && v >= 0;
296
+ }
297
+ function isLine(v) {
298
+ return typeof v === "number" && Number.isInteger(v) && v >= 1;
299
+ }
300
+ function entries(v) {
301
+ return isFields(v) ? Object.entries(v) : null;
302
+ }
303
+ /** A { path, start, end } with 1 <= start <= end, or null. */
304
+ export function parseLoc(v) {
305
+ if (!isFields(v))
306
+ return null;
307
+ const { path, start, end } = v;
308
+ if (typeof path !== "string" || !isRepoPath(path) || !isLine(start) || !isLine(end))
309
+ return null;
310
+ return end < start ? null : { path, start, end };
311
+ }
312
+ /** Like parseLoc, for argv: "path:line" or "path:start-end", the path placed like lcov's. */
313
+ export function parseLocArg(raw, place) {
314
+ const m = /^(.+):([1-9][0-9]*)(?:-([1-9][0-9]*))?$/.exec(raw);
315
+ if (m === null)
316
+ return `--at ${raw} is not <path>:<line> or <path>:<start>-<end>`;
317
+ const path = place(m[1] ?? "");
318
+ if (path === null)
319
+ return `--at ${raw} names no file of the branch tip`;
320
+ const start = Number(m[2]);
321
+ const end = m[3] === undefined ? start : Number(m[3]);
322
+ return end < start ? `--at ${raw} runs backwards` : { path, start, end };
323
+ }
324
+ function parseFile(v) {
325
+ if (!isFields(v))
326
+ return "a file is not an object";
327
+ const lines = entries(v.lines);
328
+ if (lines === null)
329
+ return "lines is not an object";
330
+ const hits = new Map();
331
+ for (const [key, n] of lines) {
332
+ if (!LINE_KEY.test(key) || !isCount(n))
333
+ return `line ${key} has no hit count`;
334
+ hits.set(Number(key), n);
335
+ }
336
+ const branchEntries = entries(v.branches ?? {});
337
+ if (branchEntries === null)
338
+ return "branches is not an object";
339
+ const branches = new Map();
340
+ for (const [key, pair] of branchEntries) {
341
+ if (!LINE_KEY.test(key) || !Array.isArray(pair) || pair.length !== 2) {
342
+ return `branches of line ${key} are not [taken, total]`;
343
+ }
344
+ const [taken, total] = pair;
345
+ if (!isCount(taken) || !isCount(total) || taken > total) {
346
+ return `branches of line ${key} are not [taken, total]`;
347
+ }
348
+ branches.set(Number(key), [taken, total]);
349
+ }
350
+ return { lines: sorted(hits), branches: sorted(branches) };
351
+ }
352
+ function parseTest(v) {
353
+ if (!isFields(v))
354
+ return "a test is not an object";
355
+ const at = v.at === undefined ? undefined : parseLoc(v.at);
356
+ if (at === null)
357
+ return "at is not a { path, start, end }";
358
+ const preview = v.preview;
359
+ if (preview !== undefined &&
360
+ !(Array.isArray(preview) && preview.every((l) => typeof l === "string"))) {
361
+ return "preview is not a list of lines";
362
+ }
363
+ const lineEntries = entries(v.lines);
364
+ if (lineEntries === null)
365
+ return "lines is not an object";
366
+ const lines = new Map();
367
+ for (const [path, list] of lineEntries) {
368
+ if (!isRepoPath(path) || !Array.isArray(list) || !list.every(isLine)) {
369
+ return `lines of ${path} are not line numbers`;
370
+ }
371
+ lines.set(path, list);
372
+ }
373
+ return {
374
+ ...(at === undefined ? {} : { at }),
375
+ ...(preview === undefined ? {} : { preview: preview.filter((l) => typeof l === "string") }),
376
+ lines,
377
+ };
378
+ }
379
+ /** The one place coverage.json becomes a Coverage. */
380
+ export function parseCoverage(v) {
381
+ if (!isFields(v))
382
+ return "not an object";
383
+ const { tip, takenAt } = v;
384
+ if (typeof tip !== "string" || !isSha(tip))
385
+ return "tip is not an object id";
386
+ if (typeof takenAt !== "string")
387
+ return "takenAt is not a time";
388
+ const fileEntries = entries(v.files);
389
+ if (fileEntries === null)
390
+ return "files is not an object";
391
+ const files = new Map();
392
+ for (const [path, raw] of fileEntries) {
393
+ if (!isRepoPath(path))
394
+ return "a file has no path";
395
+ const file = parseFile(raw);
396
+ if (typeof file === "string")
397
+ return `${path}: ${file}`;
398
+ files.set(path, file);
399
+ }
400
+ const testEntries = entries(v.tests ?? {});
401
+ if (testEntries === null)
402
+ return "tests is not an object";
403
+ const tests = new Map();
404
+ for (const [id, raw] of testEntries) {
405
+ const test = parseTest(raw);
406
+ if (typeof test === "string")
407
+ return `test ${id}: ${test}`;
408
+ tests.set(id, test);
409
+ }
410
+ return { tip, takenAt, files, tests };
411
+ }
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
@@ -320,6 +320,29 @@ export async function fetchComments(port, summary) {
320
320
  }
321
321
  return out;
322
322
  }
323
+ /**
324
+ * Offers accepted during the hosted review, on this version or a carried one, whose comment has no
325
+ * reply from the agent yet, oldest first.
326
+ */
327
+ export async function fetchWaitingOffers(port, summary) {
328
+ const state = await call(port, "GET", `${summary.url}api/state`);
329
+ if (!isFields(state))
330
+ throw new RegistrationError("the server sent no state");
331
+ const comments = Array.isArray(state["comments"]) ? state["comments"] : [];
332
+ const carried = Array.isArray(state["carried"]) ? state["carried"] : [];
333
+ const said = [
334
+ ...comments.map((raw) => parseComment(raw)),
335
+ ...carried.map((raw) => {
336
+ const c = parseCarried(raw);
337
+ return typeof c === "string" ? c : c.comment;
338
+ }),
339
+ ];
340
+ return said
341
+ .flatMap((c) => (typeof c === "string" ? [] : [c]))
342
+ .filter((c) => !(c.replies ?? []).some((r) => r.author === "agent"))
343
+ .sort((a, b) => a.createdAt.localeCompare(b.createdAt))
344
+ .flatMap((c) => (c.offer === undefined ? [] : [{ offer: c.offer, comment: c.id }]));
345
+ }
323
346
  /** One long poll of the session's event log: what happened after `since`, or nothing at timeout. */
324
347
  export async function pollEvents(port, summary, since, timeoutMs) {
325
348
  const path = `${summary.url}api/events?since=${since}&timeout=${timeoutMs}`;
@@ -373,6 +396,10 @@ export async function postComment(port, summary, comment) {
373
396
  }
374
397
  return id;
375
398
  }
399
+ /** Tells the hosted page that coverage.json changed, so it reloads its marks. */
400
+ export async function postCoverage(port, summary) {
401
+ await call(port, "POST", `${summary.url}api/coverage`);
402
+ }
376
403
  /** The consolidated report of the hosted session as it stands. */
377
404
  export async function fetchReport(port, summary, format = "markdown") {
378
405
  const res = await fetch(`${sessionUrl(port, summary)}api/report?format=${format}`);
package/dist/src/plan.js CHANGED
@@ -10,6 +10,12 @@ export function isCommentId(s) {
10
10
  export function isConcernId(s) {
11
11
  return typeof s === "string" && /^k_[0-9a-f]{8}$/.test(s);
12
12
  }
13
+ export function isOfferId(s) {
14
+ return typeof s === "string" && /^o_[0-9a-f]{8}$/.test(s);
15
+ }
16
+ /** At most this many offers in one commit and in the whole plan; most plans need none. */
17
+ export const OFFERS_PER_COMMIT = 1;
18
+ export const OFFERS_PER_PLAN = 3;
13
19
  export function isPlanVersion(n) {
14
20
  return typeof n === "number" && Number.isInteger(n) && n >= 1;
15
21
  }
@@ -29,6 +35,10 @@ export function isSeverity(s) {
29
35
  function isConcernKind(s) {
30
36
  return CONCERN_KINDS.some((x) => x === s);
31
37
  }
38
+ const OFFER_KINDS = ["state-machine", "sequence", "diagram"];
39
+ function isOfferKind(s) {
40
+ return OFFER_KINDS.some((x) => x === s);
41
+ }
32
42
  /** Narrows to an object whose named fields are still unknown, which holds for any object. */
33
43
  export function isFields(v) {
34
44
  return typeof v === "object" && v !== null && !Array.isArray(v);
@@ -123,7 +133,47 @@ function parseConcerns(v, onUnit) {
123
133
  }
124
134
  return out;
125
135
  }
126
- /** The optional narration, diagram and concerns of a commit or unit entry, or what is wrong. */
136
+ /** An offer is { kind, label }; the label names the thing to draw. */
137
+ function parseOffer(v) {
138
+ if (!isFields(v))
139
+ return "expected an object";
140
+ if (!isOfferKind(v.kind))
141
+ return `kind: expected one of ${OFFER_KINDS.join(", ")}`;
142
+ if (typeof v.label !== "string" || v.label.trim() === "") {
143
+ return 'label: expected what to draw, like "Draw UploadButton as a state machine"';
144
+ }
145
+ return { kind: v.kind, label: v.label };
146
+ }
147
+ /** A stored offer as validation placed it: with its id and its unit. */
148
+ export function parsePlacedOffer(v) {
149
+ if (!isFields(v))
150
+ return "expected an object";
151
+ if (!isOfferId(v.id))
152
+ return "id: expected an offer id";
153
+ if (v.unit !== undefined && !(typeof v.unit === "string" && isUnitId(v.unit))) {
154
+ return "unit: expected a unit id";
155
+ }
156
+ const offer = parseOffer(v);
157
+ if (typeof offer === "string")
158
+ return offer;
159
+ return { ...offer, id: v.id, ...(v.unit === undefined ? {} : { unit: v.unit }) };
160
+ }
161
+ /** Absent, null and [] all mean no offers. */
162
+ function parseOffers(v) {
163
+ if (v === undefined || v === null)
164
+ return [];
165
+ if (!isArray(v))
166
+ return "expected an array";
167
+ const out = [];
168
+ for (const [i, raw] of v.entries()) {
169
+ const offer = parseOffer(raw);
170
+ if (typeof offer === "string")
171
+ return `[${i}]: ${offer}`;
172
+ out.push(offer);
173
+ }
174
+ return out;
175
+ }
176
+ /** The optional narration, diagram, concerns and offers of a commit or unit entry, or what is wrong. */
127
177
  function parseExtras(v, onUnit) {
128
178
  const narration = parseText(v.narration);
129
179
  if (narration === INVALID)
@@ -137,7 +187,13 @@ function parseExtras(v, onUnit) {
137
187
  const concerns = parseConcerns(v.concerns, onUnit);
138
188
  if (typeof concerns === "string")
139
189
  return { field: "concerns", error: concerns };
140
- return withExtras(narration, diagram, concerns);
190
+ const offers = parseOffers(v.offers);
191
+ if (typeof offers === "string")
192
+ return { field: "offers", error: offers };
193
+ return {
194
+ ...withExtras(narration, diagram, concerns),
195
+ ...(offers.length === 0 ? {} : { offers }),
196
+ };
141
197
  }
142
198
  function withText(narration, diagram) {
143
199
  return {
@@ -160,15 +216,21 @@ function serializeExtras(x) {
160
216
  return {
161
217
  ...withText(x.narration ?? null, x.diagram ?? null),
162
218
  ...(x.concerns === undefined ? {} : { concerns: x.concerns.map(serializeConcern) }),
219
+ ...(x.offers === undefined
220
+ ? {}
221
+ : { offers: x.offers.map((o) => ({ kind: o.kind, label: o.label })) }),
163
222
  };
164
223
  }
165
- /** A unit entry is "h_x:0-3" or { "unit": "h_x:0-3", "narration", "diagram", "concerns" }. */
224
+ /**
225
+ * A unit entry is "h_x:0-3" or { "unit": "h_x:0-3", "narration", "diagram", "concerns", "offers" }.
226
+ */
166
227
  function parseUnitEntry(u) {
167
228
  if (typeof u === "string")
168
229
  return parseUnitRef(u);
169
- if (!isFields(u) || typeof u.unit !== "string") {
230
+ if (!isFields(u) ||
231
+ typeof u.unit !== "string") {
170
232
  return {
171
- error: 'expected a unit ref string or { "unit", "narration", "diagram", "concerns" }',
233
+ error: 'expected a unit ref string or { "unit", "narration", "diagram", "concerns", "offers" }',
172
234
  };
173
235
  }
174
236
  const ref = parseUnitRef(u.unit);
@@ -298,7 +360,10 @@ export function serializePlanFile(file) {
298
360
  message: c.message,
299
361
  ...(c.section === undefined ? {} : { section: c.section }),
300
362
  ...serializeExtras(c),
301
- units: c.units.map((ref) => ref.narration === undefined && ref.diagram === undefined && ref.concerns === undefined
363
+ units: c.units.map((ref) => ref.narration === undefined &&
364
+ ref.diagram === undefined &&
365
+ ref.concerns === undefined &&
366
+ ref.offers === undefined
302
367
  ? formatUnitRef(ref)
303
368
  : { unit: formatUnitRef(ref), ...serializeExtras(ref) }),
304
369
  })),
@@ -547,8 +612,51 @@ function placeConcerns(diff, file, claims, issues) {
547
612
  }
548
613
  return placedPerCommit;
549
614
  }
615
+ /** "o_" + 8 hex over the unit and the label: stable while the offer stays at its place. */
616
+ function deriveOfferId(unit, o) {
617
+ const hex = createHash("sha256")
618
+ .update(`offer\0${unit ?? ""}\0${o.label}`)
619
+ .digest("hex")
620
+ .slice(0, 8);
621
+ const id = `o_${hex}`;
622
+ if (!isOfferId(id))
623
+ throw new Error(`sha256 produced ${id}`);
624
+ return id;
625
+ }
626
+ /** Gives every offer its id: the commit's own first, then those of its unit refs in order. */
627
+ function placeOffers(file, issues) {
628
+ const ids = new Set();
629
+ return file.commits.map((commit, k) => {
630
+ const placed = [];
631
+ const add = (o, unit) => {
632
+ const id = deriveOfferId(unit, o);
633
+ if (ids.has(id)) {
634
+ issues.push({ kind: "bad-offer", commit: k, reason: `${id} repeats another offer` });
635
+ return;
636
+ }
637
+ ids.add(id);
638
+ placed.push({ ...o, id, ...(unit === null ? {} : { unit }) });
639
+ };
640
+ for (const o of commit.offers ?? [])
641
+ add(o, null);
642
+ for (const ref of commit.units)
643
+ for (const o of ref.offers ?? [])
644
+ add(o, ref.unit);
645
+ return placed;
646
+ });
647
+ }
648
+ /** At most OFFERS_PER_COMMIT in a commit, its units' included, and OFFERS_PER_PLAN in all. */
649
+ function offerLimits(file) {
650
+ const counts = file.commits.map((c) => (c.offers ?? []).length + c.units.reduce((n, r) => n + (r.offers ?? []).length, 0));
651
+ const issues = counts.flatMap((count, commit) => count > OFFERS_PER_COMMIT ? [{ kind: "too-many-offers", commit, count }] : []);
652
+ const total = counts.reduce((a, b) => a + b, 0);
653
+ if (total > OFFERS_PER_PLAN)
654
+ issues.push({ kind: "too-many-offers", commit: null, count: total });
655
+ return issues;
656
+ }
550
657
  function project(diff, file, claims, issues) {
551
658
  const concerns = placeConcerns(diff, file, claims, issues);
659
+ const offers = placeOffers(file, issues);
552
660
  const { stateAt, tipOf } = projector(diff, claims.owners);
553
661
  const present = new Map();
554
662
  for (const f of diff.textFiles.values())
@@ -560,6 +668,7 @@ function project(diff, file, claims, issues) {
560
668
  const commits = file.commits.map((commit, k) => {
561
669
  const refs = claims.resolved[k] ?? [];
562
670
  const mine = concerns[k] ?? [];
671
+ const offered = offers[k] ?? [];
563
672
  const touched = [...new Set(refs.flatMap((r) => diff.units.get(r.unit)?.path ?? []))].sort();
564
673
  const changes = touched.map((path) => {
565
674
  const state = stateAt(path, k);
@@ -591,6 +700,7 @@ function project(diff, file, claims, issues) {
591
700
  ...(commit.section === undefined ? {} : { section: commit.section }),
592
701
  ...withText(commit.narration ?? null, commit.diagram ?? null),
593
702
  ...(mine.length === 0 ? {} : { concerns: mine }),
703
+ ...(offered.length === 0 ? {} : { offers: offered }),
594
704
  refs,
595
705
  changes,
596
706
  };
@@ -655,6 +765,7 @@ export function validatePlan(diff, draft, opts = {}) {
655
765
  if (c.units.length === 0)
656
766
  issues.push({ kind: "empty-commit", commit: k });
657
767
  });
768
+ issues.push(...offerLimits(file));
658
769
  const assignment = [];
659
770
  const claims = claim(diff, file, assignment);
660
771
  issues.push(...assignment);
@@ -677,6 +788,17 @@ export function validatePlan(diff, draft, opts = {}) {
677
788
  if (length > LONG_TITLE)
678
789
  warnings.push({ kind: "long-title", commit, length });
679
790
  });
791
+ // Sections read top-down: story, steps, details, wiring. A later section never comes back.
792
+ let reached = null;
793
+ file.commits.forEach((c, commit) => {
794
+ if (c.section === undefined)
795
+ return;
796
+ if (reached !== null && SECTIONS.indexOf(c.section) < SECTIONS.indexOf(reached)) {
797
+ warnings.push({ kind: "section-order", commit, section: c.section, after: reached });
798
+ }
799
+ else
800
+ reached = c.section;
801
+ });
680
802
  const asked = new Map(review?.comments.map((c) => [c.id, c.kind === "question"]));
681
803
  for (const r of file.responses) {
682
804
  const isQuestion = asked.get(r.comment);