@shiyuphan/follow-me 0.8.0 → 0.9.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.
Files changed (98) hide show
  1. package/README.md +8 -3
  2. package/dist/src/cli.js +204 -53
  3. package/dist/src/comments.js +95 -7
  4. package/dist/src/coverage.js +173 -38
  5. package/dist/src/host.js +1 -0
  6. package/dist/src/plan.js +44 -22
  7. package/dist/src/prompt.js +58 -17
  8. package/dist/src/refs.js +239 -0
  9. package/dist/ui/dist/assets/{abnfDiagram-WEYOIA3C-Bp14ygmz.js → abnfDiagram-WEYOIA3C-BWgk01ve.js} +1 -1
  10. package/dist/ui/dist/assets/{arc-DUR6uzhI.js → arc-Cg_Qb5c4.js} +1 -1
  11. package/dist/ui/dist/assets/{architecture-WOLXFQ4H-CyfnP65O.js → architecture-WOLXFQ4H-EdfV1etv.js} +1 -1
  12. package/dist/ui/dist/assets/{architectureDiagram-ZHVYKT53-dJTicIk2.js → architectureDiagram-ZHVYKT53-Dz-gytQU.js} +1 -1
  13. package/dist/ui/dist/assets/{blockDiagram-RJAE7QA3-BYiqh5Ib.js → blockDiagram-RJAE7QA3-B7Q5KhYC.js} +1 -1
  14. package/dist/ui/dist/assets/{c4Diagram-XDPPPE54-DHHPQ7xE.js → c4Diagram-XDPPPE54-Cq9IvGR1.js} +1 -1
  15. package/dist/ui/dist/assets/channel-Da0YvuQF.js +1 -0
  16. package/dist/ui/dist/assets/{chunk-2E7WHVMD-BODm_awL.js → chunk-2E7WHVMD-BTJB-4C8.js} +1 -1
  17. package/dist/ui/dist/assets/{chunk-4EMNKR2T-CpZfjf2T.js → chunk-4EMNKR2T-BILthc2j.js} +1 -1
  18. package/dist/ui/dist/assets/{chunk-55XCU76K-CoP2pJTQ.js → chunk-55XCU76K-DH3fYrJH.js} +1 -1
  19. package/dist/ui/dist/assets/{chunk-7S6XSTFO-CaQcUvXY.js → chunk-7S6XSTFO-DDIiBvc2.js} +1 -1
  20. package/dist/ui/dist/assets/{chunk-AQDTSKZB-BZ9Czvbu.js → chunk-AQDTSKZB-b1EeJ3gb.js} +1 -1
  21. package/dist/ui/dist/assets/{chunk-DSNLSHJZ-CNjUo110.js → chunk-DSNLSHJZ-DZZ635s5.js} +1 -1
  22. package/dist/ui/dist/assets/{chunk-HAGFOTED-BTmBE_D3.js → chunk-HAGFOTED-DhhrHTIh.js} +1 -1
  23. package/dist/ui/dist/assets/{chunk-HNE4Q36P-D2cDuT0C.js → chunk-HNE4Q36P-B8wvhc2e.js} +1 -1
  24. package/dist/ui/dist/assets/{chunk-NQOKRVIF-AZcemFgG.js → chunk-NQOKRVIF-CLtGMwDx.js} +1 -1
  25. package/dist/ui/dist/assets/{chunk-UHW7PFBP-BszwjUqG.js → chunk-UHW7PFBP-B9fVLGl0.js} +1 -1
  26. package/dist/ui/dist/assets/{chunk-VQ5W62QI-vFSenYSe.js → chunk-VQ5W62QI-CgPvvMAv.js} +1 -1
  27. package/dist/ui/dist/assets/{chunk-WQQL4CDK-DZR6is1Q.js → chunk-WQQL4CDK-DdqKu6R9.js} +1 -1
  28. package/dist/ui/dist/assets/{chunk-XXDRQBXY-CACFh-si.js → chunk-XXDRQBXY-GlM07vbp.js} +1 -1
  29. package/dist/ui/dist/assets/{chunk-YZO67HFG-DRRTjGuW.js → chunk-YZO67HFG-YqT6M0W9.js} +1 -1
  30. package/dist/ui/dist/assets/{chunk-ZAUX2LTX-Bxuy-Roa.js → chunk-ZAUX2LTX-DqHK51Gc.js} +1 -1
  31. package/dist/ui/dist/assets/{chunk-ZCHMQSOR-rYXk__1o.js → chunk-ZCHMQSOR-DEeXkjWz.js} +1 -1
  32. package/dist/ui/dist/assets/{classDiagram-v2-4MYWR3UA-RiF44tTz.js → classDiagram-v2-4MYWR3UA-BcKsX4up.js} +1 -1
  33. package/dist/ui/dist/assets/{cose-bilkent-JH36ORCC-0oiQoThS.js → cose-bilkent-JH36ORCC-fu9djgYc.js} +1 -1
  34. package/dist/ui/dist/assets/{cynefin-EF2NZ3EQ-BAssFK5K.js → cynefin-EF2NZ3EQ-CFJASBsJ.js} +1 -1
  35. package/dist/ui/dist/assets/{cynefinDiagram-WF24UDRP-Bmd05bak.js → cynefinDiagram-WF24UDRP-BVJD_n0g.js} +1 -1
  36. package/dist/ui/dist/assets/{dagre-NK4GIZVX-QJkLjPxj.js → dagre-NK4GIZVX-Cx8pjz9O.js} +1 -1
  37. package/dist/ui/dist/assets/{diagram-CBK3MWTC-PSaSiYXz.js → diagram-CBK3MWTC-CVIkEZ1X.js} +1 -1
  38. package/dist/ui/dist/assets/{diagram-I5XIIV2D-BmU5X_hr.js → diagram-I5XIIV2D-2rxpF8BZ.js} +1 -1
  39. package/dist/ui/dist/assets/{diagram-ODIL2UCW-AAV39kUu.js → diagram-ODIL2UCW-NJfGHnLk.js} +1 -1
  40. package/dist/ui/dist/assets/{diagram-PFBEJUXE-DWlV-oxd.js → diagram-PFBEJUXE-DZHVJSeM.js} +1 -1
  41. package/dist/ui/dist/assets/{diagram-RJA3I4VV-D8wHRCNX.js → diagram-RJA3I4VV-CQAfOObI.js} +1 -1
  42. package/dist/ui/dist/assets/{diagram-TYUNFSNR-B_JQEsNY.js → diagram-TYUNFSNR-DGGaMOl6.js} +1 -1
  43. package/dist/ui/dist/assets/{dist-DxQIK_Ay.js → dist-CW-4NrUq.js} +1 -1
  44. package/dist/ui/dist/assets/{ebnfDiagram-V4P3TTPT-D83qV8mv.js → ebnfDiagram-V4P3TTPT-92pb2Fcg.js} +1 -1
  45. package/dist/ui/dist/assets/{elk-QONTBCTQ-BKFAKVSD.js → elk-QONTBCTQ-vu0Z4rYG.js} +1 -1
  46. package/dist/ui/dist/assets/{erDiagram-KYQIGQAA-CHYRd4aM.js → erDiagram-KYQIGQAA-BXnsRzCQ.js} +1 -1
  47. package/dist/ui/dist/assets/{eventmodeling-K75KTNOO-BDJDv3Zf.js → eventmodeling-K75KTNOO-bSr88MT1.js} +1 -1
  48. package/dist/ui/dist/assets/flowDiagram-IUGLLWBU-CUH45H9n.js +1 -0
  49. package/dist/ui/dist/assets/{ganttDiagram-R7GPNTJY-gTHjYkFj.js → ganttDiagram-R7GPNTJY-BQzVkGbj.js} +1 -1
  50. package/dist/ui/dist/assets/{gitGraph-VSP46ZUC-D4cWkxKB.js → gitGraph-VSP46ZUC-BegblPQi.js} +1 -1
  51. package/dist/ui/dist/assets/{gitGraphDiagram-PDYL7XHY-D3M9wYMr.js → gitGraphDiagram-PDYL7XHY-BaGlGpEX.js} +1 -1
  52. package/dist/ui/dist/assets/index-D74Skifa.css +1 -0
  53. package/dist/ui/dist/assets/index-D8ErIacA.js +136 -0
  54. package/dist/ui/dist/assets/{info-OHQRW6UA-B9y0ksVq.js → info-OHQRW6UA-D3HVGJDc.js} +1 -1
  55. package/dist/ui/dist/assets/{infoDiagram-MVDRKAX5-s4FMwb8r.js → infoDiagram-MVDRKAX5-Ckcp-D0C.js} +1 -1
  56. package/dist/ui/dist/assets/{ishikawaDiagram-HPEHV2GE-CnQdxybf.js → ishikawaDiagram-HPEHV2GE-CWhd0CTS.js} +1 -1
  57. package/dist/ui/dist/assets/{journeyDiagram-BOSHW7O7-I-9OpDWY.js → journeyDiagram-BOSHW7O7-CrH5cFXY.js} +1 -1
  58. package/dist/ui/dist/assets/{kanban-definition-FNSA4HC5-DqUbIdH4.js → kanban-definition-FNSA4HC5-CpORMTDZ.js} +1 -1
  59. package/dist/ui/dist/assets/{line-BELSui4d.js → line-B3El9R0Z.js} +1 -1
  60. package/dist/ui/dist/assets/{linear-77hmn2iQ.js → linear-DLfthkbB.js} +1 -1
  61. package/dist/ui/dist/assets/{mermaid-parser.core-Bxa71wW-.js → mermaid-parser.core-QtQjLaoK.js} +3 -3
  62. package/dist/ui/dist/assets/{mermaid.core-DBI9F4bh.js → mermaid.core-C2DGgMSc.js} +4 -4
  63. package/dist/ui/dist/assets/{mindmap-definition-6OY57UG3-BxXMOapP.js → mindmap-definition-6OY57UG3-CW1lSXnX.js} +1 -1
  64. package/dist/ui/dist/assets/{packet-JDAUHWVQ-DTT77cQh.js → packet-JDAUHWVQ-Dhu_m_17.js} +1 -1
  65. package/dist/ui/dist/assets/{pegDiagram-ECIUJNBX-HzrOQPT5.js → pegDiagram-ECIUJNBX-BGOhbzN4.js} +1 -1
  66. package/dist/ui/dist/assets/{pie-XZMESJXO-iHwL1Qw-.js → pie-XZMESJXO-Citt3cMX.js} +1 -1
  67. package/dist/ui/dist/assets/{pieDiagram-GNMPY74T-DZ1tLVjw.js → pieDiagram-GNMPY74T-DPVZikvz.js} +1 -1
  68. package/dist/ui/dist/assets/{prism-core-NtO0YF9C.js → prism-core-YgwsYh9d.js} +1 -1
  69. package/dist/ui/dist/assets/{quadrantDiagram-D57K4MC3-BjTi9H5L.js → quadrantDiagram-D57K4MC3-Bwye1B9e.js} +1 -1
  70. package/dist/ui/dist/assets/{radar-ABXABTNO-D2hYgXSB.js → radar-ABXABTNO-DRUb4G6V.js} +1 -1
  71. package/dist/ui/dist/assets/{railroad-I3PHUGI6-DpakXN74.js → railroad-I3PHUGI6-x3Ug2mxX.js} +1 -1
  72. package/dist/ui/dist/assets/{railroad-abnf-LNEFI6M7-B8ZFR3iy.js → railroad-abnf-LNEFI6M7-Dw14XhkZ.js} +1 -1
  73. package/dist/ui/dist/assets/{railroad-ebnf-DWYD2UWJ-S1XUyTsC.js → railroad-ebnf-DWYD2UWJ-AwoWTWgL.js} +1 -1
  74. package/dist/ui/dist/assets/{railroad-peg-JR7BVNR7-BbeHC_6K.js → railroad-peg-JR7BVNR7-U7MXfciE.js} +1 -1
  75. package/dist/ui/dist/assets/{railroadDiagram-Y72NRBL7-fd-2iyBo.js → railroadDiagram-Y72NRBL7-BxWlCZYs.js} +1 -1
  76. package/dist/ui/dist/assets/{requirementDiagram-EQYSW5RA-B9-HHKH-.js → requirementDiagram-EQYSW5RA-TPmnZguV.js} +1 -1
  77. package/dist/ui/dist/assets/{sankeyDiagram-SY24JHO5-CeQN1ZEG.js → sankeyDiagram-SY24JHO5-nPGftpl5.js} +1 -1
  78. package/dist/ui/dist/assets/{sequenceDiagram-BLEHNHQU-Vff7tx4t.js → sequenceDiagram-BLEHNHQU-BdctgaWa.js} +1 -1
  79. package/dist/ui/dist/assets/{src-D5-ZzLwt.js → src-zq235FjH.js} +1 -1
  80. package/dist/ui/dist/assets/{stateDiagram-v2-A6IESKWZ-Bzn_ma9c.js → stateDiagram-v2-A6IESKWZ-BK_E6kPg.js} +1 -1
  81. package/dist/ui/dist/assets/{swimlanes-B3ENRKFQ-CevqLchV.js → swimlanes-B3ENRKFQ-BY50Ne4l.js} +1 -1
  82. package/dist/ui/dist/assets/swimlanesDiagram-AYQRV4AK-Ctw23I3i.js +8 -0
  83. package/dist/ui/dist/assets/{timeline-definition-JXYKGLGB-D9jSDGJI.js → timeline-definition-JXYKGLGB-Bg8AXJ2P.js} +1 -1
  84. package/dist/ui/dist/assets/{treeView-D4JQ5SDB-CoXKj11m.js → treeView-D4JQ5SDB-B4hz6M4l.js} +1 -1
  85. package/dist/ui/dist/assets/{treemap-SAJKECNS-cHsansGe.js → treemap-SAJKECNS-e_Vc97Ox.js} +1 -1
  86. package/dist/ui/dist/assets/{usecaseDiagram-WPOLBNNX-CW9i3bW6.js → usecaseDiagram-WPOLBNNX-Butu_LCX.js} +1 -1
  87. package/dist/ui/dist/assets/{vennDiagram-Q3Y3IDCW-DuU3qEKJ.js → vennDiagram-Q3Y3IDCW-BGIyxlbf.js} +1 -1
  88. package/dist/ui/dist/assets/{wardley-7MLQ67FV-dl9RgHUI.js → wardley-7MLQ67FV-Btv95mqq.js} +1 -1
  89. package/dist/ui/dist/assets/{wardleyDiagram-IVAIDKWA-BOXhrK-a.js → wardleyDiagram-IVAIDKWA-DwTKExJ4.js} +1 -1
  90. package/dist/ui/dist/assets/{xychartDiagram-4URNHDU4-CLQNGrh4.js → xychartDiagram-4URNHDU4-UmlA5uxc.js} +1 -1
  91. package/dist/ui/dist/index.html +2 -2
  92. package/package.json +1 -1
  93. package/skills/follow-me/SKILL.md +8 -8
  94. package/dist/ui/dist/assets/channel-CY2cQobm.js +0 -1
  95. package/dist/ui/dist/assets/flowDiagram-IUGLLWBU-Bke3CfUH.js +0 -1
  96. package/dist/ui/dist/assets/index-BUOFihod.js +0 -134
  97. package/dist/ui/dist/assets/index-DLNJT0VS.css +0 -1
  98. package/dist/ui/dist/assets/swimlanesDiagram-AYQRV4AK-Bkv8hAmN.js +0 -8
