@pmelab/gtd 48.0.0 → 50.0.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 (45) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +31 -20
  3. package/dist/chunks/{reserved-DF1HD6jL.mjs → reserved-CdOzKFVX.mjs} +150 -24
  4. package/dist/chunks/{review-Bv40kZu9.mjs → review-BChXEl2Y.mjs} +132 -24
  5. package/dist/chunks/{steering-D7hWeMgE.mjs → steering-DtUVUz9l.mjs} +49 -13
  6. package/dist/chunks/{tail-D89fU_wf.mjs → tail-DRM2eC1z.mjs} +1 -1
  7. package/dist/gtd.bundle.mjs +22 -5
  8. package/dist/steering.mjs +2 -2
  9. package/dist/testkit.mjs +1 -1
  10. package/dist/web/main.js +27 -3
  11. package/dist/workflow-kit.mjs +1 -1
  12. package/dist/workflows/cleanup.mjs +2 -2
  13. package/dist/workflows/feature.mjs +16 -9
  14. package/dist/workflows/fix.mjs +3 -3
  15. package/dist/workflows/review.mjs +3 -3
  16. package/dist/workflows/rollout.mjs +1 -1
  17. package/dist/workflows.mjs +338 -62
  18. package/package.json +1 -1
  19. package/src/steering/index.ts +2 -0
  20. package/src/steering/review.test.ts +87 -0
  21. package/src/steering/review.ts +75 -8
  22. package/src/workflows/feature/coverage.feature +3 -3
  23. package/src/workflows/feature/fast-path.feature +2 -2
  24. package/src/workflows/feature/feature.ts +10 -3
  25. package/src/workflows/feature/flag-mode.feature +2 -2
  26. package/src/workflows/feature/lens-review.feature +128 -13
  27. package/src/workflows/feature/one-step-land.feature +2 -2
  28. package/src/workflows/feature/refactor-absorbs.feature +2 -2
  29. package/src/workflows/feature/refactor-first.feature +2 -2
  30. package/src/workflows/feature/refactor-rebuild.feature +2 -2
  31. package/src/workflows/feature/refactor-review.feature +6 -6
  32. package/src/workflows/feature/review-answers.feature +51 -5
  33. package/src/workflows/feature/review-gate.feature +23 -6
  34. package/src/workflows/feature/reviewer-walkthrough.feature +240 -0
  35. package/src/workflows/feature/seam-rejection.feature +2 -2
  36. package/src/workflows/feature/seam.feature +2 -2
  37. package/src/workflows/feature/severity-budget.feature +111 -0
  38. package/src/workflows/feature/severity-sorted-review.feature +197 -0
  39. package/src/workflows/feature/slice-findings.feature +68 -5
  40. package/src/workflows/feature/slicing.feature +6 -6
  41. package/src/workflows/feature/standards.feature +29 -1
  42. package/src/workflows/shared/review.ts +226 -25
  43. package/src/workflows/shared/steps.ts +3 -2
  44. package/src/workflows/shared/text.test.ts +216 -0
  45. package/src/workflows/shared/text.ts +201 -19
