@pmelab/gtd 24.0.0 → 24.2.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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +42 -13
- package/claude/hooks/access.ts +26 -1
- package/claude/hooks/assist.ts +135 -0
- package/claude/hooks/drive.ts +15 -7
- package/claude/hooks/judge.ts +27 -0
- package/claude/hooks/register.tsx +294 -135
- package/claude/hooks/tools.ts +121 -0
- package/claude/hooks/wording.ts +2 -35
- package/claude/types/index.d.ts +9 -0
- package/dist/chunks/Schema-Dh-mkwkq.mjs +25353 -0
- package/dist/chunks/reserved-ejn-LVYO.mjs +1388 -0
- package/dist/chunks/rolldown-runtime-8H4AJuhK.mjs +14 -0
- package/dist/chunks/steering-D-PvRC5W.mjs +16296 -0
- package/dist/chunks/wire-DA7q2ikx.mjs +473 -0
- package/dist/flows.mjs +525 -0
- package/dist/gtd.bundle.mjs +43339 -55575
- package/dist/steering.mjs +2 -0
- package/dist/testkit.mjs +13006 -0
- package/dist/wire.mjs +2 -0
- package/dist/workflow-kit.mjs +352 -0
- package/dist/workflows/feature.mjs +315 -0
- package/dist/workflows/fix.mjs +40 -0
- package/dist/workflows/review.mjs +25 -0
- package/dist/workflows.mjs +8 -6
- package/package.json +69 -8
- package/src/cli/Cli.test.ts +1329 -0
- package/src/cli/Cli.ts +1348 -0
- package/src/cli/Program.test.ts +2262 -0
- package/src/cli/Program.ts +1331 -0
- package/src/cli/ToolCommands.ts +43 -0
- package/src/cli/index.ts +14 -0
- package/src/edge/Edge.test.ts +1016 -0
- package/src/edge/Edge.ts +1225 -0
- package/src/edge/index.ts +16 -0
- package/src/emit/Emit.test.ts +425 -0
- package/src/emit/Emit.ts +139 -0
- package/src/emit/GitScript.test.ts +260 -0
- package/src/emit/GitScript.ts +131 -0
- package/src/emit/ModeContradiction.test.ts +120 -0
- package/src/emit/ModeContradiction.ts +66 -0
- package/src/emit/OutcomeScript.test.ts +238 -0
- package/src/emit/OutcomeScript.ts +73 -0
- package/src/emit/SteeringMode.test.ts +274 -0
- package/src/emit/SteeringMode.ts +256 -0
- package/src/emit/index.ts +40 -0
- package/src/install/ConfigScaffold.ts +19 -0
- package/src/install/Install.test.ts +426 -0
- package/src/install/Install.ts +634 -0
- package/src/install/Version.ts +27 -0
- package/src/install/index.ts +3 -0
- package/src/platform/CommandRunner.ts +74 -0
- package/src/platform/Commentary.test.ts +126 -0
- package/src/platform/Commentary.ts +92 -0
- package/src/platform/Git.test.ts +50 -0
- package/src/platform/Git.ts +552 -0
- package/src/platform/Host.ts +42 -0
- package/src/platform/RetainedHistory.test.ts +65 -0
- package/src/platform/RetainedHistory.ts +34 -0
- package/src/platform/Workspace.test.ts +669 -0
- package/src/platform/Workspace.ts +631 -0
- package/src/platform/WorktreeState.test.ts +162 -0
- package/src/platform/WorktreeState.ts +50 -0
- package/src/platform/index.ts +22 -0
- package/src/replay/Access.test.ts +53 -0
- package/src/replay/Access.ts +46 -0
- package/src/replay/Format.test.ts +20 -0
- package/src/replay/Format.ts +14 -0
- package/src/replay/Replay.test.ts +887 -0
- package/src/replay/Replay.ts +669 -0
- package/src/replay/Trailers.test.ts +117 -0
- package/src/replay/Trailers.ts +213 -0
- package/src/replay/Tree.test.ts +25 -0
- package/src/replay/Tree.ts +45 -0
- package/src/replay/index.ts +11 -0
- package/src/steering/Blocks.fixture.ts +6 -0
- package/src/steering/Blocks.test.ts +167 -0
- package/src/steering/Blocks.ts +321 -0
- package/src/steering/CodeThreads.test.ts +127 -0
- package/src/steering/CodeThreads.ts +197 -0
- package/src/steering/Edits.test.ts +19 -0
- package/src/steering/Edits.ts +20 -0
- package/src/steering/Eol.test.ts +16 -0
- package/src/steering/Eol.ts +2 -0
- package/src/steering/FORMATS.md +58 -0
- package/src/steering/Footnotes.test.ts +977 -0
- package/src/steering/Footnotes.ts +661 -0
- package/src/steering/Inline.fixture.ts +7 -0
- package/src/steering/Inline.test.ts +227 -0
- package/src/steering/Inline.ts +195 -0
- package/src/steering/MarkdownTree.test.ts +247 -0
- package/src/steering/MarkdownTree.ts +260 -0
- package/src/steering/Outline.ts +19 -0
- package/src/steering/SteeringFormat.test.ts +91 -0
- package/src/steering/SteeringFormat.ts +267 -0
- package/src/steering/SteeringFormats.test.ts +163 -0
- package/src/steering/Threads.ts +163 -0
- package/src/steering/contract.test.ts +264 -0
- package/src/steering/fixtures/code-threads/cache.stripped.ts.txt +6 -0
- package/src/steering/fixtures/code-threads/cache.ts.md +15 -0
- package/src/steering/fixtures/free-form/notes.md +12 -0
- package/src/steering/fixtures/free-form/notes.read.json +42 -0
- package/src/steering/fixtures/qa/answered.md +23 -0
- package/src/steering/fixtures/qa/answered.read.json +119 -0
- package/src/steering/fixtures/qa/requirements.md +160 -0
- package/src/steering/fixtures/qa/requirements.read.json +475 -0
- package/src/steering/fixtures/review/review.md +76 -0
- package/src/steering/fixtures/review/review.read.json +237 -0
- package/src/steering/fixtures/review/ticked.md +23 -0
- package/src/steering/fixtures/review/ticked.read.json +99 -0
- package/src/steering/fixtures/review-notes/baseline.md +14 -0
- package/src/steering/fixtures/review-notes/noted.md +19 -0
- package/src/steering/freeform.test.ts +311 -0
- package/src/steering/freeform.ts +148 -0
- package/src/steering/index.test.ts +62 -0
- package/src/steering/index.ts +140 -0
- package/src/steering/qa.test.ts +2160 -0
- package/src/steering/qa.ts +1104 -0
- package/src/steering/review.test.ts +1829 -0
- package/src/steering/review.ts +1180 -0
- package/src/step/LandStep.ts +31 -0
- package/src/step/RepoSnapshot.ts +29 -0
- package/src/step/accessRefusal.test.ts +58 -0
- package/src/step/accessRefusal.ts +26 -0
- package/src/step/index.ts +5 -0
- package/src/step/planStart.test.ts +202 -0
- package/src/step/planStart.ts +167 -0
- package/src/step/planStep.test.ts +104 -0
- package/src/step/planStep.ts +89 -0
- package/src/step/snapshot.fixture.ts +28 -0
- package/src/testkit/EmittedScriptRecognizer.test.ts +639 -0
- package/src/testkit/EmittedScriptRecognizer.ts +642 -0
- package/src/testkit/FakeGitOperations.ts +95 -0
- package/src/testkit/GitTiers.test.ts +11 -0
- package/src/testkit/GitTiers.ts +1052 -0
- package/src/testkit/InMemRepo.ts +467 -0
- package/src/testkit/Layers.test.ts +58 -0
- package/src/testkit/Layers.ts +241 -0
- package/src/testkit/StrictGitOperations.ts +31 -0
- package/src/testkit/TestRepo.test.ts +119 -0
- package/src/testkit/TestRepo.ts +274 -0
- package/src/testkit/cliIo.ts +36 -0
- package/src/testkit/index.ts +9 -0
- package/src/testkit/steps.ts +177 -0
- package/src/testkit/testkit.feature +65 -0
- package/src/wire/BeatDocument.test.ts +419 -0
- package/src/wire/BeatDocument.ts +114 -0
- package/src/wire/BeatPlain.test.ts +113 -0
- package/src/wire/BeatPlain.ts +127 -0
- package/src/wire/BeatStatus.test.ts +59 -0
- package/src/wire/BeatStatus.ts +81 -0
- package/src/wire/Demand.test.ts +142 -0
- package/src/wire/Demand.ts +108 -0
- package/src/wire/ExitCodes.test.ts +21 -0
- package/src/wire/ExitCodes.ts +16 -0
- package/src/wire/Glob.ts +29 -0
- package/src/wire/Judge.ts +22 -0
- package/src/wire/Land.test.ts +120 -0
- package/src/wire/Land.ts +75 -0
- package/src/wire/Select.test.ts +148 -0
- package/src/wire/Select.ts +78 -0
- package/src/wire/Sessions.test.ts +59 -0
- package/src/wire/Sessions.ts +39 -0
- package/src/wire/WireSchema.test.ts +162 -0
- package/src/wire/constants.ts +22 -0
- package/src/wire/golden/1/beat-capture.json +34 -0
- package/src/wire/golden/1/beat-message.json +34 -0
- package/src/wire/golden/1/beat-prompt.json +44 -0
- package/src/wire/golden/1/beat-script.json +34 -0
- package/src/wire/golden/1/beat-stalled.json +34 -0
- package/src/wire/golden/1/judge.json +14 -0
- package/src/wire/golden/1/land-noop.json +10 -0
- package/src/wire/golden/1/land.json +10 -0
- package/src/wire/index.ts +12 -0
- package/src/wire/types.ts +46 -0
- package/src/workflow/Diagnostic.test.ts +93 -0
- package/src/workflow/Diagnostic.ts +54 -0
- package/src/workflow/SteeringFormats.test.ts +27 -0
- package/src/workflow/SteeringFormats.ts +6 -0
- package/src/workflow/Workflow.ts +77 -0
- package/src/workflow/compile.test.ts +53 -0
- package/src/workflow/compile.ts +56 -0
- package/src/workflow/discovery.ts +122 -0
- package/src/workflow/fingerprint.test.ts +52 -0
- package/src/workflow/fingerprint.ts +53 -0
- package/src/workflow/index.ts +33 -0
- package/src/workflow/load.test.ts +1017 -0
- package/src/workflow/load.ts +836 -0
- package/src/workflow/skills.test.ts +109 -0
- package/src/workflow/skills.ts +90 -0
- package/src/workflow/vars.ts +33 -0
- package/src/workflows/feature/feature.test.ts +27 -33
- package/src/workflows/feature/index.ts +10 -0
- package/src/workflows/fix/fix.feature +80 -0
- package/src/workflows/fix/fix.test.ts +58 -78
- package/src/workflows/fix/index.ts +6 -0
- package/src/workflows/index.ts +2 -10
- package/src/workflows/review/index.ts +6 -0
- package/src/workflows/review/review.test.ts +85 -29
- package/src/workflows/review/review.ts +3 -1
- package/src/workflows/shared/config.fixture.ts +12 -0
- package/src/workflows/shared/reserved.ts +11 -0
- package/src/workflows/shared/workflows.steps.ts +7 -0
|
@@ -0,0 +1,1829 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import { FOOTNOTE_ACTION_TITLE, steeringFormatFor, viewOf } from "./index.js"
|
|
3
|
+
import { parseReviewDoc } from "./review.js"
|
|
4
|
+
import { getParseCount } from "./index.js"
|
|
5
|
+
|
|
6
|
+
describe("parseReviewDoc", () => {
|
|
7
|
+
it("parses a well-formed review with one chunk, no explanations", () => {
|
|
8
|
+
const content = [
|
|
9
|
+
"# Review: abc1234",
|
|
10
|
+
"",
|
|
11
|
+
"<!-- base: abc1234def5678901234567890123456789abcd -->",
|
|
12
|
+
"",
|
|
13
|
+
"## Add calculator",
|
|
14
|
+
"",
|
|
15
|
+
"New add function for the calculator.",
|
|
16
|
+
"",
|
|
17
|
+
"- [ ] ./src/calc.ts#1-1",
|
|
18
|
+
"- [ ] ./src/calc.ts#5-9",
|
|
19
|
+
"",
|
|
20
|
+
].join("\n")
|
|
21
|
+
|
|
22
|
+
expect(parseReviewDoc(content)).toEqual({
|
|
23
|
+
shortHash: "abc1234",
|
|
24
|
+
fullHash: "abc1234def5678901234567890123456789abcd",
|
|
25
|
+
changesets: [
|
|
26
|
+
{
|
|
27
|
+
title: "Add calculator",
|
|
28
|
+
headingLine: 4,
|
|
29
|
+
description: "New add function for the calculator.",
|
|
30
|
+
descriptionInline: [{ kind: "text", value: "New add function for the calculator." }],
|
|
31
|
+
descriptionNodes: [
|
|
32
|
+
{
|
|
33
|
+
title: "New add function for the calculator.",
|
|
34
|
+
anchor: { kind: "paragraph", line: 6 },
|
|
35
|
+
block: {
|
|
36
|
+
kind: "paragraph",
|
|
37
|
+
inline: [{ kind: "text", value: "New add function for the calculator." }],
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
],
|
|
41
|
+
files: [
|
|
42
|
+
{
|
|
43
|
+
path: "./src/calc.ts",
|
|
44
|
+
line: 1,
|
|
45
|
+
rangeEnd: 1,
|
|
46
|
+
checked: false,
|
|
47
|
+
sourceLine: 8,
|
|
48
|
+
endLine: 8,
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
path: "./src/calc.ts",
|
|
52
|
+
line: 5,
|
|
53
|
+
rangeEnd: 9,
|
|
54
|
+
checked: false,
|
|
55
|
+
sourceLine: 9,
|
|
56
|
+
endLine: 9,
|
|
57
|
+
},
|
|
58
|
+
],
|
|
59
|
+
},
|
|
60
|
+
],
|
|
61
|
+
findings: [],
|
|
62
|
+
})
|
|
63
|
+
})
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
describe("parseReviewDoc — pointer range grammar", () => {
|
|
67
|
+
const pointerLine = (line: string) =>
|
|
68
|
+
[
|
|
69
|
+
"# Review: abc1234",
|
|
70
|
+
"",
|
|
71
|
+
"<!-- base: abc1234def5678901234567890123456789abcd -->",
|
|
72
|
+
"",
|
|
73
|
+
"## Chunk",
|
|
74
|
+
"",
|
|
75
|
+
`- [ ] ${line}`,
|
|
76
|
+
"",
|
|
77
|
+
].join("\n")
|
|
78
|
+
|
|
79
|
+
const fileOf = (content: string) => parseReviewDoc(content).changesets[0]!.files[0]!
|
|
80
|
+
|
|
81
|
+
it("parses '#42-70' to path, start 42, end 70", () => {
|
|
82
|
+
const file = fileOf(pointerLine("./path/to/file.ts#42-70 does a thing"))
|
|
83
|
+
expect(file.path).toBe("./path/to/file.ts")
|
|
84
|
+
expect(file.rangeEnd).toBe(70)
|
|
85
|
+
expect(file.line).toBe(42)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it("takes the LAST '#' as the range separator — a '#' or '-' earlier in the path is never read as one", () => {
|
|
89
|
+
const file = fileOf(pointerLine("./a-b/c#d#1-9 note"))
|
|
90
|
+
expect(file.path).toBe("./a-b/c#d")
|
|
91
|
+
expect(file.line).toBe(1)
|
|
92
|
+
expect(file.rangeEnd).toBe(9)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it("a token with no '#' at all parses as the whole path, no range and no line", () => {
|
|
96
|
+
const file = fileOf(pointerLine("./some-file.ts note"))
|
|
97
|
+
expect(file.path).toBe("./some-file.ts")
|
|
98
|
+
expect(file.rangeEnd).toBeUndefined()
|
|
99
|
+
expect(file.line).toBeUndefined()
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it("a bare '#42' parses as path './x.ts' with line 42 — never a path literally named 'x.ts#42'", () => {
|
|
103
|
+
const file = fileOf(pointerLine("./x.ts#42 note"))
|
|
104
|
+
expect(file.path).toBe("./x.ts")
|
|
105
|
+
expect(file.line).toBe(42)
|
|
106
|
+
expect(file.rangeEnd).toBeUndefined()
|
|
107
|
+
})
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
const review = steeringFormatFor("review")!
|
|
111
|
+
|
|
112
|
+
const doc = (lines: readonly string[]): string => lines.join("\n")
|
|
113
|
+
|
|
114
|
+
const HEADER = "# Review: abc123"
|
|
115
|
+
const BASE = "<!-- base: 0000000000000000000000000000000000000000 -->"
|
|
116
|
+
|
|
117
|
+
describe("review — structure (validate)", () => {
|
|
118
|
+
it("parses a well-formed review with one chunk, no explanations, cleanly", () => {
|
|
119
|
+
const content = doc([HEADER, "", BASE, "", "## Chunk", "", "- [ ] ./a.ts#1-1 does a thing", ""])
|
|
120
|
+
expect(review.validate(content)).toEqual([])
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it("errors when the header is missing", () => {
|
|
124
|
+
const content = doc([BASE, "", "## Chunk", "", "- [ ] ./a.ts#1 does a thing", ""])
|
|
125
|
+
expect(review.validate(content)).toContainEqual(
|
|
126
|
+
expect.objectContaining({
|
|
127
|
+
message: "Missing or malformed '# Review: <hash>' header as the document's first line",
|
|
128
|
+
}),
|
|
129
|
+
)
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it("errors when the base comment is missing", () => {
|
|
133
|
+
const content = doc([HEADER, "", "## Chunk", "", "- [ ] ./a.ts#1 does a thing", ""])
|
|
134
|
+
expect(review.validate(content)).toContainEqual(
|
|
135
|
+
expect.objectContaining({ message: "Missing '<!-- base: <hash> -->' comment" }),
|
|
136
|
+
)
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it("errors when there are no chunks at all", () => {
|
|
140
|
+
const content = doc([HEADER, "", BASE, ""])
|
|
141
|
+
expect(review.validate(content)).toContainEqual(
|
|
142
|
+
expect.objectContaining({ message: "REVIEW.md has no '##' chunks" }),
|
|
143
|
+
)
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it("collects all applicable errors at once for a fully malformed document", () => {
|
|
147
|
+
const findings = review.validate("")
|
|
148
|
+
expect(findings.length).toBeGreaterThanOrEqual(2)
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it("keeps a hyphenated path whole and its #line, instead of splitting at the first hyphen", () => {
|
|
152
|
+
const content = doc([HEADER, "", BASE, "", "## Chunk", "", "- [ ] ./my-file.ts#42-70 note", ""])
|
|
153
|
+
expect(review.validate(content)).toEqual([])
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it("keeps a # not followed by digits in the path, with no line parsed, and no findings", () => {
|
|
157
|
+
const content = doc([HEADER, "", BASE, "", "## Chunk", "", "- [ ] ./file#hash.ts note", ""])
|
|
158
|
+
expect(review.validate(content)).toEqual([])
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it("still refuses a bare box, a non-./ path, and a ./ with nothing after it — no pointer, so chunk is empty", () => {
|
|
162
|
+
const content = doc([HEADER, "", BASE, "", "## Chunk", "", "- [ ] just prose here", ""])
|
|
163
|
+
expect(review.validate(content)).toContainEqual(
|
|
164
|
+
expect.objectContaining({ message: 'Chunk "Chunk" has no file pointers' }),
|
|
165
|
+
)
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it("an ordinary chunk still isn't exempted merely by carrying prose that resembles an assumption", () => {
|
|
169
|
+
const content = doc([
|
|
170
|
+
HEADER,
|
|
171
|
+
"",
|
|
172
|
+
BASE,
|
|
173
|
+
"",
|
|
174
|
+
"## Not Assumptions",
|
|
175
|
+
"",
|
|
176
|
+
"- some inferred note with no pointer",
|
|
177
|
+
"",
|
|
178
|
+
])
|
|
179
|
+
expect(review.validate(content)).toContainEqual(
|
|
180
|
+
expect.objectContaining({ message: 'Chunk "Not Assumptions" has no file pointers' }),
|
|
181
|
+
)
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
it("a literal Assumptions chunk with no file pointers is a finding too — no title is exempt", () => {
|
|
185
|
+
const content = doc([
|
|
186
|
+
HEADER,
|
|
187
|
+
"",
|
|
188
|
+
BASE,
|
|
189
|
+
"",
|
|
190
|
+
"## Assumptions",
|
|
191
|
+
"",
|
|
192
|
+
"- some inferred note with no pointer",
|
|
193
|
+
"",
|
|
194
|
+
])
|
|
195
|
+
expect(review.validate(content)).toContainEqual(
|
|
196
|
+
expect.objectContaining({ message: 'Chunk "Assumptions" has no file pointers' }),
|
|
197
|
+
)
|
|
198
|
+
})
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
describe("parseReviewDoc — a chunk's description is its own prose, never a node containing a hunk pointer", () => {
|
|
202
|
+
it("yields an empty description when the only pointers sit inside a blockquote (no top-level `list`)", () => {
|
|
203
|
+
const content = [
|
|
204
|
+
"# Review: abc1234",
|
|
205
|
+
"<!-- base: abc1234def5678901234567890123456789abcd -->",
|
|
206
|
+
"",
|
|
207
|
+
"## Chunk",
|
|
208
|
+
"",
|
|
209
|
+
"> - [ ] ./src/a.ts#1 — thing",
|
|
210
|
+
"",
|
|
211
|
+
].join("\n")
|
|
212
|
+
const result = parseReviewDoc(content)
|
|
213
|
+
expect(result.changesets[0]?.description).toBe("")
|
|
214
|
+
expect(result.changesets[0]?.files[0]?.path).toBe("./src/a.ts")
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
it("the pointer run still starts at the real list even when a four-space-indented line above it looks like a pointer (a code block, not a list)", () => {
|
|
218
|
+
const content = [
|
|
219
|
+
"# Review: abc1234",
|
|
220
|
+
"<!-- base: abc1234def5678901234567890123456789abcd -->",
|
|
221
|
+
"",
|
|
222
|
+
"## Chunk",
|
|
223
|
+
"",
|
|
224
|
+
" - [ ] ./src/a.ts#1",
|
|
225
|
+
"",
|
|
226
|
+
"- [ ] ./src/b.ts#1",
|
|
227
|
+
"",
|
|
228
|
+
].join("\n")
|
|
229
|
+
const result = parseReviewDoc(content)
|
|
230
|
+
// The indented line parses as a `code` node, not a list — it never
|
|
231
|
+
// registers as a pointer, so the real pointer run still starts (and
|
|
232
|
+
// stays) at `./src/b.ts#1` below. The code node itself, however, now
|
|
233
|
+
// sits in the leading run like any other node kind, so its own text
|
|
234
|
+
// surfaces as the description — this is Task 1's own drop fix, not a
|
|
235
|
+
// regression in where the pointer run starts.
|
|
236
|
+
expect(result.changesets[0]?.description).toBe("- [ ] ./src/a.ts#1")
|
|
237
|
+
expect(result.changesets[0]?.files).toEqual([
|
|
238
|
+
{ path: "./src/b.ts", line: 1, checked: false, sourceLine: 7, endLine: 7 },
|
|
239
|
+
])
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
it("a `###` sub-heading before the pointers now yields real description text", () => {
|
|
243
|
+
const content = [
|
|
244
|
+
"# Review: abc1234",
|
|
245
|
+
"<!-- base: abc1234def5678901234567890123456789abcd -->",
|
|
246
|
+
"",
|
|
247
|
+
"## Chunk",
|
|
248
|
+
"",
|
|
249
|
+
"### Sub",
|
|
250
|
+
"",
|
|
251
|
+
"- [ ] ./src/a.ts#1",
|
|
252
|
+
"",
|
|
253
|
+
].join("\n")
|
|
254
|
+
const result = parseReviewDoc(content)
|
|
255
|
+
expect(result.changesets[0]?.description).toBe("### Sub")
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
it("an HTML comment before the pointers now yields real description text", () => {
|
|
259
|
+
const content = [
|
|
260
|
+
"# Review: abc1234",
|
|
261
|
+
"<!-- base: abc1234def5678901234567890123456789abcd -->",
|
|
262
|
+
"",
|
|
263
|
+
"## Chunk",
|
|
264
|
+
"",
|
|
265
|
+
"<!-- x -->",
|
|
266
|
+
"",
|
|
267
|
+
"- [ ] ./src/a.ts#1",
|
|
268
|
+
"",
|
|
269
|
+
].join("\n")
|
|
270
|
+
const result = parseReviewDoc(content)
|
|
271
|
+
expect(result.changesets[0]?.description).toBe("<!-- x -->")
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
it("still yields real leading prose as the description", () => {
|
|
275
|
+
const content = [
|
|
276
|
+
"# Review: abc1234",
|
|
277
|
+
"<!-- base: abc1234def5678901234567890123456789abcd -->",
|
|
278
|
+
"",
|
|
279
|
+
"## Chunk",
|
|
280
|
+
"",
|
|
281
|
+
"Real prose about this chunk.",
|
|
282
|
+
"",
|
|
283
|
+
"- [ ] ./src/a.ts#1",
|
|
284
|
+
"",
|
|
285
|
+
].join("\n")
|
|
286
|
+
const result = parseReviewDoc(content)
|
|
287
|
+
expect(result.changesets[0]?.description).toBe("Real prose about this chunk.")
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
it("keeps every block kind in the leading run — heading, fenced code, HTML block and thematic break alike — in document order in `descriptionNodes`", () => {
|
|
291
|
+
const content = [
|
|
292
|
+
"# Review: abc1234",
|
|
293
|
+
"<!-- base: abc1234def5678901234567890123456789abcd -->",
|
|
294
|
+
"",
|
|
295
|
+
"## Chunk",
|
|
296
|
+
"",
|
|
297
|
+
"### Sub",
|
|
298
|
+
"",
|
|
299
|
+
"```ts",
|
|
300
|
+
"const x = 1",
|
|
301
|
+
"```",
|
|
302
|
+
"",
|
|
303
|
+
"<!-- a comment -->",
|
|
304
|
+
"",
|
|
305
|
+
"---",
|
|
306
|
+
"",
|
|
307
|
+
"- [ ] ./src/a.ts#1",
|
|
308
|
+
"",
|
|
309
|
+
].join("\n")
|
|
310
|
+
const result = parseReviewDoc(content)
|
|
311
|
+
expect(result.changesets[0]?.descriptionNodes.map((n) => n.block?.kind ?? n.title)).toEqual([
|
|
312
|
+
"heading",
|
|
313
|
+
"code",
|
|
314
|
+
"<!-- a comment -->",
|
|
315
|
+
"---",
|
|
316
|
+
])
|
|
317
|
+
})
|
|
318
|
+
|
|
319
|
+
it("excludes a `footnoteDefinition` from both `description` and `descriptionNodes`, even inside the leading run", () => {
|
|
320
|
+
const content = [
|
|
321
|
+
"# Review: abc1234",
|
|
322
|
+
"<!-- base: abc1234def5678901234567890123456789abcd -->",
|
|
323
|
+
"",
|
|
324
|
+
"## Chunk",
|
|
325
|
+
"",
|
|
326
|
+
"Real prose about this chunk.[^1]",
|
|
327
|
+
"",
|
|
328
|
+
"[^1]: a footnote body",
|
|
329
|
+
"",
|
|
330
|
+
"- [ ] ./src/a.ts#1",
|
|
331
|
+
"",
|
|
332
|
+
].join("\n")
|
|
333
|
+
const result = parseReviewDoc(content)
|
|
334
|
+
expect(result.changesets[0]?.description).toBe("Real prose about this chunk.")
|
|
335
|
+
expect(result.changesets[0]?.descriptionNodes).toHaveLength(1)
|
|
336
|
+
expect(result.changesets[0]?.descriptionNodes[0]?.block?.kind).toBe("paragraph")
|
|
337
|
+
})
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
describe("parseReviewDoc — a same-line note (trailing the pointer on its own line)", () => {
|
|
341
|
+
it("parses as a pointer, with path, #line, and checked state intact, and the trailing text as its note", () => {
|
|
342
|
+
const content = [
|
|
343
|
+
"# Review: abc1234",
|
|
344
|
+
"<!-- base: abc1234def5678901234567890123456789abcd -->",
|
|
345
|
+
"",
|
|
346
|
+
"## Add thing.ts",
|
|
347
|
+
"",
|
|
348
|
+
"- [x] ./src/Edge.ts#42 — what this hunk does",
|
|
349
|
+
"",
|
|
350
|
+
].join("\n")
|
|
351
|
+
const result = parseReviewDoc(content)
|
|
352
|
+
expect(result.changesets[0]?.files[0]).toEqual({
|
|
353
|
+
path: "./src/Edge.ts",
|
|
354
|
+
line: 42,
|
|
355
|
+
checked: true,
|
|
356
|
+
note: "what this hunk does",
|
|
357
|
+
noteInline: [{ kind: "text", value: "what this hunk does" }],
|
|
358
|
+
sourceLine: 5,
|
|
359
|
+
endLine: 5,
|
|
360
|
+
})
|
|
361
|
+
})
|
|
362
|
+
})
|
|
363
|
+
|
|
364
|
+
describe("parseReviewDoc — spec-feedback regression: a nested list item's own text is not dropped from descriptionInline (R2/T3)", () => {
|
|
365
|
+
it("descriptionInline carries a nested item's own text, matching what the flattened description string already carries", () => {
|
|
366
|
+
const content = [
|
|
367
|
+
"# Review: abc1234",
|
|
368
|
+
"<!-- base: abc1234def5678901234567890123456789abcd -->",
|
|
369
|
+
"",
|
|
370
|
+
"## Chunk",
|
|
371
|
+
"",
|
|
372
|
+
"- item one",
|
|
373
|
+
"- item two",
|
|
374
|
+
" - nested",
|
|
375
|
+
"",
|
|
376
|
+
// A `*`-marker list, deliberately: CommonMark merges adjacent
|
|
377
|
+
// SAME-marker bullet lists into one node even across a blank line, so
|
|
378
|
+
// a `-`-marker pointer list here would swallow the description list
|
|
379
|
+
// above into itself (leaving nothing before the first pointer) — the
|
|
380
|
+
// `*` keeps the description its own separate leading-run node.
|
|
381
|
+
"* [ ] ./a.ts#1",
|
|
382
|
+
"",
|
|
383
|
+
].join("\n")
|
|
384
|
+
const result = parseReviewDoc(content)
|
|
385
|
+
const chunk = result.changesets[0]
|
|
386
|
+
expect(chunk?.description).toContain("nested")
|
|
387
|
+
const joined = (chunk?.descriptionInline ?? [])
|
|
388
|
+
.map((n) => ("value" in n ? n.value : ""))
|
|
389
|
+
.join("")
|
|
390
|
+
expect(joined).toContain("nested")
|
|
391
|
+
})
|
|
392
|
+
})
|
|
393
|
+
|
|
394
|
+
describe("parseReviewDoc — spec-feedback regression: a nested list item's own text is not dropped from a hunk's noteInline either", () => {
|
|
395
|
+
it("noteInline carries a nested item's own text inside a blockquote sibling, matching the flattened note string", () => {
|
|
396
|
+
// A nested LIST directly under the pointer item is excluded from a
|
|
397
|
+
// hunk's own note by design (`hunkNote`'s own `otherChildren` filter —
|
|
398
|
+
// that shape is a NESTED HUNK pointer list, never prose). A blockquote
|
|
399
|
+
// sibling wrapping a list, though, is ordinary note prose, and its own
|
|
400
|
+
// nested list item is exactly the shape this regression covers.
|
|
401
|
+
const content = [
|
|
402
|
+
"# Review: abc1234",
|
|
403
|
+
"<!-- base: abc1234def5678901234567890123456789abcd -->",
|
|
404
|
+
"",
|
|
405
|
+
"## Chunk",
|
|
406
|
+
"",
|
|
407
|
+
"- [ ] ./a.ts#1",
|
|
408
|
+
"",
|
|
409
|
+
" > - item one",
|
|
410
|
+
" > - nested",
|
|
411
|
+
"",
|
|
412
|
+
].join("\n")
|
|
413
|
+
const result = parseReviewDoc(content)
|
|
414
|
+
const file = result.changesets[0]?.files[0]
|
|
415
|
+
expect(file?.note ?? "").toContain("nested")
|
|
416
|
+
const joined = (file?.noteInline ?? []).map((n) => ("value" in n ? n.value : "")).join("")
|
|
417
|
+
expect(joined).toContain("nested")
|
|
418
|
+
})
|
|
419
|
+
})
|
|
420
|
+
|
|
421
|
+
describe("parseReviewDoc — spec-feedback regression: a real image through the parser collapses to alt text on detailInline (R2/T5)", () => {
|
|
422
|
+
it("a chunk description's image never reaches detailInline as an 'image' kind — collapsed server-side, not merely absent from a hand-built fixture", () => {
|
|
423
|
+
const content = [
|
|
424
|
+
"# Review: abc1234",
|
|
425
|
+
"<!-- base: abc1234def5678901234567890123456789abcd -->",
|
|
426
|
+
"",
|
|
427
|
+
"## Chunk",
|
|
428
|
+
"",
|
|
429
|
+
"See  here.",
|
|
430
|
+
"",
|
|
431
|
+
"- [ ] ./a.ts#1",
|
|
432
|
+
"",
|
|
433
|
+
].join("\n")
|
|
434
|
+
const result = parseReviewDoc(content)
|
|
435
|
+
const inline = result.changesets[0]?.descriptionInline ?? []
|
|
436
|
+
expect(inline.some((n) => n.kind === "image")).toBe(false)
|
|
437
|
+
const joined = inline.map((n) => ("value" in n ? n.value : "")).join("")
|
|
438
|
+
expect(joined).toBe("See a diagram here.")
|
|
439
|
+
})
|
|
440
|
+
})
|
|
441
|
+
|
|
442
|
+
describe("parseReviewDoc — spec-feedback regression: raw HTML stays inert text, never an empty detailInline", () => {
|
|
443
|
+
it("a hunk's own note carries a block-level <div> as one text node, never []", () => {
|
|
444
|
+
const content = [
|
|
445
|
+
"# Review: abc1234",
|
|
446
|
+
"<!-- base: abc1234def5678901234567890123456789abcd -->",
|
|
447
|
+
"",
|
|
448
|
+
"## Chunk",
|
|
449
|
+
"",
|
|
450
|
+
"- [ ] ./a.ts#1",
|
|
451
|
+
"",
|
|
452
|
+
" <div>raw</div>",
|
|
453
|
+
"",
|
|
454
|
+
].join("\n")
|
|
455
|
+
const result = parseReviewDoc(content)
|
|
456
|
+
const file = result.changesets[0]?.files[0]
|
|
457
|
+
expect(file?.note).toBe("<div>raw</div>")
|
|
458
|
+
expect(file?.noteInline).toEqual([{ kind: "text", value: "<div>raw</div>" }])
|
|
459
|
+
})
|
|
460
|
+
|
|
461
|
+
it("a chunk's own leading-run description carries a block-level <div> as one text node, never []", () => {
|
|
462
|
+
const content = [
|
|
463
|
+
"# Review: abc1234",
|
|
464
|
+
"<!-- base: abc1234def5678901234567890123456789abcd -->",
|
|
465
|
+
"",
|
|
466
|
+
"## Chunk",
|
|
467
|
+
"",
|
|
468
|
+
"<div>raw</div>",
|
|
469
|
+
"",
|
|
470
|
+
"- [ ] ./a.ts#1",
|
|
471
|
+
"",
|
|
472
|
+
].join("\n")
|
|
473
|
+
const result = parseReviewDoc(content)
|
|
474
|
+
const chunk = result.changesets[0]
|
|
475
|
+
expect(chunk?.description).toBe("<div>raw</div>")
|
|
476
|
+
expect(chunk?.descriptionInline).toEqual([{ kind: "text", value: "<div>raw</div>" }])
|
|
477
|
+
})
|
|
478
|
+
})
|
|
479
|
+
|
|
480
|
+
describe("review — same-line note", () => {
|
|
481
|
+
it("produces zero validation errors for an ordinary same-line note", () => {
|
|
482
|
+
const content = doc([
|
|
483
|
+
HEADER,
|
|
484
|
+
"",
|
|
485
|
+
BASE,
|
|
486
|
+
"",
|
|
487
|
+
"## Chunk",
|
|
488
|
+
"",
|
|
489
|
+
"- [ ] ./a.ts#1-1 explains the change",
|
|
490
|
+
"",
|
|
491
|
+
])
|
|
492
|
+
expect(review.validate(content)).toEqual([])
|
|
493
|
+
})
|
|
494
|
+
|
|
495
|
+
it("does not report 'no file pointers' for a chunk whose only pointer carries a same-line note", () => {
|
|
496
|
+
const content = doc([HEADER, "", BASE, "", "## Chunk", "", "- [ ] ./a.ts#1 note here", ""])
|
|
497
|
+
expect(review.validate(content)).not.toContainEqual(
|
|
498
|
+
expect.objectContaining({ message: expect.stringContaining("no file pointers") }),
|
|
499
|
+
)
|
|
500
|
+
})
|
|
501
|
+
|
|
502
|
+
it("a hyphen inside a filename is never read as a separator", () => {
|
|
503
|
+
const content = doc([
|
|
504
|
+
HEADER,
|
|
505
|
+
"",
|
|
506
|
+
BASE,
|
|
507
|
+
"",
|
|
508
|
+
"## Chunk",
|
|
509
|
+
"",
|
|
510
|
+
"- [ ] ./my-file.ts#1-1 — dash note",
|
|
511
|
+
"",
|
|
512
|
+
])
|
|
513
|
+
expect(review.validate(content)).toEqual([])
|
|
514
|
+
})
|
|
515
|
+
|
|
516
|
+
it("strips an em dash, en dash, or hyphen run from the same-line segment", () => {
|
|
517
|
+
const cases = ["—", "–", "-"]
|
|
518
|
+
for (const dash of cases) {
|
|
519
|
+
const content = doc([
|
|
520
|
+
HEADER,
|
|
521
|
+
"",
|
|
522
|
+
BASE,
|
|
523
|
+
"",
|
|
524
|
+
"## Chunk",
|
|
525
|
+
"",
|
|
526
|
+
`- [ ] ./a.ts#1-1 ${dash} note text`,
|
|
527
|
+
"",
|
|
528
|
+
])
|
|
529
|
+
expect(review.validate(content)).toEqual([])
|
|
530
|
+
}
|
|
531
|
+
})
|
|
532
|
+
|
|
533
|
+
it("joins a same-line segment with below-pointer lines, single space, same-line segment first", () => {
|
|
534
|
+
const content = doc([
|
|
535
|
+
HEADER,
|
|
536
|
+
"",
|
|
537
|
+
BASE,
|
|
538
|
+
"",
|
|
539
|
+
"## Chunk",
|
|
540
|
+
"",
|
|
541
|
+
"- [ ] ./a.ts#1-1 same-line note",
|
|
542
|
+
" more detail below",
|
|
543
|
+
"",
|
|
544
|
+
])
|
|
545
|
+
expect(review.validate(content)).toEqual([])
|
|
546
|
+
})
|
|
547
|
+
})
|
|
548
|
+
|
|
549
|
+
describe("review — a note starting with a second pointer is a positioned finding", () => {
|
|
550
|
+
it("a same-line note whose first token is itself a pointer token yields one finding at the pointer's own sourceLine", () => {
|
|
551
|
+
const content = doc([HEADER, "", BASE, "", "## Chunk", "", "- [ ] ./a.ts#1 ./b.ts#2", ""])
|
|
552
|
+
const findings = review.validate(content)
|
|
553
|
+
expect(findings).toContainEqual(
|
|
554
|
+
expect.objectContaining({ line: 6, message: expect.stringContaining("second pointer") }),
|
|
555
|
+
)
|
|
556
|
+
})
|
|
557
|
+
|
|
558
|
+
it("still fires when a separator sits between the two pointers", () => {
|
|
559
|
+
const content = doc([HEADER, "", BASE, "", "## Chunk", "", "- [ ] ./a.ts#1 — ./b.ts#2", ""])
|
|
560
|
+
expect(review.validate(content).some((f) => f.message.includes("second pointer"))).toBe(true)
|
|
561
|
+
})
|
|
562
|
+
|
|
563
|
+
it("does not fire for a below-pointer line opening with a bare path — only the same-line segment is checked", () => {
|
|
564
|
+
const content = doc([
|
|
565
|
+
HEADER,
|
|
566
|
+
"",
|
|
567
|
+
BASE,
|
|
568
|
+
"",
|
|
569
|
+
"## Chunk",
|
|
570
|
+
"",
|
|
571
|
+
"- [ ] ./a.ts#1 note",
|
|
572
|
+
" ./src/foo.ts is the caller",
|
|
573
|
+
"",
|
|
574
|
+
])
|
|
575
|
+
expect(review.validate(content).some((f) => f.message.includes("second pointer"))).toBe(false)
|
|
576
|
+
})
|
|
577
|
+
|
|
578
|
+
it("does not fire for a same-line note whose first word merely contains a dot", () => {
|
|
579
|
+
const content = doc([
|
|
580
|
+
HEADER,
|
|
581
|
+
"",
|
|
582
|
+
BASE,
|
|
583
|
+
"",
|
|
584
|
+
"## Chunk",
|
|
585
|
+
"",
|
|
586
|
+
"- [ ] ./a.ts#1 v1.2.3 released",
|
|
587
|
+
"",
|
|
588
|
+
])
|
|
589
|
+
expect(review.validate(content).some((f) => f.message.includes("second pointer"))).toBe(false)
|
|
590
|
+
})
|
|
591
|
+
|
|
592
|
+
it("does not fire for a bare './' token, reusing the minimum-path-length rule", () => {
|
|
593
|
+
const content = doc([HEADER, "", BASE, "", "## Chunk", "", "- [ ] ./a.ts#1 ./ trailing", ""])
|
|
594
|
+
expect(review.validate(content).some((f) => f.message.includes("second pointer"))).toBe(false)
|
|
595
|
+
})
|
|
596
|
+
|
|
597
|
+
it("two pointer tokens crammed onto one hunk's own line are refused", () => {
|
|
598
|
+
const content = doc([HEADER, "", BASE, "", "## Chunk", "", "- [ ] ./a.ts#1 ./b.ts#2", ""])
|
|
599
|
+
expect(
|
|
600
|
+
review.validate(content).some((f) => f.message.includes("note starts with a second pointer")),
|
|
601
|
+
).toBe(true)
|
|
602
|
+
})
|
|
603
|
+
|
|
604
|
+
it("renders the target as path#start-end when the first pointer carries a range", () => {
|
|
605
|
+
const content = doc([HEADER, "", BASE, "", "## Chunk", "", "- [ ] ./a.ts#1-9 ./b.ts#2", ""])
|
|
606
|
+
expect(review.validate(content).some((f) => f.message.includes("./a.ts#1-9's note"))).toBe(true)
|
|
607
|
+
})
|
|
608
|
+
})
|
|
609
|
+
|
|
610
|
+
describe("review — the line-without-a-range finding", () => {
|
|
611
|
+
it("a bare '#42' pointer produces exactly one finding, naming the pointer and its chunk", () => {
|
|
612
|
+
const content = doc([HEADER, "", BASE, "", "## Chunk", "", "- [ ] ./src/calc.ts#42 — note", ""])
|
|
613
|
+
const findings = review.validate(content)
|
|
614
|
+
expect(findings).toHaveLength(1)
|
|
615
|
+
expect(findings[0]!.message).toContain("./src/calc.ts#42")
|
|
616
|
+
expect(findings[0]!.message).toContain('Chunk "Chunk"')
|
|
617
|
+
})
|
|
618
|
+
|
|
619
|
+
it("the finding's line is the pointer's own 0-based sourceLine, and its range covers exactly the token", () => {
|
|
620
|
+
const content = doc([HEADER, "", BASE, "", "## Chunk", "", "- [ ] ./src/calc.ts#42 — note", ""])
|
|
621
|
+
const findings = review.validate(content)
|
|
622
|
+
expect(findings[0]!.line).toBe(6)
|
|
623
|
+
expect(findings[0]!.range).toEqual({
|
|
624
|
+
start: { line: 6, character: 6 },
|
|
625
|
+
end: { line: 6, character: 22 },
|
|
626
|
+
})
|
|
627
|
+
})
|
|
628
|
+
|
|
629
|
+
it("a '#42-70' range pointer produces zero findings", () => {
|
|
630
|
+
const content = doc([
|
|
631
|
+
HEADER,
|
|
632
|
+
"",
|
|
633
|
+
BASE,
|
|
634
|
+
"",
|
|
635
|
+
"## Chunk",
|
|
636
|
+
"",
|
|
637
|
+
"- [ ] ./src/calc.ts#42-70 — note",
|
|
638
|
+
"",
|
|
639
|
+
])
|
|
640
|
+
expect(review.validate(content)).toEqual([])
|
|
641
|
+
})
|
|
642
|
+
|
|
643
|
+
it("a pointer with no '#' at all produces zero findings", () => {
|
|
644
|
+
const content = doc([HEADER, "", BASE, "", "## Chunk", "", "- [ ] ./src/calc.ts — note", ""])
|
|
645
|
+
expect(review.validate(content)).toEqual([])
|
|
646
|
+
})
|
|
647
|
+
})
|
|
648
|
+
|
|
649
|
+
describe("review — additional structural edges", () => {
|
|
650
|
+
it("an entirely empty document reports the missing-header finding with no range (no first node to point at)", () => {
|
|
651
|
+
const findings = review.validate("")
|
|
652
|
+
expect(findings).toContainEqual({
|
|
653
|
+
message: "Missing or malformed '# Review: <hash>' header as the document's first line",
|
|
654
|
+
})
|
|
655
|
+
})
|
|
656
|
+
|
|
657
|
+
it("a bare pointer with nothing else on its line or below has no note and is valid", () => {
|
|
658
|
+
const content = doc([HEADER, "", BASE, "", "## Chunk", "", "- [ ] ./a.ts#1-1", ""])
|
|
659
|
+
expect(review.validate(content)).toEqual([])
|
|
660
|
+
})
|
|
661
|
+
|
|
662
|
+
it("the whole-chunk toggle action works from the last chunk (no next chunk to bound its end)", () => {
|
|
663
|
+
const content = doc([HEADER, "", BASE, "", "## Chunk", "", "- [ ] ./a.ts#1 note", ""])
|
|
664
|
+
const actions = review.actions(content, {
|
|
665
|
+
start: { line: 6, character: 2 },
|
|
666
|
+
end: { line: 6, character: 2 },
|
|
667
|
+
})
|
|
668
|
+
expect(actions.some((a) => a.title.startsWith("gtd: check all hunks"))).toBe(true)
|
|
669
|
+
})
|
|
670
|
+
|
|
671
|
+
it("a chunk description made of multiple prose nodes joins them with a space, footnote definitions excluded", () => {
|
|
672
|
+
const content = doc([
|
|
673
|
+
HEADER,
|
|
674
|
+
"",
|
|
675
|
+
BASE,
|
|
676
|
+
"",
|
|
677
|
+
"## Chunk",
|
|
678
|
+
"",
|
|
679
|
+
"First description line.",
|
|
680
|
+
"",
|
|
681
|
+
"Second description line.[^fn1]",
|
|
682
|
+
"",
|
|
683
|
+
"- [ ] ./a.ts#1-1 note",
|
|
684
|
+
"",
|
|
685
|
+
"[^fn1]:",
|
|
686
|
+
" Detail that is longer than eighty characters so it definitely wraps here nicely.",
|
|
687
|
+
"",
|
|
688
|
+
])
|
|
689
|
+
expect(review.validate(content)).toEqual([])
|
|
690
|
+
})
|
|
691
|
+
})
|
|
692
|
+
|
|
693
|
+
describe("review — inline segment resolution edge cases", () => {
|
|
694
|
+
it("a pointer alone on its own line, with the note only below, has no same-line segment to flag as a second pointer", () => {
|
|
695
|
+
const content = doc([
|
|
696
|
+
HEADER,
|
|
697
|
+
"",
|
|
698
|
+
BASE,
|
|
699
|
+
"",
|
|
700
|
+
"## Chunk",
|
|
701
|
+
"",
|
|
702
|
+
"- [ ] ./a.ts#1",
|
|
703
|
+
" ./b.ts#2 is unrelated prose",
|
|
704
|
+
])
|
|
705
|
+
expect(review.validate(content).some((f) => f.message.includes("second pointer"))).toBe(false)
|
|
706
|
+
})
|
|
707
|
+
|
|
708
|
+
it("a pointer with an empty paragraph (no text at all after it) has no findings", () => {
|
|
709
|
+
const content = doc([HEADER, "", BASE, "", "## Chunk", "", "- [ ] ./a.ts#1-1", ""])
|
|
710
|
+
expect(review.validate(content)).toEqual([])
|
|
711
|
+
})
|
|
712
|
+
})
|
|
713
|
+
|
|
714
|
+
describe("review — continuation-line dash stripping", () => {
|
|
715
|
+
it("strips a leading em dash or en dash from a continuation line", () => {
|
|
716
|
+
const content = doc([
|
|
717
|
+
HEADER,
|
|
718
|
+
"",
|
|
719
|
+
BASE,
|
|
720
|
+
"",
|
|
721
|
+
"## Chunk",
|
|
722
|
+
"",
|
|
723
|
+
"- [ ] ./a.ts#1-1",
|
|
724
|
+
" — continuation note",
|
|
725
|
+
"",
|
|
726
|
+
])
|
|
727
|
+
expect(review.validate(content)).toEqual([])
|
|
728
|
+
})
|
|
729
|
+
|
|
730
|
+
it("keeps a dash mid-sentence on a continuation line", () => {
|
|
731
|
+
const content = doc([
|
|
732
|
+
HEADER,
|
|
733
|
+
"",
|
|
734
|
+
BASE,
|
|
735
|
+
"",
|
|
736
|
+
"## Chunk",
|
|
737
|
+
"",
|
|
738
|
+
"- [ ] ./a.ts#1-1",
|
|
739
|
+
" a well-known dash mid-word",
|
|
740
|
+
"",
|
|
741
|
+
])
|
|
742
|
+
expect(review.validate(content)).toEqual([])
|
|
743
|
+
})
|
|
744
|
+
})
|
|
745
|
+
|
|
746
|
+
describe("review — outline", () => {
|
|
747
|
+
it("emits only headlines of chunks with an unchecked hunk, no children when no footnotes", () => {
|
|
748
|
+
const content = doc([HEADER, "", BASE, "", "## Chunk", "", "- [ ] ./a.ts#1 note", ""])
|
|
749
|
+
const outline = viewOf(review, content).outline
|
|
750
|
+
expect(outline).toHaveLength(1)
|
|
751
|
+
expect(outline[0]).not.toHaveProperty("children")
|
|
752
|
+
})
|
|
753
|
+
|
|
754
|
+
it("omits a fully-checked chunk's headline when it carries no footnotes", () => {
|
|
755
|
+
const content = doc([HEADER, "", BASE, "", "## Chunk", "", "- [x] ./a.ts#1 note", ""])
|
|
756
|
+
expect(viewOf(review, content).outline).toEqual([])
|
|
757
|
+
})
|
|
758
|
+
|
|
759
|
+
it("still includes a fully-checked chunk when it carries a footnote", () => {
|
|
760
|
+
const content = doc([
|
|
761
|
+
HEADER,
|
|
762
|
+
"",
|
|
763
|
+
BASE,
|
|
764
|
+
"",
|
|
765
|
+
"## Chunk",
|
|
766
|
+
"",
|
|
767
|
+
"- [x] ./a.ts#1 note[^fn1]",
|
|
768
|
+
"",
|
|
769
|
+
"[^fn1]:",
|
|
770
|
+
" Detail that is longer than eighty characters so it definitely wraps here nicely.",
|
|
771
|
+
"",
|
|
772
|
+
])
|
|
773
|
+
expect(viewOf(review, content).outline).toHaveLength(1)
|
|
774
|
+
})
|
|
775
|
+
|
|
776
|
+
it("the last chunk's outline range ends at its own last block, not the trailing blank line", () => {
|
|
777
|
+
const content = doc([HEADER, "", BASE, "", "## Chunk", "", "- [ ] ./a.ts#1 note", "", "", ""])
|
|
778
|
+
const node = viewOf(review, content).outline[0]!
|
|
779
|
+
expect(node.range.end.line).toBe(6)
|
|
780
|
+
})
|
|
781
|
+
|
|
782
|
+
it("an interior chunk's outline range ends at its own last block too", () => {
|
|
783
|
+
const content = doc([
|
|
784
|
+
HEADER,
|
|
785
|
+
"",
|
|
786
|
+
BASE,
|
|
787
|
+
"",
|
|
788
|
+
"## Chunk A",
|
|
789
|
+
"",
|
|
790
|
+
"- [ ] ./a.ts#1 note",
|
|
791
|
+
"",
|
|
792
|
+
"",
|
|
793
|
+
"## Chunk B",
|
|
794
|
+
"",
|
|
795
|
+
"- [ ] ./b.ts#1 note",
|
|
796
|
+
"",
|
|
797
|
+
])
|
|
798
|
+
const node = viewOf(review, content).outline[0]!
|
|
799
|
+
expect(node.range.end.line).toBe(6)
|
|
800
|
+
})
|
|
801
|
+
|
|
802
|
+
it("the last chunk's outline range still ends correctly with no trailing newline", () => {
|
|
803
|
+
const content = `${HEADER}\n\n${BASE}\n\n## Chunk\n\n- [ ] ./a.ts#1 note`
|
|
804
|
+
const node = viewOf(review, content).outline[0]!
|
|
805
|
+
expect(node.range.end.line).toBe(6)
|
|
806
|
+
})
|
|
807
|
+
})
|
|
808
|
+
|
|
809
|
+
describe("review — actions", () => {
|
|
810
|
+
it("offers a single-hunk toggle when the range sits on a hunk's pointer line", () => {
|
|
811
|
+
const content = doc([HEADER, "", BASE, "", "## Chunk", "", "- [ ] ./a.ts#1 note", ""])
|
|
812
|
+
const actions = review.actions(content, {
|
|
813
|
+
start: { line: 6, character: 5 },
|
|
814
|
+
end: { line: 6, character: 5 },
|
|
815
|
+
})
|
|
816
|
+
expect(actions.some((a) => a.title === "gtd: check this hunk")).toBe(true)
|
|
817
|
+
})
|
|
818
|
+
|
|
819
|
+
it("offers the SAME toggle when the range sits on a continuation line below the pointer", () => {
|
|
820
|
+
const content = doc([HEADER, "", BASE, "", "## Chunk", "", "- [ ] ./a.ts#1", " more note", ""])
|
|
821
|
+
const actions = review.actions(content, {
|
|
822
|
+
start: { line: 7, character: 3 },
|
|
823
|
+
end: { line: 7, character: 3 },
|
|
824
|
+
})
|
|
825
|
+
expect(actions.some((a) => a.title === "gtd: check this hunk")).toBe(true)
|
|
826
|
+
})
|
|
827
|
+
|
|
828
|
+
it("offers a whole-chunk toggle when the range sits on the chunk heading", () => {
|
|
829
|
+
const content = doc([HEADER, "", BASE, "", "## Chunk", "", "- [ ] ./a.ts#1 note", ""])
|
|
830
|
+
const actions = review.actions(content, {
|
|
831
|
+
start: { line: 4, character: 2 },
|
|
832
|
+
end: { line: 4, character: 2 },
|
|
833
|
+
})
|
|
834
|
+
expect(actions.some((a) => a.title.startsWith("gtd: check all hunks"))).toBe(true)
|
|
835
|
+
})
|
|
836
|
+
|
|
837
|
+
it("offers no chunk action anywhere inside a chunk with zero file pointers", () => {
|
|
838
|
+
const content = doc([HEADER, "", BASE, "", "## Chunk A", "", "prose only, no hunks.", ""])
|
|
839
|
+
const actions = review.actions(content, {
|
|
840
|
+
start: { line: 6, character: 2 },
|
|
841
|
+
end: { line: 6, character: 2 },
|
|
842
|
+
})
|
|
843
|
+
expect(actions.some((a) => a.title.includes("check all hunks"))).toBe(false)
|
|
844
|
+
})
|
|
845
|
+
|
|
846
|
+
it("uncheck-all is offered when a strict majority of hunks are already checked", () => {
|
|
847
|
+
const content = doc([
|
|
848
|
+
HEADER,
|
|
849
|
+
"",
|
|
850
|
+
BASE,
|
|
851
|
+
"",
|
|
852
|
+
"## Chunk",
|
|
853
|
+
"",
|
|
854
|
+
"- [x] ./a.ts#1 note",
|
|
855
|
+
"- [x] ./b.ts#1 note",
|
|
856
|
+
"- [ ] ./c.ts#1 note",
|
|
857
|
+
"",
|
|
858
|
+
])
|
|
859
|
+
const actions = review.actions(content, {
|
|
860
|
+
start: { line: 4, character: 2 },
|
|
861
|
+
end: { line: 4, character: 2 },
|
|
862
|
+
})
|
|
863
|
+
expect(actions.some((a) => a.title.startsWith("gtd: uncheck all hunks"))).toBe(true)
|
|
864
|
+
})
|
|
865
|
+
|
|
866
|
+
it("defaults to check-all on an even split (no strict majority either way)", () => {
|
|
867
|
+
const content = doc([
|
|
868
|
+
HEADER,
|
|
869
|
+
"",
|
|
870
|
+
BASE,
|
|
871
|
+
"",
|
|
872
|
+
"## Chunk",
|
|
873
|
+
"",
|
|
874
|
+
"- [x] ./a.ts#1 note",
|
|
875
|
+
"- [ ] ./b.ts#1 note",
|
|
876
|
+
"",
|
|
877
|
+
])
|
|
878
|
+
const actions = review.actions(content, {
|
|
879
|
+
start: { line: 4, character: 2 },
|
|
880
|
+
end: { line: 4, character: 2 },
|
|
881
|
+
})
|
|
882
|
+
expect(actions.some((a) => a.title.startsWith("gtd: check all hunks"))).toBe(true)
|
|
883
|
+
})
|
|
884
|
+
|
|
885
|
+
it("produces zero edits (no chunk action at all) for a chunk with no hunks", () => {
|
|
886
|
+
const content = doc([HEADER, "", BASE, "", "## Chunk", "", "prose only", ""])
|
|
887
|
+
const actions = review.actions(content, {
|
|
888
|
+
start: { line: 4, character: 2 },
|
|
889
|
+
end: { line: 4, character: 2 },
|
|
890
|
+
})
|
|
891
|
+
expect(actions.filter((a) => a.title.includes("all hunks"))).toEqual([])
|
|
892
|
+
})
|
|
893
|
+
})
|
|
894
|
+
|
|
895
|
+
describe("review — pointerAt", () => {
|
|
896
|
+
it("jumps to the hunk's file at its 1-based #line, mapped to 0-based", () => {
|
|
897
|
+
const content = doc([HEADER, "", BASE, "", "## Chunk", "", "- [ ] ./a.ts#42 note", ""])
|
|
898
|
+
expect(review.pointerAt!(content, { line: 6, character: 5 })).toEqual(
|
|
899
|
+
expect.objectContaining({ path: "./a.ts", line: 41 }),
|
|
900
|
+
)
|
|
901
|
+
})
|
|
902
|
+
|
|
903
|
+
it("lands at line 0 for a bare ./path with no #line", () => {
|
|
904
|
+
const content = doc([HEADER, "", BASE, "", "## Chunk", "", "- [ ] ./a.ts note", ""])
|
|
905
|
+
expect(review.pointerAt!(content, { line: 6, character: 5 })).toEqual(
|
|
906
|
+
expect.objectContaining({ path: "./a.ts", line: 0 }),
|
|
907
|
+
)
|
|
908
|
+
})
|
|
909
|
+
|
|
910
|
+
it("returns undefined when the line is not a hunk pointer", () => {
|
|
911
|
+
const content = doc([HEADER, "", BASE, "", "## Chunk", "", "- [ ] ./a.ts#1 note", ""])
|
|
912
|
+
expect(review.pointerAt!(content, { line: 4, character: 2 })).toBeUndefined()
|
|
913
|
+
})
|
|
914
|
+
|
|
915
|
+
it("returns the full path on a hyphenated hunk line, not a truncated prefix", () => {
|
|
916
|
+
const content = doc([HEADER, "", BASE, "", "## Chunk", "", "- [ ] ./my-file.ts#3 note", ""])
|
|
917
|
+
expect(review.pointerAt!(content, { line: 6, character: 5 })).toEqual(
|
|
918
|
+
expect.objectContaining({ path: "./my-file.ts" }),
|
|
919
|
+
)
|
|
920
|
+
})
|
|
921
|
+
})
|
|
922
|
+
|
|
923
|
+
describe("review — clearFilePointerTicks (clearTicks)", () => {
|
|
924
|
+
it("clears [X] as well as [x]", () => {
|
|
925
|
+
const content = doc([HEADER, "", BASE, "", "## Chunk", "", "- [X] ./a.ts#1 note", ""])
|
|
926
|
+
expect(review.clearTicks(content)).toContain("- [ ] ./a.ts#1 note")
|
|
927
|
+
})
|
|
928
|
+
|
|
929
|
+
it("preserves path, inline note, continuation lines, chunk headings, the base comment byte for byte", () => {
|
|
930
|
+
const content = doc([
|
|
931
|
+
HEADER,
|
|
932
|
+
"",
|
|
933
|
+
BASE,
|
|
934
|
+
"",
|
|
935
|
+
"## Chunk",
|
|
936
|
+
"",
|
|
937
|
+
"- [x] ./a.ts#1 note",
|
|
938
|
+
" more detail",
|
|
939
|
+
"",
|
|
940
|
+
])
|
|
941
|
+
const cleared = review.clearTicks(content)
|
|
942
|
+
expect(cleared).toBe(content.replace("[x]", "[ ]"))
|
|
943
|
+
})
|
|
944
|
+
|
|
945
|
+
it("clears a checked range pointer ('#42-70') back to '- [ ]'", () => {
|
|
946
|
+
const content = doc([
|
|
947
|
+
HEADER,
|
|
948
|
+
"",
|
|
949
|
+
BASE,
|
|
950
|
+
"",
|
|
951
|
+
"## Chunk",
|
|
952
|
+
"",
|
|
953
|
+
"- [x] ./src/calc.ts#42-70 — note",
|
|
954
|
+
"",
|
|
955
|
+
])
|
|
956
|
+
expect(review.clearTicks(content)).toContain("- [ ] ./src/calc.ts#42-70 — note")
|
|
957
|
+
})
|
|
958
|
+
|
|
959
|
+
it("a CRLF document with a ticked range pointer keeps its CRLF line endings byte for byte after clearing", () => {
|
|
960
|
+
const content = doc([
|
|
961
|
+
HEADER,
|
|
962
|
+
"",
|
|
963
|
+
BASE,
|
|
964
|
+
"",
|
|
965
|
+
"## Chunk",
|
|
966
|
+
"",
|
|
967
|
+
"- [x] ./a.ts#1-9 note",
|
|
968
|
+
"",
|
|
969
|
+
]).replace(/\n/g, "\r\n")
|
|
970
|
+
const cleared = review.clearTicks(content)
|
|
971
|
+
expect(cleared).toBe(content.replace("[x]", "[ ]"))
|
|
972
|
+
expect(cleared).toContain("\r\n")
|
|
973
|
+
expect(cleared).not.toMatch(/(?<!\r)\n/)
|
|
974
|
+
})
|
|
975
|
+
|
|
976
|
+
it("clears an indented checked task item too, when its own content is itself a pointer", () => {
|
|
977
|
+
const content = doc([
|
|
978
|
+
HEADER,
|
|
979
|
+
"",
|
|
980
|
+
BASE,
|
|
981
|
+
"",
|
|
982
|
+
"## Chunk",
|
|
983
|
+
"",
|
|
984
|
+
"- [ ] ./a.ts#1",
|
|
985
|
+
" - [x] ./nested.ts#1",
|
|
986
|
+
])
|
|
987
|
+
expect(review.clearTicks(content)).toContain("- [ ] ./nested.ts#1")
|
|
988
|
+
})
|
|
989
|
+
|
|
990
|
+
it("does NOT clear an indented checked item whose content isn't a pointer — never 'every checked task item'", () => {
|
|
991
|
+
const content = doc([
|
|
992
|
+
HEADER,
|
|
993
|
+
"",
|
|
994
|
+
BASE,
|
|
995
|
+
"",
|
|
996
|
+
"## Chunk",
|
|
997
|
+
"",
|
|
998
|
+
"- [ ] ./a.ts#1",
|
|
999
|
+
" - [x] not a real path",
|
|
1000
|
+
])
|
|
1001
|
+
expect(review.clearTicks(content)).toContain("- [x] not a real path")
|
|
1002
|
+
})
|
|
1003
|
+
|
|
1004
|
+
it("leaves a [x] in prose alone", () => {
|
|
1005
|
+
const content = doc([
|
|
1006
|
+
HEADER,
|
|
1007
|
+
"",
|
|
1008
|
+
BASE,
|
|
1009
|
+
"",
|
|
1010
|
+
"## Chunk",
|
|
1011
|
+
"",
|
|
1012
|
+
"prose with [x] in it",
|
|
1013
|
+
"- [ ] ./a.ts#1",
|
|
1014
|
+
])
|
|
1015
|
+
expect(review.clearTicks(content)).toBe(content)
|
|
1016
|
+
})
|
|
1017
|
+
|
|
1018
|
+
it("leaves a [x] in a chunk heading alone", () => {
|
|
1019
|
+
const content = doc([HEADER, "", BASE, "", "## Chunk [x]", "", "- [ ] ./a.ts#1"])
|
|
1020
|
+
expect(review.clearTicks(content)).toBe(content)
|
|
1021
|
+
})
|
|
1022
|
+
|
|
1023
|
+
it("leaves a `- [x]` line with no whitespace-delimited pointer token after the box alone (qa-shaped content)", () => {
|
|
1024
|
+
const content = doc([HEADER, "", BASE, "", "## Chunk", "", "- [x] not a pointer at all", ""])
|
|
1025
|
+
expect(review.clearTicks(content)).toBe(content)
|
|
1026
|
+
})
|
|
1027
|
+
|
|
1028
|
+
it("is idempotent", () => {
|
|
1029
|
+
const content = doc([HEADER, "", BASE, "", "## Chunk", "", "- [x] ./a.ts#1 note", ""])
|
|
1030
|
+
const once = review.clearTicks(content)
|
|
1031
|
+
expect(review.clearTicks(once)).toBe(once)
|
|
1032
|
+
})
|
|
1033
|
+
|
|
1034
|
+
it("is total and never throws on an empty or malformed string", () => {
|
|
1035
|
+
expect(() => review.clearTicks("")).not.toThrow()
|
|
1036
|
+
expect(review.clearTicks("")).toBe("")
|
|
1037
|
+
expect(() => review.clearTicks("not markdown at all {{{")).not.toThrow()
|
|
1038
|
+
})
|
|
1039
|
+
|
|
1040
|
+
it("is a no-op (returns the identical string) when there is nothing to clear", () => {
|
|
1041
|
+
const content = doc([HEADER, "", BASE, "", "## Chunk", "", "- [ ] ./a.ts#1 note", ""])
|
|
1042
|
+
expect(review.clearTicks(content)).toBe(content)
|
|
1043
|
+
})
|
|
1044
|
+
})
|
|
1045
|
+
|
|
1046
|
+
describe("review — footnotes wired into the review format", () => {
|
|
1047
|
+
it("excludes a definition below a hunk pointer from that hunk's note and endLine span", () => {
|
|
1048
|
+
const content = doc([
|
|
1049
|
+
HEADER,
|
|
1050
|
+
"",
|
|
1051
|
+
BASE,
|
|
1052
|
+
"",
|
|
1053
|
+
"## Chunk",
|
|
1054
|
+
"",
|
|
1055
|
+
"- [ ] ./a.ts#1-1 note[^fn1]",
|
|
1056
|
+
"",
|
|
1057
|
+
"[^fn1]:",
|
|
1058
|
+
" Detail that is longer than eighty characters so it definitely wraps here nicely.",
|
|
1059
|
+
"",
|
|
1060
|
+
])
|
|
1061
|
+
expect(review.validate(content)).toEqual([])
|
|
1062
|
+
})
|
|
1063
|
+
|
|
1064
|
+
it("surfaces all four footnote findings through validate, each with its line", () => {
|
|
1065
|
+
const content = doc([HEADER, "", BASE, "", "## Chunk", "", "- [ ] ./a.ts#1 note[^orphan]", ""])
|
|
1066
|
+
const findings = review.validate(content)
|
|
1067
|
+
expect(findings.some((f) => f.message.includes("has no matching definition"))).toBe(true)
|
|
1068
|
+
})
|
|
1069
|
+
|
|
1070
|
+
it("review.validate(sample) returns zero findings", () => {
|
|
1071
|
+
expect(review.validate(review.sample)).toEqual([])
|
|
1072
|
+
})
|
|
1073
|
+
|
|
1074
|
+
it("strips a marker written directly against the pointer token, instead of corrupting the path", () => {
|
|
1075
|
+
const content = doc([
|
|
1076
|
+
HEADER,
|
|
1077
|
+
"",
|
|
1078
|
+
BASE,
|
|
1079
|
+
"",
|
|
1080
|
+
"## Chunk",
|
|
1081
|
+
"",
|
|
1082
|
+
"- [ ] ./a.ts#1-1[^fn1] note",
|
|
1083
|
+
"",
|
|
1084
|
+
"[^fn1]:",
|
|
1085
|
+
" Detail that is longer than eighty characters so it definitely wraps here nicely.",
|
|
1086
|
+
"",
|
|
1087
|
+
])
|
|
1088
|
+
expect(review.validate(content)).toEqual([])
|
|
1089
|
+
})
|
|
1090
|
+
})
|
|
1091
|
+
|
|
1092
|
+
describe("review — 'gtd: add a footnote' action", () => {
|
|
1093
|
+
it("offers the footnote action under the shared FOOTNOTE_ACTION_TITLE constant", () => {
|
|
1094
|
+
const content = doc([HEADER, "", BASE, "", "## Chunk", "", "- [ ] ./a.ts#1 note", ""])
|
|
1095
|
+
const actions = review.actions(content, {
|
|
1096
|
+
start: { line: 6, character: 5 },
|
|
1097
|
+
end: { line: 6, character: 5 },
|
|
1098
|
+
})
|
|
1099
|
+
expect(actions.some((a) => a.title === FOOTNOTE_ACTION_TITLE)).toBe(true)
|
|
1100
|
+
})
|
|
1101
|
+
|
|
1102
|
+
it("is LAST, with both the hunk and chunk toggle actions preceding it, on a hunk line inside a chunk", () => {
|
|
1103
|
+
const content = doc([HEADER, "", BASE, "", "## Chunk", "", "- [ ] ./a.ts#1 note", ""])
|
|
1104
|
+
const actions = review.actions(content, {
|
|
1105
|
+
start: { line: 6, character: 5 },
|
|
1106
|
+
end: { line: 6, character: 5 },
|
|
1107
|
+
})
|
|
1108
|
+
expect(actions.map((a) => a.title)).toEqual([
|
|
1109
|
+
"gtd: check this hunk",
|
|
1110
|
+
'gtd: check all hunks in "Chunk"',
|
|
1111
|
+
FOOTNOTE_ACTION_TITLE,
|
|
1112
|
+
])
|
|
1113
|
+
})
|
|
1114
|
+
|
|
1115
|
+
it("in a multi-paragraph hunk note, lands after the LAST non-blank line of the hunk's span", () => {
|
|
1116
|
+
const content = doc([
|
|
1117
|
+
HEADER,
|
|
1118
|
+
"",
|
|
1119
|
+
BASE,
|
|
1120
|
+
"",
|
|
1121
|
+
"## Chunk",
|
|
1122
|
+
"",
|
|
1123
|
+
"- [ ] ./a.ts#1 note",
|
|
1124
|
+
" more detail",
|
|
1125
|
+
"",
|
|
1126
|
+
" and even more",
|
|
1127
|
+
"",
|
|
1128
|
+
])
|
|
1129
|
+
const actions = review.actions(content, {
|
|
1130
|
+
start: { line: 6, character: 3 },
|
|
1131
|
+
end: { line: 6, character: 3 },
|
|
1132
|
+
})
|
|
1133
|
+
expect(actions.some((a) => a.title === "gtd: add a footnote")).toBe(true)
|
|
1134
|
+
})
|
|
1135
|
+
|
|
1136
|
+
it("in ordinary prose (a chunk's description, before any hunk), lands after the current block's last line", () => {
|
|
1137
|
+
const content = doc([
|
|
1138
|
+
HEADER,
|
|
1139
|
+
"",
|
|
1140
|
+
BASE,
|
|
1141
|
+
"",
|
|
1142
|
+
"## Chunk",
|
|
1143
|
+
"",
|
|
1144
|
+
"Some description.",
|
|
1145
|
+
"",
|
|
1146
|
+
"- [ ] ./a.ts#1",
|
|
1147
|
+
"",
|
|
1148
|
+
])
|
|
1149
|
+
const actions = review.actions(content, {
|
|
1150
|
+
start: { line: 6, character: 3 },
|
|
1151
|
+
end: { line: 6, character: 3 },
|
|
1152
|
+
})
|
|
1153
|
+
expect(actions.some((a) => a.title === "gtd: add a footnote")).toBe(true)
|
|
1154
|
+
})
|
|
1155
|
+
|
|
1156
|
+
it("is refused with the cursor inside an existing marker's span", () => {
|
|
1157
|
+
const content = doc([
|
|
1158
|
+
HEADER,
|
|
1159
|
+
"",
|
|
1160
|
+
BASE,
|
|
1161
|
+
"",
|
|
1162
|
+
"## Chunk",
|
|
1163
|
+
"",
|
|
1164
|
+
"- [ ] ./a.ts#1 note[^fn1]",
|
|
1165
|
+
"",
|
|
1166
|
+
"[^fn1]:",
|
|
1167
|
+
" Detail that is longer than eighty characters so it definitely wraps here nicely.",
|
|
1168
|
+
"",
|
|
1169
|
+
])
|
|
1170
|
+
const actions = review.actions(content, {
|
|
1171
|
+
start: { line: 6, character: 20 },
|
|
1172
|
+
end: { line: 6, character: 20 },
|
|
1173
|
+
})
|
|
1174
|
+
expect(actions.some((a) => a.title === "gtd: add a footnote")).toBe(false)
|
|
1175
|
+
})
|
|
1176
|
+
})
|
|
1177
|
+
|
|
1178
|
+
describe("review — header, base comment, and chunk headings come from nodes", () => {
|
|
1179
|
+
it("a '# Review: <hash>' line inside a fence is not the header", () => {
|
|
1180
|
+
const content = doc([
|
|
1181
|
+
"```",
|
|
1182
|
+
"# Review: abc123",
|
|
1183
|
+
"```",
|
|
1184
|
+
"",
|
|
1185
|
+
BASE,
|
|
1186
|
+
"",
|
|
1187
|
+
"## Chunk",
|
|
1188
|
+
"",
|
|
1189
|
+
"- [ ] ./a.ts#1",
|
|
1190
|
+
])
|
|
1191
|
+
expect(review.validate(content)).toContainEqual(
|
|
1192
|
+
expect.objectContaining({
|
|
1193
|
+
message: "Missing or malformed '# Review: <hash>' header as the document's first line",
|
|
1194
|
+
}),
|
|
1195
|
+
)
|
|
1196
|
+
})
|
|
1197
|
+
|
|
1198
|
+
it("finds the base comment wherever it appears in the document", () => {
|
|
1199
|
+
const content = doc([HEADER, "", "## Chunk", "", "- [ ] ./a.ts#1 note", "", BASE, ""])
|
|
1200
|
+
expect(review.validate(content)).not.toContainEqual(
|
|
1201
|
+
expect.objectContaining({ message: "Missing '<!-- base: <hash> -->' comment" }),
|
|
1202
|
+
)
|
|
1203
|
+
})
|
|
1204
|
+
|
|
1205
|
+
it("a '## ' chunk heading inside a fence is not a chunk", () => {
|
|
1206
|
+
const content = doc([
|
|
1207
|
+
HEADER,
|
|
1208
|
+
"",
|
|
1209
|
+
BASE,
|
|
1210
|
+
"",
|
|
1211
|
+
"```",
|
|
1212
|
+
"## Fake chunk",
|
|
1213
|
+
"```",
|
|
1214
|
+
"",
|
|
1215
|
+
"## Chunk",
|
|
1216
|
+
"",
|
|
1217
|
+
"- [ ] ./a.ts#1",
|
|
1218
|
+
])
|
|
1219
|
+
const findings = review.validate(content)
|
|
1220
|
+
expect(findings.some((f) => f.message.includes('Chunk "Fake chunk"'))).toBe(false)
|
|
1221
|
+
})
|
|
1222
|
+
})
|
|
1223
|
+
|
|
1224
|
+
describe("review — nested hunks are the same hunks", () => {
|
|
1225
|
+
it("a '- [x] ./file.ts#1' indented two spaces IS a hunk pointer, cleared by clearTicks", () => {
|
|
1226
|
+
const content = doc([
|
|
1227
|
+
HEADER,
|
|
1228
|
+
"",
|
|
1229
|
+
BASE,
|
|
1230
|
+
"",
|
|
1231
|
+
"## Chunk",
|
|
1232
|
+
"",
|
|
1233
|
+
"- [ ] ./a.ts#1",
|
|
1234
|
+
" - [x] ./nested.ts#1",
|
|
1235
|
+
"",
|
|
1236
|
+
])
|
|
1237
|
+
expect(review.clearTicks(content)).toContain("- [ ] ./nested.ts#1")
|
|
1238
|
+
})
|
|
1239
|
+
|
|
1240
|
+
it("the parent hunk's note does NOT contain its nested hunk's text", () => {
|
|
1241
|
+
const content = doc([
|
|
1242
|
+
HEADER,
|
|
1243
|
+
"",
|
|
1244
|
+
BASE,
|
|
1245
|
+
"",
|
|
1246
|
+
"## Chunk",
|
|
1247
|
+
"",
|
|
1248
|
+
"- [ ] ./a.ts#1-1 parent note",
|
|
1249
|
+
" - [ ] ./nested.ts#1-1 nested note",
|
|
1250
|
+
"",
|
|
1251
|
+
])
|
|
1252
|
+
expect(review.validate(content)).toEqual([])
|
|
1253
|
+
})
|
|
1254
|
+
})
|
|
1255
|
+
|
|
1256
|
+
describe("review — total over a structurally broken document", () => {
|
|
1257
|
+
it("clearFilePointerTicks still clears ticks in a structurally broken document", () => {
|
|
1258
|
+
expect(() => review.clearTicks("not markdown at all {{{ [x]")).not.toThrow()
|
|
1259
|
+
})
|
|
1260
|
+
|
|
1261
|
+
it("parseReviewDoc (via validate) never throws on arbitrary/malformed input", () => {
|
|
1262
|
+
expect(() => review.validate("\0\0\0")).not.toThrow()
|
|
1263
|
+
})
|
|
1264
|
+
})
|
|
1265
|
+
|
|
1266
|
+
describe("review.view", () => {
|
|
1267
|
+
const NESTED_CONTENT = [
|
|
1268
|
+
"# Review: abc1234",
|
|
1269
|
+
"<!-- base: abc1234def5678901234567890123456789abcd -->",
|
|
1270
|
+
"",
|
|
1271
|
+
"## Chunk one",
|
|
1272
|
+
"",
|
|
1273
|
+
"Some description.",
|
|
1274
|
+
"",
|
|
1275
|
+
"- [ ] ./a.ts#1 outer hunk",
|
|
1276
|
+
" - [x] ./b.ts#2 nested hunk",
|
|
1277
|
+
"",
|
|
1278
|
+
"## Chunk two",
|
|
1279
|
+
"",
|
|
1280
|
+
"- [ ] ./c.ts#3",
|
|
1281
|
+
"",
|
|
1282
|
+
].join("\n")
|
|
1283
|
+
|
|
1284
|
+
it("exposes every chunk and every file pointer, including pointers nested at any depth", () => {
|
|
1285
|
+
const view = review.view(NESTED_CONTENT)
|
|
1286
|
+
expect(view.nodes.map((c) => c.title)).toEqual(["Chunk one", "Chunk two"])
|
|
1287
|
+
expect(view.nodes[0]!.children!.map((f) => f.path)).toEqual(["./a.ts", "./b.ts"])
|
|
1288
|
+
expect(view.nodes[1]!.children!.map((f) => f.path)).toEqual(["./c.ts"])
|
|
1289
|
+
})
|
|
1290
|
+
|
|
1291
|
+
it("carries each chunk's own prose as `detail`, empty for a chunk with none", () => {
|
|
1292
|
+
const view = review.view(NESTED_CONTENT)
|
|
1293
|
+
expect(view.nodes[0]!.detail).toBe("Some description.")
|
|
1294
|
+
expect(view.nodes[1]!.detail).toBe("")
|
|
1295
|
+
})
|
|
1296
|
+
|
|
1297
|
+
it("is built from one parse of the document, not one per element", () => {
|
|
1298
|
+
const uniqueContent = [
|
|
1299
|
+
"# Review: def4567",
|
|
1300
|
+
"<!-- base: abc1234def5678901234567890123456789abcd -->",
|
|
1301
|
+
"",
|
|
1302
|
+
"## Solo chunk",
|
|
1303
|
+
"",
|
|
1304
|
+
"- [ ] ./z.ts#9 solo hunk",
|
|
1305
|
+
"",
|
|
1306
|
+
].join("\n")
|
|
1307
|
+
const before = getParseCount()
|
|
1308
|
+
review.view(uniqueContent)
|
|
1309
|
+
expect(getParseCount()).toBe(before + 1)
|
|
1310
|
+
})
|
|
1311
|
+
|
|
1312
|
+
it("carries the header hash", () => {
|
|
1313
|
+
const view = review.view(NESTED_CONTENT)
|
|
1314
|
+
expect(view.header).toBe("abc1234")
|
|
1315
|
+
})
|
|
1316
|
+
|
|
1317
|
+
it("a '#42-70' pointer's hunk node carries line: 42 and endLine: 70", () => {
|
|
1318
|
+
const content = [
|
|
1319
|
+
"# Review: abc1234",
|
|
1320
|
+
"<!-- base: abc1234def5678901234567890123456789abcd -->",
|
|
1321
|
+
"",
|
|
1322
|
+
"## Chunk",
|
|
1323
|
+
"",
|
|
1324
|
+
"- [ ] ./a.ts#42-70 note",
|
|
1325
|
+
"",
|
|
1326
|
+
].join("\n")
|
|
1327
|
+
const hunk = review.view(content).nodes[0]!.children![0]!
|
|
1328
|
+
expect(hunk.line).toBe(42)
|
|
1329
|
+
expect(hunk.endLine).toBe(70)
|
|
1330
|
+
})
|
|
1331
|
+
|
|
1332
|
+
it("a bare '#1' pointer's hunk node carries no endLine", () => {
|
|
1333
|
+
const hunk = review.view(NESTED_CONTENT).nodes[0]!.children![0]!
|
|
1334
|
+
expect(hunk.line).toBe(1)
|
|
1335
|
+
expect(hunk.endLine).toBeUndefined()
|
|
1336
|
+
})
|
|
1337
|
+
})
|
|
1338
|
+
|
|
1339
|
+
describe("review.annotate", () => {
|
|
1340
|
+
const CONTENT = [
|
|
1341
|
+
"# Review: abc1234",
|
|
1342
|
+
"<!-- base: abc1234def5678901234567890123456789abcd -->",
|
|
1343
|
+
"",
|
|
1344
|
+
"## Chunk one",
|
|
1345
|
+
"",
|
|
1346
|
+
"- [ ] ./a.ts#1-1 outer hunk",
|
|
1347
|
+
"",
|
|
1348
|
+
].join("\n")
|
|
1349
|
+
|
|
1350
|
+
it("accepts a chunk-level anchor", () => {
|
|
1351
|
+
const result = review.annotate(CONTENT, { kind: "chunk", index: 0 }, "a real note")
|
|
1352
|
+
expect(result.ok).toBe(true)
|
|
1353
|
+
})
|
|
1354
|
+
|
|
1355
|
+
it("accepts a hunk-level anchor", () => {
|
|
1356
|
+
const result = review.annotate(
|
|
1357
|
+
CONTENT,
|
|
1358
|
+
{ kind: "hunk", chunkIndex: 0, index: 0 },
|
|
1359
|
+
"a real note",
|
|
1360
|
+
)
|
|
1361
|
+
expect(result.ok).toBe(true)
|
|
1362
|
+
})
|
|
1363
|
+
|
|
1364
|
+
it("accepts a paragraph anchor in a prose-only document", () => {
|
|
1365
|
+
const result = review.annotate(
|
|
1366
|
+
"Just some prose.\n",
|
|
1367
|
+
{ kind: "paragraph", line: 0 },
|
|
1368
|
+
"a real note",
|
|
1369
|
+
)
|
|
1370
|
+
expect(result.ok).toBe(true)
|
|
1371
|
+
})
|
|
1372
|
+
|
|
1373
|
+
it("rejects an anchor that no longer resolves, rather than silently dropping it", () => {
|
|
1374
|
+
expect(review.annotate(CONTENT, { kind: "chunk", index: 5 }, "note")).toEqual({
|
|
1375
|
+
ok: false,
|
|
1376
|
+
reason: "anchor-not-found",
|
|
1377
|
+
})
|
|
1378
|
+
expect(review.annotate(CONTENT, { kind: "hunk", chunkIndex: 0, index: 5 }, "note")).toEqual({
|
|
1379
|
+
ok: false,
|
|
1380
|
+
reason: "anchor-not-found",
|
|
1381
|
+
})
|
|
1382
|
+
expect(review.annotate(CONTENT, { kind: "question", index: 0 }, "note")).toEqual({
|
|
1383
|
+
ok: false,
|
|
1384
|
+
reason: "anchor-not-found",
|
|
1385
|
+
})
|
|
1386
|
+
})
|
|
1387
|
+
|
|
1388
|
+
it("attaches the given text verbatim, and the resulting document passes its own format's validator (T2's last criterion)", () => {
|
|
1389
|
+
const result = review.annotate(
|
|
1390
|
+
CONTENT,
|
|
1391
|
+
{ kind: "hunk", chunkIndex: 0, index: 0 },
|
|
1392
|
+
"a real reason a human actually typed",
|
|
1393
|
+
)
|
|
1394
|
+
expect(result.ok).toBe(true)
|
|
1395
|
+
if (!result.ok) return
|
|
1396
|
+
const lines = CONTENT.split("\n")
|
|
1397
|
+
const toOffset = (pos: { readonly line: number; readonly character: number }): number => {
|
|
1398
|
+
let offset = 0
|
|
1399
|
+
for (let i = 0; i < pos.line; i += 1) offset += (lines[i]?.length ?? 0) + 1
|
|
1400
|
+
return offset + pos.character
|
|
1401
|
+
}
|
|
1402
|
+
const sorted = [...result.edits].sort(
|
|
1403
|
+
(a, b) => toOffset(b.range.start) - toOffset(a.range.start),
|
|
1404
|
+
)
|
|
1405
|
+
let applied = CONTENT
|
|
1406
|
+
for (const edit of sorted) {
|
|
1407
|
+
applied =
|
|
1408
|
+
applied.slice(0, toOffset(edit.range.start)) +
|
|
1409
|
+
edit.newText +
|
|
1410
|
+
applied.slice(toOffset(edit.range.end))
|
|
1411
|
+
}
|
|
1412
|
+
expect(applied).toContain("a real reason a human actually typed")
|
|
1413
|
+
// review.validate below already proves this raises no "has an empty
|
|
1414
|
+
// body" finding — stronger than checking for the retired placeholder text.
|
|
1415
|
+
expect(review.validate(applied)).toEqual([])
|
|
1416
|
+
})
|
|
1417
|
+
})
|
|
1418
|
+
|
|
1419
|
+
/** Applies edits back-to-front (as `ui/Write.ts#applySteeringEdits` does) — local to this test file, mirroring `review.annotate`'s own describe block's inline splice pattern above. */
|
|
1420
|
+
const applyEdits = (
|
|
1421
|
+
content: string,
|
|
1422
|
+
edits: readonly {
|
|
1423
|
+
readonly range: {
|
|
1424
|
+
readonly start: { readonly line: number; readonly character: number }
|
|
1425
|
+
readonly end: { readonly line: number; readonly character: number }
|
|
1426
|
+
}
|
|
1427
|
+
readonly newText: string
|
|
1428
|
+
}[],
|
|
1429
|
+
): string => {
|
|
1430
|
+
const lines = content.split("\n")
|
|
1431
|
+
const toOffset = (pos: { readonly line: number; readonly character: number }): number => {
|
|
1432
|
+
let offset = 0
|
|
1433
|
+
for (let i = 0; i < pos.line; i += 1) offset += (lines[i]?.length ?? 0) + 1
|
|
1434
|
+
return offset + pos.character
|
|
1435
|
+
}
|
|
1436
|
+
const sorted = [...edits].sort((a, b) => toOffset(b.range.start) - toOffset(a.range.start))
|
|
1437
|
+
let result = content
|
|
1438
|
+
for (const edit of sorted) {
|
|
1439
|
+
result =
|
|
1440
|
+
result.slice(0, toOffset(edit.range.start)) +
|
|
1441
|
+
edit.newText +
|
|
1442
|
+
result.slice(toOffset(edit.range.end))
|
|
1443
|
+
}
|
|
1444
|
+
return result
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
describe("review.apply", () => {
|
|
1448
|
+
const NESTED_CONTENT = [
|
|
1449
|
+
"# Review: abc1234",
|
|
1450
|
+
"<!-- base: abc1234def5678901234567890123456789abcd -->",
|
|
1451
|
+
"",
|
|
1452
|
+
"## Chunk one",
|
|
1453
|
+
"",
|
|
1454
|
+
"Some description.",
|
|
1455
|
+
"",
|
|
1456
|
+
"- [ ] ./a.ts#1 outer hunk",
|
|
1457
|
+
" - [x] ./b.ts#2 nested hunk",
|
|
1458
|
+
"",
|
|
1459
|
+
"## Chunk two",
|
|
1460
|
+
"",
|
|
1461
|
+
"- [ ] ./c.ts#3",
|
|
1462
|
+
"",
|
|
1463
|
+
].join("\n")
|
|
1464
|
+
|
|
1465
|
+
it("a hunk anchor sets just that hunk's tick", () => {
|
|
1466
|
+
const result = review.apply(
|
|
1467
|
+
NESTED_CONTENT,
|
|
1468
|
+
{ kind: "hunk", chunkIndex: 0, index: 0 },
|
|
1469
|
+
{
|
|
1470
|
+
checked: true,
|
|
1471
|
+
},
|
|
1472
|
+
)
|
|
1473
|
+
expect(result.ok).toBe(true)
|
|
1474
|
+
if (!result.ok) return
|
|
1475
|
+
const applied = applyEdits(NESTED_CONTENT, result.edits)
|
|
1476
|
+
const { changesets } = parseReviewDoc(applied)
|
|
1477
|
+
expect(changesets[0]!.files.map((f) => f.checked)).toEqual([true, true])
|
|
1478
|
+
})
|
|
1479
|
+
|
|
1480
|
+
it("a chunk anchor sets the tick on every hunk beneath it, at any nesting depth, to the exact target state — not a majority-flip", () => {
|
|
1481
|
+
const result = review.apply(
|
|
1482
|
+
NESTED_CONTENT,
|
|
1483
|
+
{ kind: "chunk", index: 0 },
|
|
1484
|
+
{
|
|
1485
|
+
checked: true,
|
|
1486
|
+
},
|
|
1487
|
+
)
|
|
1488
|
+
expect(result.ok).toBe(true)
|
|
1489
|
+
if (!result.ok) return
|
|
1490
|
+
const applied = applyEdits(NESTED_CONTENT, result.edits)
|
|
1491
|
+
const { changesets } = parseReviewDoc(applied)
|
|
1492
|
+
// The outer hunk was unchecked, the nested one already checked — a
|
|
1493
|
+
// majority-flip heuristic (`chunkToggleTarget`) would uncheck both since
|
|
1494
|
+
// one of two was already checked; `apply` instead drives both to the
|
|
1495
|
+
// caller's own exact target, `true`.
|
|
1496
|
+
expect(changesets[0]!.files.map((f) => f.checked)).toEqual([true, true])
|
|
1497
|
+
// Chunk two, untouched, keeps its own state.
|
|
1498
|
+
expect(changesets[1]!.files.map((f) => f.checked)).toEqual([false])
|
|
1499
|
+
})
|
|
1500
|
+
|
|
1501
|
+
it("a chunk anchor can also drive every hunk beneath it to unchecked", () => {
|
|
1502
|
+
const result = review.apply(
|
|
1503
|
+
NESTED_CONTENT,
|
|
1504
|
+
{ kind: "chunk", index: 0 },
|
|
1505
|
+
{
|
|
1506
|
+
checked: false,
|
|
1507
|
+
},
|
|
1508
|
+
)
|
|
1509
|
+
expect(result.ok).toBe(true)
|
|
1510
|
+
if (!result.ok) return
|
|
1511
|
+
const applied = applyEdits(NESTED_CONTENT, result.edits)
|
|
1512
|
+
const { changesets } = parseReviewDoc(applied)
|
|
1513
|
+
expect(changesets[0]!.files.map((f) => f.checked)).toEqual([false, false])
|
|
1514
|
+
})
|
|
1515
|
+
|
|
1516
|
+
it("a stale chunk/hunk index refuses anchor-not-found", () => {
|
|
1517
|
+
expect(review.apply(NESTED_CONTENT, { kind: "chunk", index: 5 }, { checked: true })).toEqual({
|
|
1518
|
+
ok: false,
|
|
1519
|
+
reason: "anchor-not-found",
|
|
1520
|
+
})
|
|
1521
|
+
expect(
|
|
1522
|
+
review.apply(NESTED_CONTENT, { kind: "hunk", chunkIndex: 0, index: 5 }, { checked: true }),
|
|
1523
|
+
).toEqual({ ok: false, reason: "anchor-not-found" })
|
|
1524
|
+
})
|
|
1525
|
+
|
|
1526
|
+
it("a question/option anchor — not this format's own kind — refuses anchor-not-found", () => {
|
|
1527
|
+
expect(review.apply(NESTED_CONTENT, { kind: "question", index: 0 }, { checked: true })).toEqual(
|
|
1528
|
+
{ ok: false, reason: "anchor-not-found" },
|
|
1529
|
+
)
|
|
1530
|
+
})
|
|
1531
|
+
})
|
|
1532
|
+
|
|
1533
|
+
describe("review.view — chunk-level footnote projection", () => {
|
|
1534
|
+
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", () => {
|
|
1535
|
+
// review.sample carries exactly this shape: `## Sample chunk[^naduiqc4]`
|
|
1536
|
+
// (a chunk-level footnote) plus `- [ ] ./sample.ts#1 what this hunk does[^fn1]`
|
|
1537
|
+
// (an ordinary hunk-level note) — see the sample's own doc comment.
|
|
1538
|
+
const view = review.view(review.sample)
|
|
1539
|
+
const chunk = view.nodes[0]
|
|
1540
|
+
expect(chunk?.note).toBe(
|
|
1541
|
+
"Attached via the phone UI, this note demonstrates a chunk-level comment with a `multi word code span` that exceeds eighty characters in total length here.",
|
|
1542
|
+
)
|
|
1543
|
+
// The hunk's own prose is `detail` (read-only context above the diff),
|
|
1544
|
+
// never `note` (the human reviewer's own attached footnote) — see the
|
|
1545
|
+
// "hunk detail/note channels" describe block below for the full split.
|
|
1546
|
+
expect(chunk?.children?.[0]?.detail).toBe("what this hunk does")
|
|
1547
|
+
expect(chunk?.children?.[0]?.note).toBe(
|
|
1548
|
+
"This note explains why the hunk exists in more detail than fits on one line for a reviewer.",
|
|
1549
|
+
)
|
|
1550
|
+
})
|
|
1551
|
+
|
|
1552
|
+
it("a chunk with no heading-line footnote has no `note` on its view node, even when its hunks carry their own", () => {
|
|
1553
|
+
const content = [
|
|
1554
|
+
"# Review: abc1234",
|
|
1555
|
+
"<!-- base: abc1234def5678901234567890123456789abcd -->",
|
|
1556
|
+
"",
|
|
1557
|
+
"## Add calculator",
|
|
1558
|
+
"",
|
|
1559
|
+
"- [ ] ./src/calc.ts#1 a hunk-level note[^fn1]",
|
|
1560
|
+
"",
|
|
1561
|
+
"[^fn1]: explains the hunk",
|
|
1562
|
+
"",
|
|
1563
|
+
].join("\n")
|
|
1564
|
+
const view = review.view(content)
|
|
1565
|
+
expect(view.nodes[0]?.note).toBeUndefined()
|
|
1566
|
+
expect(view.nodes[0]?.children?.[0]?.detail).toBe("a hunk-level note")
|
|
1567
|
+
expect(view.nodes[0]?.children?.[0]?.note).toBe("explains the hunk")
|
|
1568
|
+
})
|
|
1569
|
+
|
|
1570
|
+
it("a chunk carrying only its own footnote (every hunk ticked) still projects `note` — the badge-worthy shape T4 asks for", () => {
|
|
1571
|
+
const content = [
|
|
1572
|
+
"# Review: abc1234",
|
|
1573
|
+
"<!-- base: abc1234def5678901234567890123456789abcd -->",
|
|
1574
|
+
"",
|
|
1575
|
+
"## Fully reviewed[^chunknote]",
|
|
1576
|
+
"",
|
|
1577
|
+
"- [x] ./src/done.ts#1",
|
|
1578
|
+
"",
|
|
1579
|
+
"[^chunknote]: please double-check the retry logic before landing",
|
|
1580
|
+
"",
|
|
1581
|
+
].join("\n")
|
|
1582
|
+
const view = review.view(content)
|
|
1583
|
+
expect(view.nodes[0]?.note).toBe("please double-check the retry logic before landing")
|
|
1584
|
+
expect(view.nodes[0]?.children?.every((h) => h.checked)).toBe(true)
|
|
1585
|
+
})
|
|
1586
|
+
|
|
1587
|
+
it("a hunk with two footnotes attached at its pointer line joins their bodies with a single space, matching the chunk path", () => {
|
|
1588
|
+
const content = [
|
|
1589
|
+
"# Review: abc1234",
|
|
1590
|
+
"<!-- base: abc1234def5678901234567890123456789abcd -->",
|
|
1591
|
+
"",
|
|
1592
|
+
"## Add calculator",
|
|
1593
|
+
"",
|
|
1594
|
+
"- [ ] ./src/calc.ts#1 does the thing[^fn1][^fn2]",
|
|
1595
|
+
"",
|
|
1596
|
+
"[^fn1]: first reason",
|
|
1597
|
+
"[^fn2]: second reason",
|
|
1598
|
+
"",
|
|
1599
|
+
].join("\n")
|
|
1600
|
+
const view = review.view(content)
|
|
1601
|
+
expect(view.nodes[0]?.children?.[0]?.detail).toBe("does the thing")
|
|
1602
|
+
expect(view.nodes[0]?.children?.[0]?.note).toBe("first reason second reason")
|
|
1603
|
+
})
|
|
1604
|
+
|
|
1605
|
+
it("drops a hunk footnote marker whose definition is missing, rather than emitting undefined text", () => {
|
|
1606
|
+
const content = [
|
|
1607
|
+
"# Review: abc1234",
|
|
1608
|
+
"<!-- base: abc1234def5678901234567890123456789abcd -->",
|
|
1609
|
+
"",
|
|
1610
|
+
"## Add calculator",
|
|
1611
|
+
"",
|
|
1612
|
+
"- [ ] ./src/calc.ts#1 does the thing[^missing]",
|
|
1613
|
+
"",
|
|
1614
|
+
].join("\n")
|
|
1615
|
+
const view = review.view(content)
|
|
1616
|
+
// An orphan marker (no matching definition anywhere) never parses as a
|
|
1617
|
+
// real `footnoteReference` node, so `sourceText` has nothing to excise
|
|
1618
|
+
// from `detail` — that part of the text is unaffected by this package.
|
|
1619
|
+
// What's under test is `note`: `hunkNoteOf`'s definition lookup drops
|
|
1620
|
+
// the marker rather than emitting `undefined` as text.
|
|
1621
|
+
expect(view.nodes[0]?.children?.[0]?.note).toBeUndefined()
|
|
1622
|
+
})
|
|
1623
|
+
|
|
1624
|
+
it("a hunk with only a description and no attached footnote carries it in `detail` with no `note` — the note textbox must open empty", () => {
|
|
1625
|
+
const content = [
|
|
1626
|
+
"# Review: abc1234",
|
|
1627
|
+
"<!-- base: abc1234def5678901234567890123456789abcd -->",
|
|
1628
|
+
"",
|
|
1629
|
+
"## Add calculator",
|
|
1630
|
+
"",
|
|
1631
|
+
"- [ ] ./src/calc.ts#1 what this hunk does",
|
|
1632
|
+
"",
|
|
1633
|
+
].join("\n")
|
|
1634
|
+
const view = review.view(content)
|
|
1635
|
+
expect(view.nodes[0]?.children?.[0]?.detail).toBe("what this hunk does")
|
|
1636
|
+
expect(view.nodes[0]?.children?.[0]?.note).toBeUndefined()
|
|
1637
|
+
})
|
|
1638
|
+
})
|
|
1639
|
+
|
|
1640
|
+
describe("review — thread outline", () => {
|
|
1641
|
+
it("review nests a thread under its chunk", () => {
|
|
1642
|
+
const doc = [
|
|
1643
|
+
"# Review: abc1234",
|
|
1644
|
+
"<!-- base: abc1234def5678901234567890123456789abcd -->",
|
|
1645
|
+
"",
|
|
1646
|
+
"## Chunk one",
|
|
1647
|
+
"",
|
|
1648
|
+
"- [ ] ./src/a.ts#1 [^t1]",
|
|
1649
|
+
"",
|
|
1650
|
+
"[^t1]:",
|
|
1651
|
+
" - H: q",
|
|
1652
|
+
" - A: a",
|
|
1653
|
+
"",
|
|
1654
|
+
].join("\n")
|
|
1655
|
+
const chunk = steeringFormatFor("review")!.outline(doc)[0]!
|
|
1656
|
+
expect(chunk.children!.some((c) => c.name === "[^t1]" && c.detail === "waiting on you")).toBe(
|
|
1657
|
+
true,
|
|
1658
|
+
)
|
|
1659
|
+
})
|
|
1660
|
+
})
|
|
1661
|
+
|
|
1662
|
+
describe("review view — threads", () => {
|
|
1663
|
+
const doc = [
|
|
1664
|
+
"# Review: abc1234",
|
|
1665
|
+
"<!-- base: abc1234def5678901234567890123456789abcd -->",
|
|
1666
|
+
"",
|
|
1667
|
+
"## Chunk[^t1]",
|
|
1668
|
+
"",
|
|
1669
|
+
"- [ ] ./a.ts#1-1 hunk[^t2]",
|
|
1670
|
+
"- [ ] ./b.ts#1-1 other[^n1]",
|
|
1671
|
+
"",
|
|
1672
|
+
"[^t1]:",
|
|
1673
|
+
" - H: chunk ask",
|
|
1674
|
+
" - A: which?",
|
|
1675
|
+
"",
|
|
1676
|
+
"[^t2]:",
|
|
1677
|
+
" - H: hunk ask",
|
|
1678
|
+
"",
|
|
1679
|
+
"[^n1]: one-shot",
|
|
1680
|
+
"",
|
|
1681
|
+
].join("\n")
|
|
1682
|
+
|
|
1683
|
+
it("a chunk and a hunk with a thread expose `thread` in order and no `note`; a one-shot keeps `note`", () => {
|
|
1684
|
+
const chunk = steeringFormatFor("review")!.view(doc).nodes[0]!
|
|
1685
|
+
expect(chunk.note).toBeUndefined()
|
|
1686
|
+
expect(chunk.thread).toEqual({
|
|
1687
|
+
name: "t1",
|
|
1688
|
+
entries: [
|
|
1689
|
+
{ author: "me", text: "chunk ask" },
|
|
1690
|
+
{ author: "agent", text: "which?" },
|
|
1691
|
+
],
|
|
1692
|
+
waitingOn: "human",
|
|
1693
|
+
})
|
|
1694
|
+
const [hunk, other] = chunk.children!
|
|
1695
|
+
expect(hunk!.note).toBeUndefined()
|
|
1696
|
+
expect(hunk!.thread).toEqual({
|
|
1697
|
+
name: "t2",
|
|
1698
|
+
entries: [{ author: "me", text: "hunk ask" }],
|
|
1699
|
+
waitingOn: "agent",
|
|
1700
|
+
})
|
|
1701
|
+
expect(other!.note).toBe("one-shot")
|
|
1702
|
+
expect(other!.thread).toBeUndefined()
|
|
1703
|
+
})
|
|
1704
|
+
})
|
|
1705
|
+
|
|
1706
|
+
describe("reviewNotes", async () => {
|
|
1707
|
+
const { reviewNotes } = await import("./review.js")
|
|
1708
|
+
const doc = (chunkBody: string, extra = ""): string =>
|
|
1709
|
+
[
|
|
1710
|
+
"# Review: abc1234",
|
|
1711
|
+
"",
|
|
1712
|
+
"<!-- base: abc1234def5678901234567890123456789abcd -->",
|
|
1713
|
+
"",
|
|
1714
|
+
"## Chunk one",
|
|
1715
|
+
"",
|
|
1716
|
+
chunkBody,
|
|
1717
|
+
"",
|
|
1718
|
+
"- [ ] ./src/a.ts#1-3",
|
|
1719
|
+
extra,
|
|
1720
|
+
].join("\n")
|
|
1721
|
+
const base = doc("Prose.")
|
|
1722
|
+
|
|
1723
|
+
it("identical texts yield nothing", () => {
|
|
1724
|
+
expect(reviewNotes(base, base)).toEqual([])
|
|
1725
|
+
})
|
|
1726
|
+
|
|
1727
|
+
it("a pointer note added", () => {
|
|
1728
|
+
const after = doc("Prose.").replace("#1-3", "#1-3 rename this")
|
|
1729
|
+
expect(reviewNotes(base, after)).toEqual([
|
|
1730
|
+
{
|
|
1731
|
+
id: "note-1",
|
|
1732
|
+
kind: "pointer",
|
|
1733
|
+
anchor: "Chunk one ./src/a.ts#1-3",
|
|
1734
|
+
before: "",
|
|
1735
|
+
text: "rename this",
|
|
1736
|
+
},
|
|
1737
|
+
])
|
|
1738
|
+
})
|
|
1739
|
+
|
|
1740
|
+
it("a pointer note extended carries the reviewer's text as before", () => {
|
|
1741
|
+
const was = base.replace("#1-3", "#1-3 why")
|
|
1742
|
+
const after = base.replace("#1-3", "#1-3 why not")
|
|
1743
|
+
const [n] = reviewNotes(was, after)
|
|
1744
|
+
expect(n).toMatchObject({ kind: "pointer", before: "why", text: "why not" })
|
|
1745
|
+
})
|
|
1746
|
+
|
|
1747
|
+
it("a footnote added", () => {
|
|
1748
|
+
const after = base.replace("#1-3", "#1-3[^n1]") + "\n[^n1]: please split\n"
|
|
1749
|
+
expect(reviewNotes(base, after)).toEqual([
|
|
1750
|
+
{
|
|
1751
|
+
id: "note-1",
|
|
1752
|
+
kind: "footnote",
|
|
1753
|
+
anchor: "- [ ] ./src/a.ts#1-3[^n1]",
|
|
1754
|
+
before: "",
|
|
1755
|
+
text: "please split",
|
|
1756
|
+
},
|
|
1757
|
+
])
|
|
1758
|
+
})
|
|
1759
|
+
|
|
1760
|
+
it("a thread footnote never appears", () => {
|
|
1761
|
+
const after = base.replace("#1-3", "#1-3[^t1]") + "\n[^t1]:\n - H: why?\n"
|
|
1762
|
+
expect(reviewNotes(base, after)).toEqual([])
|
|
1763
|
+
})
|
|
1764
|
+
|
|
1765
|
+
it("chunk prose added", () => {
|
|
1766
|
+
const [n] = reviewNotes(base, doc("Prose. Also this is wrong."))
|
|
1767
|
+
expect(n).toMatchObject({ kind: "chunk", anchor: "Chunk one", before: "Prose." })
|
|
1768
|
+
})
|
|
1769
|
+
|
|
1770
|
+
it("a baseline already holding an answered note excludes it", () => {
|
|
1771
|
+
const answered = base.replace("#1-3", "#1-3 why?\n A: because")
|
|
1772
|
+
expect(reviewNotes(answered, answered)).toEqual([])
|
|
1773
|
+
const more = answered.replace("#1-3", "#1-3[^n2]") + "\n[^n2]: another\n"
|
|
1774
|
+
expect(reviewNotes(answered, more).map((n) => n.kind)).toEqual(["footnote"])
|
|
1775
|
+
})
|
|
1776
|
+
|
|
1777
|
+
it("ids run in document order", () => {
|
|
1778
|
+
const after = base.replace("#1-3", "#1-3 a[^n1]") + "\n[^n1]: b\n"
|
|
1779
|
+
expect(reviewNotes(base, after).map((n) => [n.id, n.kind])).toEqual([
|
|
1780
|
+
["note-1", "pointer"],
|
|
1781
|
+
["note-2", "footnote"],
|
|
1782
|
+
])
|
|
1783
|
+
})
|
|
1784
|
+
})
|
|
1785
|
+
|
|
1786
|
+
describe("reviewRisks", async () => {
|
|
1787
|
+
const { reviewRisks } = await import("./review.js")
|
|
1788
|
+
const doc = (description: string, pointers: string[]): string =>
|
|
1789
|
+
[
|
|
1790
|
+
"# Review: abc1234",
|
|
1791
|
+
"",
|
|
1792
|
+
"<!-- base: abc1234def5678901234567890123456789abcd -->",
|
|
1793
|
+
"",
|
|
1794
|
+
"## Chunk one",
|
|
1795
|
+
"",
|
|
1796
|
+
description,
|
|
1797
|
+
"",
|
|
1798
|
+
...pointers,
|
|
1799
|
+
].join("\n")
|
|
1800
|
+
|
|
1801
|
+
it("returns every Risk: pointer note in order with risk-<n> ids", () => {
|
|
1802
|
+
const risks = reviewRisks(
|
|
1803
|
+
doc("Prose.", [
|
|
1804
|
+
"- [ ] ./src/a.ts#1-3 — Risk: first",
|
|
1805
|
+
"- [ ] ./src/b.ts#4-6 — plain note",
|
|
1806
|
+
"- [ ] ./src/c.ts#7-9 — Risk: second",
|
|
1807
|
+
]),
|
|
1808
|
+
)
|
|
1809
|
+
expect(risks).toEqual([
|
|
1810
|
+
{ id: "risk-1", anchor: "Chunk one ./src/a.ts#1-3", text: "Risk: first" },
|
|
1811
|
+
{ id: "risk-2", anchor: "Chunk one ./src/c.ts#7-9", text: "Risk: second" },
|
|
1812
|
+
])
|
|
1813
|
+
})
|
|
1814
|
+
|
|
1815
|
+
it("ignores Risk: in a chunk description or mid-note", () => {
|
|
1816
|
+
expect(
|
|
1817
|
+
reviewRisks(
|
|
1818
|
+
doc("Risk: in prose.", [
|
|
1819
|
+
"- [ ] ./src/a.ts#1-3 — no Risk: here",
|
|
1820
|
+
"- [ ] ./src/b.ts#1-3 — no risk here",
|
|
1821
|
+
]),
|
|
1822
|
+
),
|
|
1823
|
+
).toEqual([])
|
|
1824
|
+
})
|
|
1825
|
+
|
|
1826
|
+
it("no marker yields an empty list", () => {
|
|
1827
|
+
expect(reviewRisks(doc("Prose.", ["- [ ] ./src/a.ts#1-3 — fine"]))).toEqual([])
|
|
1828
|
+
})
|
|
1829
|
+
})
|