@@ -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 = parseLoc(raw);
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. The reason
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)) {
@@ -148,6 +169,55 @@ export function mapOffTip(map, lines, show = (path) => path) {
148
169
  ...(map.transitions ?? []).flatMap((t, i) => t.at.flatMap((loc, j) => check(loc, `map.transitions[${i}].at[${j}]`))),
149
170
  ];
150
171
  }
172
+ function parseSpan(v) {
173
+ if (v === null)
174
+ return null;
175
+ if (!isFields(v) || !isLine(v.start) || !isLine(v.end) || v.end < v.start) {
176
+ return "expected null or { start, end } with 1 <= start <= end";
177
+ }
178
+ return { start: v.start, end: v.end };
179
+ }
180
+ function parseCodeRef(v) {
181
+ if (!isFields(v))
182
+ return "expected an object";
183
+ const { n, path, start, end, at, quote, tip } = v;
184
+ if (!isLine(n))
185
+ return "n is not a number from 1";
186
+ if (typeof path !== "string" || !isRepoPath(path))
187
+ return "path is not a repo path";
188
+ if (!isLine(start) || !isLine(end) || end < start)
189
+ return "expected 1 <= start <= end";
190
+ if (at !== null && at !== "base" && !isLine(at))
191
+ return 'at is not a commit, "base" or null';
192
+ if (!isArray(quote) || !quote.every(isString))
193
+ return "quote is not a list of strings";
194
+ if (typeof tip !== "string" || !isSha(tip))
195
+ return "tip is not a commit sha";
196
+ const lines = parseSpan(v.lines);
197
+ const old = parseSpan(v.old);
198
+ if (typeof lines === "string")
199
+ return `lines: ${lines}`;
200
+ if (typeof old === "string")
201
+ return `old: ${old}`;
202
+ return { n, path, start, end, at, quote: quote.filter(isString), tip, lines, old };
203
+ }
204
+ /** refs as stored beside an agent's words: absent, or a list numbered 1, 2, 3 in order. */
205
+ function parseCodeRefs(v) {
206
+ if (v === undefined)
207
+ return [];
208
+ if (!isArray(v))
209
+ return "refs is not a list";
210
+ const refs = [];
211
+ for (const [i, raw] of v.entries()) {
212
+ const ref = parseCodeRef(raw);
213
+ if (typeof ref === "string")
214
+ return `refs[${i}]: ${ref}`;
215
+ if (ref.n !== i + 1)
216
+ return `refs[${i}]: n is not ${i + 1}`;
217
+ refs.push(ref);
218
+ }
219
+ return refs;
220
+ }
151
221
  /** A stored reply. Replies a client posts go through parseNewReply, which mints id and time. */
