@pmelab/gtd 51.0.1 → 52.1.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 (74) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +46 -40
  3. package/dist/chunks/{reserved-CUkk9XgL.mjs → reserved-DxB5CROj.mjs} +73 -38
  4. package/dist/chunks/{review-BaCXlIT2.mjs → review-CKMSdvgW.mjs} +11 -9
  5. package/dist/chunks/{steering-DtUVUz9l.mjs → steering-CLhu8C7q.mjs} +262 -52
  6. package/dist/chunks/{tail-CX6Pdsql.mjs → tail-QZc7Uz2U.mjs} +1 -1
  7. package/dist/gtd.bundle.mjs +198 -43
  8. package/dist/steering.mjs +2 -2
  9. package/dist/testkit.mjs +1 -1
  10. package/dist/web/main.css +1 -1
  11. package/dist/web/main.js +510 -147
  12. package/dist/workflow-kit.mjs +1 -1
  13. package/dist/workflows/cleanup.mjs +2 -2
  14. package/dist/workflows/feature.mjs +228 -222
  15. package/dist/workflows/fix.mjs +3 -3
  16. package/dist/workflows/review.mjs +3 -3
  17. package/dist/workflows/rollout.mjs +1 -1
  18. package/dist/workflows.mjs +570 -317
  19. package/package.json +5 -4
  20. package/src/steering/FORMATS.md +4 -1
  21. package/src/steering/SteeringFormat.ts +7 -1
  22. package/src/steering/contract.test.ts +2 -1
  23. package/src/steering/fixtures/review/sectioned.md +31 -0
  24. package/src/steering/fixtures/review/sectioned.read.json +119 -0
  25. package/src/steering/index.ts +4 -0
  26. package/src/steering/qa.ts +1 -1
  27. package/src/steering/review.test.ts +270 -4
  28. package/src/steering/review.ts +328 -69
  29. package/src/testkit/steps.ts +13 -0
  30. package/src/workflows/feature/acceptance-criteria.feature +159 -3
  31. package/src/workflows/feature/continuation-cap.feature +4 -0
  32. package/src/workflows/feature/continuation-rebuild.feature +4 -0
  33. package/src/workflows/feature/continuation-slices.feature +4 -0
  34. package/src/workflows/feature/continuation.feature +4 -0
  35. package/src/workflows/feature/criteria.test.ts +60 -1
  36. package/src/workflows/feature/criteria.ts +87 -8
  37. package/src/workflows/feature/fast-path.feature +5 -6
  38. package/src/workflows/feature/feature.test.ts +6 -5
  39. package/src/workflows/feature/feature.ts +1 -2
  40. package/src/workflows/feature/flag-mode.feature +5 -6
  41. package/src/workflows/feature/green.ts +22 -0
  42. package/src/workflows/feature/hands-on-skip.feature +384 -0
  43. package/src/workflows/feature/lens-models.feature +4 -4
  44. package/src/workflows/feature/lens-review.feature +10 -8
  45. package/src/workflows/feature/naive-acceptance.feature +4 -0
  46. package/src/workflows/feature/one-step-land.feature +5 -6
  47. package/src/workflows/feature/planning.ts +2 -1
  48. package/src/workflows/feature/prototype-rounds.feature +5 -1
  49. package/src/workflows/feature/prototype.feature +18 -9
  50. package/src/workflows/feature/prototype.ts +48 -17
  51. package/src/workflows/feature/refactor-absorbs.feature +7 -8
  52. package/src/workflows/feature/refactor-first.feature +7 -8
  53. package/src/workflows/feature/refactor-lens-models.feature +4 -0
  54. package/src/workflows/feature/refactor-rebuild.feature +11 -12
  55. package/src/workflows/feature/refactor-review.feature +72 -72
  56. package/src/workflows/feature/review-answers.feature +94 -5
  57. package/src/workflows/feature/review-gate.feature +23 -53
  58. package/src/workflows/feature/reviewer-walkthrough.feature +33 -12
  59. package/src/workflows/feature/risk-tier.feature +32 -0
  60. package/src/workflows/feature/seam-rejection.feature +9 -6
  61. package/src/workflows/feature/seam.feature +9 -6
  62. package/src/workflows/feature/seam.test.ts +5 -3
  63. package/src/workflows/feature/seam.ts +2 -3
  64. package/src/workflows/feature/severity-budget.feature +11 -7
  65. package/src/workflows/feature/severity-sorted-review.feature +54 -54
  66. package/src/workflows/feature/slice-findings.feature +9 -15
  67. package/src/workflows/feature/slicing.feature +36 -24
  68. package/src/workflows/feature/standards.feature +4 -4
  69. package/src/workflows/feature/tier-pin.feature +4 -0
  70. package/src/workflows/shared/review.ts +26 -6
  71. package/src/workflows/shared/text.test.ts +85 -11
  72. package/src/workflows/shared/text.ts +95 -42
  73. package/src/workflows/feature/coverage.feature +0 -165
  74. package/src/workflows/feature/coverage.ts +0 -120
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmelab/gtd",
3
- "version": "51.0.1",
3
+ "version": "52.1.0",
4
4
  "private": false,
