@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.
package/src/markdown.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
- * Markdown block model (map #22): parse source into addressable blocks that
3
- * record their source line ranges. Rendered→source mapping is block+line
2
+ * Markdown block model: parse source into addressable blocks that record
3
+ * their source line ranges. Rendered-to-source mapping is block+line
4
4
  * granular; the quote carries sub-block precision. Round-trip safe for the
5
5
  * subset cueloop plans use; unknown constructs fall back to paragraph blocks
6
6
  * so no content is ever lost.
@@ -19,113 +19,128 @@ export interface Block {
19
19
  lineEnd: number;
20
20
  }
21
21
 
22
- function isMarkerLine(l: string): boolean {
22
+ function isMarkerLine(line: string): boolean {
23
23
  return (
24
- l.startsWith("```") ||
25
- l.startsWith("# ") ||
26
- l.startsWith("## ") ||
27
- l.startsWith("### ") ||
28
- l.startsWith("> ") ||
29
- /^- /.test(l) ||
30
- /^\d+\. /.test(l) ||
31
- /^(---|\*\*\*|___)\s*$/.test(l)
24
+ line.startsWith("```") ||
25
+ line.startsWith("# ") ||
26
+ line.startsWith("## ") ||
27
+ line.startsWith("### ") ||
28
+ line.startsWith("> ") ||
29
+ line.startsWith("- ") ||
30
+ /^\d+\. /.test(line) ||
31
+ /^(---|\*\*\*|___)\s*$/.test(line)
32
32
  );
33
33
  }
34
34
 
35
- export function parseBlocks(md: string): Block[] {
36
- const lines = md.split("\n");
35
+ export function parseBlocks(markdown: string): Block[] {
36
+ const lines = markdown.split("\n");
37
37
  const blocks: Block[] = [];
38
- let i = 0;
39
- while (i < lines.length) {
40
- const l = lines[i]!;
41
- if (l.trim() === "") {
42
- i++;
38
+ let lineIndex = 0;
39
+ while (lineIndex < lines.length) {
40
+ const line = lines[lineIndex]!;
41
+ if (line.trim() === "") {
42
+ lineIndex++;
43
43
  continue;
44
44
  }
45
- if (l.startsWith("```")) {
46
- const start = i;
47
- const lang = l.slice(3).trim() || undefined;
48
- i++;
45
+ if (line.startsWith("```")) {
46
+ const start = lineIndex;
47
+ const lang = line.slice(3).trim() || undefined;
48
+ lineIndex++;
49
49
  const body: string[] = [];
50
- while (i < lines.length && !lines[i]!.startsWith("```")) {
51
- body.push(lines[i]!);
52
- i++;
50
+ while (lineIndex < lines.length && !lines[lineIndex]!.startsWith("```")) {
51
+ body.push(lines[lineIndex]!);
52
+ lineIndex++;
53
53
  }
54
- const end = Math.min(i, lines.length - 1);
55
- i++;
54
+ const end = Math.min(lineIndex, lines.length - 1);
55
+ lineIndex++;
56
56
  blocks.push({ kind: "code", text: body.join("\n"), lang, lineStart: start, lineEnd: end });
57
- } else if (l.startsWith("### ")) {
58
- blocks.push({ kind: "h3", text: l.slice(4), lineStart: i, lineEnd: i });
59
- i++;
60
- } else if (l.startsWith("## ")) {
61
- blocks.push({ kind: "h2", text: l.slice(3), lineStart: i, lineEnd: i });
62
- i++;
63
- } else if (l.startsWith("# ")) {
64
- blocks.push({ kind: "h1", text: l.slice(2), lineStart: i, lineEnd: i });
65
- i++;
66
- } else if (/^(---|\*\*\*|___)\s*$/.test(l)) {
67
- blocks.push({ kind: "hr", text: "", lineStart: i, lineEnd: i });
68
- i++;
69
- } else if (l.startsWith("> ")) {
70
- const start = i;
57
+ } else if (line.startsWith("### ")) {
58
+ blocks.push({ kind: "h3", text: line.slice(4), lineStart: lineIndex, lineEnd: lineIndex });
59
+ lineIndex++;
60
+ } else if (line.startsWith("## ")) {
61
+ blocks.push({ kind: "h2", text: line.slice(3), lineStart: lineIndex, lineEnd: lineIndex });
62
+ lineIndex++;
63
+ } else if (line.startsWith("# ")) {
64
+ blocks.push({ kind: "h1", text: line.slice(2), lineStart: lineIndex, lineEnd: lineIndex });
65
+ lineIndex++;
66
+ } else if (/^(---|\*\*\*|___)\s*$/.test(line)) {
67
+ blocks.push({ kind: "hr", text: "", lineStart: lineIndex, lineEnd: lineIndex });
68
+ lineIndex++;
69
+ } else if (line.startsWith("> ")) {
70
+ const start = lineIndex;
71
71
  const body: string[] = [];
72
- while (i < lines.length && lines[i]!.startsWith("> ")) {
73
- body.push(lines[i]!.slice(2));
74
- i++;
72
+ while (lineIndex < lines.length && lines[lineIndex]!.startsWith("> ")) {
73
+ body.push(lines[lineIndex]!.slice(2));
74
+ lineIndex++;
75
75
  }
76
- blocks.push({ kind: "quote", text: body.join("\n"), lineStart: start, lineEnd: i - 1 });
77
- } else if (/^- /.test(l)) {
78
- blocks.push({ kind: "li", text: l.slice(2), lineStart: i, lineEnd: i });
79
- i++;
80
- } else if (/^\d+\. /.test(l)) {
81
- blocks.push({ kind: "oli", text: l.replace(/^\d+\. /, ""), lineStart: i, lineEnd: i });
82
- i++;
76
+ blocks.push({
77
+ kind: "quote",
78
+ text: body.join("\n"),
79
+ lineStart: start,
80
+ lineEnd: lineIndex - 1,
81
+ });
82
+ } else if (line.startsWith("- ")) {
83
+ blocks.push({ kind: "li", text: line.slice(2), lineStart: lineIndex, lineEnd: lineIndex });
84
+ lineIndex++;
85
+ } else if (/^\d+\. /.test(line)) {
86
+ blocks.push({
87
+ kind: "oli",
88
+ text: line.replace(/^\d+\. /, ""),
89
+ lineStart: lineIndex,
90
+ lineEnd: lineIndex,
91
+ });
92
+ lineIndex++;
83
93
  } else {
84
- const start = i;
94
+ const start = lineIndex;
85
95
  const body: string[] = [];
86
- while (i < lines.length && lines[i]!.trim() !== "" && !isMarkerLine(lines[i]!)) {
87
- body.push(lines[i]!);
88
- i++;
96
+ while (
97
+ lineIndex < lines.length &&
98
+ lines[lineIndex]!.trim() !== "" &&
99
+ !isMarkerLine(lines[lineIndex]!)
100
+ ) {
101
+ body.push(lines[lineIndex]!);
102
+ lineIndex++;
89
103
  }
90
- blocks.push({ kind: "p", text: body.join("\n"), lineStart: start, lineEnd: i - 1 });
104
+ blocks.push({ kind: "p", text: body.join("\n"), lineStart: start, lineEnd: lineIndex - 1 });
91
105
  }
92
106
  }
93
107
  return blocks;
94
108
  }
95
109
 
96
110
  /** Serialize one block back to its markdown chunk. */
97
- export function blockToMd(b: Block, ordinal = 1): string {
98
- switch (b.kind) {
111
+ export function blockToMd(block: Block, ordinal = 1): string {
112
+ switch (block.kind) {
99
113
  case "h1":
100
- return "# " + b.text;
114
+ return "# " + block.text;
101
115
  case "h2":
102
- return "## " + b.text;
116
+ return "## " + block.text;
103
117
  case "h3":
104
- return "### " + b.text;
118
+ return "### " + block.text;
105
119
  case "li":
106
- return "- " + b.text;
120
+ return "- " + block.text;
107
121
  case "oli":
108
- return `${ordinal}. ` + b.text;
122
+ return `${ordinal}. ` + block.text;
109
123
  case "quote":
110
- return b.text
124
+ return block.text
111
125
  .split("\n")
112
- .map((l) => "> " + l)
126
+ .map((line) => "> " + line)
113
127
  .join("\n");
114
128
  case "code":
115
- return "```" + (b.lang ?? "") + "\n" + b.text + "\n```";
129
+ return "```" + (block.lang ?? "") + "\n" + block.text + "\n```";
116
130
  case "hr":
117
131
  return "---";
118
132
  default:
119
- return b.text;
133
+ return block.text;
120
134
  }
121
135
  }
122
136
 
123
137
  /** The section (nearest preceding heading) a block belongs to. */
124
138
  export function sectionOf(blocks: Block[], index: number): string {
125
- let sec = "";
126
- for (let i = 0; i <= index && i < blocks.length; i++) {
127
- const b = blocks[i]!;
128
- if (b.kind === "h1" || b.kind === "h2" || b.kind === "h3") sec = b.text;
139
+ let sectionTitle = "";
140
+ for (let blockIndex = 0; blockIndex <= index && blockIndex < blocks.length; blockIndex++) {
141
+ const block = blocks[blockIndex]!;
142
+ if (block.kind === "h1" || block.kind === "h2" || block.kind === "h3")
143
+ sectionTitle = block.text;
129
144
  }
130
- return sec;
145
+ return sectionTitle;
131
146
  }
@@ -0,0 +1,58 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { registerParticipant } from "./participants";
3
+ import { SCHEMA_VERSION, type ReviewSession } from "./types";
4
+
5
+ function emptySession(): ReviewSession {
6
+ return {
7
+ schemaVersion: SCHEMA_VERSION,
8
+ id: "ses_1",
9
+ workspace: { repoRoot: "/repo", branch: "main" },
10
+ artifact: { type: "plan", content: "# Plan\n", meta: {} },
11
+ revisions: [],
12
+ annotations: [],
13
+ verdict: null,
14
+ status: "pending",
15
+ createdAt: "2026-01-01T00:00:00.000Z",
16
+ };
17
+ }
18
+
19
+ describe("registerParticipant", () => {
20
+ test("adds a new author with a display name", () => {
21
+ // Act
22
+ const next = registerParticipant(emptySession(), "SHA256:ana", "Ana");
23
+
24
+ // Assert
25
+ expect(next.participants).toEqual([{ id: "SHA256:ana", provider: "ssh", name: "Ana" }]);
26
+ });
27
+
28
+ test("records presence without a name (renders anonymous)", () => {
29
+ // Act
30
+ const next = registerParticipant(emptySession(), "SHA256:ana");
31
+
32
+ // Assert
33
+ expect(next.participants).toEqual([{ id: "SHA256:ana", provider: "ssh" }]);
34
+ });
35
+
36
+ test("a nameless revisit never erases a name a past visit set", () => {
37
+ // Arrange
38
+ const named = registerParticipant(emptySession(), "SHA256:ana", "Ana");
39
+
40
+ // Act
41
+ const next = registerParticipant(named, "SHA256:ana");
42
+
43
+ // Assert
44
+ expect(next).toBe(named);
45
+ expect(next.participants).toEqual([{ id: "SHA256:ana", provider: "ssh", name: "Ana" }]);
46
+ });
47
+
48
+ test("a later name updates the existing identity in place", () => {
49
+ // Arrange
50
+ const present = registerParticipant(emptySession(), "SHA256:ana");
51
+
52
+ // Act
53
+ const next = registerParticipant(present, "SHA256:ana", "Ana");
54
+
55
+ // Assert
56
+ expect(next.participants).toEqual([{ id: "SHA256:ana", provider: "ssh", name: "Ana" }]);
57
+ });
58
+ });
@@ -0,0 +1,36 @@
1
+ /**
2
+ * The participant registry: every annotation carries an `author` id, and the
3
+ * rail resolves that id to a display name through `session.participants`. This
4
+ * registers an author in that registry so a name search on "participant" or
5
+ * "author name" lands here.
6
+ */
7
+
8
+ import type { Identity, ReviewSession } from "./types";
9
+
10
+ /**
11
+ * Return the session with `author` present in the participant registry, setting
12
+ * the display name when one is given. A nameless call only records presence (the
13
+ * rail renders anonymous) and never erases a name a past visit set. Immutable:
14
+ * the input session is not mutated.
15
+ */
16
+ export function registerParticipant(
17
+ session: ReviewSession,
18
+ author: string,
19
+ name?: string,
20
+ ): ReviewSession {
21
+ const participants = session.participants ?? [];
22
+ const existing = participants.find((participant) => participant.id === author);
23
+ const trimmed = name?.trim();
24
+ if (existing && !trimmed) return session;
25
+ const next: Identity = {
26
+ id: author,
27
+ provider: "ssh",
28
+ ...(trimmed ? { name: trimmed } : existing?.name ? { name: existing.name } : {}),
29
+ };
30
+ return {
31
+ ...session,
32
+ participants: existing
33
+ ? participants.map((participant) => (participant.id === author ? next : participant))
34
+ : [...participants, next],
35
+ };
36
+ }
@@ -0,0 +1,21 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { newAnnotationId, verdictAllows } from "./types";
3
+
4
+ describe("verdictAllows", () => {
5
+ test("only approve maps to allow", () => {
6
+ expect(verdictAllows("approve")).toBe(true);
7
+ expect(verdictAllows("comment")).toBe(false);
8
+ expect(verdictAllows("request_changes")).toBe(false);
9
+ });
10
+ });
11
+
12
+ describe("newAnnotationId", () => {
13
+ test("unique by construction within a process, even on one millisecond", () => {
14
+ // Act
15
+ const ids = new Set(Array.from({ length: 10_000 }, () => newAnnotationId()));
16
+
17
+ // Assert
18
+ expect(ids.size).toBe(10_000);
19
+ for (const id of ids) expect(id).toMatch(/^a_[0-9a-z]+_[0-9a-z]+$/);
20
+ });
21
+ });
package/src/types.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
- * The ReviewSession primitive (map #2, #9). Everything in cueloop renders,
3
- * annotates, or resolves this one noun. This module is pure data shapes -
4
- * no IO, no dependencies.
2
+ * The ReviewSession primitive. Everything in cueloop renders, annotates, or
3
+ * resolves this one noun. This module is pure data shapes - no IO, no
4
+ * dependencies.
5
5
  */
6
6
 
7
7
  export const SCHEMA_VERSION = "1";
@@ -23,14 +23,34 @@ export interface ArtifactMeta {
23
23
  planPath?: string;
24
24
  /** Pull request reference the diff came from, so the verdict can be posted back. */
25
25
  pr?: string;
26
+ /** herdr pane the submitting agent runs in - the review returns focus there. */
27
+ herdrPane?: string;
26
28
  title?: string;
27
29
  }
28
30
 
31
+ /** Full old/new contents of one changed file, keyed by its repo-relative path. */
32
+ /** How a file changed, so curation emits the right create/delete headers. */
33
+ export type DiffFileStatus = "added" | "modified" | "deleted";
34
+
35
+ export interface DiffFileContents {
36
+ path: string;
37
+ oldContents: string;
38
+ newContents: string;
39
+ /** git's own classification - not inferred from empty contents, so an
40
+ * existing-empty-file edit is a modify, not a create/delete. */
41
+ status: DiffFileStatus;
42
+ }
43
+
29
44
  export interface Artifact {
30
45
  type: ArtifactType;
31
46
  /** Markdown source for plans; unified-diff text for diffs. */
32
47
  content: string;
33
48
  meta: ArtifactMeta;
49
+ /**
50
+ * Full file contents for a diff artifact, so hunk curation produces an
51
+ * exactly applyable patch; absent for legacy or partial (PR) diffs.
52
+ */
53
+ files?: DiffFileContents[];
34
54
  }
35
55
 
36
56
  /**
@@ -49,20 +69,54 @@ export interface Anchor {
49
69
  end?: number;
50
70
  }
51
71
 
52
- /** The annotation kind set is open (map #2); these are the built-ins. */
53
- export type AnnotationKind = "comment" | "suggestion" | (string & {});
72
+ /** The annotation kind set is open; these are the built-ins. */
73
+ export type AnnotationKind = "comment" | (string & {});
74
+
75
+ /**
76
+ * Agent-authored context, not reviewer feedback: the guided walk's per-file
77
+ * notes (kind "note", anchored by the file path). Excluded from the feedback
78
+ * document and the reviewer's pending counts - an agent must never receive
79
+ * its own notes back as items to address.
80
+ */
81
+ export function isAgentNote(annotation: Pick<Annotation, "kind">): boolean {
82
+ return annotation.kind === "note";
83
+ }
54
84
 
55
85
  export interface Annotation {
56
86
  id: string;
57
87
  kind: AnnotationKind;
58
88
  anchor: Anchor;
59
- /** Comment body, or the replacement text for suggestions. */
89
+ /** Comment body. */
60
90
  body: string;
61
91
  /** Set by resolution when the quote can no longer be found. */
62
92
  orphan?: boolean;
93
+ /**
94
+ * SSH key fingerprint of a share collaborator who authored this note. Absent
95
+ * on the planner's own annotations; the sharing gateway stamps it so the
96
+ * planner can tell whose note is whose and never overwrite a collaborator's.
97
+ */
98
+ author?: string;
99
+ /**
100
+ * Set when a revision addressed this annotation: the agent reported the id
101
+ * on resubmit ("agent"), or the quoted text disappeared from the revised
102
+ * plan ("drift"). Addressed annotations leave the default rail view and the
103
+ * next feedback document, but are never deleted.
104
+ */
105
+ resolution?: AnnotationResolution;
63
106
  createdAt: string;
64
107
  }
65
108
 
109
+ export interface AnnotationResolution {
110
+ /** The revision number whose submission addressed this annotation. */
111
+ revision: number;
112
+ source: "agent" | "drift";
113
+ }
114
+
115
+ /** An annotation a revision has addressed; it leaves the default views. */
116
+ export function isAddressed(annotation: Annotation): boolean {
117
+ return annotation.resolution !== undefined;
118
+ }
119
+
66
120
  export type VerdictKind = "comment" | "approve" | "request_changes";
67
121
 
68
122
  export interface Verdict {
@@ -81,6 +135,22 @@ export interface Revision {
81
135
 
82
136
  export type SessionStatus = "pending" | "resolved";
83
137
 
138
+ /**
139
+ * A person who authored annotations on a session. `id` is the stable key an
140
+ * annotation's `author` points at - the SSH fingerprint today, an OAuth id
141
+ * ("github:…") later. Provider-agnostic so those identities slot in unchanged.
142
+ */
143
+ export interface Identity {
144
+ /** Stable identity key; equals an annotation's `author`. */
145
+ id: string;
146
+ /** Identity source. One value today; widen the union when OAuth lands. */
147
+ provider: "ssh";
148
+ /** Display name; absent = the collaborator stayed anonymous. */
149
+ name?: string;
150
+ /** Provider handle: a github login, an email, or a short fingerprint. */
151
+ handle?: string;
152
+ }
153
+
84
154
  export interface ReviewSession {
85
155
  schemaVersion: string;
86
156
  id: string;
@@ -95,12 +165,38 @@ export interface ReviewSession {
95
165
  * Undefined = no direct edits.
96
166
  */
97
167
  workingCopy?: string;
168
+ /**
169
+ * File paths the reviewer marked viewed during the guided walk (diff
170
+ * sessions). Persisting with the session means a resumed review keeps its
171
+ * progress. Undefined = the walk never started.
172
+ */
173
+ viewedPaths?: string[];
98
174
  verdict: Verdict | null;
99
175
  status: SessionStatus;
100
176
  createdAt: string;
177
+ /** Share id once published; lets the planner pull collaborator notes back. */
178
+ shareId?: string;
179
+ /** SSH fingerprint that created the share; the gateway stamps it to gate pulls. */
180
+ owner?: string;
181
+ /**
182
+ * Identities that authored annotations here, keyed by id (union-by-id, like
183
+ * annotations). The gateway records a collaborator's identity and chosen name;
184
+ * the rail resolves an annotation's `author` against this registry.
185
+ */
186
+ participants?: Identity[];
101
187
  }
102
188
 
103
189
  /** comment and request_changes both map to deny in agent-native contracts. */
104
190
  export function verdictAllows(kind: VerdictKind): boolean {
105
191
  return kind === "approve";
106
192
  }
193
+
194
+ /**
195
+ * Annotation ids are minted client-side. The counter makes ids unique within
196
+ * a process by construction; the random suffix separates concurrent
197
+ * annotators in different processes on the same millisecond.
198
+ */
199
+ let annotationSeq = 0;
200
+ export function newAnnotationId(): string {
201
+ return `a_${Date.now().toString(36)}${(annotationSeq++).toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
202
+ }
@@ -0,0 +1,155 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { cutBlock, restoreBlock, restoreLine, sourceChunk } from "./working-copy";
3
+ import { parseBlocks, type Block } from "./markdown";
4
+
5
+ const BASE = `# Plan
6
+
7
+ ## Context
8
+
9
+ A paragraph that
10
+ spans two lines.
11
+
12
+ - first item
13
+ - second item
14
+
15
+ \`\`\`ts
16
+ const x = 1;
17
+ const y = 2;
18
+ \`\`\`
19
+ `;
20
+
21
+ function block(markdown: string, text: string): Block {
22
+ return parseBlocks(markdown).find((block) => block.text.includes(text))!;
23
+ }
24
+
25
+ describe("sourceChunk", () => {
26
+ test("returns the exact source lines a block occupies", () => {
27
+ expect(sourceChunk(BASE, block(BASE, "first item"))).toBe("- first item");
28
+ expect(sourceChunk(BASE, block(BASE, "spans two lines"))).toBe(
29
+ "A paragraph that\nspans two lines.",
30
+ );
31
+ });
32
+
33
+ test("code block chunk includes both fence lines", () => {
34
+ expect(sourceChunk(BASE, block(BASE, "const x"))).toBe(
35
+ "```ts\nconst x = 1;\nconst y = 2;\n```",
36
+ );
37
+ });
38
+ });
39
+
40
+ describe("cutBlock", () => {
41
+ test("removes the block lines and the blank line before it", () => {
42
+ // Act
43
+ const cut = cutBlock(BASE, block(BASE, "spans two lines"));
44
+
45
+ // Assert
46
+ expect(cut).not.toContain("spans two lines");
47
+ expect(cut).toContain("## Context\n\n- first item");
48
+ });
49
+
50
+ test("cut at the start of the document leaves the leading blank line", () => {
51
+ // Act
52
+ const cut = cutBlock(BASE, block(BASE, "Plan"));
53
+
54
+ // Assert
55
+ expect(cut).not.toContain("# Plan");
56
+ expect(cut.trimStart().startsWith("## Context")).toBe(true);
57
+ });
58
+
59
+ test("cut at the end of the document keeps the rest intact", () => {
60
+ // Act
61
+ const cut = cutBlock(BASE, block(BASE, "const x"));
62
+
63
+ // Assert
64
+ expect(cut).not.toContain("const x");
65
+ expect(cut).toContain("- second item");
66
+ });
67
+ });
68
+
69
+ describe("restoreBlock", () => {
70
+ test("restoring the only cut returns undefined - back to pristine", () => {
71
+ // Arrange
72
+ const listItem = block(BASE, "second item");
73
+ const cut = cutBlock(BASE, listItem);
74
+ // the cut block re-enters before the code fence, the next surviving block
75
+ const next = parseBlocks(cut).find((candidate) => candidate.kind === "code");
76
+
77
+ // Act
78
+ const restored = restoreBlock(BASE, cut, listItem, restoreLine(next, cut.split("\n").length));
79
+
80
+ // Assert
81
+ expect(restored).toBeUndefined();
82
+ });
83
+
84
+ test("restore after another edit returns the merged working copy", () => {
85
+ // Arrange
86
+ const listItem = block(BASE, "second item");
87
+ const edited = cutBlock(BASE, listItem).replace("first item", "first item reworded");
88
+ const next = parseBlocks(edited).find((candidate) => candidate.kind === "code");
89
+
90
+ // Act
91
+ const restored = restoreBlock(
92
+ BASE,
93
+ edited,
94
+ listItem,
95
+ restoreLine(next, edited.split("\n").length),
96
+ );
97
+
98
+ // Assert
99
+ expect(restored).toContain("- second item");
100
+ expect(restored).toContain("first item reworded");
101
+ });
102
+
103
+ test("restore at the end of the document (no next block)", () => {
104
+ // Arrange
105
+ const code = block(BASE, "const x");
106
+ const cut = cutBlock(BASE, code);
107
+
108
+ // Act
109
+ const restored = restoreBlock(BASE, cut, code, restoreLine(undefined, cut.split("\n").length));
110
+
111
+ // Assert
112
+ expect(restored).toBeUndefined();
113
+ });
114
+
115
+ test("restore a multi-line block round-trips to pristine", () => {
116
+ // Arrange
117
+ const paragraph = block(BASE, "spans two lines");
118
+ const cut = cutBlock(BASE, paragraph);
119
+ const next = parseBlocks(cut).find((block) => block.text === "first item");
120
+
121
+ // Act
122
+ const restored = restoreBlock(BASE, cut, paragraph, restoreLine(next, cut.split("\n").length));
123
+
124
+ // Assert
125
+ expect(restored).toBeUndefined();
126
+ });
127
+
128
+ test("signature rule ignores blank-line layout but not text changes", () => {
129
+ // Arrange
130
+ const listItem = block(BASE, "second item");
131
+ const cut = cutBlock(BASE, listItem);
132
+ const next = parseBlocks(cut).find((candidate) => candidate.kind === "code");
133
+ const line = restoreLine(next, cut.split("\n").length);
134
+
135
+ // Assert
136
+ // extra blank lines elsewhere do not block pristine detection
137
+ expect(
138
+ restoreBlock(BASE, cut.replace("## Context", "## Context\n"), listItem, line),
139
+ ).toBeUndefined();
140
+ // a real text change keeps the working copy alive
141
+ const changed = restoreBlock(BASE, cut.replace("first", "1st"), listItem, line);
142
+ expect(changed).toContain("1st item");
143
+ });
144
+ });
145
+
146
+ describe("restoreLine", () => {
147
+ test("next surviving block's start, or the line count at the end", () => {
148
+ // Arrange
149
+ const listItem = block(BASE, "first item");
150
+
151
+ // Assert
152
+ expect(restoreLine(listItem, 99)).toBe(listItem.lineStart);
153
+ expect(restoreLine(undefined, 99)).toBe(99);
154
+ });
155
+ });