152
222
  export function parseReply(v) {
153
223
  if (!isFields(v)) {
@@ -165,32 +235,43 @@ export function parseReply(v) {
165
235
  const map = v.map === undefined ? undefined : parseStateMap(v.map, v.diagram);
166
236
  if (typeof map === "string")
167
237
  return map;
238
+ const refs = parseCodeRefs(v.refs);
239
+ if (typeof refs === "string")
240
+ return refs;
168
241
  return {
169
242
  id: v.id,
170
243
  author: v.author,
171
244
  body: v.body,
172
245
  ...(v.diagram === undefined ? {} : { diagram: v.diagram }),
173
246
  ...(map === undefined ? {} : { map }),
247
+ ...(refs.length === 0 ? {} : { refs }),
174
248
  createdAt: v.createdAt,
175
249
  };
176
250
  }
177
251
  /**
178
252
  * The body of POST .../replies: who speaks, what, and an optional Mermaid diagram. The body may be
179
- * empty only when the diagram is the answer.
253
+ * empty only when the diagram is the answer. Only the agent's words carry refs.
180
254
  */
181
255
  export function parseNewReply(v) {
182
- if (!isFields(v))
256
+ if (!isFields(v)) {
183
257
  return "the reply is not an object";
258
+ }
184
259
  if (v.author !== "agent" && v.author !== "reviewer")
185
260
  return 'author is not "agent" or "reviewer"';
186
261
  if (typeof v.body !== "string")
187
262
  return "body is not a string";
263
+ const refs = parseCodeRefs(v.refs);
264
+ if (typeof refs === "string")
265
+ return refs;
266
+ if (refs.length > 0 && v.author !== "agent")
267
+ return "refs are only for the agent's words";
268
+ const withRefs = refs.length === 0 ? {} : { refs };
188
269
  if (v.diagram === undefined) {
189
270
  if (v.map !== undefined)
190
271
  return "map needs the diagram it ties to code";
191
272
  if (v.body.trim() === "")
192
273
  return "body is empty; a reply needs words or a diagram";
193
- return { author: v.author, body: v.body };
274
+ return { author: v.author, body: v.body, ...withRefs };
194
275
  }
195
276
  if (typeof v.diagram !== "string")
196
277
  return "diagram is not a string";
@@ -205,6 +286,7 @@ export function parseNewReply(v) {
205
286
  body: v.body,
206
287
  diagram: v.diagram,
207
288
  ...(map === undefined ? {} : { map }),
289
+ ...withRefs,
208
290
  };
209
291
  }
210
292
  function isString(v) {
@@ -262,6 +344,11 @@ export function parseComment(v) {
262
344
  return "offer is only for a reviewer's question";
263
345
  }
264
346
  }
347
+ const refs = parseCodeRefs(v.refs);
348
+ if (typeof refs === "string")
349
+ return refs;
350
+ if (refs.length > 0 && v.author !== "agent")
351
+ return "refs are only for an agent's comment";
265
352
  const replies = parseList(v.replies, parseReply, "replies");
266
353
  if (typeof replies === "string")
267
354
  return replies;
@@ -281,6 +368,7 @@ export function parseComment(v) {
281
368
  ...(replies.length === 0 ? {} : { replies }),
282
369
  ...(v.author === "agent" ? { author: "agent" } : {}),
283
370
  ...(v.diagram === undefined ? {} : { diagram: v.diagram }),
371
+ ...(refs.length === 0 ? {} : { refs }),
284
372
  ...(offer === undefined ? {} : { offer }),
285
373
  };
286
374
  }
@@ -15,9 +15,12 @@ export class LcovError extends Error {
15
15
  const RECORD = /^([A-Z][A-Z0-9_]*):(.*)$/;
16
16
  const DA = /^([1-9][0-9]*),([0-9]+)(?:,.*)?$/;
17
17
  const BRDA = /^([1-9][0-9]*),([^,]+),(.+),(-|[0-9]+)$/;
18
+ /** FN:<start>,<name>, or lcov 2's FN:<start>,<end>,<name>. */
19
+ const FN = /^([1-9][0-9]*),(?:([1-9][0-9]*),)?(.+)$/;
18
20
  /**
19
21
  * 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.
22
+ * and BRDA give line and branch hits, FN a function's place, end_of_record closes it. Other
23
+ * records are skipped.
21
24
  */
22
25
  export function parseLcov(text) {
23
26
  const records = [];
@@ -50,10 +53,10 @@ export function parseLcov(text) {
50
53
  close();
51
54
  if (value === "")
52
55
  throw new LcovError(n, "SF needs a path");
53
- open = { path: value, test, lines: new Map(), branches: new Map() };
56
+ open = { path: value, test, lines: new Map(), branches: new Map(), functions: [] };
54
57
  continue;
55
58
  }
56
- if (key !== "DA" && key !== "BRDA")
59
+ if (key !== "DA" && key !== "BRDA" && key !== "FN")
57
60
  continue;
58
61
  if (open === null)
59
62
  throw new LcovError(n, `${key} comes before any SF`);
@@ -65,15 +68,27 @@ export function parseLcov(text) {
65
68
  open.lines.set(line, (open.lines.get(line) ?? 0) + Number(da[2]));
66
69
  continue;
67
70
  }
71
+ if (key === "FN") {
72
+ const fn = FN.exec(value);
73
+ if (fn === null)
74
+ throw new LcovError(n, `FN needs <line>,<name>, got "${value}"`);
75
+ const start = Number(fn[1]);
76
+ const end = fn[2] === undefined ? undefined : Number(fn[2]);
77
+ const name = fn[3] ?? "";
78
+ if (end !== undefined && end < start)
79
+ throw new LcovError(n, "FN ends before it starts");
80
+ open.functions.push(end === undefined ? { name, start } : { name, start, end });
81
+ continue;
82
+ }
68
83
  const br = BRDA.exec(value);
69
84
  if (br === null) {
70
85
  throw new LcovError(n, `BRDA needs <line>,<block>,<branch>,<taken>, got "${value}"`);
71
86
  }
72
87
  const line = Number(br[1]);
73
88
  const id = `${br[2]},${br[3]}`;
74
- const taken = br[4] !== "-" && Number(br[4]) > 0;
89
+ const taken = br[4] === "-" ? 0 : Number(br[4]);
75
90
  const branches = open.branches.get(line) ?? new Map();
76
- branches.set(id, (branches.get(id) ?? false) || taken);
91
+ branches.set(id, (branches.get(id) ?? 0) + taken);
77
92
  open.branches.set(line, branches);
78
93
  }
79
94
  close();
@@ -110,10 +125,42 @@ export function tipPlacer(root, cwd, atTip) {
110
125
  return null;
111
126
  };
112
127
  }
128
+ /** Adds a record's branch counts into `into`, keeping lcov's branch order per line. */
129
+ function countInto(into, path, record) {
130
+ const joined = into.get(path) ?? new Map();
131
+ for (const [line, ids] of record.branches) {
132
+ const on = joined.get(line) ?? new Map();
133
+ for (const [id, n] of ids)
134
+ on.set(id, (on.get(id) ?? 0) + n);
135
+ joined.set(line, on);
136
+ }
137
+ into.set(path, joined);
138
+ }
139
+ function countsOf(counting) {
140
+ const out = new Map();
141
+ for (const [path, byLine] of counting) {
142
+ if (byLine.size === 0)
143
+ continue;
144
+ out.set(path, sorted(new Map([...byLine].map(([line, ids]) => [line, [...ids.values()]]))));
145
+ }
146
+ return out;
147
+ }
148
+ /** Functions of both lists, once each by start and name, in line order. */
149
+ function joinFunctions(a, b) {
150
+ const seen = new Map();
151
+ for (const f of [...a, ...b]) {
152
+ const key = `${f.start}|${f.name}`;
153
+ const had = seen.get(key);
154
+ if (had === undefined || (had.end === undefined && f.end !== undefined))
155
+ seen.set(key, f);
156
+ }
157
+ return [...seen.values()].sort((x, y) => x.start - y.start);
158
+ }
113
159
  /** Sums hits and joins branches over records of the same file; drops what `place` rejects. */
114
160
  export function runOf(records, place) {
115
161
  const lines = new Map();
116
162
  const branches = new Map();
163
+ const functions = new Map();
117
164
  const tests = new Map();
118
165
  for (const record of records) {
119
166
  const path = place(record.path);
@@ -123,37 +170,42 @@ export function runOf(records, place) {
123
170
  for (const [line, n] of record.lines)
124
171
  hits.set(line, (hits.get(line) ?? 0) + n);
125
172
  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);
173
+ countInto(branches, path, record);
174
+ functions.set(path, joinFunctions(functions.get(path) ?? [], record.functions));
134
175
  if (record.test === null)
135
176
  continue;
136
- const ran = tests.get(record.test) ?? new Map();
137
- const set = ran.get(path) ?? new Set();
177
+ const ran = tests.get(record.test) ?? { lines: new Map(), branches: new Map() };
178
+ const set = ran.lines.get(path) ?? new Set();
138
179
  for (const [line, n] of record.lines)
139
180
  if (n > 0)
140
181
  set.add(line);
141
- ran.set(path, set);
182
+ ran.lines.set(path, set);
183
+ countInto(ran.branches, path, record);
142
184
  tests.set(record.test, ran);
143
185
  }
144
186
  const files = new Map();
145
187
  for (const [path, hits] of lines) {
146
188
  const counted = new Map();
147
189
  for (const [line, ids] of branches.get(path) ?? []) {
148
- counted.set(line, [[...ids.values()].filter(Boolean).length, ids.size]);
190
+ const n = [...ids.values()];
191
+ counted.set(line, [n.filter((c) => c > 0).length, n.length]);
149
192
  }
150
- files.set(path, { lines: sorted(hits), branches: sorted(counted) });
193
+ const fns = functions.get(path) ?? [];
194
+ files.set(path, {
195
+ lines: sorted(hits),
196
+ branches: sorted(counted),
197
+ ...(fns.length === 0 ? {} : { functions: fns }),
198
+ });
151
199
  }
152
200
  return {
153
201
  files,
202
+ counts: countsOf(branches),
154
203
  tests: new Map([...tests].map(([id, ran]) => [
155
204
  id,
156
- new Map([...ran].map(([path, set]) => [path, [...set].sort((a, b) => a - b)])),
205
+ {
206
+ lines: new Map([...ran.lines].map(([path, set]) => [path, [...set].sort((a, b) => a - b)])),
207
+ branches: countsOf(ran.branches),
208
+ },
157
209
  ])),
158
210
  };
159
211
  }
@@ -184,7 +236,12 @@ function mergeMax(a, b) {
184
236
  const [t, n] = branches.get(l) ?? [0, 0];
185
237
  branches.set(l, [Math.max(taken, t), Math.max(total, n)]);
186
238
  }
187
- out.set(path, { lines: sorted(lines), branches: sorted(branches) });
239
+ const functions = joinFunctions(had.functions ?? [], f.functions ?? []);
240
+ out.set(path, {
241
+ lines: sorted(lines),
242
+ branches: sorted(branches),
243
+ ...(functions.length === 0 ? {} : { functions }),
244
+ });
188
245
  }
