@cueloop/schema 0.1.0-alpha.4 → 0.1.0-alpha.41

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.
@@ -13,7 +13,9 @@ Review sessions live only in daemon memory today.
13
13
  Sessions are written as one JSON document per session.
14
14
  `;
15
15
 
16
- function ann(partial: Partial<Annotation> & { anchor: Annotation["anchor"] }): Annotation {
16
+ function makeAnnotation(
17
+ partial: Partial<Annotation> & { anchor: Annotation["anchor"] },
18
+ ): Annotation {
17
19
  return {
18
20
  id: "a1",
19
21
  kind: "comment",
@@ -25,7 +27,8 @@ function ann(partial: Partial<Annotation> & { anchor: Annotation["anchor"] }): A
25
27
 
26
28
  describe("renderFeedback", () => {
27
29
  test("plan edits section carries the one unified diff, directive framing first", () => {
28
- const out = renderFeedback({
30
+ // Act
31
+ const feedback = renderFeedback({
29
32
  verdictKind: "request_changes",
30
33
  summary: "Tighten the storage section.",
31
34
  artifactContent: PLAN,
@@ -33,83 +36,168 @@ describe("renderFeedback", () => {
33
36
  annotations: [],
34
37
  planPath: "docs/plan.md",
35
38
  });
36
- expect(out).toContain("# Review: request changes");
37
- expect(out).toContain("Tighten the storage section.");
38
- expect(out).toContain("## Plan edits");
39
- expect(out).toContain("Apply this exact diff first");
40
- expect(out).toContain("--- a/docs/plan.md");
41
- expect(out).toContain("-Sessions are written as one JSON document per session.");
42
- expect(out).toContain("+Sessions are written as one JSON record per session.");
39
+
40
+ // Assert
41
+ expect(feedback).toContain("# Review: request changes");
42
+ expect(feedback).toContain("Tighten the storage section.");
43
+ expect(feedback).toContain("## Plan edits");
44
+ expect(feedback).toContain("Apply this exact diff first");
45
+ expect(feedback).toContain("--- a/docs/plan.md");
46
+ expect(feedback).toContain("-Sessions are written as one JSON document per session.");
47
+ expect(feedback).toContain("+Sessions are written as one JSON record per session.");
48
+ });
49
+
50
+ test("a diff working copy is handed back as the curated patch, not a diff of diffs", () => {
51
+ // Arrange
52
+ const submittedPatch = `--- a/src/x.ts
53
+ +++ b/src/x.ts
54
+ @@ -1,1 +1,1 @@
55
+ -const limit = 100;
56
+ +const limit = 250;
57
+ `;
58
+ const curatedPatch = `--- a/src/x.ts
59
+ +++ b/src/x.ts
60
+ @@ -1,1 +1,1 @@
61
+ -const limit = 100;
62
+ +const limit = 175;
63
+ `;
64
+
65
+ // Act
66
+ const feedback = renderFeedback({
67
+ verdictKind: "request_changes",
68
+ summary: "",
69
+ artifactContent: submittedPatch,
70
+ workingCopy: curatedPatch,
71
+ artifactType: "diff",
72
+ annotations: [],
73
+ });
74
+
75
+ // Assert
76
+ expect(feedback).toContain("## Curated changes");
77
+ expect(feedback).toContain("+const limit = 175;");
78
+ // the submitted patch is not re-diffed against the curated one
79
+ expect(feedback).not.toContain("Plan edits");
80
+ expect(feedback).not.toContain("++const");
81
+ });
82
+
83
+ test("a diff working copy that rejected everything says so", () => {
84
+ // Act
85
+ const feedback = renderFeedback({
86
+ verdictKind: "request_changes",
87
+ summary: "",
88
+ artifactContent: "--- a/x\n+++ b/x\n@@ -1 +1 @@\n-a\n+b\n",
89
+ workingCopy: "",
90
+ artifactType: "diff",
91
+ annotations: [],
92
+ });
93
+
94
+ // Assert
95
+ expect(feedback).toContain("## Curated changes");
96
+ expect(feedback).toContain("rejected all of your proposed changes");
43
97
  });
44
98
 
45
99
  test("annotations are located by quote and section", () => {
46
- const out = renderFeedback({
100
+ // Act
101
+ const feedback = renderFeedback({
47
102
  verdictKind: "comment",
48
103
  summary: "",
49
104
  artifactContent: PLAN,
50
105
  annotations: [
51
- ann({
106
+ makeAnnotation({
52
107
  anchor: { quote: "daemon memory", prefix: "live only in ", suffix: " today." },
53
108
  }),
54
109
  ],
55
110
  });
56
- expect(out).toContain("## Annotations (1)");
57
- expect(out).toContain("### 1. Comment (§ Context)");
58
- expect(out).toContain("> daemon memory");
59
- expect(out).toContain("Please clarify.");
111
+
112
+ // Assert
113
+ expect(feedback).toContain("## Annotations (1)");
114
+ expect(feedback).toContain("### 1. Comment (§ Context)");
115
+ expect(feedback).toContain("> daemon memory");
116
+ expect(feedback).toContain("Please clarify.");
60
117
  });
61
118
 
62
- test("suggestions render as replace/with pairs", () => {
63
- const out = renderFeedback({
64
- verdictKind: "request_changes",
119
+ test("orphaned anchors are flagged, never dropped", () => {
120
+ // Act
121
+ const feedback = renderFeedback({
122
+ verdictKind: "comment",
65
123
  summary: "",
66
124
  artifactContent: PLAN,
67
125
  annotations: [
68
- ann({
69
- kind: "suggestion",
70
- body: "one durable JSON record",
71
- anchor: { quote: "one JSON document", prefix: "written as ", suffix: " per session." },
72
- }),
126
+ makeAnnotation({ anchor: { quote: "text that is gone", prefix: "", suffix: "" } }),
73
127
  ],
74
128
  });
75
- expect(out).toContain("### 1. Suggested change (§ Storage)");
76
- expect(out).toContain("Replace:\n> one JSON document");
77
- expect(out).toContain("With:\n> one durable JSON record");
129
+
130
+ // Assert
131
+ expect(feedback).toContain("[orphaned anchor: the quoted text is no longer present]");
132
+ expect(feedback).toContain("> text that is gone");
78
133
  });
79
134
 
80
- test("orphaned anchors are flagged, never dropped", () => {
81
- const out = renderFeedback({
82
- verdictKind: "comment",
135
+ test("empty review says so explicitly", () => {
136
+ // Act
137
+ const feedback = renderFeedback({
138
+ verdictKind: "approve",
83
139
  summary: "",
84
140
  artifactContent: PLAN,
85
- annotations: [ann({ anchor: { quote: "text that is gone", prefix: "", suffix: "" } })],
141
+ annotations: [],
86
142
  });
87
- expect(out).toContain("[orphaned anchor: the quoted text is no longer present]");
88
- expect(out).toContain("> text that is gone");
143
+
144
+ // Assert
145
+ expect(feedback).toContain("_No edits or annotations._");
89
146
  });
90
147
 
91
- test("empty review says so explicitly", () => {
92
- const out = renderFeedback({
148
+ test("agent notes never come back as feedback items", () => {
149
+ // Arrange
150
+ const note = makeAnnotation({
151
+ kind: "note",
152
+ anchor: { quote: "src/a.ts", prefix: "", suffix: "" },
153
+ body: "The agent's own explanation of the change.",
154
+ });
155
+
156
+ // Act
157
+ const noteOnly = renderFeedback({
93
158
  verdictKind: "approve",
94
159
  summary: "",
95
160
  artifactContent: PLAN,
96
- annotations: [],
161
+ annotations: [note],
162
+ });
163
+
164
+ // Assert
165
+ expect(noteOnly).toContain("_No edits or annotations._");
166
+ expect(noteOnly).not.toContain("The agent's own explanation of the change.");
167
+ const mixed = renderFeedback({
168
+ verdictKind: "request_changes",
169
+ summary: "",
170
+ artifactContent: PLAN,
171
+ annotations: [
172
+ note,
173
+ makeAnnotation({
174
+ anchor: { quote: "one JSON document", prefix: "written as ", suffix: " per session." },
175
+ }),
176
+ ],
97
177
  });
98
- expect(out).toContain("_No edits or annotations._");
178
+ expect(mixed).toContain("## Annotations (1)");
179
+ expect(mixed).not.toContain("The agent's own explanation of the change.");
99
180
  });
100
181
 
101
182
  test("anchors resolve against the working copy when present", () => {
183
+ // Arrange
102
184
  // annotation made on text that only exists in the edited copy
103
185
  const working = PLAN.replace("## Storage", "## Persistence");
104
- const out = renderFeedback({
186
+
187
+ // Act
188
+ const feedback = renderFeedback({
105
189
  verdictKind: "request_changes",
106
190
  summary: "",
107
191
  artifactContent: PLAN,
108
192
  workingCopy: working,
109
193
  annotations: [
110
- ann({ anchor: { quote: "one JSON document", prefix: "written as ", suffix: " per session." } }),
194
+ makeAnnotation({
195
+ anchor: { quote: "one JSON document", prefix: "written as ", suffix: " per session." },
196
+ }),
111
197
  ],
112
198
  });
113
- expect(out).toContain("(§ Persistence)");
199
+
200
+ // Assert
201
+ expect(feedback).toContain("(§ Persistence)");
114
202
  });
115
203
  });
package/src/feedback.ts CHANGED
@@ -1,11 +1,18 @@
1
1
  /**
2
2
  * feedback.md: the ONE structured markdown document the agent receives on
3
- * resolve (map #2, verified in the edit-mode deep-dive). Directive framing -
4
- * soft phrasing gets ignored by models: the plan diff is applied verbatim
5
- * first, then every annotation is addressed, located by quoted text.
3
+ * resolve. Directive framing - soft phrasing gets ignored by models: the plan
4
+ * diff is applied verbatim first, then every annotation is addressed, located
5
+ * by quoted text.
6
6
  */
7
7
 
8
- import type { Annotation, ReviewSession, VerdictKind } from "./types";
8
+ import {
9
+ isAddressed,
10
+ isAgentNote,
11
+ type Annotation,
12
+ type ArtifactType,
13
+ type ReviewSession,
14
+ type VerdictKind,
15
+ } from "./types";
9
16
  import { parseBlocks, sectionOf } from "./markdown";
10
17
  import { resolveAnchor } from "./anchor";
11
18
  import { unifiedDiffText } from "./diff";
@@ -15,18 +22,34 @@ export interface FeedbackInput {
15
22
  summary: string;
16
23
  /** The submitted artifact content (latest revision). */
17
24
  artifactContent: string;
18
- /** The reviewer's working copy; undefined = no direct edits. */
25
+ /** The reviewer's working copy; undefined = no direct edits. For a diff it is
26
+ * the curated patch (accepted hunks); for a plan it is the edited source. */
19
27
  workingCopy?: string;
28
+ /** Artifact kind; defaults to "plan". A diff working copy is already a patch. */
29
+ artifactType?: ArtifactType;
20
30
  annotations: Annotation[];
21
31
  /** Path the agent knows the plan by, for direct reference. */
22
32
  planPath?: string;
33
+ /** Session id, so the document can teach the addressed-ids resubmit call. */
34
+ sessionId?: string;
23
35
  }
24
36
 
25
- const quoteLines = (s: string) => "> " + s.replace(/\n/g, "\n> ");
37
+ const quoteLines = (text: string) => "> " + text.replace(/\n/g, "\n> ");
26
38
 
27
39
  export function renderFeedback(input: FeedbackInput): string {
28
40
  const path = input.planPath ?? "plan.md";
29
- const blocks = parseBlocks(input.workingCopy ?? input.artifactContent);
41
+ // agent notes are the submitter's own context - never echoed back as
42
+ // feedback - and annotations a previous revision already addressed stay out
43
+ // of the next document, so the agent only ever sees the open items
44
+ const annotations = input.annotations.filter(
45
+ (annotation) => !isAgentNote(annotation) && !isAddressed(annotation),
46
+ );
47
+ const isDiff = input.artifactType === "diff";
48
+ // A diff's annotations anchor to the submitted patch rows; resolve against it,
49
+ // not the curated working copy (which may drop the rejected rows).
50
+ const blocks = parseBlocks(
51
+ isDiff ? input.artifactContent : (input.workingCopy ?? input.artifactContent),
52
+ );
30
53
  const lines: string[] = [];
31
54
  lines.push("# Review: " + input.verdictKind.replace("_", " "));
32
55
  lines.push("");
@@ -35,66 +58,104 @@ export function renderFeedback(input: FeedbackInput): string {
35
58
  lines.push("");
36
59
  }
37
60
 
38
- const diff =
39
- input.workingCopy !== undefined ? unifiedDiffText(input.artifactContent, input.workingCopy, path) : null;
40
- if (diff) {
41
- lines.push("## Plan edits");
61
+ let hasEdits = false;
62
+ if (isDiff && input.workingCopy !== undefined) {
63
+ // The diff working copy is already the curated patch - the accepted subset
64
+ // of the submitted changes - so hand it back verbatim, not a diff of diffs.
65
+ hasEdits = true;
66
+ lines.push("## Curated changes");
42
67
  lines.push("");
43
- lines.push(`The reviewer edited ${path} directly. Apply this exact diff first;`);
44
- lines.push("it is a unified diff against the version you submitted.");
45
- lines.push("");
46
- lines.push("```diff");
47
- lines.push(diff);
48
- lines.push("```");
68
+ if (input.workingCopy.trim()) {
69
+ lines.push("The reviewer accepted a subset of your proposed changes. Apply this");
70
+ lines.push("exact unified diff; it replaces what you submitted.");
71
+ lines.push("");
72
+ lines.push("```diff");
73
+ lines.push(input.workingCopy);
74
+ lines.push("```");
75
+ } else {
76
+ lines.push("The reviewer rejected all of your proposed changes.");
77
+ }
49
78
  lines.push("");
