@cueloop/schema 0.1.0-alpha.8 → 0.1.0-alpha.81

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/types.ts CHANGED
@@ -1,38 +1,116 @@
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 Thread primitive. Everything in cueloop renders, annotates, or
3
+ * extends this one noun. This module is pure data shapes - no IO, no
4
+ * dependencies beyond the history shapes.
5
5
  */
6
6
 
7
+ import type { SessionHistory } from "./history";
8
+
7
9
  export const SCHEMA_VERSION = "1";
8
10
 
9
- /** A workspace is a repo/branch context holding review sessions. */
11
+ /** A workspace is a repo/branch context holding threads. */
10
12
  export interface WorkspaceKey {
11
13
  repoRoot: string;
12
14
  branch: string;
15
+ /** Earliest root commit SHA; the project key that survives moving or re-cloning the repo. Absent for a standalone thread. */
16
+ rootCommit?: string;
17
+ /** Origin remote URL when present; for display and repair only, never the project key. */
18
+ remote?: string;
19
+ }
20
+
21
+ /**
22
+ * What kind of artifact a thread holds. `plan` and `reply` are both
23
+ * markdown documents (see isMarkdownArtifact) - a plan is a proposal written
24
+ * forward, a reply is the agent's previous message pulled back for review.
25
+ * `diff` is a unified-diff patch; `prototype` is a component design doc (API /
26
+ * Composition / Callstack) in markdown, with an opt-in experimental pixel mode.
27
+ *
28
+ * One runtime union: every consumer that names the supported set - daemon
29
+ * wire validation, CLI flag parsing, adapter tool schemas - derives from this
30
+ * constant, so a new primitive extends them all without another hardcoded list.
31
+ */
32
+ export const ARTIFACT_TYPES = ["plan", "diff", "prototype", "reply"] as const;
33
+
34
+ export type ArtifactType = (typeof ARTIFACT_TYPES)[number];
35
+
36
+ /** Harness workflows; review and refine are compositions, never artifact types. */
37
+ export const WORKFLOW_KINDS = ["plan", "reply", "prototype", "diff", "review", "refine"] as const;
38
+
39
+ export type WorkflowKind = (typeof WORKFLOW_KINDS)[number];
40
+
41
+ /** Trust-boundary guard: is this string one of the artifact primitives? */
42
+ export function isArtifactType(value: string): value is ArtifactType {
43
+ return ARTIFACT_TYPES.some((candidate) => candidate === value);
13
44
  }
14
45
 
15
- export type ArtifactType = "plan" | "diff";
46
+ /**
47
+ * Markdown artifacts (plan, reply, prototype) are block-parsed and quote-anchored,
48
+ * so they share the plan render path, first-heading title derivation, and revision
49
+ * drift-assist. A prototype is a component design doc (API / Composition / Callstack)
50
+ * by default; its opt-in experimental pixel mode is a client render override, not a
51
+ * different artifact. A diff (a patch) does not - keep this the one place that names
52
+ * the set, so a new markdown primitive joins here once.
53
+ */
54
+ export function isMarkdownArtifact(type: ArtifactType): boolean {
55
+ return type === "plan" || type === "reply" || type === "prototype";
56
+ }
16
57
 
17
58
  export interface ArtifactMeta {
59
+ /** The workflow that submitted this artifact, distinct from its artifact type. */
60
+ workflow?: WorkflowKind;
18
61
  cwd?: string;
19
62
  agent?: string;
20
63
  /** Agent-native session id, for resume/fork context. */
21
64
  agentSessionId?: string;
22
- /** Path to the plan file on disk, so feedback can reference it. */
65
+ /** Path to the plan or reply markdown file on disk, so feedback can reference it. */
23
66
  planPath?: string;
24
- /** Pull request reference the diff came from, so the verdict can be posted back. */
67
+ /** Path to the prototype's entry HTML file on disk. */
68
+ prototypePath?: string;
69
+ /** Pull request reference the diff came from, so the message can be posted back. */
25
70
  pr?: string;
26
71
  /** herdr pane the submitting agent runs in - the review returns focus there. */
27
72
  herdrPane?: string;
28
73
  title?: string;
74
+ /** A self-initiated per-repo workbench thread (a bare launch's first comment), not an agent submission. */
75
+ workbench?: boolean;
76
+ /** A frozen point-in-time capture of a workbench thread's diff, for a remote reviewer who has no working tree. */
77
+ snapshot?: boolean;
78
+ }
79
+
80
+ /** Full old/new contents of one changed file, keyed by its repo-relative path. */
81
+ /** How a file changed, so curation emits the right create/delete headers. */
82
+ export type DiffFileStatus = "added" | "modified" | "deleted";
83
+
84
+ /**
85
+ * One reject decision of a diff review: a whole hunk, or one change block of
86
+ * it when `changeIndex` is set. The daemon curates the working copy from the
87
+ * full set, so every client sees the same patch.
88
+ */
89
+ export interface HunkRejection {
90
+ path: string;
91
+ hunkIndex: number;
92
+ changeIndex?: number;
93
+ }
94
+
95
+ export interface DiffFileContents {
96
+ path: string;
97
+ oldContents: string;
98
+ newContents: string;
99
+ /** git's own classification - not inferred from empty contents, so an
100
+ * existing-empty-file edit is a modify, not a create/delete. */
101
+ status: DiffFileStatus;
29
102
  }