189
246
  return out;
190
247
  }
@@ -197,7 +254,7 @@ export const PREVIEW_LINES = 8;
197
254
  export function recordRun(prior, run, at) {
198
255
  const { tip, takenAt, test } = at;
199
256
  if (test === undefined) {
200
- const tests = new Map([...run.tests].map(([id, lines]) => [id, { lines }]));
257
+ const tests = new Map([...run.tests].map(([id, ran]) => [id, testEntry(ran)]));
201
258
  return { tip, takenAt, files: run.files, tests };
202
259
  }
203
260
  const base = prior !== null && prior.tip === tip ? prior : null;
@@ -205,18 +262,18 @@ export function recordRun(prior, run, at) {
205
262
  const entry = {
206
263
  ...(test.at === undefined ? {} : { at: test.at }),
207
264
  ...(test.preview === undefined ? {} : { preview: test.preview }),
208
- lines: hitLines(run.files),
265
+ ...testEntry({ lines: hitLines(run.files), branches: run.counts }),
209
266
  };
210
267
  tests.set(test.id, entry);
211
268
  const files = base === null ? run.files : mergeMax(base.files, run.files);
212
269
  return { tip, takenAt, files, tests };
213
270
  }
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();
271
+ /** A test's lines, and its branch counts when the run had any. */
272
+ function testEntry(ran) {
273
+ return { lines: ran.lines, ...(ran.branches.size === 0 ? {} : { branches: ran.branches }) };
274
+ }
275
+ /** Calls visit with each added row's tip line, per hunk, for files the run covers. */
276
+ function eachAddedLine(units, files, visit) {
220
277
  for (const unit of units.values()) {
221
278
  if (unit.kind !== "hunk")
222
279
  continue;
@@ -224,22 +281,37 @@ export function changedLines(units, files) {
224
281
  if (file === undefined)
225
282
  continue;
226
283
  let line = tipStartOf(unit, units);
227
- let { covered, executable } = out.get(unit.path) ?? { covered: 0, executable: 0 };
228
284
  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
- }
285
+ if (row.op === "+")
286
+ visit(unit.path, file, line);
235
287
  if (row.op !== "-")
236
288
  line += 1;
237
289
  }
238
- if (executable > 0)
239
- out.set(unit.path, { covered, executable });
240
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
+ });
241
305
  return out;