5
5
  "description": "Git-aware CLI that emits the next prompt for an autonomous coding agent based on the current repository state",
6
6
  "bin": {
@@ -165,8 +165,9 @@
165
165
  "@types/react": "^19.2.7",
166
166
  "@types/react-dom": "^19.2.3",
167
167
  "@vitejs/plugin-react": "^5.2.0",
168
- "@vitest/browser": "^3.2.7",
169
- "@vitest/coverage-v8": "^3.2.7",
168
+ "@vitest/browser": "^4.1.12",
169
+ "@vitest/browser-playwright": "^4.1.12",
170
+ "@vitest/coverage-v8": "^4.1.12",
170
171
  "dependency-cruiser": "^18.3.1",
171
172
  "fallow": "3.25.0",
172
173
  "fast-check": "^4.8.0",
@@ -186,7 +187,7 @@
186
187
  "tailwindcss": "^4.3.3",
187
188
  "tsdown": "^0.22.8",
188
189
  "turbo": "^2.10.9",
189
- "vitest": "^3.2.0",
190
+ "vitest": "^4.1.12",
190
191
  "yaml": "^2.8.2",
191
192
  "zustand": "^5.0.15"
192
193
  },
@@ -14,7 +14,10 @@ read them.
14
14
  `<!-- base: <sha> -->` comment, then `##` chunks with prose and `- [ ]` hunk
15
15
  pointers (`./path`, `./path#start-end`), each optionally followed by
16
16
  `— <note>`. A pointer note starting `Risk:` is a reviewer's risk. A ticked
17
- pointer is a hunk the human has read.
17
+ pointer is a hunk the human has read. A chunk's `Tick exactly one:` list is
18
+ its decision. A `## Findings` or `## Walkthrough` heading groups the `###`
19
+ chunks under it; a walkthrough chunk takes no decision. Any other `##` is a
20
+ chunk of its own.
18
21
  - **Free-form**: any markdown, for a file with no mode or an unknown one. Only
19
22
  its threads are validated.
20
23
  - **Threads**, shared by all three: a footnote whose definition is a list of