30
103
 
31
104
  export interface Artifact {
32
105
  type: ArtifactType;
33
- /** Markdown source for plans; unified-diff text for diffs. */
106
+ /** Markdown source for plans and replies; unified-diff text for diffs. */
34
107
  content: string;
35
108
  meta: ArtifactMeta;
109
+ /**
110
+ * Full file contents for a diff artifact, so hunk curation produces an
111
+ * exactly applyable patch; absent for legacy or partial (PR) diffs.
112
+ */
113
+ files?: DiffFileContents[];
36
114
  }
37
115
 
38
116
  /**
@@ -44,35 +122,126 @@ export interface Anchor {
44
122
  quote: string;
45
123
  prefix: string;
46
124
  suffix: string;
47
- /** Index of the block the anchor was made in (hint). */
125
+ /** Index of the block the anchor starts in (hint). */
48
126
  blockIndex?: number;
49
- /** Character offsets within that block's text (hint). */
127
+ /** Last block of a quote that spans blocks (hint); absent for one block. */
128
+ endBlockIndex?: number;
129
+ /** Character offsets: `start` within the first block, `end` within the last (hint). */
50
130
  start?: number;
51
131
  end?: number;
132
+ /** Prototype anchors: the CSS selector of the annotated element (authority). */
133
+ selector?: string;
134
+ }
135
+
136
+ /** The annotation kind set is open; these are the built-ins. */
137
+ export type AnnotationKind = "comment" | (string & {});
138
+
139
+ /**
140
+ * The surface an annotation was made on. Name-only: the ref names the surface and the
141
+ * client loads that surface's text to resolve the quote anchor. Absent means the session's
142
+ * reviewed artifact, so every existing annotation keeps its meaning with no data change.
143
+ * A `file` target's `rev` doubles as the diff side: worktree for an added or context row,
144
+ * head for a deletion.
145
+ */
146
+ export type AnnotationTarget =
147
+ | { kind: "artifact" }
148
+ | { kind: "file"; path: string; rev: "worktree" | "head" }
149
+ | { kind: "welcome" };
150
+
151
+ /** The target an annotation resolves against; absent records mean the reviewed artifact. */
152
+ export function annotationTarget(annotation: Pick<Annotation, "target">): AnnotationTarget {
153
+ return annotation.target ?? { kind: "artifact" };
52
154
  }
53
155
 
54
- /** The annotation kind set is open (map #2); these are the built-ins. */
55
- export type AnnotationKind = "comment" | "suggestion" | (string & {});
156
+ /**
157
+ * Agent-authored context, not reviewer feedback: the guided walk's per-file
158
+ * notes (kind "note", anchored by the file path). Excluded from the feedback
159
+ * document and the reviewer's pending counts - an agent must never receive
160
+ * its own notes back as items to address.
161
+ */
162
+ export function isAgentNote(annotation: Pick<Annotation, "kind">): boolean {
163
+ return annotation.kind === "note";
164
+ }
56
165
 