242
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
+ }
243
315
  export function totalOf(tallies) {
244
316
  let covered = 0;
245
317
  let executable = 0;
@@ -278,6 +350,7 @@ export function serializeCoverage(c) {
278
350
  {
279
351
  lines: Object.fromEntries(f.lines),
280
352
  ...(f.branches.size === 0 ? {} : { branches: Object.fromEntries(f.branches) }),
353
+ ...(f.functions === undefined ? {} : { functions: f.functions }),
281
354
  },
282
355
  ]));
283
356
  const tests = Object.fromEntries([...c.tests].map(([id, t]) => [
@@ -286,6 +359,11 @@ export function serializeCoverage(c) {
286
359
  ...(t.at === undefined ? {} : { at: t.at }),
287
360
  ...(t.preview === undefined ? {} : { preview: [...t.preview] }),
288
361
  lines: Object.fromEntries([...t.lines].map(([p, ls]) => [p, [...ls]])),
362
+ ...(t.branches === undefined
363
+ ? {}
364
+ : {
365
+ branches: Object.fromEntries([...t.branches].map(([p, byLine]) => [p, Object.fromEntries(byLine)])),
366
+ }),
289
367
  },
290
368
  ]));
291
369
  return { tip: c.tip, takenAt: c.takenAt, files, ...(c.tests.size === 0 ? {} : { tests }) };