79
+ } else {
80
+ const diff =
81
+ input.workingCopy !== undefined
82
+ ? unifiedDiffText(input.artifactContent, input.workingCopy, path)
83
+ : null;
84
+ if (diff) {
85
+ hasEdits = true;
86
+ lines.push("## Plan edits");
87
+ lines.push("");
88
+ lines.push(`The reviewer edited ${path} directly. Apply this exact diff first;`);
89
+ lines.push("it is a unified diff against the version you submitted.");
90
+ lines.push("");
91
+ lines.push("```diff");
92
+ lines.push(diff);
93
+ lines.push("```");
94
+ lines.push("");
95
+ }
50
96
  }
51
97
 
52
- if (input.annotations.length) {
53
- lines.push(`## Annotations (${input.annotations.length})`);
98
+ if (annotations.length) {
99
+ lines.push(`## Annotations (${annotations.length})`);
54
100
  lines.push("");
55
101
  lines.push(`Address every item. Locate each one in ${path} by its quoted text.`);
56
102
  lines.push("");
57
- input.annotations.forEach((a, i) => {
58
- const res = resolveAnchor(a.anchor, blocks);
59
- const sec = res ? sectionOf(blocks, res.blockIndex) : "";
60
- const loc = sec ? ` (§ ${sec})` : "";
61
- const flag = res === null ? " [orphaned anchor: the quoted text is no longer present]" : "";
62
- if (a.kind === "suggestion") {
63
- lines.push(`### ${i + 1}. Suggested change${loc}${flag}`);
64
- lines.push("");
65
- lines.push("Replace:");
66
- lines.push(quoteLines(a.anchor.quote));
67
- lines.push("With:");
68
- lines.push(quoteLines(a.body));
69
- } else {
70
- lines.push(`### ${i + 1}. ${capitalize(a.kind)}${loc}${flag}`);
71
- lines.push("");
72
- lines.push(quoteLines(a.anchor.quote));
73
- lines.push("");
74
- lines.push(a.body);
75
- }
103
+ annotations.forEach((annotation, annotationIndex) => {
104
+ const resolved = resolveAnchor(annotation.anchor, blocks);
105
+ const sectionTitle = resolved ? sectionOf(blocks, resolved.blockIndex) : "";
106
+ const location = sectionTitle ? ` (§ ${sectionTitle})` : "";
107
+ const orphanFlag =
108
+ resolved === null ? " [orphaned anchor: the quoted text is no longer present]" : "";
109
+ lines.push(
110
+ `### ${annotationIndex + 1}. ${capitalize(annotation.kind)}${location}${orphanFlag}`,
111
+ );
112
+ lines.push("");
113
+ lines.push(quoteLines(annotation.anchor.quote));
114
+ lines.push("");
115
+ lines.push(annotation.body);
116
+ lines.push("");
117
+ lines.push(`annotation id: \`${annotation.id}\``);
76
118
  lines.push("");
77
119
  });
120
+ if (input.sessionId) {
121
+ lines.push("## Reporting what you addressed");
122
+ lines.push("");
123
+ lines.push("When you resubmit the revised plan, list the annotation ids you acted on -");
124
+ lines.push("they are marked addressed for the reviewer and leave the open list:");
125
+ lines.push("");
126
+ lines.push("```sh");
127
+ lines.push(
128
+ `cueloop session submit-revision ${input.sessionId} --content-file ${path} --addressed <id,id,...>`,
129
+ );
130
+ lines.push("```");
131
+ lines.push("");
132
+ }
78
133
  }
79
134
 
80
- if (!diff && !input.annotations.length) {
135
+ if (!hasEdits && !annotations.length) {
81
136
  lines.push("_No edits or annotations._");
82
137
  lines.push("");
83
138
  }
84
139
  return lines.join("\n");
85
140
  }
86
141
 
87
- export function feedbackForSession(session: ReviewSession, verdictKind: VerdictKind, summary: string): string {
142
+ export function feedbackForSession(
143
+ session: ReviewSession,
144
+ verdictKind: VerdictKind,
145
+ summary: string,
146
+ ): string {
88
147
  return renderFeedback({
89
148
  verdictKind,
90
149
  summary,
91
150
  artifactContent: session.artifact.content,
92
151
  workingCopy: session.workingCopy,
152
+ artifactType: session.artifact.type,
93
153
  annotations: session.annotations,
94
154
  planPath: session.artifact.meta.planPath,
155
+ sessionId: session.id,
95
156
  });
96
157
  }
97
158
 
98
- function capitalize(s: string): string {
99
- return s.charAt(0).toUpperCase() + s.slice(1);
159
+ function capitalize(word: string): string {
160
+ return word.charAt(0).toUpperCase() + word.slice(1);
100
161
  }
@@ -0,0 +1,50 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { detectHerdr, insideHerdr, returnPaneFor } from "./herdr";
3
+
4
+ describe("detectHerdr", () => {
5
+ test("null outside herdr, context inside", () => {
6
+ expect(detectHerdr({})).toBeNull();
7
+ // no HERDR_ENV or no pane id means we are not in a usable herdr pane
8
+ expect(detectHerdr({ HERDR_PANE_ID: "p1" })).toBeNull();
9
+ expect(detectHerdr({ HERDR_ENV: "1" })).toBeNull();
10
+ // herdr 0.8.0 sets no HERDR_BIN_PATH: the binary defaults to `herdr` on PATH
11
+ expect(detectHerdr({ HERDR_ENV: "1", HERDR_PANE_ID: "p1" })).toEqual({
12
+ paneId: "p1",
13
+ binPath: "herdr",
14
+ });
15
+ // an explicit HERDR_BIN_PATH (tests, or a pinned install) still wins
16
+ expect(
17
+ detectHerdr({ HERDR_ENV: "1", HERDR_PANE_ID: "p1", HERDR_BIN_PATH: "/x/herdr" }),
18
+ ).toEqual({
19
+ paneId: "p1",
20
+ binPath: "/x/herdr",
21
+ });
22
+ });
23
+ });
24
+
25
+ describe("insideHerdr", () => {
26
+ test("true only when HERDR_ENV is exactly 1", () => {
27
+ expect(insideHerdr({})).toBeFalse();
28
+ expect(insideHerdr({ HERDR_ENV: "0" })).toBeFalse();
29
+ expect(insideHerdr({ HERDR_ENV: "1" })).toBeTrue();
30
+ });
31
+ });
32
+
33
+ describe("returnPaneFor", () => {
34
+ test("outside herdr there is never a return target", () => {
35
+ expect(returnPaneFor("w1:p1", {})).toBeUndefined();
36
+ });
37
+
38
+ test("env override wins, session meta is the fallback", () => {
39
+ // Arrange
40
+ const inside = { HERDR_ENV: "1", HERDR_PANE_ID: "w1:p9" };
41
+
42
+ // Assert
43
+ expect(returnPaneFor("w1:p1", inside)).toBe("w1:p1");
44
+ expect(returnPaneFor("w1:p1", { ...inside, CUELOOP_RETURN_PANE: "w1:p2" })).toBe("w1:p2");
45
+ });
46
+
47
+ test("returning to our own pane is a no-op", () => {
48
+ expect(returnPaneFor("w1:p1", { HERDR_ENV: "1", HERDR_PANE_ID: "w1:p1" })).toBeUndefined();
49
+ });
50
+ });
package/src/herdr.ts ADDED
@@ -0,0 +1,51 @@
1
+ /**
2
+ * The herdr ambient env contract, single-sourced. A process running inside a
3
+ * herdr pane sees HERDR_ENV=1 plus the pane id; the herdr CLI itself reaches
4
+ * the app over HERDR_SOCKET_PATH (which the spawned `herdr` reads from the
5
+ * inherited env), so the binary is just `herdr` on PATH. Both the agent-side
6
+ * reporter (adapters) and the reviewer-side return-focus (client) read this
7
+ * contract; decoding it in one place stops the two sides drifting on which
8
+ * vars are required. Pure: no IO, env is only the default argument so callers
9
+ * can pass a fixture.
10
+ */
11
+
12
+ export interface HerdrContext {
13
+ paneId: string;
14
+ binPath: string;
15
+ }
16
+
17
+ export type HerdrEnv = Record<string, string | undefined>;
18
+
19
+ /**
20
+ * The pane's full contract - pane id and binary path - or null outside a
21
+ * functioning herdr. Required by anything that spawns the herdr CLI.
22
+ */
23
+ export function detectHerdr(env: HerdrEnv = process.env): HerdrContext | null {
24
+ if (env.HERDR_ENV !== "1") return null;
25
+ const paneId = env.HERDR_PANE_ID;
26
+ if (!paneId) return null;
27
+ // herdr 0.8.0 exposes no HERDR_BIN_PATH; the CLI is `herdr` on PATH and finds
28
+ // the app over HERDR_SOCKET_PATH from the inherited env. Tests still override
29
+ // HERDR_BIN_PATH to point the spawn at a stub script.
30
+ const binPath = env.HERDR_BIN_PATH ?? "herdr";
31
+ return { paneId, binPath };
32
+ }
33
+
34
+ /** True whenever this process runs inside a herdr pane. */
35
+ export function insideHerdr(env: HerdrEnv = process.env): boolean {
36
+ return env.HERDR_ENV === "1";
37
+ }
38
+
39
+ /**
40
+ * The pane a closing review should hand focus back to: the opener's
41
+ * CUELOOP_RETURN_PANE, or the session's recorded pane, but never this pane
42
+ * itself. Undefined outside herdr or when the only candidate is our own pane.
43
+ */
44
+ export function returnPaneFor(
45
+ sessionHerdrPane?: string,
46
+ env: HerdrEnv = process.env,
47
+ ): string | undefined {
48
+ if (!insideHerdr(env)) return undefined;
49
+ const pane = env.CUELOOP_RETURN_PANE ?? sessionHerdrPane;
50
+ return pane && pane !== env.HERDR_PANE_ID ? pane : undefined;
51
+ }
package/src/index.ts CHANGED
@@ -1,5 +1,8 @@
1
1
  export * from "./types";
2
+ export * from "./participants";
2
3
  export * from "./markdown";
4
+ export * from "./working-copy";
3
5
  export * from "./diff";
4
6
  export * from "./anchor";
5
7
  export * from "./feedback";
8
+ export * from "./herdr";
@@ -29,8 +29,11 @@ const y = 2;
29
29
 
30
30
  describe("parseBlocks", () => {
31
31
  test("parses every block kind with source line ranges", () => {
32
+ // Act
32
33
  const blocks = parseBlocks(SAMPLE);
33
- expect(blocks.map((b) => b.kind)).toEqual([
34
+
35
+ // Assert
36
+ expect(blocks.map((block) => block.kind)).toEqual([
34
37
  "h1",
35
38
  "h2",
36
39
  "p",
@@ -43,7 +46,7 @@ describe("parseBlocks", () => {
43
46
  "quote",
44
47
  "hr",
45
48
  ]);
46
- const code = blocks.find((b) => b.kind === "code")!;
49
+ const code = blocks.find((block) => block.kind === "code")!;
47
50
  expect(code.lang).toBe("ts");
48
51
  expect(code.text).toBe("const x = 1;\nconst y = 2;");
49
52
  // fence lines included in the source range
@@ -51,50 +54,71 @@ describe("parseBlocks", () => {
51
54
  });
52
55
 
53
56
  test("multi-line paragraph keeps its text and range", () => {
57
+ // Arrange
54
58
  const blocks = parseBlocks(SAMPLE);
55
- const p = blocks.find((b) => b.kind === "p")!;
56
- expect(p.text).toBe("A paragraph that\nspans two lines.");
57
- expect(p.lineEnd - p.lineStart).toBe(1);
59
+ const paragraph = blocks.find((block) => block.kind === "p")!;
60
+
61
+ // Assert
62
+ expect(paragraph.text).toBe("A paragraph that\nspans two lines.");
63
+ expect(paragraph.lineEnd - paragraph.lineStart).toBe(1);
58
64
  });
59
65
 
60
66
  test("line ranges index into the source", () => {
67
+ // Arrange
61
68
  const lines = SAMPLE.split("\n");
62
- for (const b of parseBlocks(SAMPLE)) {
63
- if (b.kind === "code") {
64
- expect(lines[b.lineStart]!.startsWith("```")).toBe(true);
65
- } else if (b.kind === "h2") {
66
- expect(lines[b.lineStart]!.startsWith("## ")).toBe(true);
69
+
70
+ // Assert
71
+ for (const block of parseBlocks(SAMPLE)) {
72
+ if (block.kind === "code") {
73
+ expect(lines[block.lineStart]!.startsWith("```")).toBe(true);
74
+ } else if (block.kind === "h2") {
75
+ expect(lines[block.lineStart]!.startsWith("## ")).toBe(true);
67
76
  }
68
77
  }
69
78
  });
70
79
 
71
80
  test("round-trips through blockToMd", () => {
81
+ // Arrange
72
82
  const blocks = parseBlocks(SAMPLE);
73
- let oli = 0;
83
+ let orderedItemCount = 0;
74
84
  const rebuilt = blocks
75
- .map((b) => {
76
- oli = b.kind === "oli" ? oli + 1 : 0;
77
- return blockToMd(b, oli || 1);
85
+ .map((block) => {
86
+ orderedItemCount = block.kind === "oli" ? orderedItemCount + 1 : 0;
87
+ return blockToMd(block, orderedItemCount || 1);
78
88
  })
79
89
  .join("\n\n");
90
+
91
+ // Act
80
92
  // re-parsing the rebuild yields the same kinds and texts
81
93
  const again = parseBlocks(rebuilt);
82
- expect(again.map((b) => [b.kind, b.text])).toEqual(blocks.map((b) => [b.kind, b.text]));
94
+
95
+ // Assert
96
+ expect(again.map((block) => [block.kind, block.text])).toEqual(
97
+ blocks.map((block) => [block.kind, block.text]),
98
+ );
83
99
  });
84
100
 
85
101
  test("unknown constructs degrade to paragraphs, no content lost", () => {
102
+ // Arrange
86
103
  const md = "| a | b |\n|---|---|\n| 1 | 2 |";
104
+
105
+ // Act
87
106
  const blocks = parseBlocks(md);
88
- expect(blocks.map((b) => b.text).join("\n")).toContain("| a | b |");
107
+
108
+ // Assert
109
+ expect(blocks.map((block) => block.text).join("\n")).toContain("| a | b |");
89
110
  });
90
111
  });
91
112
 
92
113
  describe("sectionOf", () => {
93
114
  test("returns the nearest preceding heading", () => {
115
+ // Arrange
94
116
  const blocks = parseBlocks(SAMPLE);
95
- const oliIdx = blocks.findIndex((b) => b.kind === "oli");
96
- expect(sectionOf(blocks, oliIdx)).toBe("Steps");
97
- const pIdx = blocks.findIndex((b) => b.kind === "p");
98
- expect(sectionOf(blocks, pIdx)).toBe("Context");
117
+
118
+ // Assert
119
+ const orderedItemIndex = blocks.findIndex((block) => block.kind === "oli");
120
+ expect(sectionOf(blocks, orderedItemIndex)).toBe("Steps");
121
+ const paragraphIndex = blocks.findIndex((block) => block.kind === "p");
122
+ expect(sectionOf(blocks, paragraphIndex)).toBe("Context");
99
123
  });
100
124
  });