57
166
  export interface Annotation {
58
167
  id: string;
59
168
  kind: AnnotationKind;
60
169
  anchor: Anchor;
61
- /** Comment body, or the replacement text for suggestions. */
170
+ /** The surface this note was made on; absent means the session's reviewed artifact. */
171
+ target?: AnnotationTarget;
172
+ /** Comment body. */
62
173
  body: string;
63
174
  /** Set by resolution when the quote can no longer be found. */
64
175
  orphan?: boolean;
176
+ /**
177
+ * SSH key fingerprint of a share collaborator who authored this note. Absent
178
+ * on the planner's own annotations; the sharing gateway stamps it so the
179
+ * planner can tell whose note is whose and never overwrite a collaborator's.
180
+ */
181
+ author?: string;
182
+ /**
183
+ * The root comment this one replies to. Absent on a root. A reply shares its
184
+ * root's anchor, so a discussion stays one conversation when the text moves.
185
+ */
186
+ replyTo?: string;
187
+ /**
188
+ * Set when a revision addressed this annotation: the agent reported the id
189
+ * on resubmit ("agent"), or the quoted text disappeared from the revised
190
+ * plan ("drift"). Addressed annotations leave the default rail view and the
191
+ * next feedback document, but are never deleted.
192
+ */
193
+ resolution?: AnnotationResolution;
65
194
  createdAt: string;
66
195
  }
67
196
 
68
- export type VerdictKind = "comment" | "approve" | "request_changes";
197
+ export interface AnnotationResolution {
198
+ /** The revision number whose submission addressed this annotation. */
199
+ revision: number;
200
+ source: "agent" | "drift";
201
+ }
69
202
 