@@ -321,6 +399,52 @@ export function parseLocArg(raw, place) {
321
399
  const end = m[3] === undefined ? start : Number(m[3]);
322
400
  return end < start ? `--at ${raw} runs backwards` : { path, start, end };
323
401
  }
402
+ function parseFunction(v) {
403
+ if (!isFields(v))
404
+ return null;
405
+ const { name, start, end } = v;
406
+ if (typeof name !== "string" || !isLine(start))
407
+ return null;
408
+ if (end === undefined)
409
+ return { name, start };
410
+ return isLine(end) && end >= start ? { name, start, end } : null;
411
+ }
412
+ function parseFunctions(v) {
413
+ if (v === undefined)
414
+ return undefined;
415
+ if (!Array.isArray(v))
416
+ return "functions is not a list";
417
+ const out = [];
418
+ for (const raw of v) {
419
+ const f = parseFunction(raw);
420
+ if (f === null)
421
+ return "a function is not a { name, start, end? }";
422
+ out.push(f);
423
+ }
424
+ return out;
425
+ }
426
+ function parseCounts(v) {
427
+ if (v === undefined)
428
+ return undefined;
429
+ const files = entries(v);
430
+ if (files === null)
431
+ return "branches is not an object";
432
+ const out = new Map();
433
+ for (const [path, raw] of files) {
434
+ const lines = entries(raw);
435
+ if (!isRepoPath(path) || lines === null)
436
+ return `branches of ${path} are not by line`;
437
+ const byLine = new Map();
438
+ for (const [key, counts] of lines) {
439
+ if (!LINE_KEY.test(key) || !Array.isArray(counts) || !counts.every(isCount)) {
440
+ return `branches of ${path}:${key} are not counts`;
441
+ }
442
+ byLine.set(Number(key), counts.filter(isCount));
443
+ }
444
+ out.set(path, sorted(byLine));
445
+ }
446
+ return out;
447
+ }
324
448
  function parseFile(v) {
325
449
  if (!isFields(v))
326
450
  return "a file is not an object";
@@ -347,7 +471,14 @@ function parseFile(v) {
347
471
  }
348
472
  branches.set(Number(key), [taken, total]);
349
473
  }