@@ -5,6 +5,7 @@ import {
5
5
  judge,
6
6
  numeric,
7
7
  read,
8
+ refuse,
8
9
  requireRevert,
9
10
  restoreScript,
10
11
  run,
@@ -15,7 +16,7 @@ import {
15
16
  type Judgment,
16
17
  type ScopeAccess,
17
18
  } from "../../flows/index.js"
18
- import type { ReviewNote } from "../../steering/index.js"
19
+ import { reviewChunks, reviewRisks, type ReviewNote } from "../../steering/index.js"
19
20
  import * as kit from "../../workflow-kit/index.js"
20
21
  import { guarded, healthy, type EscalationCount } from "./health.js"
21
22
  import { NO_FINDINGS, type LensReport } from "./lenses.js"
@@ -28,10 +29,11 @@ import {
28
29
  fixRisks,
29
30
  REQUIREMENTS,
30
31
  REVIEW,
32
+ reviewing,
31
33
  reviewMissing,
32
34
  } from "./steps.js"
33
35
  import { learnStandards, settlesStandard, standardsAddedSince, standardsPending } from "./learn.js"
34
- import { parseStandards, STANDARDS } from "./standards.js"
36
+ import { isConflictTitle, parseStandards, STANDARDS } from "./standards.js"
35
37
  import { closeReview, isOptionalObject, tail, type TailOptions } from "./tail.js"
36
38
  import * as t from "./text.js"
37
39
 
@@ -68,10 +70,15 @@ interface Severity {
68
70
  readonly reason: string
69
71
  /** Capture order: 0 one-way door, 1 new concept, 2 everything else. */
70
72
  readonly rank: 0 | 1 | 2
73
+ readonly security?: true
71
74
  }
72
75
 
73
76
  /** Anything unanswered, unrecognised, cut or below `minP` is `critical`, never `could`. */
74
- export const severityOf = (note: ReviewNote, judgment: Judgment, minP: number): Severity => {
77
+ export const severityOf = (
78
+ note: { readonly id: string; readonly forced?: boolean | undefined },
79
+ judgment: Judgment,
80
+ minP: number,
81
+ ): Severity => {
75
82
  const ans = (q: string) => judgment.answers[`${note.id}.${q}`]
76
83
  const a = (q: string): string | undefined => ans(q)?.answer
77
84
  const rank = (): 0 | 1 | 2 =>
@@ -88,7 +95,12 @@ export const severityOf = (note: ReviewNote, judgment: Judgment, minP: number):
88
95
  ...(a("reversibility") === "one-way" ? ["one-way door"] : []),
89
96
  ]
90
97
  if (critical.length > 0)
91
- return { tier: "critical", reason: `critical: ${critical.join(", ")}`, rank: rank() }
98
+ return {
99
+ tier: "critical",
100
+ reason: `critical: ${critical.join(", ")}`,
101
+ rank: rank(),
102
+ ...(a("security") === "yes" ? { security: true as const } : {}),
103
+ }
92
104
  const should = [
93
105
  ...(a("blastRadius") === "contract" ? ["contract"] : []),
94
106
  ...(a("newConcept") === "yes" ? ["new concept"] : []),
@@ -98,31 +110,32 @@ export const severityOf = (note: ReviewNote, judgment: Judgment, minP: number):
98
110
  return { tier: "could", reason: "could", rank: 2 }
99
111
  }
100
112
 
101
- const severityQuestions = (note: ReviewNote): JudgeQuestion[] => [
113
+ /** `asks` names the change in full, `change` refers back to it. */
114
+ const severityQuestions = (id: string, asks: string, change: string): JudgeQuestion[] => [
102
115
  {
103
- id: `${note.id}.security`,
116
+ id: `${id}.security`,
104
117
  primitive: "noul",
105
- instructions: `Does the change the human asks for in note "${note.id}" (on "${note.anchor}") touch security — authentication, authorisation, secrets, untrusted input or data exposure?`,
118
+ instructions: `Does ${asks} touch security — authentication, authorisation, secrets, untrusted input or data exposure?`,
106
119
  criteria: "yes: it changes or could weaken a security property. no: it does not.",
107
120
  },
108
121
  {
109
- id: `${note.id}.reversibility`,
122
+ id: `${id}.reversibility`,
110
123
  primitive: "choice",
111
- instructions: `Once the change in note "${note.id}" ships, can it be undone without breaking anyone who already depends on it?`,
124
+ instructions: `Once ${change} ships, can it be undone without breaking anyone who already depends on it?`,
112
125
  criteria:
113
126
  "one-way: a published API, file format, persisted data, protocol or other contract that cannot be taken back. two-way: can be reverted freely.",
114
127
  },
115
128
  {
116
- id: `${note.id}.blastRadius`,
129
+ id: `${id}.blastRadius`,
117
130
  primitive: "choice",
118
- instructions: `How far does the change in note "${note.id}" reach?`,
131
+ instructions: `How far does ${change} reach?`,
119
132
  criteria:
120
133
  "local: one function or file. module: a whole module or several files. contract: a public interface other code or users depend on.",
121
134
  },
122
135
  {
123
- id: `${note.id}.newConcept`,
136
+ id: `${id}.newConcept`,
124
137
  primitive: "noul",
125
- instructions: `Does the change in note "${note.id}" introduce a concept new to this codebase — a new abstraction, entity or term? Judge from the evidence, never a glossary; reusing or extending an existing concept is not new.`,
138
+ instructions: `Does ${change} introduce a concept new to this codebase — a new abstraction, entity or term? Judge from the evidence, never a glossary; reusing or extending an existing concept is not new.`,
126
139
  criteria: "yes: a genuinely new concept. no: reuses or extends an existing one.",
127
140
  },
128
141
  ]
@@ -142,7 +155,14 @@ const triage =
142
155
  asked.length === 0
143
156
  ? { answers: {}, truncated: [] }
144
157
  : await judge("review.triage", {
145
- questions: asked.flatMap((n) => [verdictQuestion(n), ...severityQuestions(n)]),
158
+ questions: asked.flatMap((n) => [
159
+ verdictQuestion(n),
160
+ ...severityQuestions(
161
+ n.id,
162
+ `the change the human asks for in note "${n.id}" (on "${n.anchor}")`,
163
+ `the change in note "${n.id}"`,
164
+ ),
165
+ ]),
146
166
  evidence: Object.fromEntries(
147
167
  asked.map((n) => [
148
168
  n.id,
@@ -168,8 +188,108 @@ const triage =
168
188
  )
169
189
  }
170
190
 
191
+ /** Review order: security, one-way doors, the judge unsure, then should, then could. */
192
+ const chunkKey = (s: Severity): number =>
193
+ s.tier === "could" ? 4 : s.tier === "should" ? 3 : s.security === true ? 0 : s.rank === 0 ? 1 : 2
194
+
195
+ type Pointer = ReturnType<typeof reviewChunks>[number]["files"][number]
196
+
197
+ /** A chunk's text, then the code each of its pointers covers: a bare path the whole file. */
198
+ const chunkEvidence = (text: string, files: readonly Pointer[]): string =>
199
+ [
200
+ text,
201
+ ...files.map(({ path, line, rangeEnd }) => {
202
+ const where = `${path}${line === undefined ? "" : `#${line}${rangeEnd === undefined ? "" : `-${rangeEnd}`}`}`
203
+ const lines = read(path.replace(/^\.\//, ""))?.split("\n")
204
+ if (lines === undefined) return `${where}: no longer exists`
205
+ const code = line === undefined ? lines : lines.slice(line - 1, rangeEnd ?? line)
206
+ return `${where}:\n${code.join("\n")}`
207
+ }),
208
+ ].join("\n\n")
209
+
210
+ const bytes = (text: string): number => new TextEncoder().encode(text).length
211
+
212
+ /** Fills each batch while the budget's even share still holds its widest evidence, so nothing is cut that fits alone. */
213
+ const batched = <T extends { readonly evidence: string }>(
214
+ items: readonly T[],
215
+ budget: number,
216
+ ): T[][] => {
217
+ const batches: T[][] = []
218
+ let batch: T[] = []
219
+ let widest = 0
220
+ for (const item of items) {
221
+ const size = bytes(item.evidence)
222
+ if (batch.length > 0 && Math.floor(budget / (batch.length + 1)) < Math.max(widest, size)) {
223
+ batches.push(batch)
224
+ batch = []
225
+ widest = 0
226
+ }
227
+ batch.push(item)
228
+ widest = Math.max(widest, size)
229
+ }
230
+ return batch.length === 0 ? batches : [...batches, batch]
231
+ }
232
+
233
+ /**
234
+ * Rates every chunk the reviewer wrote — a walkthrough chunk or a carried
235
+ * finding — and sorts \`full\` by it, each chunk led by its tier and reason.
236
+ * gtd's summary chunks are not rated and stay last.
237
+ */
238
+ const ranked = async (review: string, full: string): Promise<string> => {
239
+ const texts = t.chunkTexts(full)
240
+ const chunks = reviewChunks(review).map((c, i) => ({
241
+ id: `C${i + 1}`,
242
+ title: c.title,
243
+ evidence: chunkEvidence(texts.get(c.title) ?? "", c.files),
244
+ }))
245
+ if (chunks.length === 0) return full
246
+ const minP = numeric(vars.reviewSeverityMinP, Infinity)
247
+ const batches = batched(chunks, numeric(vars.judgeBudgetBytes, 32768))
248
+ const severities = new Map<string, Severity>()
249
+ for (const [i, batch] of batches.entries()) {
250
+ const judgment = await judge("review.ranking", {
251
+ questions: batch.flatMap((c) =>
252
+ severityQuestions(
253
+ c.id,
254
+ `the change review chunk "${c.id}" ("${c.title}") walks through`,
255
+ `the change in review chunk "${c.id}"`,
256
+ ),
257
+ ),
258
+ evidence: Object.fromEntries(batch.map((c) => [c.id, c.evidence])),
259
+ message: t.reviewRankingMessage(),
260
+ label:
261
+ batches.length === 1
262
+ ? "Ranking the review"
263
+ : `Ranking the review (${i + 1}/${batches.length})`,
264
+ })
265
+ for (const c of batch) severities.set(c.id, severityOf(c, judgment, minP))
266
+ }
267
+ return t.rankedReview(
268
+ full,
269
+ chunks
270
+ .map((c, i) => ({ c, i, s: severities.get(c.id)! }))
271
+ .sort((x, y) => chunkKey(x.s) - chunkKey(y.s) || x.i - y.i)
272
+ .map(({ c, s }) => ({ title: c.title, label: s.reason })),
273
+ )
274
+ }
275
+
171
276
  export const isCode = (path: string): boolean => path !== ".gtd" && !path.startsWith(".gtd/")
172
277
 
278
+ const composedOf = (
279
+ base: string,
280
+ report: LensReport,
281
+ nitFix: string | undefined,
282
+ answered: ReadonlySet<string>,
283
+ ): t.ComposedReview => ({
284
+ ...report,
285
+ nitFix,
286
+ answered,
287
+ standards: {
288
+ added: standardsAddedSince(base),
289
+ undecided: parseStandards(read(STANDARDS) ?? "").undecided,
290
+ },
291
+ })
292
+
173
293
  /**
174
294
  * gtd writes `.gtd/REVIEW.md` from the quality lap's `report` — no reviewer
175
295
  * turn. A re-review
@@ -189,20 +309,84 @@ const composing = (
189
309
  ? writeScript(
190
310
  REVIEW,
191
311
  t.composedReview(base, {
192
- ...report,
312
+ ...composedOf(base, report, nitFix, answered),
193
313
  weakened: kit.removedAssertions(changesSince(base)),
194
- nitFix,
195
- answered,
196
- standards: {
197
- added: standardsAddedSince(base),
198
- undecided: parseStandards(read(STANDARDS) ?? "").undecided,
199
- },
200
314
  }),
201
315
  )
202
316
  : showScript(carry, REVIEW, REVIEW),
203
317
  { label: "Composing the review", file: REVIEW, base },
204
318
  )
205
319
 
320
+ /**
321
+ * Refuses a review with a chunk pointing at no code, or a lap note without a
322
+ * chunk of its own. A range with no code change has nothing to point at, so
323
+ * there only a note's chunk needs a pointer.
324
+ */
325
+ const requirePointers = (
326
+ review: string,
327
+ notes: readonly { title: string }[],
328
+ codeChanged: boolean,
329
+ ): void => {
330
+ const chunks = reviewChunks(review)
331
+ const titles = new Set(chunks.map((c) => c.title))
332
+ const noted = new Set(notes.map((n) => n.title))
333
+ const problems = [
334
+ ...chunks
335
+ .filter((c) => c.pointers === 0 && (codeChanged || noted.has(c.title)))
336
+ .map((c) => ` - ${REVIEW}:${c.line + 1}: ${c.title} — points at no code`),
337
+ ...chunks
338
+ .filter((c) => isConflictTitle(c.title))
339
+ .map((c) => ` - ${REVIEW}:${c.line + 1}: ${c.title} — gtd writes conflicts; leave it out`),
340
+ ...notes.filter((n) => !titles.has(n.title)).map((n) => ` - ${n.title} — no chunk of its own`),
341
+ // Notes and answers are matched to their chunk by title.
342
+ ...chunks
343
+ .filter((c, i) => chunks.findIndex((d) => d.title === c.title) !== i)
344
+ .map((c) => ` - ${REVIEW}:${c.line + 1}: ${c.title} — a second chunk with this title`),
345
+ // A risk is fixed before the human sees it; a note is the human's to decide.
346
+ ...reviewRisks(review)
347
+ .filter((r) => [...noted].some((title) => r.anchor.startsWith(`${title} ./`)))
348
+ .map((r) => ` - ${r.anchor} — no \`Risk:\` in a quality finding's chunk`),
349
+ ]
350
+ if (problems.length === 0) return
351
+ refuse(
352
+ `gtd land: review-pointers: ${problems.length} problem(s) in ${REVIEW} — every chunk needs a pointer to its code, and every quality finding its own chunk, headed exactly as the prompt gave it:\n${problems.join("\n")}`,
353
+ )
354
+ }
355
+
356
+ /**
357
+ * A reviewer walks the human through everything since `base`, every chunk
358
+ * pointing at its code and each of the lap's notes a chunk of its own; gtd
359
+ * then adds each note's text and answer boxes, and the summary chunks.
360
+ */
361
+ const walkthrough = async (
362
+ base: string,
363
+ report: t.ComposedReview,
364
+ lap: t.ReviewRange,
365
+ carry: string | undefined,
366
+ ): Promise<void> => {
367
+ const notes = t.titledNotes(report)
368
+ await reviewing(base, carry, lap, t.walkthroughBlock(notes, report.nitFix))
369
+ const written = read(REVIEW)
370
+ // A review the reviewer did not write is the round's own refusal or rest.
371
+ if (written === undefined) return
372
+ const review = t.unranked(written)
373
+ // Against the file as written, so a refusal's line numbers match it.
374
+ requirePointers(
375
+ written,
376
+ notes,
377
+ changesSince(base).some((c) => isCode(c.path)),
378
+ )
379
+ // Ranked even when it marks risks: the review written after their fix reaches the human with its own.
380
+ const full = await ranked(review, t.withWalkthrough(review, report))
381
+ if (full !== written) {
382
+ await run("review.carrying", writeScript(REVIEW, full), {
383
+ label: "Ordering the review and adding the quality lap's findings",
384
+ file: REVIEW,
385
+ base,
386
+ })
387
+ }
388
+ }
389
+
206
390
  /**
207
391
  * The quality lap's notes reach a human over everything since `base`, who
208
392
  * answers each one — fix as suggested, won't fix, or their own answer — and
@@ -234,12 +418,27 @@ export const humanReview = (
234
418
  isCode,
235
419
  reviewing: (b, carry) => {
236
420
  const fixedAt = nitFix
237
- nitFix = undefined
238
- return composing(b, options.report ?? NO_FINDINGS, carry, fixedAt, answered)
421
+ const report = options.report ?? NO_FINDINGS
422
+ return options.walkthrough === true
423
+ ? guarded(
424
+ () =>
425
+ walkthrough(
426
+ b,
427
+ composedOf(b, report, fixedAt, answered),
428
+ options.lap ?? "first",
429
+ carry,
430
+ ),
431
+ guard,
432
+ )()
433
+ : composing(b, report, carry, fixedAt, answered)
239
434
  },
240
435
  fixRisks: guarded((notes) => fixRisks(notes, fixAccess, rules), guard),
241
436
  keepGreen: () => healthy(() => fixCheck(rules), { escalations, guard, guardRests, rules }),
242
- awaitReview,
437
+ // Kept until the human sees a review, so a re-review after a risk fix still names it.
438
+ awaitReview: (b) => {
439
+ nitFix = undefined
440
+ return awaitReview(b)
441
+ },
243
442
  reviewMissing,
244
443
  collecting: feedback.collecting,
245
444
  triage: triage(severities),
@@ -297,8 +496,10 @@ export interface BuildTailOptions extends TailOptions {
297
496
  readonly feedback?: FeedbackTarget | undefined
298
497
  /** Write access for the nit and risk fix turns; absent: the bundled `access` export decides. */
299
498
  readonly fixAccess?: ScopeAccess | undefined
300
- /** @deprecated No reviewer turn reads it since the review is composed. */
499
+ /** Which round a `walkthrough` reviewer writes; absent: `first`. */
301
500
  readonly lap?: t.ReviewRange | undefined
501
+ /** A reviewer turn writes the review as a walkthrough of the code, the lap's notes among its chunks; absent: gtd composes it from the notes alone. */
502
+ readonly walkthrough?: boolean | undefined
302
503
  /** What the triaged quality lap left for the review; absent: nothing. */
303
504
  readonly report?: LensReport | undefined
304
505
  }
@@ -94,15 +94,16 @@ export const fixQuality = (rules?: string): Promise<void> =>
94
94
  allowEmpty: true,
95
95
  })
96
96
 
97
- /** Write `.gtd/REVIEW.md` over everything since `base`, carrying the answers of commit `carry` when given. */
97
+ /** Write `.gtd/REVIEW.md` over everything since `base`, carrying the answers of commit `carry` when given; `extra` joins the prompt. */
98
98
  export const reviewing = (
99
99
  base: string,
100
100
  carry?: string,
101
101
  lap: t.ReviewRange = "first",
102
+ extra = "",
102
103
  ): Promise<void> =>
103
104
  t.agentWithSkills(
104
105
  "review.reviewing",
105
- t.buildReviewReviewingPrompt(base, lap, carry, removedAssertions(changesSince(base))),
106
+ t.buildReviewReviewingPrompt(base, lap, carry, removedAssertions(changesSince(base)), extra),
106
107
  {
107
108
  label: "Reviewing",
108
109
  file: REVIEW,
@@ -30,6 +30,11 @@ import {
30
30
  summaryPrompt,
31
31
  withSkills,
32
32
  composedReview,
33
+ titledNotes,
34
+ walkthroughBlock,
35
+ withWalkthrough,
36
+ rankedReview,
37
+ unranked,
33
38
  } from "./text.js"
34
39
  import { reviewDecisions, steeringFormatFor, unansweredNotes } from "../../steering/index.js"
35
40
  import { captureStep, renderText } from "./text.fixture.js"
@@ -1391,3 +1396,214 @@ describe("composedReview", () => {
1391
1396
  expect(doc).toContain("## a (2)\n\n2")
1392
1397
  })
1393
1398
  })
1399
+
1400
+ describe("walkthrough", () => {
1401
+ const empty = { notes: [], fixed: [], noise: [] }
1402
+ const review = [
1403
+ "# Review: abc1234",
1404
+ "",
1405
+ "<!-- base: abc1234 -->",
1406
+ "",
1407
+ "## Subtract",
1408
+ "",
1409
+ "- [ ] ./src/calc.ts#2-2 — subtract(a, b)",
1410
+ "",
1411
+ "## Fixed automatically",
1412
+ "",
1413
+ "- [ ] ./src/calc.ts#2-2 — the reviewer's own chunk",
1414
+ "",
1415
+ ].join("\n")
1416
+
1417
+ it("titles notes uniquely, never a summary's, and leaves out answered ones", () => {
1418
+ const notes = [
1419
+ { title: "Fixed automatically", body: "a" },
1420
+ { title: "x", body: "b" },
1421
+ { title: "x", body: "c" },
1422
+ ]
1423
+ expect(titledNotes({ ...empty, notes }).map((n) => n.title)).toEqual([
1424
+ "Fixed automatically (2)",
1425
+ "x",
1426
+ "x (2)",
1427
+ ])
1428
+ expect(titledNotes({ ...empty, notes, answered: new Set(["x"]) }).map((n) => n.title)).toEqual([
1429
+ "Fixed automatically (2)",
1430
+ "x (2)",
1431
+ ])
1432
+ })
1433
+
1434
+ it("lists each note with its exact heading, origin and quoted body", () => {
1435
+ const block = walkthroughBlock([
1436
+ { title: "No test", body: "Add one.\n\n- [ ] a box", slice: "2", lens: "tests" },
1437
+ ])
1438
+ expect(block).toContain(
1439
+ "- `## No test` (slice 2, lens tests):\n\n > Add one.\n >\n > - a box",
1440
+ )
1441
+ expect(block).toContain("its heading exactly as given")
1442
+ })
1443
+
1444
+ it("names the nit fix's commit, and says nothing with nothing to tell", () => {
1445
+ expect(walkthroughBlock([], "0123456789")).toContain(
1446
+ "Commit `0123456` worked on the human's nits",
1447
+ )
1448
+ expect(walkthroughBlock([])).toBe("")
1449
+ })
1450
+
1451
+ it("puts a note's text under its heading and its boxes after the reviewer's pointers", () => {
1452
+ const out = withWalkthrough(review, {
1453
+ ...empty,
1454
+ notes: [{ title: "Subtract", body: "It may overflow.", unresolved: true }],
1455
+ })
1456
+ expect(out).toContain(
1457
+ [
1458
+ "## Subtract",
1459
+ "",
1460
+ "_The fixer left this unresolved._",
1461
+ "",
1462
+ "It may overflow.",
1463
+ "",
1464
+ "In the code:",
1465
+ "",
1466
+ "- [ ] ./src/calc.ts#2-2 — subtract(a, b)",
1467
+ "",
1468
+ "Tick exactly one:",
1469
+ "",
1470
+ "- [ ] fix as suggested",
1471
+ "- [ ] won't fix",
1472
+ "- [ ] _your answer_",
1473
+ "",
1474
+ "## Fixed automatically",
1475
+ ].join("\n"),
1476
+ )
1477
+ })
1478
+
1479
+ it("appends the fixes and noise, numbered past a title the reviewer took", () => {
1480
+ const out = withWalkthrough(review, {
1481
+ ...empty,
1482
+ fixed: [{ title: "a", commit: "0123456789", slice: "1" }],
1483
+ noise: [{ title: "b", p: 0.9 }],
1484
+ })
1485
+ expect(
1486
+ out.endsWith(
1487
+ "## Fixed automatically (2)\n\n- a (0123456, slice 1)\n\n## Dropped as noise\n\n- b (p=0.9)\n",
1488
+ ),
1489
+ ).toBe(true)
1490
+ })
1491
+
1492
+ it("leaves a review with nothing to add byte for byte, trailing newlines and all", () => {
1493
+ expect(withWalkthrough(review, empty)).toBe(review)
1494
+ expect(withWalkthrough(review.trimEnd(), empty)).toBe(review.trimEnd())
1495
+ expect(withWalkthrough(review, { ...empty, notes: [{ title: "absent", body: "x" }] })).toBe(
1496
+ review,
1497
+ )
1498
+ })
1499
+
1500
+ it("keeps a note ending in a list apart from the pointers, so its answer carries the list", () => {
1501
+ const out = withWalkthrough(review, {
1502
+ ...empty,
1503
+ notes: [{ title: "Subtract", body: "Do this:\n\n- step one\n- step two" }],
1504
+ }).replace("- [ ] fix as suggested", "- [x] fix as suggested")
1505
+ const [decision] = reviewDecisions("", out)
1506
+ expect(decision?.body).toContain("step two")
1507
+ expect(decision?.body).not.toContain("./src/calc.ts")
1508
+ })
1509
+
1510
+ it("shows a title holding backticks as one unbroken code span", () => {
1511
+ expect(walkthroughBlock([{ title: "`sub` drifts", body: "x" }])).toContain(
1512
+ "- `` ## `sub` drifts ``:",
1513
+ )
1514
+ })
1515
+
1516
+ it("keeps Risk: out of a finding's chunk", () => {
1517
+ expect(walkthroughBlock([{ title: "x", body: "y" }])).toContain(
1518
+ "never open a note in a finding's chunk with `Risk:`",
1519
+ )
1520
+ })
1521
+ })
1522
+
1523
+ describe("rankedReview", () => {
1524
+ const review = [
1525
+ "# Review: abc1234",
1526
+ "",
1527
+ "<!-- base: abc1234 -->",
1528
+ "",
1529
+ "## Rename",
1530
+ "",
1531
+ "- [ ] ./a.ts#1-1",
1532
+ "",
1533
+ "## Token",
1534
+ "",
1535
+ "_could_",
1536
+ "",
1537
+ "Reads the token.",
1538
+ "",
1539
+ "- [ ] ./a.ts#2-2",
1540
+ "",
1541
+ "## Fixed automatically",
1542
+ "",
1543
+ "- x (abc1234)",
1544
+ "",
1545
+ ].join("\n")
1546
+
1547
+ it("puts the ranked chunks first, each led by its tier, and leaves the rest in place", () => {
1548
+ expect(
1549
+ rankedReview(unranked(review), [
1550
+ { title: "Token", label: "critical: security" },
1551
+ { title: "Rename", label: "could" },
1552
+ ]),
1553
+ ).toBe(
1554
+ [
1555
+ "# Review: abc1234",
1556
+ "",
1557
+ "<!-- base: abc1234 -->",
1558
+ "",
1559
+ "## Token",
1560
+ "",
1561
+ "_critical: security_",
1562
+ "",
1563
+ "Reads the token.",
1564
+ "",
1565
+ "- [ ] ./a.ts#2-2",
1566
+ "",
1567
+ "## Rename",
1568
+ "",
1569
+ "_could_",
1570
+ "",
1571
+ "- [ ] ./a.ts#1-1",
1572
+ "",
1573
+ "## Fixed automatically",
1574
+ "",
1575
+ "- x (abc1234)",
1576
+ "",
1577
+ ].join("\n"),
1578
+ )
1579
+ })
1580
+
1581
+ it("keeps a setext heading whole", () => {
1582
+ expect(
1583
+ rankedReview("# Review: a\n\nToken\n-----\n\n- [ ] ./a.ts#1\n", [
1584
+ { title: "Token", label: "could" },
1585
+ ]),
1586
+ ).toBe("# Review: a\n\nToken\n-----\n\n_could_\n\n- [ ] ./a.ts#1\n")
1587
+ })
1588
+
1589
+ it("keeps a chunk's leading indented code block indented", () => {
1590
+ const code = "# Review: abc1234\n\n## Code\n\n const x = 1\n\n- [ ] ./a.ts#1-1\n"
1591
+ expect(rankedReview(code, [{ title: "Code", label: "could" }])).toContain(
1592
+ "_could_\n\n const x = 1",
1593
+ )
1594
+ })
1595
+
1596
+ it("drops a stale tier with no blank line under the heading, never a longer paragraph", () => {
1597
+ expect(unranked("## A\n_could_\n\nbody\n")).toBe("## A\nbody\n")
1598
+ expect(unranked("## A\n\n_could_\n- [ ] ./a.ts#1\n")).toBe("## A\n\n- [ ] ./a.ts#1\n")
1599
+ const code = "## A\n\n _could_\n"
1600
+ expect(unranked(code)).toBe(code)
1601
+ const prose = "## A\n\n_should: x_\nruns on\n"
1602
+ expect(unranked(prose)).toBe(prose)
1603
+ })
1604
+
1605
+ it("drops a stale tier, so a re-review is ranked afresh", () => {
1606
+ expect(unranked(review)).not.toContain("_could_")
1607
+ expect(unranked(unranked(review))).toBe(unranked(review))
1608
+ })
1609
+ })