70
- export interface Verdict {
71
- kind: VerdictKind;
203
+ /** An annotation a revision has addressed; it leaves the default views. */
204
+ export function isAddressed(annotation: Annotation): boolean {
205
+ return annotation.resolution !== undefined;
206
+ }
207
+
208
+ /** A harness session bound to a Thread. Harness identity stays outside the Thread itself. */
209
+ export interface HarnessBinding {
210
+ id: string;
211
+ threadId: string;
212
+ harness: string;
213
+ harnessSessionId: string;
214
+ createdAt: string;
215
+ /** Approval whose one unchanged plan resubmission this harness has consumed. */
216
+ approvedRetryMessageId?: string;
217
+ }
218
+
219
+ /** Durable routing state for one Message sent to one harness binding. */
220
+ export interface Delivery {
221
+ id: string;
222
+ messageId: string;
223
+ bindingId: string;
224
+ status: "pending" | "acknowledged";
225
+ createdAt: string;
226
+ acknowledgedAt?: string;
227
+ }
228
+
229
+ /** What a harness adapter pulls: routing state plus its immutable Message payload. */
230
+ export interface PendingDelivery {
231
+ delivery: Delivery;
232
+ message: Message;
233
+ }
234
+
235
+ export type MessageOutcome = "approved" | "changes_requested";
236
+
237
+ export interface Message {
238
+ /** Stable identity used to deduplicate at-least-once harness delivery. */
239
+ id: string;
240
+ outcome: MessageOutcome;
72
241
  summary: string;
73
- /** The one structured feedback document sent to the agent. */
74
- feedback: string;
75
- resolvedAt: string;
242
+ /** The structured review document sent to the harness. */
243
+ body: string;
244
+ sentAt: string;
76
245
  }
77
246
 
78
247
  export interface Revision {
@@ -83,7 +252,49 @@ export interface Revision {
83
252
 
84
253
  export type SessionStatus = "pending" | "resolved";
85
254
 
86
- export interface ReviewSession {
255
+ /**
256
+ * A person who authored annotations on a session. `id` is the stable key an
257
+ * annotation's `author` points at - the SSH fingerprint today, an OAuth id
258
+ * ("github:…") later. Provider-agnostic so those identities slot in unchanged.
259
+ */
260
+ export interface Identity {
261
+ /** Stable identity key; equals an annotation's `author`. */
262
+ id: string;
263
+ /** Identity source: the SSH key that authored, or a verified GitHub login. */
264
+ provider: "ssh" | "github";
265
+ /** Display name; absent = the collaborator stayed anonymous. */
266
+ name?: string;
267
+ /** Provider handle: a github login, an email, or a short fingerprint. */
268
+ handle?: string;
269
+ }
270
+
271
+ /** Owner-set access control for a private share: only these GitHub logins may open it. Absent = a public share. */
272
+ export interface ShareAccess {
273
+ githubLogins: string[];
274
+ }
275
+
276
+ /**
277
+ * One published share link for a thread. A thread can have several, each an
278
+ * independent gateway blob keyed by its own `id`. `name` is a local-only label
279
+ * to tell links apart; `requireAuth` gates the link behind the `allowlist` of
280
+ * GitHub logins (empty + requireAuth is a private link with no one added yet).
281
+ */
282
+ export interface ShareLink {
283
+ /** The share id (`p_…`), the link's address and gateway blob key. */
284
+ id: string;
285
+ /** A local, cosmetic label for the list; never leaves the planner's machine. */
286
+ name?: string;
287
+ /** True = private (only `allowlist` may open it); false = public. */
288
+ requireAuth: boolean;
289
+ /** GitHub logins allowed when `requireAuth`; ignored when public. */
290
+ allowlist: string[];
291
+ /** SSH fingerprint that created the link; the gateway stamps it to gate pulls/pushes/revokes. */
292
+ owner?: string;
293
+ /** The branch this link follows and shows collaborators; `main` when absent. */
294
+ shareBranch?: string;
295
+ }
296
+
297
+ export interface Thread {
87
298
  schemaVersion: string;
88
299
  id: string;
89
300
  workspace: WorkspaceKey;
@@ -91,20 +302,66 @@ export interface ReviewSession {
91
302
  /** Revision history; artifact.content always equals the latest revision. */
92
303
  revisions: Revision[];
93
304
  annotations: Annotation[];
305
+ /**
306
+ * The session's history as a tree of entries with named branches; the
307
+ * artifact text and the open comments derive from the active path. Absent
308
+ * only on records written before histories existed; the store migrates
309
+ * those on read.
310
+ */
311
+ history?: SessionHistory;
312
+ /** A diff review's reject decisions; the working copy is the patch they leave. */
313
+ curation?: HunkRejection[];
94
314
  /**
95
315
  * The reviewer's working copy of the artifact source (plan edits).
96
316
  * Serializes as ONE unified diff against the submitted revision.
97
317
  * Undefined = no direct edits.
98
318
  */
99
319
  workingCopy?: string;
100
- verdict: Verdict | null;
320
+ /**
321
+ * File paths the reviewer marked viewed during the guided walk (diff
322
+ * sessions). Persisting with the session means a resumed review keeps its
323
+ * progress. Undefined = the walk never started.
324
+ */
325
+ viewedPaths?: string[];
326
+ message: Message | null;
101
327
  status: SessionStatus;
102
328
  createdAt: string;
329
+ /**
330
+ * Comments off the active path - removed, or made on a segment a tip moved
331
+ * away from. Nothing is deleted: a navigate or switch that brings their
332
+ * entries back onto the path shows them again.
333
+ */
334
+ shelvedAnnotations?: Annotation[];
335
+ /** The session this one was forked from. */
336
+ parentSessionId?: string;
337
+ /** Published share links for this thread; each is an independent gateway blob. Migrated from the legacy scalar fields on read. */
338
+ shares?: ShareLink[];
339
+ /** @deprecated Legacy single-share id; migrated into `shares` on read. */
340
+ shareId?: string;
341
+ /** @deprecated Legacy single-share branch; migrated into `shares` on read. */
342
+ shareBranch?: string;
343
+ /** @deprecated Legacy single-share owner fingerprint; migrated into `shares` on read. */
344
+ owner?: string;
345
+ /** @deprecated Legacy single-share allowlist; migrated into `shares` on read. */
346
+ access?: ShareAccess;
347
+ /**
348
+ * Identities that authored annotations here, keyed by id (union-by-id, like
349
+ * annotations). The gateway records a collaborator's identity and chosen name;
350
+ * the rail resolves an annotation's `author` against this registry.
351
+ */
352
+ participants?: Identity[];
103
353
  }
104
354
 
105
- /** comment and request_changes both map to deny in agent-native contracts. */
106
- export function verdictAllows(kind: VerdictKind): boolean {
107
- return kind === "approve";
355
+ /** Whether a Message releases a harness gate. */
356
+ export function messageAllows(outcome: MessageOutcome): boolean {
357
+ return outcome === "approved";
358
+ }
359
+
360
+ let messageSeq = 0;
361
+
362
+ /** A process-unique, time-sortable Message id for delivery deduplication. */
363
+ export function newMessageId(): string {
364
+ return `msg_${Date.now().toString(36)}${(messageSeq++).toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
108
365
  }
109
366
 
110
367
  /**
@@ -113,6 +370,7 @@ export function verdictAllows(kind: VerdictKind): boolean {
113
370
  * annotators in different processes on the same millisecond.
114
371
  */
115
372
  let annotationSeq = 0;
373
+
116
374
  export function newAnnotationId(): string {
117
375
  return `a_${Date.now().toString(36)}${(annotationSeq++).toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
118
376
  }
@@ -18,36 +18,49 @@ const y = 2;
18
18
  \`\`\`
19
19
  `;
20
20
 
21
- function block(md: string, text: string): Block {
22
- return parseBlocks(md).find((b) => b.text.includes(text))!;
21
+ function block(markdown: string, text: string): Block {
22
+ return parseBlocks(markdown).find((block) => block.text.includes(text))!;
23
23
  }
24
24
 
25
25
  describe("sourceChunk", () => {
26
26
  test("returns the exact source lines a block occupies", () => {
27
27
  expect(sourceChunk(BASE, block(BASE, "first item"))).toBe("- first item");
28
- expect(sourceChunk(BASE, block(BASE, "spans two lines"))).toBe("A paragraph that\nspans two lines.");
28
+ expect(sourceChunk(BASE, block(BASE, "spans two lines"))).toBe(
29
+ "A paragraph that\nspans two lines.",
30
+ );
29
31
  });
30
32
 
31
33
  test("code block chunk includes both fence lines", () => {
32
- expect(sourceChunk(BASE, block(BASE, "const x"))).toBe("```ts\nconst x = 1;\nconst y = 2;\n```");
34
+ expect(sourceChunk(BASE, block(BASE, "const x"))).toBe(
35
+ "```ts\nconst x = 1;\nconst y = 2;\n```",
36
+ );
33
37
  });
34
38
  });
35
39
 
36
40
  describe("cutBlock", () => {
37
41
  test("removes the block lines and the blank line before it", () => {
42
+ // Act
38
43
  const cut = cutBlock(BASE, block(BASE, "spans two lines"));
44
+
45
+ // Assert
39
46
  expect(cut).not.toContain("spans two lines");
40
47
  expect(cut).toContain("## Context\n\n- first item");
41
48
  });
42
49
 
43
50
  test("cut at the start of the document leaves the leading blank line", () => {
51
+ // Act
44
52
  const cut = cutBlock(BASE, block(BASE, "Plan"));
53
+
54
+ // Assert
45
55
  expect(cut).not.toContain("# Plan");
46
56
  expect(cut.trimStart().startsWith("## Context")).toBe(true);
47
57
  });
48
58
 
49
59
  test("cut at the end of the document keeps the rest intact", () => {
60
+ // Act
50
61
  const cut = cutBlock(BASE, block(BASE, "const x"));
62
+
63
+ // Assert
51
64
  expect(cut).not.toContain("const x");
52
65
  expect(cut).toContain("- second item");
53
66
  });
@@ -55,55 +68,89 @@ describe("cutBlock", () => {
55
68
 
56
69
  describe("restoreBlock", () => {
57
70
  test("restoring the only cut returns undefined - back to pristine", () => {
58
- const li = block(BASE, "second item");
59
- const cut = cutBlock(BASE, li);
71
+ // Arrange
72
+ const listItem = block(BASE, "second item");
73
+ const cut = cutBlock(BASE, listItem);
60
74
  // the cut block re-enters before the code fence, the next surviving block
61
- const next = parseBlocks(cut).find((b) => b.kind === "code");
62
- const restored = restoreBlock(BASE, cut, li, restoreLine(next, cut.split("\n").length));
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
63
81
  expect(restored).toBeUndefined();
64
82
  });
65
83
 
66
84
  test("restore after another edit returns the merged working copy", () => {
67
- const li = block(BASE, "second item");
68
- const edited = cutBlock(BASE, li).replace("first item", "first item reworded");
69
- const next = parseBlocks(edited).find((b) => b.kind === "code");
70
- const restored = restoreBlock(BASE, edited, li, restoreLine(next, edited.split("\n").length));
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
71
99
  expect(restored).toContain("- second item");
72
100
  expect(restored).toContain("first item reworded");
73
101
  });
74
102
 
75
103
  test("restore at the end of the document (no next block)", () => {
104
+ // Arrange
76
105
  const code = block(BASE, "const x");
77
106
  const cut = cutBlock(BASE, code);
107
+
108
+ // Act
78
109
  const restored = restoreBlock(BASE, cut, code, restoreLine(undefined, cut.split("\n").length));
110
+
111
+ // Assert
79
112
  expect(restored).toBeUndefined();
80
113
  });
81
114
 
82
115
  test("restore a multi-line block round-trips to pristine", () => {
83
- const p = block(BASE, "spans two lines");
84
- const cut = cutBlock(BASE, p);
85
- const next = parseBlocks(cut).find((b) => b.text === "first item");
86
- const restored = restoreBlock(BASE, cut, p, restoreLine(next, cut.split("\n").length));
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
87
125
  expect(restored).toBeUndefined();
88
126
  });
89
127
 
90
128
  test("signature rule ignores blank-line layout but not text changes", () => {
91
- const li = block(BASE, "second item");
92
- const cut = cutBlock(BASE, li);
93
- const next = parseBlocks(cut).find((b) => b.kind === "code");
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");
94
133
  const line = restoreLine(next, cut.split("\n").length);
134
+
135
+ // Assert
95
136
  // extra blank lines elsewhere do not block pristine detection
96
- expect(restoreBlock(BASE, cut.replace("## Context", "## Context\n"), li, line)).toBeUndefined();
137
+ expect(
138
+ restoreBlock(BASE, cut.replace("## Context", "## Context\n"), listItem, line),
139
+ ).toBeUndefined();
97
140
  // a real text change keeps the working copy alive
98
- const changed = restoreBlock(BASE, cut.replace("first", "1st"), li, line);
141
+ const changed = restoreBlock(BASE, cut.replace("first", "1st"), listItem, line);
142
+
99
143
  expect(changed).toContain("1st item");
100
144
  });
101
145
  });
102
146
 
103
147
  describe("restoreLine", () => {
104
148
  test("next surviving block's start, or the line count at the end", () => {
105
- const li = block(BASE, "first item");
106
- expect(restoreLine(li, 99)).toBe(li.lineStart);
149
+ // Arrange
150
+ const listItem = block(BASE, "first item");
151
+
152
+ // Assert
153
+ expect(restoreLine(listItem, 99)).toBe(listItem.lineStart);
107
154
  expect(restoreLine(undefined, 99)).toBe(99);
108
155
  });
109
156
  });
@@ -10,6 +10,7 @@ import { parseBlocks, type Block } from "./markdown";
10
10
  /** Chunk of the base source a block occupies (for restore and display). */
11
11
  export function sourceChunk(base: string, block: Block): string {
12
12
  const lines = base.split("\n");
13
+
13
14
  return lines.slice(block.lineStart, block.lineEnd + 1).join("\n");
14
15
  }
15
16
 
@@ -18,7 +19,9 @@ export function cutBlock(working: string, block: Block): string {
18
19
  const lines = working.split("\n");
19
20
  const before = lines.slice(0, block.lineStart);
20
21
  const after = lines.slice(block.lineEnd + 1);
22
+
21
23
  while (before.length && before[before.length - 1]!.trim() === "") before.pop();
24
+
22
25
  return [...before, ...after].join("\n");
23
26
  }
24
27
 
@@ -34,10 +37,24 @@ export function restoreLine(next: Block | undefined, workingLineCount: number):
34
37
  */
35
38
  function signature(text: string): string {
36
39
  return parseBlocks(text)
37
- .map((b) => b.kind + " " + b.text)
40
+ .map((block) => block.kind + " " + block.text)
38
41
  .join("\0");
39
42
  }
40
43
 
44
+ /**
45
+ * Whether a base block is missing from the working source: the working copy
46
+ * holds fewer blocks with its kind and text than the base does. Only such a
47
+ * block can be restored without duplicating content.
48
+ */
49
+ export function isBlockCut(base: string, working: string, block: Block): boolean {
50
+ const matches = (text: string) =>
51
+ parseBlocks(text).filter(
52
+ (candidate) => candidate.kind === block.kind && candidate.text === block.text,
53
+ ).length;
54
+
55
+ return matches(working) < matches(base);
56
+ }
57
+
41
58
  /**
42
59
  * Re-insert a cut base block into the working source before `beforeLine`.
43
60
  * Returns undefined when the block structure round-trips to the base content
@@ -53,9 +70,11 @@ export function restoreBlock(
53
70
  const lines = working.split("\n");
54
71
  const before = lines.slice(0, beforeLine);
55
72
  const after = lines.slice(beforeLine);
56
- const ins = sourceChunk(base, block).split("\n");
57
- if (before.length && before[before.length - 1]!.trim() !== "") ins.unshift("");
58
- if (after.length && after[0]!.trim() !== "") ins.push("");
59
- const restored = [...before, ...ins, ...after].join("\n");
73
+ const insertedLines = sourceChunk(base, block).split("\n");
74
+
75
+ if (before.length && before[before.length - 1]!.trim() !== "") insertedLines.unshift("");
76
+ if (after.length && after[0]!.trim() !== "") insertedLines.push("");
77
+ const restored = [...before, ...insertedLines, ...after].join("\n");
78
+
60
79
  return signature(restored) === signature(base) ? undefined : restored;
61
80
  }