350
- return { lines: sorted(hits), branches: sorted(branches) };
474
+ const functions = parseFunctions(v.functions);
475
+ if (typeof functions === "string")
476
+ return functions;
477
+ return {
478
+ lines: sorted(hits),
479
+ branches: sorted(branches),
480
+ ...(functions === undefined ? {} : { functions }),
481
+ };
351
482
  }
352
483
  function parseTest(v) {
353
484
  if (!isFields(v))
@@ -370,10 +501,14 @@ function parseTest(v) {
370
501
  }
371
502
  lines.set(path, list);
372
503
  }
504
+ const branches = parseCounts(v.branches);
505
+ if (typeof branches === "string")
506
+ return branches;
373
507
  return {
374
508
  ...(at === undefined ? {} : { at }),
375
509
  ...(preview === undefined ? {} : { preview: preview.filter((l) => typeof l === "string") }),
376
510
  lines,
511
+ ...(branches === undefined ? {} : { branches }),
377
512
  };
378
513
  }
379
514
  /** The one place coverage.json becomes a Coverage. */
package/dist/src/host.js CHANGED
@@ -122,6 +122,7 @@ async function loadHosted(registration, held) {
122
122
  }
123
123
  const verdict = validatePlan(session.diff, serializePlanFile(stored.file), {
124
124
  lastReview: session.lastReview(),
125
+ frozen: true,
125
126
  });
