@cueloop/schema 0.1.0-alpha.3 → 0.1.0-alpha.30
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/package.json +7 -2
- package/src/anchor.test.ts +72 -36
- package/src/anchor.ts +20 -19
- package/src/diff.test.ts +40 -16
- package/src/diff.ts +83 -71
- package/src/feedback.test.ts +73 -29
- package/src/feedback.ts +42 -22
- package/src/herdr.test.ts +40 -0
- package/src/herdr.ts +43 -0
- package/src/index.ts +2 -0
- package/src/markdown.test.ts +42 -20
- package/src/markdown.ts +72 -72
- package/src/types.test.ts +21 -0
- package/src/types.ts +82 -4
- package/src/working-copy.test.ts +144 -0
- package/src/working-copy.ts +61 -0
package/src/feedback.test.ts
CHANGED
|
@@ -13,7 +13,7 @@ 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
|
|
16
|
+
function makeAnnotation(partial: Partial<Annotation> & { anchor: Annotation["anchor"] }): Annotation {
|
|
17
17
|
return {
|
|
18
18
|
id: "a1",
|
|
19
19
|
kind: "comment",
|
|
@@ -25,7 +25,8 @@ function ann(partial: Partial<Annotation> & { anchor: Annotation["anchor"] }): A
|
|
|
25
25
|
|
|
26
26
|
describe("renderFeedback", () => {
|
|
27
27
|
test("plan edits section carries the one unified diff, directive framing first", () => {
|
|
28
|
-
|
|
28
|
+
// Act
|
|
29
|
+
const feedback = renderFeedback({
|
|
29
30
|
verdictKind: "request_changes",
|
|
30
31
|
summary: "Tighten the storage section.",
|
|
31
32
|
artifactContent: PLAN,
|
|
@@ -33,83 +34,126 @@ describe("renderFeedback", () => {
|
|
|
33
34
|
annotations: [],
|
|
34
35
|
planPath: "docs/plan.md",
|
|
35
36
|
});
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
expect(
|
|
39
|
-
expect(
|
|
40
|
-
expect(
|
|
41
|
-
expect(
|
|
42
|
-
expect(
|
|
37
|
+
|
|
38
|
+
// Assert
|
|
39
|
+
expect(feedback).toContain("# Review: request changes");
|
|
40
|
+
expect(feedback).toContain("Tighten the storage section.");
|
|
41
|
+
expect(feedback).toContain("## Plan edits");
|
|
42
|
+
expect(feedback).toContain("Apply this exact diff first");
|
|
43
|
+
expect(feedback).toContain("--- a/docs/plan.md");
|
|
44
|
+
expect(feedback).toContain("-Sessions are written as one JSON document per session.");
|
|
45
|
+
expect(feedback).toContain("+Sessions are written as one JSON record per session.");
|
|
43
46
|
});
|
|
44
47
|
|
|
45
48
|
test("annotations are located by quote and section", () => {
|
|
46
|
-
|
|
49
|
+
// Act
|
|
50
|
+
const feedback = renderFeedback({
|
|
47
51
|
verdictKind: "comment",
|
|
48
52
|
summary: "",
|
|
49
53
|
artifactContent: PLAN,
|
|
50
54
|
annotations: [
|
|
51
|
-
|
|
55
|
+
makeAnnotation({
|
|
52
56
|
anchor: { quote: "daemon memory", prefix: "live only in ", suffix: " today." },
|
|
53
57
|
}),
|
|
54
58
|
],
|
|
55
59
|
});
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
expect(
|
|
59
|
-
expect(
|
|
60
|
+
|
|
61
|
+
// Assert
|
|
62
|
+
expect(feedback).toContain("## Annotations (1)");
|
|
63
|
+
expect(feedback).toContain("### 1. Comment (§ Context)");
|
|
64
|
+
expect(feedback).toContain("> daemon memory");
|
|
65
|
+
expect(feedback).toContain("Please clarify.");
|
|
60
66
|
});
|
|
61
67
|
|
|
62
68
|
test("suggestions render as replace/with pairs", () => {
|
|
63
|
-
|
|
69
|
+
// Act
|
|
70
|
+
const feedback = renderFeedback({
|
|
64
71
|
verdictKind: "request_changes",
|
|
65
72
|
summary: "",
|
|
66
73
|
artifactContent: PLAN,
|
|
67
74
|
annotations: [
|
|
68
|
-
|
|
75
|
+
makeAnnotation({
|
|
69
76
|
kind: "suggestion",
|
|
70
77
|
body: "one durable JSON record",
|
|
71
78
|
anchor: { quote: "one JSON document", prefix: "written as ", suffix: " per session." },
|
|
72
79
|
}),
|
|
73
80
|
],
|
|
74
81
|
});
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
expect(
|
|
82
|
+
|
|
83
|
+
// Assert
|
|
84
|
+
expect(feedback).toContain("### 1. Suggested change (§ Storage)");
|
|
85
|
+
expect(feedback).toContain("Replace:\n> one JSON document");
|
|
86
|
+
expect(feedback).toContain("With:\n> one durable JSON record");
|
|
78
87
|
});
|
|
79
88
|
|
|
80
89
|
test("orphaned anchors are flagged, never dropped", () => {
|
|
81
|
-
|
|
90
|
+
// Act
|
|
91
|
+
const feedback = renderFeedback({
|
|
82
92
|
verdictKind: "comment",
|
|
83
93
|
summary: "",
|
|
84
94
|
artifactContent: PLAN,
|
|
85
|
-
annotations: [
|
|
95
|
+
annotations: [makeAnnotation({ anchor: { quote: "text that is gone", prefix: "", suffix: "" } })],
|
|
86
96
|
});
|
|
87
|
-
|
|
88
|
-
|
|
97
|
+
|
|
98
|
+
// Assert
|
|
99
|
+
expect(feedback).toContain("[orphaned anchor: the quoted text is no longer present]");
|
|
100
|
+
expect(feedback).toContain("> text that is gone");
|
|
89
101
|
});
|
|
90
102
|
|
|
91
103
|
test("empty review says so explicitly", () => {
|
|
92
|
-
|
|
104
|
+
// Act
|
|
105
|
+
const feedback = renderFeedback({
|
|
93
106
|
verdictKind: "approve",
|
|
94
107
|
summary: "",
|
|
95
108
|
artifactContent: PLAN,
|
|
96
109
|
annotations: [],
|
|
97
110
|
});
|
|
98
|
-
|
|
111
|
+
|
|
112
|
+
// Assert
|
|
113
|
+
expect(feedback).toContain("_No edits or annotations._");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("agent notes never come back as feedback items", () => {
|
|
117
|
+
// Arrange
|
|
118
|
+
const note = makeAnnotation({
|
|
119
|
+
kind: "note",
|
|
120
|
+
anchor: { quote: "src/a.ts", prefix: "", suffix: "" },
|
|
121
|
+
body: "The agent's own explanation of the change.",
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// Act
|
|
125
|
+
const noteOnly = renderFeedback({ verdictKind: "approve", summary: "", artifactContent: PLAN, annotations: [note] });
|
|
126
|
+
|
|
127
|
+
// Assert
|
|
128
|
+
expect(noteOnly).toContain("_No edits or annotations._");
|
|
129
|
+
expect(noteOnly).not.toContain("The agent's own explanation of the change.");
|
|
130
|
+
const mixed = renderFeedback({
|
|
131
|
+
verdictKind: "request_changes",
|
|
132
|
+
summary: "",
|
|
133
|
+
artifactContent: PLAN,
|
|
134
|
+
annotations: [note, makeAnnotation({ anchor: { quote: "one JSON document", prefix: "written as ", suffix: " per session." } })],
|
|
135
|
+
});
|
|
136
|
+
expect(mixed).toContain("## Annotations (1)");
|
|
137
|
+
expect(mixed).not.toContain("The agent's own explanation of the change.");
|
|
99
138
|
});
|
|
100
139
|
|
|
101
140
|
test("anchors resolve against the working copy when present", () => {
|
|
141
|
+
// Arrange
|
|
102
142
|
// annotation made on text that only exists in the edited copy
|
|
103
143
|
const working = PLAN.replace("## Storage", "## Persistence");
|
|
104
|
-
|
|
144
|
+
|
|
145
|
+
// Act
|
|
146
|
+
const feedback = renderFeedback({
|
|
105
147
|
verdictKind: "request_changes",
|
|
106
148
|
summary: "",
|
|
107
149
|
artifactContent: PLAN,
|
|
108
150
|
workingCopy: working,
|
|
109
151
|
annotations: [
|
|
110
|
-
|
|
152
|
+
makeAnnotation({ anchor: { quote: "one JSON document", prefix: "written as ", suffix: " per session." } }),
|
|
111
153
|
],
|
|
112
154
|
});
|
|
113
|
-
|
|
155
|
+
|
|
156
|
+
// Assert
|
|
157
|
+
expect(feedback).toContain("(§ Persistence)");
|
|
114
158
|
});
|
|
115
159
|
});
|
package/src/feedback.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* feedback.md: the ONE structured markdown document the agent receives on
|
|
3
|
-
* resolve
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
|
8
|
+
import { isAddressed, isAgentNote, type Annotation, type ReviewSession, type VerdictKind } from "./types";
|
|
9
9
|
import { parseBlocks, sectionOf } from "./markdown";
|
|
10
10
|
import { resolveAnchor } from "./anchor";
|
|
11
11
|
import { unifiedDiffText } from "./diff";
|
|
@@ -20,12 +20,18 @@ export interface FeedbackInput {
|
|
|
20
20
|
annotations: Annotation[];
|
|
21
21
|
/** Path the agent knows the plan by, for direct reference. */
|
|
22
22
|
planPath?: string;
|
|
23
|
+
/** Session id, so the document can teach the addressed-ids resubmit call. */
|
|
24
|
+
sessionId?: string;
|
|
23
25
|
}
|
|
24
26
|
|
|
25
|
-
const quoteLines = (
|
|
27
|
+
const quoteLines = (text: string) => "> " + text.replace(/\n/g, "\n> ");
|
|
26
28
|
|
|
27
29
|
export function renderFeedback(input: FeedbackInput): string {
|
|
28
30
|
const path = input.planPath ?? "plan.md";
|
|
31
|
+
// agent notes are the submitter's own context - never echoed back as
|
|
32
|
+
// feedback - and annotations a previous revision already addressed stay out
|
|
33
|
+
// of the next document, so the agent only ever sees the open items
|
|
34
|
+
const annotations = input.annotations.filter((annotation) => !isAgentNote(annotation) && !isAddressed(annotation));
|
|
29
35
|
const blocks = parseBlocks(input.workingCopy ?? input.artifactContent);
|
|
30
36
|
const lines: string[] = [];
|
|
31
37
|
lines.push("# Review: " + input.verdictKind.replace("_", " "));
|
|
@@ -49,35 +55,48 @@ export function renderFeedback(input: FeedbackInput): string {
|
|
|
49
55
|
lines.push("");
|
|
50
56
|
}
|
|
51
57
|
|
|
52
|
-
if (
|
|
53
|
-
lines.push(`## Annotations (${
|
|
58
|
+
if (annotations.length) {
|
|
59
|
+
lines.push(`## Annotations (${annotations.length})`);
|
|
54
60
|
lines.push("");
|
|
55
61
|
lines.push(`Address every item. Locate each one in ${path} by its quoted text.`);
|
|
56
62
|
lines.push("");
|
|
57
|
-
|
|
58
|
-
const
|
|
59
|
-
const
|
|
60
|
-
const
|
|
61
|
-
const
|
|
62
|
-
if (
|
|
63
|
-
lines.push(`### ${
|
|
63
|
+
annotations.forEach((annotation, annotationIndex) => {
|
|
64
|
+
const resolved = resolveAnchor(annotation.anchor, blocks);
|
|
65
|
+
const sectionTitle = resolved ? sectionOf(blocks, resolved.blockIndex) : "";
|
|
66
|
+
const location = sectionTitle ? ` (§ ${sectionTitle})` : "";
|
|
67
|
+
const orphanFlag = resolved === null ? " [orphaned anchor: the quoted text is no longer present]" : "";
|
|
68
|
+
if (annotation.kind === "suggestion") {
|
|
69
|
+
lines.push(`### ${annotationIndex + 1}. Suggested change${location}${orphanFlag}`);
|
|
64
70
|
lines.push("");
|
|
65
71
|
lines.push("Replace:");
|
|
66
|
-
lines.push(quoteLines(
|
|
72
|
+
lines.push(quoteLines(annotation.anchor.quote));
|
|
67
73
|
lines.push("With:");
|
|
68
|
-
lines.push(quoteLines(
|
|
74
|
+
lines.push(quoteLines(annotation.body));
|
|
69
75
|
} else {
|
|
70
|
-
lines.push(`### ${
|
|
76
|
+
lines.push(`### ${annotationIndex + 1}. ${capitalize(annotation.kind)}${location}${orphanFlag}`);
|
|
71
77
|
lines.push("");
|
|
72
|
-
lines.push(quoteLines(
|
|
78
|
+
lines.push(quoteLines(annotation.anchor.quote));
|
|
73
79
|
lines.push("");
|
|
74
|
-
lines.push(
|
|
80
|
+
lines.push(annotation.body);
|
|
75
81
|
}
|
|
76
82
|
lines.push("");
|
|
83
|
+
lines.push(`annotation id: \`${annotation.id}\``);
|
|
84
|
+
lines.push("");
|
|
77
85
|
});
|
|
86
|
+
if (input.sessionId) {
|
|
87
|
+
lines.push("## Reporting what you addressed");
|
|
88
|
+
lines.push("");
|
|
89
|
+
lines.push("When you resubmit the revised plan, list the annotation ids you acted on -");
|
|
90
|
+
lines.push("they are marked addressed for the reviewer and leave the open list:");
|
|
91
|
+
lines.push("");
|
|
92
|
+
lines.push("```sh");
|
|
93
|
+
lines.push(`cueloop session submit-revision ${input.sessionId} --content-file ${path} --addressed <id,id,...>`);
|
|
94
|
+
lines.push("```");
|
|
95
|
+
lines.push("");
|
|
96
|
+
}
|
|
78
97
|
}
|
|
79
98
|
|
|
80
|
-
if (!diff && !
|
|
99
|
+
if (!diff && !annotations.length) {
|
|
81
100
|
lines.push("_No edits or annotations._");
|
|
82
101
|
lines.push("");
|
|
83
102
|
}
|
|
@@ -92,9 +111,10 @@ export function feedbackForSession(session: ReviewSession, verdictKind: VerdictK
|
|
|
92
111
|
workingCopy: session.workingCopy,
|
|
93
112
|
annotations: session.annotations,
|
|
94
113
|
planPath: session.artifact.meta.planPath,
|
|
114
|
+
sessionId: session.id,
|
|
95
115
|
});
|
|
96
116
|
}
|
|
97
117
|
|
|
98
|
-
function capitalize(
|
|
99
|
-
return
|
|
118
|
+
function capitalize(word: string): string {
|
|
119
|
+
return word.charAt(0).toUpperCase() + word.slice(1);
|
|
100
120
|
}
|
|
@@ -0,0 +1,40 @@
|
|
|
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
|
+
expect(detectHerdr({ HERDR_ENV: "1", HERDR_PANE_ID: "p1" })).toBeNull();
|
|
8
|
+
expect(detectHerdr({ HERDR_ENV: "1", HERDR_PANE_ID: "p1", HERDR_BIN_PATH: "/x/herdr" })).toEqual({
|
|
9
|
+
paneId: "p1",
|
|
10
|
+
binPath: "/x/herdr",
|
|
11
|
+
});
|
|
12
|
+
});
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
describe("insideHerdr", () => {
|
|
16
|
+
test("true only when HERDR_ENV is exactly 1", () => {
|
|
17
|
+
expect(insideHerdr({})).toBeFalse();
|
|
18
|
+
expect(insideHerdr({ HERDR_ENV: "0" })).toBeFalse();
|
|
19
|
+
expect(insideHerdr({ HERDR_ENV: "1" })).toBeTrue();
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe("returnPaneFor", () => {
|
|
24
|
+
test("outside herdr there is never a return target", () => {
|
|
25
|
+
expect(returnPaneFor("w1:p1", {})).toBeUndefined();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("env override wins, session meta is the fallback", () => {
|
|
29
|
+
// Arrange
|
|
30
|
+
const inside = { HERDR_ENV: "1", HERDR_PANE_ID: "w1:p9" };
|
|
31
|
+
|
|
32
|
+
// Assert
|
|
33
|
+
expect(returnPaneFor("w1:p1", inside)).toBe("w1:p1");
|
|
34
|
+
expect(returnPaneFor("w1:p1", { ...inside, CUELOOP_RETURN_PANE: "w1:p2" })).toBe("w1:p2");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("returning to our own pane is a no-op", () => {
|
|
38
|
+
expect(returnPaneFor("w1:p1", { HERDR_ENV: "1", HERDR_PANE_ID: "w1:p1" })).toBeUndefined();
|
|
39
|
+
});
|
|
40
|
+
});
|
package/src/herdr.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
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 and the herdr binary path.
|
|
4
|
+
* Both the agent-side reporter (adapters) and the reviewer-side return-focus
|
|
5
|
+
* (client) read this contract; decoding it in one place stops the two sides
|
|
6
|
+
* drifting on which vars are required. Pure: no IO, env is only the default
|
|
7
|
+
* argument so callers can pass a fixture.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export interface HerdrContext {
|
|
11
|
+
paneId: string;
|
|
12
|
+
binPath: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type HerdrEnv = Record<string, string | undefined>;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The pane's full contract - pane id and binary path - or null outside a
|
|
19
|
+
* functioning herdr. Required by anything that spawns the herdr CLI.
|
|
20
|
+
*/
|
|
21
|
+
export function detectHerdr(env: HerdrEnv = process.env): HerdrContext | null {
|
|
22
|
+
if (env.HERDR_ENV !== "1") return null;
|
|
23
|
+
const paneId = env.HERDR_PANE_ID;
|
|
24
|
+
const binPath = env.HERDR_BIN_PATH;
|
|
25
|
+
if (!paneId || !binPath) return null;
|
|
26
|
+
return { paneId, binPath };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** True whenever this process runs inside a herdr pane. */
|
|
30
|
+
export function insideHerdr(env: HerdrEnv = process.env): boolean {
|
|
31
|
+
return env.HERDR_ENV === "1";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The pane a closing review should hand focus back to: the opener's
|
|
36
|
+
* CUELOOP_RETURN_PANE, or the session's recorded pane, but never this pane
|
|
37
|
+
* itself. Undefined outside herdr or when the only candidate is our own pane.
|
|
38
|
+
*/
|
|
39
|
+
export function returnPaneFor(sessionHerdrPane?: string, env: HerdrEnv = process.env): string | undefined {
|
|
40
|
+
if (!insideHerdr(env)) return undefined;
|
|
41
|
+
const pane = env.CUELOOP_RETURN_PANE ?? sessionHerdrPane;
|
|
42
|
+
return pane && pane !== env.HERDR_PANE_ID ? pane : undefined;
|
|
43
|
+
}
|
package/src/index.ts
CHANGED
package/src/markdown.test.ts
CHANGED
|
@@ -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
|
-
|
|
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((
|
|
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,69 @@ 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
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
expect(lines[
|
|
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
|
|
83
|
+
let orderedItemCount = 0;
|
|
74
84
|
const rebuilt = blocks
|
|
75
|
-
.map((
|
|
76
|
-
|
|
77
|
-
return blockToMd(
|
|
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
|
-
|
|
94
|
+
|
|
95
|
+
// Assert
|
|
96
|
+
expect(again.map((block) => [block.kind, block.text])).toEqual(blocks.map((block) => [block.kind, block.text]));
|
|
83
97
|
});
|
|
84
98
|
|
|
85
99
|
test("unknown constructs degrade to paragraphs, no content lost", () => {
|
|
100
|
+
// Arrange
|
|
86
101
|
const md = "| a | b |\n|---|---|\n| 1 | 2 |";
|
|
102
|
+
|
|
103
|
+
// Act
|
|
87
104
|
const blocks = parseBlocks(md);
|
|
88
|
-
|
|
105
|
+
|
|
106
|
+
// Assert
|
|
107
|
+
expect(blocks.map((block) => block.text).join("\n")).toContain("| a | b |");
|
|
89
108
|
});
|
|
90
109
|
});
|
|
91
110
|
|
|
92
111
|
describe("sectionOf", () => {
|
|
93
112
|
test("returns the nearest preceding heading", () => {
|
|
113
|
+
// Arrange
|
|
94
114
|
const blocks = parseBlocks(SAMPLE);
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
const
|
|
98
|
-
expect(sectionOf(blocks,
|
|
115
|
+
|
|
116
|
+
// Assert
|
|
117
|
+
const orderedItemIndex = blocks.findIndex((block) => block.kind === "oli");
|
|
118
|
+
expect(sectionOf(blocks, orderedItemIndex)).toBe("Steps");
|
|
119
|
+
const paragraphIndex = blocks.findIndex((block) => block.kind === "p");
|
|
120
|
+
expect(sectionOf(blocks, paragraphIndex)).toBe("Context");
|
|
99
121
|
});
|
|
100
122
|
});
|