@@ -64,6 +64,8 @@ export interface SteeringLink {
64
64
  * format-agnostic: `review`'s chunk/hunk and `qa`'s question/option are the
65
65
  * same two things here (a container and one of its numbered children), plus
66
66
  * `paragraph`, which a client resolves from its cursor rather than the view.
67
+ * A `review` finding's answer boxes are `option`s too, `questionIndex` being
68
+ * the chunk's index.
67
69
  */
68
70
  export type SteeringAnchor =
69
71
  | { readonly kind: "chunk"; readonly index: number }
@@ -110,7 +112,7 @@ export interface SteeringViewNode {
110
112
  readonly detail?: string
111
113
  /** A free-form status label, when the format has one (`qa`'s `"open"`/`"answered"`). */
112
114
  readonly status?: string
113
- /** `true` when this node's own condition is fully satisfied (`qa`'s answered-question flag) — distinct from `checked`, which is a per-item tick. */
115
+ /** `true` when this node's own condition is fully satisfied (`qa`'s answered question, `review`'s decided finding) — distinct from `checked`, which is a per-item tick. */
114
116
  readonly answered?: boolean
115
117
  /** This node's own checkbox state, when it has one (a hunk's tick, an option's tick). */
116
118
  readonly checked?: boolean
@@ -138,6 +140,10 @@ export interface SteeringViewNode {
138
140
  readonly endLine?: number
139
141
  /** Where `annotate` attaches a NEW note to this node. */
140
142
  readonly anchor: SteeringAnchor
143
+ /** The section a top-level node sits in, when the format groups them (`review`'s `Findings` / `Walkthrough`). */
144
+ readonly group?: string
145
+ /** A `review` finding's answer boxes, in order — fix as suggested, won't fix, and the free-text slot, whose `title` is the human's text (`""` while unfilled). Kept out of `children`, which are its hunks. */
146
+ readonly decision?: readonly SteeringViewNode[]
141
147
  readonly children?: readonly SteeringViewNode[]
142
148
  /**
143
149
  * A node's body, a SIBLING of `children` and never a reuse of it: a `qa`
@@ -97,7 +97,8 @@ describe.each(FORMATS)("the %s format", (dir, format, ticks) => {
97
97
 
98
98
  if (ticks)
99
99
  it("round-trips every tick: writing back what was read leaves the bytes unchanged", () => {
100
- for (const node of nodesOf(format.view(content).nodes)) {
100
+ const nodes = nodesOf(format.view(content).nodes)
101
+ for (const node of [...nodes, ...nodes.flatMap((n) => n.decision ?? [])]) {
101
102
  if (node.checked === undefined || node.anchor.kind === "chunk") continue
102
103
  const anchor: SteeringAnchor = node.anchor
103
104
  const same = write(
@@ -0,0 +1,31 @@
1
+ # Review: 1a2b3c4
2
+
3
+ <!-- base: 1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b -->
4
+
5
+ ## Findings
6
+
7
+ ### A lone CR is kept
8
+
9
+ _should: contract_
10
+
11
+ The parser keeps a lone `\r` as content.
12
+
13
+ In the code:
14
+
15
+ - [x] ./src/parse.ts#30-31 — where it is kept
16
+
17
+ Tick exactly one:
18
+
19
+ - [ ] fix as suggested
20
+ - [x] won't fix
21
+ - [ ] _your answer_
22
+
23
+ ## Walkthrough
24
+
25
+ ### Parser accepts CRLF
26
+
27
+ - [ ] ./src/parse.ts#10-14 — normalizes `\r\n` first
28
+
29
+ ## Fixed automatically
30
+
31
+ - a test name typo (0123456)
@@ -0,0 +1,119 @@
1
+ {
2
+ "header": "1a2b3c4",
3
+ "nodes": [
4
+ {
5
+ "title": "A lone CR is kept",
6
+ "detail": "The parser keeps a lone `\\r` as content. In the code:",
7
+ "body": [
8
+ {
9
+ "title": "The parser keeps a lone `\\r` as content.",
10
+ "anchor": {
11
+ "kind": "paragraph",
12
+ "line": 10
13
+ }
14
+ },
15
+ {
16
+ "title": "In the code:",
17
+ "anchor": {
18
+ "kind": "paragraph",
19
+ "line": 12
20
+ }
21
+ }
22
+ ],
23
+ "status": "should: contract",
24
+ "anchor": {
25
+ "kind": "chunk",
26
+ "index": 0
27
+ },
28
+ "group": "Findings",
29
+ "answered": true,
30
+ "decision": [
31
+ {
32
+ "title": "fix as suggested",
33
+ "checked": false,
34
+ "anchor": {
35
+ "kind": "option",
36
+ "questionIndex": 0,
37
+ "index": 0
38
+ }
39
+ },
40
+ {
41
+ "title": "won't fix",
42
+ "checked": true,
43
+ "anchor": {
44
+ "kind": "option",
45
+ "questionIndex": 0,
46
+ "index": 1
47
+ }
48
+ },
49
+ {
50
+ "title": "",
51
+ "checked": false,
52
+ "anchor": {
53
+ "kind": "option",
54
+ "questionIndex": 0,
55
+ "index": 2
56
+ }
57
+ }
58
+ ],
59
+ "children": [
60
+ {
61
+ "title": "./src/parse.ts#30-31",
62
+ "path": "./src/parse.ts",
63
+ "line": 30,
64
+ "endLine": 31,
65
+ "checked": true,
66
+ "detail": "where it is kept",
67
+ "anchor": {
68
+ "kind": "hunk",
69
+ "chunkIndex": 0,
70
+ "index": 0
71
+ }
72
+ }
73
+ ]
74
+ },
75
+ {
76
+ "title": "Parser accepts CRLF",
77
+ "detail": "",
78
+ "body": [],
79
+ "anchor": {
80
+ "kind": "chunk",
81
+ "index": 1
82
+ },
83
+ "group": "Walkthrough",
84
+ "children": [
85
+ {
86
+ "title": "./src/parse.ts#10-14",
87
+ "path": "./src/parse.ts",
88
+ "line": 10,
89
+ "endLine": 14,
90
+ "checked": false,
91
+ "detail": "normalizes `\\r\\n` first",
92
+ "anchor": {
93
+ "kind": "hunk",
94
+ "chunkIndex": 1,
95
+ "index": 0
96
+ }
97
+ }
98
+ ]
99
+ },
100
+ {
101
+ "title": "Fixed automatically",
102
+ "detail": "- a test name typo (0123456)",
103
+ "body": [
104
+ {
105
+ "title": "a test name typo (0123456)",
106
+ "anchor": {
107
+ "kind": "paragraph",
108
+ "line": 30
109
+ }
110
+ }
111
+ ],
112
+ "anchor": {
113
+ "kind": "chunk",
114
+ "index": 2
115
+ },
116
+ "children": []
117
+ }
118
+ ]
119
+ }
@@ -16,11 +16,15 @@ export {
16
16
  reviewDecisions,
17
17
  reviewNotes,
18
18
  reviewRisks,
19
+ reviewSections,
20
+ REVIEW_SECTIONS,
21
+ sectionedReview,
19
22
  severityLine,
20
23
  unansweredNotes,
21
24
  type ReviewChoice,
22
25
  type ReviewDecision,
23
26
  type ReviewNote,
27
+ type ReviewSection,
24
28
  } from "./review.js"
25
29
  export { applySteeringEdits } from "./Edits.js"
26
30
 
@@ -830,7 +830,7 @@ const replaceOptionTextEdit = (
830
830
  * offset for, breaking this anchor's `apply` forever. The placeholder reads
831
831
  * back as `""`, so an erase still reads as unanswered.
832
832
  */
833
- const normalizeFreeTextAnswer = (text: string): string => {
833
+ export const normalizeFreeTextAnswer = (text: string): string => {
834
834
  const normalized = text.replace(/\s+/g, " ").trim()
835
835
  return normalized.length === 0 ? FREE_TEXT_PLACEHOLDER : normalized
836
836
  }
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, it } from "vitest"
2
2
  import { FOOTNOTE_ACTION_TITLE, steeringFormatFor, viewOf } from "./index.js"
3
- import { parseReviewDoc } from "./review.js"
3
+ import { parseReviewDoc, unansweredNotes } from "./review.js"
4
4
  import { getParseCount } from "./index.js"
5
5
 
6
6
  describe("parseReviewDoc", () => {
@@ -1532,13 +1532,90 @@ describe("review.apply", () => {
1532
1532
  ).toEqual({ ok: false, reason: "anchor-not-found" })
1533
1533
  })
1534
1534
 
1535
- it("a question/option anchor — not this format's own kind — refuses anchor-not-found", () => {
1535
+ it("a question anchor — not this format's own kind — refuses anchor-not-found", () => {
1536
1536
  expect(review.apply(NESTED_CONTENT, { kind: "question", index: 0 }, { checked: true })).toEqual(
1537
1537
  { ok: false, reason: "anchor-not-found" },
1538
1538
  )
1539
1539
  })
1540
1540
  })
1541
1541
 
1542
+ describe("review decisions — a finding's answer boxes", () => {
1543
+ const FINDINGS = [
1544
+ "# Review: abc1234",
1545
+ "",
1546
+ "<!-- base: abc1234def5678901234567890123456789abcd -->",
1547
+ "",
1548
+ "## Findings",
1549
+ "",
1550
+ "### Token check",
1551
+ "",
1552
+ "- [ ] ./src/auth.ts#1-2",
1553
+ "",
1554
+ "Tick exactly one:",
1555
+ "",
1556
+ "- [ ] fix as suggested",
1557
+ "- [x] won't fix",
1558
+ "- [ ] _your answer_",
1559
+ "",
1560
+ "## Walkthrough",
1561
+ "",
1562
+ "### Rename",
1563
+ "",
1564
+ "- [ ] ./src/calc.ts#1-1",
1565
+ "",
1566
+ ].join("\n")
1567
+ const option = (index: number) => ({ kind: "option" as const, questionIndex: 0, index })
1568
+ const write = (content: string, index: number, opts: { checked?: boolean; text?: string }) => {
1569
+ const result = review.apply(content, option(index), opts)
1570
+ if (!result.ok) throw new Error(result.reason)
1571
+ return applyEdits(content, result.edits)
1572
+ }
1573
+
1574
+ it("views a finding's boxes as its decision, apart from its hunks", () => {
1575
+ const [finding, walkthrough] = review.view(FINDINGS).nodes
1576
+ expect(finding!.answered).toBe(true)
1577
+ expect(finding!.decision!.map((o) => [o.title, o.checked])).toEqual([
1578
+ ["fix as suggested", false],
1579
+ ["won't fix", true],
1580
+ ["", false],
1581
+ ])
1582
+ expect(finding!.children!.map((c) => c.anchor.kind)).toEqual(["hunk"])
1583
+ expect(walkthrough!.decision).toBeUndefined()
1584
+ expect(walkthrough!.answered).toBeUndefined()
1585
+ })
1586
+
1587
+ it("ticking a box unticks the others", () => {
1588
+ const after = write(FINDINGS, 0, { checked: true })
1589
+ expect(after).toContain("- [x] fix as suggested\n- [ ] won't fix\n- [ ] _your answer_")
1590
+ expect(unansweredNotes(after)).toEqual([])
1591
+ })
1592
+
1593
+ it("an answer ticks the free-text slot and writes its text in one edit set", () => {
1594
+ const after = write(FINDINGS, 2, { checked: true, text: "Only for\n admin tokens " })
1595
+ expect(after).toContain("- [ ] won't fix\n- [x] Only for admin tokens\n")
1596
+ expect(review.view(after).nodes[0]!.decision![2]!.title).toBe("Only for admin tokens")
1597
+ expect(unansweredNotes(after)).toEqual([])
1598
+ expect(review.validate(after)).toEqual([])
1599
+ })
1600
+
1601
+ it("clearing the answer unticks the slot and restores its placeholder", () => {
1602
+ const answered = write(FINDINGS, 2, { checked: true, text: "Later" })
1603
+ const cleared = write(answered, 2, { checked: false, text: "" })
1604
+ expect(cleared).toContain("- [ ] won't fix\n- [ ] _your answer_\n")
1605
+ expect(review.view(cleared).nodes[0]!.answered).toBe(false)
1606
+ })
1607
+
1608
+ it("a walkthrough chunk or a missing box refuses anchor-not-found", () => {
1609
+ expect(
1610
+ review.apply(FINDINGS, { kind: "option", questionIndex: 1, index: 0 }, { checked: true }),
1611
+ ).toEqual({ ok: false, reason: "anchor-not-found" })
1612
+ expect(review.apply(FINDINGS, option(3), { checked: true })).toEqual({
1613
+ ok: false,
1614
+ reason: "anchor-not-found",
1615
+ })
1616
+ })
1617
+ })
1618
+
1542
1619
  describe("review.view — chunk-level footnote projection", () => {
1543
1620
  it("projects a footnote marker on the chunk's own heading line as that chunk node's own `note`, distinct from a hunk's own note", () => {
1544
1621
  // review.sample carries exactly this shape: `## Sample chunk[^naduiqc4]`
@@ -1899,7 +1976,7 @@ describe("reviewChunks", async () => {
1899
1976
  "",
1900
1977
  "<!-- base: abc1234def5678901234567890123456789abcd -->",
1901
1978
  "",
1902
- "## Walkthrough",
1979
+ "## Changes",
1903
1980
  "",
1904
1981
  "- [ ] ./src/a.ts#1-3 — first",
1905
1982
  "- [ ] ./src/b.ts#4-6 — second",
@@ -1914,7 +1991,7 @@ describe("reviewChunks", async () => {
1914
1991
  ].join("\n")
1915
1992
  expect(reviewChunks(content)).toEqual([
1916
1993
  {
1917
- title: "Walkthrough",
1994
+ title: "Changes",
1918
1995
  line: 4,
1919
1996
  pointers: 2,
1920
1997
  files: [
@@ -2039,3 +2116,192 @@ describe("answer boxes", async () => {
2039
2116
  ])
2040
2117
  })
2041
2118
  })
2119
+
2120
+ describe("review — sectioned layout", async () => {
2121
+ const { reviewChunks, reviewDecisions, reviewSections, sectionedReview, unansweredNotes } =
2122
+ await import("./review.js")
2123
+ const boxes = (ticked?: 0 | 1 | 2): string[] => [
2124
+ "Tick exactly one:",
2125
+ "",
2126
+ `- [${ticked === 0 ? "x" : " "}] fix as suggested`,
2127
+ `- [${ticked === 1 ? "x" : " "}] won't fix`,
2128
+ `- [${ticked === 2 ? "x" : " "}] _your answer_`,
2129
+ ]
2130
+ const head = [
2131
+ "# Review: abc1234",
2132
+ "",
2133
+ "<!-- base: abc1234def5678901234567890123456789abcd -->",
2134
+ "",
2135
+ ]
2136
+ const sectioned = (ticked?: 0 | 1 | 2): string =>
2137
+ [
2138
+ ...head,
2139
+ "## Findings",
2140
+ "",
2141
+ "### A finding",
2142
+ "",
2143
+ "_critical: security_",
2144
+ "",
2145
+ "Leaks a token.",
2146
+ "",
2147
+ "- [ ] ./src/a.ts#1-2 — here",
2148
+ "",
2149
+ ...boxes(ticked),
2150
+ "",
2151
+ "## Walkthrough",
2152
+ "",
2153
+ "### Wiring",
2154
+ "",
2155
+ "- [x] ./src/b.ts#3-4 — wires it",
2156
+ "",
2157
+ "## Fixed automatically",
2158
+ "",
2159
+ "- f (fedcba9)",
2160
+ "",
2161
+ ].join("\n")
2162
+
2163
+ it("reads `###` chunks under the sections and other `## ` chunks on their own", () => {
2164
+ expect(review.validate(sectioned())).toEqual([])
2165
+ expect(reviewChunks(sectioned())).toEqual([
2166
+ expect.objectContaining({ title: "A finding", line: 6, pointers: 1, section: "findings" }),
2167
+ expect.objectContaining({ title: "Wiring", line: 22, pointers: 1, section: "walkthrough" }),
2168
+ { title: "Fixed automatically", line: 26, pointers: 0, files: [] },
2169
+ ])
2170
+ expect(reviewSections(sectioned())).toEqual([
2171
+ { section: "findings", line: 4 },
2172
+ { section: "walkthrough", line: 20 },
2173
+ ])
2174
+ })
2175
+
2176
+ it("reads a decision from a finding", () => {
2177
+ expect(unansweredNotes(sectioned())).toEqual([
2178
+ { title: "A finding", line: 7, reason: "tick exactly one box" },
2179
+ ])
2180
+ expect(reviewDecisions(sectioned(), sectioned(1))).toEqual([
2181
+ expect.objectContaining({ anchor: "A finding", choice: "wontfix" }),
2182
+ ])
2183
+ })
2184
+
2185
+ it("reads no decision in the walkthrough: boxes there are a chunk gone wrong", () => {
2186
+ const content = sectioned(0).replace(
2187
+ "- [x] ./src/b.ts#3-4 — wires it\n",
2188
+ ["- [x] ./src/b.ts#3-4 — wires it", "", ...boxes(1), ""].join("\n"),
2189
+ )
2190
+ expect(unansweredNotes(content)).toEqual([])
2191
+ expect(reviewDecisions(sectioned(), content)).toEqual([
2192
+ expect.objectContaining({ anchor: "A finding", choice: "fix" }),
2193
+ ])
2194
+ })
2195
+
2196
+ it("still reads the flat layout, every `## ` a chunk and its boxes a decision", () => {
2197
+ const flat = [...head, "## A finding", "", "Leaks a token.", "", ...boxes(), ""].join("\n")
2198
+ expect(review.validate(flat)).toEqual([])
2199
+ expect(reviewChunks(flat)).toEqual([{ title: "A finding", line: 4, pointers: 0, files: [] }])
2200
+ expect(unansweredNotes(flat)).toHaveLength(1)
2201
+ })
2202
+
2203
+ it("outlines the sections as containers, each finding's decision a node of its own", () => {
2204
+ const outline = viewOf(review, sectioned()).outline
2205
+ expect(outline.map((n) => n.name)).toEqual(["Findings", "Walkthrough", "Fixed automatically"])
2206
+ expect(outline[0]!.children!.map((n) => n.name)).toEqual(["A finding (0/1)"])
2207
+ expect(outline[0]!.children![0]!.children!.map((n) => n.name)).toEqual(["Decision: open"])
2208
+ expect(outline[0]!.children![0]!.children![0]!.selectionRange.start.line).toBe(14)
2209
+ // A fully ticked walkthrough chunk is reviewed: its section is left empty.
2210
+ expect(outline[1]!.children).toEqual([])
2211
+ })
2212
+
2213
+ it("names the ticked decision, and keeps a decided finding while a hunk is unticked", () => {
2214
+ const outline = viewOf(review, sectioned(1)).outline
2215
+ expect(outline[0]!.children![0]!.children!.map((n) => n.name)).toEqual(["Decision: won't fix"])
2216
+ })
2217
+
2218
+ it("groups the view's chunks by section, anchors unchanged", () => {
2219
+ const { nodes } = review.view(sectioned())
2220
+ expect(nodes.map((n) => [n.title, n.group, n.anchor])).toEqual([
2221
+ ["A finding", "Findings", { kind: "chunk", index: 0 }],
2222
+ ["Wiring", "Walkthrough", { kind: "chunk", index: 1 }],
2223
+ ["Fixed automatically", undefined, { kind: "chunk", index: 2 }],
2224
+ ])
2225
+ })
2226
+
2227
+ it("offers a chunk's toggle only within its own span, never on the next section's heading", () => {
2228
+ const titles = viewOf(review, sectioned())
2229
+ .actionsAt({ start: { line: 20, character: 0 }, end: { line: 20, character: 0 } })
2230
+ .map((a) => a.title)
2231
+ expect(titles.some((t) => t.includes("A finding"))).toBe(false)
2232
+ })
2233
+
2234
+ describe("sectionedReview", () => {
2235
+ const flat = [
2236
+ ...head,
2237
+ "## Weakened scenarios",
2238
+ "",
2239
+ "- a.feature — s — Then x",
2240
+ "",
2241
+ "## A finding",
2242
+ "",
2243
+ "_should: contract_",
2244
+ "",
2245
+ "Leaks a token.",
2246
+ "",
2247
+ ...boxes(),
2248
+ "",
2249
+ "Wiring",
2250
+ "------",
2251
+ "",
2252
+ "### Detail",
2253
+ "",
2254
+ "- [ ] ./src/b.ts#3-4 — wires it",
2255
+ "",
2256
+ "## Fixed automatically",
2257
+ "",
2258
+ "- f (fedcba9)",
2259
+ "",
2260
+ ].join("\n")
2261
+
2262
+ it("moves boxed chunks under Findings and the named ones under Walkthrough, a level deeper", () => {
2263
+ expect(sectionedReview(flat, new Set(["Wiring"]))).toBe(
2264
+ [
2265
+ ...head,
2266
+ "## Weakened scenarios",
2267
+ "",
2268
+ "- a.feature — s — Then x",
2269
+ "",
2270
+ "## Findings",
2271
+ "",
2272
+ "### A finding",
2273
+ "",
2274
+ "_should: contract_",
2275
+ "",
2276
+ "Leaks a token.",
2277
+ "",
2278
+ ...boxes(),
2279
+ "",
2280
+ "## Walkthrough",
2281
+ "",
2282
+ "### Wiring",
2283
+ "",
2284
+ "#### Detail",
2285
+ "",
2286
+ "- [ ] ./src/b.ts#3-4 — wires it",
2287
+ "",
2288
+ "## Fixed automatically",
2289
+ "",
2290
+ "- f (fedcba9)",
2291
+ "",
2292
+ ].join("\n"),
2293
+ )
2294
+ })
2295
+
2296
+ it("is valid and stable once sectioned", () => {
2297
+ const once = sectionedReview(flat, new Set(["Wiring"]))
2298
+ expect(review.validate(once)).toEqual([])
2299
+ expect(sectionedReview(once, new Set(["Wiring"]))).toBe(once)
2300
+ })
2301
+
2302
+ it("leaves a review with nothing to group byte for byte", () => {
2303
+ const plain = [...head, "## Nothing to note", "", "Review the diff yourself.", ""].join("\n")
2304
+ expect(sectionedReview(plain, new Set())).toBe(plain)
2305
+ })
2306
+ })
2307
+ })