126
127
  if (!verdict.ok) {
127
128
  throw new RegistrationError(`plan v${version} no longer fits ${branch}; check it again`);
package/dist/src/plan.js CHANGED
@@ -556,12 +556,13 @@ function deriveConcernId(unit, c) {
556
556
  }
557
557
  /**
558
558
  * Gives every concern its id and checks where it sits: lines must be change lines the commit
559
- * takes, and a unit carries at most one concern across the whole plan.
559
+ * takes, and a unit carries at most one concern in each commit, so each slice of a hunk split
560
+ * across commits may carry its own.
560
561
  */
561
562
  function placeConcerns(diff, file, claims, issues) {
562
563
  const ids = new Set();
563
- const holders = new Map();
564
564
  const placedPerCommit = file.commits.map((commit, k) => {
565
+ const held = new Map();
565
566
  const placed = [];
566
567
  const add = (c, unit) => {
567
568
  const id = deriveConcernId(unit, c);
@@ -578,7 +579,7 @@ function placeConcerns(diff, file, claims, issues) {
578
579
  const concerns = ref.concerns ?? [];
579
580
  if (concerns.length === 0)
580
581
  return;
581
- holders.set(ref.unit, [...(holders.get(ref.unit) ?? []), ...concerns.map(() => k)]);
582
+ held.set(ref.unit, (held.get(ref.unit) ?? 0) + concerns.length);
582
583
  const taken = claims.resolved[k]?.[j]?.select;
583
584
  const unit = diff.units.get(ref.unit);
584
585
  for (const c of concerns) {
@@ -591,25 +592,25 @@ function placeConcerns(diff, file, claims, issues) {
591
592
  });
592
593
  continue;
593
594
  }
594
- const outside = taken?.kind === "lines" ? lines.filter((n) => !taken.changes.includes(n)) : [];
595
- if (outside.length > 0) {
595
+ const takes = taken?.kind === "lines" ? taken : null;
596
+ const outside = takes === null ? [] : lines.filter((n) => !takes.changes.includes(n));
597
+ if (takes !== null && outside.length > 0) {
596
598
  issues.push({
597
599
  kind: "bad-concern",
598
600
  commit: k,
599
- reason: `${ref.unit}: concern lines ${outside.join(", ")} are not change lines this commit takes`,
601
+ reason: `${ref.unit}: concern lines ${outside.join(", ")} are not change lines this commit takes; \`lines\` are the hunk's [n] as prompt.md shows them, on a slice too, and this commit takes ${formatUnitRef({ unit: ref.unit, select: takes })}`,
600
602
  });
601
603
  }
602
604
  else
603
605
  add(c, ref.unit);
604
606
  }
605
607
  });
608
+ for (const [unit, count] of held) {
609
+ if (count > 1)
610
+ issues.push({ kind: "too-many-concerns", unit, commit: k, count });
611
+ }
606
612
  return placed;
607
613
  });
608
- for (const [unit, commits] of holders) {
609
- if (commits.length > 1) {
610
- issues.push({ kind: "too-many-concerns", unit, commits: [...new Set(commits)] });
611
- }
612
- }
613
614
  return placedPerCommit;
614
615
  }
615
616
  /** "o_" + 8 hex over the unit and the label: stable while the offer stays at its place. */
@@ -744,6 +745,36 @@ function answerProblem(file, r, isQuestion) {
744
745
  * Parses and checks a draft against the diff: every change line used exactly once, every
745
746
  * commit's tree valid, and the last commit equal to the tip. Collects all issues at once.
746
747
  */
748
+ /**
749
+ * Sections read top-down: story, steps, details, wiring. A later section never comes back, so a
750
+ * commit whose section ranks below an earlier commit's must move ahead of the first such commit.
751
+ */
752
+ function sectionOrder(file) {
753
+ const issues = [];
754
+ const placed = [];
755
+ file.commits.forEach((c, commit) => {
756
+ if (c.section === undefined)
757
+ return;
758
+ const rank = SECTIONS.indexOf(c.section);
759
+ const first = placed.find((p) => p.rank > rank);
760
+ if (first !== undefined) {
761
+ issues.push({
762
+ kind: "section-order",
763
+ commit,
764
+ section: c.section,
765
+ before: first.commit,
766
+ beforeSection: first.section,
767
+ });
768
+ }
769
+ else
770
+ placed.push({ commit, rank, section: c.section });
771
+ });
772
+ return issues;
773
+ }
774
+ /**
775
+ * Judges a draft against the diff. `frozen` re-checks a plan that already passed when it was
776
+ * frozen: section order is a rule for drafts, so a plan frozen before it was one still fits.
777
+ */
747
778
  export function validatePlan(diff, draft, opts = {}) {
748
779
  const parsed = parsePlanFile(draft);
749
780
  if (Array.isArray(parsed))
@@ -766,6 +797,8 @@ export function validatePlan(diff, draft, opts = {}) {
766
797
  issues.push({ kind: "empty-commit", commit: k });
767
798
  });
768
799
  issues.push(...offerLimits(file));
800
+ if (opts.frozen !== true)
801
+ issues.push(...sectionOrder(file));
769
802
  const assignment = [];
770
803
  const claims = claim(diff, file, assignment);
771
804
  issues.push(...assignment);
@@ -788,17 +821,6 @@ export function validatePlan(diff, draft, opts = {}) {
788
821
  if (length > LONG_TITLE)
789
822
  warnings.push({ kind: "long-title", commit, length });
790
823
  });
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
- });
802
824
  const asked = new Map(review?.comments.map((c) => [c.id, c.kind === "question"]));
803
825
  for (const r of file.responses) {
804
826
  const isQuestion = asked.get(r.comment);