@cueloop/schema 0.1.0-alpha.10 → 0.1.0-alpha.101
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 +17 -17
- package/src/agent-comment.test.ts +26 -0
- package/src/agent-comment.ts +17 -0
- package/src/agent-harness.ts +72 -0
- package/src/agent-input.test.ts +9 -0
- package/src/agent-input.ts +9 -0
- package/src/agent-submission.test.ts +15 -0
- package/src/agent-submission.ts +38 -0
- package/src/anchor.test.ts +340 -40
- package/src/anchor.ts +374 -43
- package/src/diff.test.ts +42 -25
- package/src/diff.ts +112 -79
- package/src/feedback.test.ts +318 -41
- package/src/feedback.ts +218 -55
- package/src/fuzzy.test.ts +110 -0
- package/src/fuzzy.ts +129 -0
- package/src/harness-output-routing.test.ts +18 -0
- package/src/harness-output-routing.ts +12 -0
- package/src/herdr.test.ts +50 -0
- package/src/herdr.ts +54 -0
- package/src/history.test.ts +401 -0
- package/src/history.ts +413 -0
- package/src/index.ts +19 -0
- package/src/inline.test.ts +195 -0
- package/src/inline.ts +234 -0
- package/src/markdown.test.ts +138 -22
- package/src/markdown.ts +332 -86
- package/src/participants.test.ts +105 -0
- package/src/participants.ts +51 -0
- package/src/path-view.test.ts +233 -0
- package/src/path-view.ts +104 -0
- package/src/share-access.test.ts +29 -0
- package/src/share-access.ts +14 -0
- package/src/shared-agent.ts +15 -0
- package/src/thread-agent.ts +97 -0
- package/src/thread-surface.test.ts +6 -0
- package/src/thread-surface.ts +6 -0
- package/src/types.test.ts +66 -5
- package/src/types.ts +261 -33
- package/src/working-copy.test.ts +209 -24
- package/src/working-copy.ts +148 -12
package/src/types.ts
CHANGED
|
@@ -1,38 +1,121 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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
|
|
11
|
+
/** A workspace is a repo/branch context holding threads. */
|
|
10
12
|
export interface WorkspaceKey {
|
|
11
13
|
repoRoot: string;
|
|
12
14
|
branch: string;
|
|
15
|
+
rootCommit?: string;
|
|
16
|
+
remote?: string;
|
|
13
17
|
}
|
|
14
18
|
|
|
15
|
-
|
|
19
|
+
/**
|
|
20
|
+
* What kind of artifact a thread holds. `plan` and `reply` are both
|
|
21
|
+
* markdown documents (see isMarkdownArtifact) - a plan is a proposal written
|
|
22
|
+
* forward, a reply is the agent's previous message pulled back for review.
|
|
23
|
+
* `diff` is a unified-diff patch; `prototype` is a component design doc (API /
|
|
24
|
+
* Composition / Callstack) in markdown, with an opt-in experimental pixel mode.
|
|
25
|
+
*
|
|
26
|
+
* One runtime union: every consumer that names the supported set - daemon
|
|
27
|
+
* wire validation, CLI flag parsing, adapter tool schemas - derives from this
|
|
28
|
+
* constant, so a new primitive extends them all without another hardcoded list.
|
|
29
|
+
*/
|
|
30
|
+
export const ARTIFACT_TYPES = ["plan", "diff", "prototype", "reply"] as const;
|
|
31
|
+
|
|
32
|
+
export type ArtifactType = (typeof ARTIFACT_TYPES)[number];
|
|
33
|
+
|
|
34
|
+
/** Harness workflows; review and refine are compositions, never artifact types. */
|
|
35
|
+
export const WORKFLOW_KINDS = ["plan", "reply", "prototype", "diff", "review", "refine"] as const;
|
|
36
|
+
|
|
37
|
+
export type WorkflowKind = (typeof WORKFLOW_KINDS)[number];
|
|
38
|
+
|
|
39
|
+
/** Agent skills installed by each harness, including workflows that don't submit an artifact. */
|
|
40
|
+
export const WORKFLOW_SKILL_KINDS = [...WORKFLOW_KINDS, "pair"] as const;
|
|
41
|
+
|
|
42
|
+
export type WorkflowSkillKind = (typeof WORKFLOW_SKILL_KINDS)[number];
|
|
43
|
+
|
|
44
|
+
/** Trust-boundary guard: is this string one of the artifact primitives? */
|
|
45
|
+
export function isArtifactType(value: string): value is ArtifactType {
|
|
46
|
+
return ARTIFACT_TYPES.some((candidate) => candidate === value);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Markdown artifacts (plan, reply, prototype) are block-parsed and quote-anchored,
|
|
51
|
+
* so they share the plan render path, first-heading title derivation, and revision
|
|
52
|
+
* drift-assist. A prototype is a component design doc (API / Composition / Callstack)
|
|
53
|
+
* by default; its opt-in experimental pixel mode is a client render override, not a
|
|
54
|
+
* different artifact. A diff (a patch) does not - keep this the one place that names
|
|
55
|
+
* the set, so a new markdown primitive joins here once.
|
|
56
|
+
*/
|
|
57
|
+
export function isMarkdownArtifact(type: ArtifactType): boolean {
|
|
58
|
+
return type === "plan" || type === "reply" || type === "prototype";
|
|
59
|
+
}
|
|
16
60
|
|
|
17
61
|
export interface ArtifactMeta {
|
|
62
|
+
workflow?: WorkflowKind;
|
|
18
63
|
cwd?: string;
|
|
64
|
+
vcs?: string;
|
|
65
|
+
vcsChangeId?: string;
|
|
66
|
+
vcsRevisionId?: string;
|
|
19
67
|
agent?: string;
|
|
20
|
-
/** Agent-native session id, for resume/fork context. */
|
|
21
68
|
agentSessionId?: string;
|
|
22
|
-
/** Path to the plan file on disk, so feedback can reference it. */
|
|
23
69
|
planPath?: string;
|
|
24
|
-
|
|
70
|
+
prototypePath?: string;
|
|
25
71
|
pr?: string;
|
|
26
|
-
|
|
72
|
+
prBrief?: string;
|
|
73
|
+
prBaseSha?: string;
|
|
74
|
+
prHeadSha?: string;
|
|
75
|
+
prRefreshBaseSha?: string;
|
|
76
|
+
prRefreshHeadSha?: string;
|
|
77
|
+
prUrl?: string;
|
|
27
78
|
herdrPane?: string;
|
|
28
79
|
title?: string;
|
|
80
|
+
workbench?: boolean;
|
|
81
|
+
snapshot?: boolean;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** VCS provenance for a submitted diff revision. */
|
|
85
|
+
export interface DiffSource {
|
|
86
|
+
vcs: string;
|
|
87
|
+
changeId?: string;
|
|
88
|
+
revisionId?: string;
|
|
89
|
+
baseRevisionId?: string;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Full old/new contents of one changed file, keyed by its repo-relative path. */
|
|
93
|
+
/** How a file changed, so curation emits the right create/delete headers. */
|
|
94
|
+
export type DiffFileStatus = "added" | "modified" | "deleted";
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* One reject decision of a diff review: a whole hunk, or one change block of
|
|
98
|
+
* it when `changeIndex` is set. The daemon curates the working copy from the
|
|
99
|
+
* full set, so every client sees the same patch.
|
|
100
|
+
*/
|
|
101
|
+
export interface HunkRejection {
|
|
102
|
+
path: string;
|
|
103
|
+
hunkIndex: number;
|
|
104
|
+
changeIndex?: number;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface DiffFileContents {
|
|
108
|
+
path: string;
|
|
109
|
+
oldContents: string;
|
|
110
|
+
newContents: string;
|
|
111
|
+
status: DiffFileStatus;
|
|
29
112
|
}
|
|
30
113
|
|
|
31
114
|
export interface Artifact {
|
|
32
115
|
type: ArtifactType;
|
|
33
|
-
/** Markdown source for plans; unified-diff text for diffs. */
|
|
34
116
|
content: string;
|
|
35
117
|
meta: ArtifactMeta;
|
|
118
|
+
files?: DiffFileContents[];
|
|
36
119
|
}
|
|
37
120
|
|
|
38
121
|
/**
|
|
@@ -44,67 +127,211 @@ export interface Anchor {
|
|
|
44
127
|
quote: string;
|
|
45
128
|
prefix: string;
|
|
46
129
|
suffix: string;
|
|
47
|
-
/** Index of the block the anchor was made in (hint). */
|
|
48
130
|
blockIndex?: number;
|
|
49
|
-
|
|
131
|
+
endBlockIndex?: number;
|
|
50
132
|
start?: number;
|
|
51
133
|
end?: number;
|
|
134
|
+
selector?: string;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** The annotation kind set is open; these are the built-ins. */
|
|
138
|
+
export type AnnotationKind = "comment" | (string & {});
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* The surface an annotation was made on. Name-only: the ref names the surface and the
|
|
142
|
+
* client loads that surface's text to resolve the quote anchor. Absent means the session's
|
|
143
|
+
* reviewed artifact, so every existing annotation keeps its meaning with no data change.
|
|
144
|
+
* A `file` target's `rev` doubles as the diff side: worktree for an added or context row,
|
|
145
|
+
* head for a deletion.
|
|
146
|
+
*/
|
|
147
|
+
export type AnnotationTarget =
|
|
148
|
+
| { kind: "artifact" }
|
|
149
|
+
| { kind: "file"; path: string; rev: "worktree" | "head" }
|
|
150
|
+
| { kind: "welcome" };
|
|
151
|
+
|
|
152
|
+
/** The target an annotation resolves against; absent records mean the reviewed artifact. */
|
|
153
|
+
export function annotationTarget(annotation: Pick<Annotation, "target">): AnnotationTarget {
|
|
154
|
+
return annotation.target ?? { kind: "artifact" };
|
|
52
155
|
}
|
|
53
156
|
|
|
54
|
-
/**
|
|
55
|
-
|
|
157
|
+
/**
|
|
158
|
+
* Agent-authored context, not reviewer feedback: the guided walk's per-file
|
|
159
|
+
* notes (kind "note", anchored by the file path). Excluded from the feedback
|
|
160
|
+
* document and the reviewer's pending counts - an agent must never receive
|
|
161
|
+
* its own notes back as items to address.
|
|
162
|
+
*/
|
|
163
|
+
export function isAgentNote(annotation: Pick<Annotation, "kind">): boolean {
|
|
164
|
+
return annotation.kind === "note";
|
|
165
|
+
}
|
|
56
166
|
|
|
57
167
|
export interface Annotation {
|
|
58
168
|
id: string;
|
|
59
169
|
kind: AnnotationKind;
|
|
60
170
|
anchor: Anchor;
|
|
61
|
-
|
|
171
|
+
target?: AnnotationTarget;
|
|
62
172
|
body: string;
|
|
63
|
-
/** Set by resolution when the quote can no longer be found. */
|
|
64
173
|
orphan?: boolean;
|
|
174
|
+
author?: string;
|
|
175
|
+
reviewComment?: ReviewComment;
|
|
176
|
+
replyTo?: string;
|
|
177
|
+
resolution?: AnnotationResolution;
|
|
178
|
+
createdAt: string;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export const REVIEW_SEVERITIES = ["p0", "p1", "p2"] as const;
|
|
182
|
+
|
|
183
|
+
export type ReviewSeverity = (typeof REVIEW_SEVERITIES)[number];
|
|
184
|
+
|
|
185
|
+
/** Data needed to render and publish one agent-authored pull request comment. */
|
|
186
|
+
export interface ReviewComment {
|
|
187
|
+
severity: ReviewSeverity;
|
|
188
|
+
title: string;
|
|
189
|
+
path: string;
|
|
190
|
+
line: number;
|
|
191
|
+
startLine?: number;
|
|
192
|
+
startAnchor?: Anchor;
|
|
193
|
+
side: "LEFT" | "RIGHT";
|
|
194
|
+
suggestion?: string;
|
|
195
|
+
prompt?: string;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export interface AnnotationResolution {
|
|
199
|
+
revision: number;
|
|
200
|
+
source: "agent" | "drift";
|
|
201
|
+
}
|
|
202
|
+
|
|
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;
|
|
65
214
|
createdAt: string;
|
|
215
|
+
approvedRetryMessageId?: string;
|
|
66
216
|
}
|
|
67
217
|
|
|
68
|
-
|
|
218
|
+
/** Durable routing state for one Message sent to one harness binding. */
|
|
219
|
+
export interface Delivery {
|
|
220
|
+
id: string;
|
|
221
|
+
messageId: string;
|
|
222
|
+
bindingId: string;
|
|
223
|
+
status: "pending" | "acknowledged";
|
|
224
|
+
createdAt: string;
|
|
225
|
+
acknowledgedAt?: string;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** What a harness adapter pulls: routing state plus its immutable Message payload. */
|
|
229
|
+
export interface PendingDelivery {
|
|
230
|
+
delivery: Delivery;
|
|
231
|
+
message: Message;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export const MESSAGE_OUTCOMES = ["comment", "approved", "changes_requested"] as const;
|
|
235
|
+
|
|
236
|
+
export type MessageOutcome = (typeof MESSAGE_OUTCOMES)[number];
|
|
69
237
|
|
|
70
|
-
|
|
71
|
-
|
|
238
|
+
/** Durable acceptance records are committed together with the mutation they identify. */
|
|
239
|
+
export interface OperationReceipt<Result> {
|
|
240
|
+
operationId: string;
|
|
241
|
+
fingerprint: string;
|
|
242
|
+
result: Result;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export interface Message {
|
|
246
|
+
id: string;
|
|
247
|
+
outcome: MessageOutcome;
|
|
72
248
|
summary: string;
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
249
|
+
body: string;
|
|
250
|
+
annotations?: Annotation[];
|
|
251
|
+
sentAt: string;
|
|
76
252
|
}
|
|
77
253
|
|
|
78
254
|
export interface Revision {
|
|
79
255
|
revision: number;
|
|
80
256
|
content: string;
|
|
81
257
|
submittedAt: string;
|
|
258
|
+
source?: DiffSource;
|
|
259
|
+
files?: DiffFileContents[];
|
|
82
260
|
}
|
|
83
261
|
|
|
84
262
|
export type SessionStatus = "pending" | "resolved";
|
|
85
263
|
|
|
86
|
-
|
|
264
|
+
/**
|
|
265
|
+
* A person who authored annotations on a session. `id` is the stable key an
|
|
266
|
+
* annotation's `author` points at - the SSH fingerprint today, an OAuth id
|
|
267
|
+
* ("github:…") later. Provider-agnostic so those identities slot in unchanged.
|
|
268
|
+
*/
|
|
269
|
+
export interface Identity {
|
|
270
|
+
id: string;
|
|
271
|
+
provider: "ssh" | "github";
|
|
272
|
+
name?: string;
|
|
273
|
+
handle?: string;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** A share link's public or private policy; a private link with an empty allowlist admits no viewers. */
|
|
277
|
+
export type SharePolicy = Pick<ShareLink, "requireAuth" | "allowlist" | "agentEnabled">;
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* One published share link for a thread. A thread can have several, each an
|
|
281
|
+
* independent gateway blob keyed by its own `id`. `name` is a local-only label
|
|
282
|
+
* to tell links apart; `requireAuth` gates the link behind the `allowlist` of
|
|
283
|
+
* GitHub logins (empty + requireAuth is a private link with no one added yet).
|
|
284
|
+
*/
|
|
285
|
+
export interface ShareLink {
|
|
286
|
+
id: string;
|
|
287
|
+
name?: string;
|
|
288
|
+
requireAuth: boolean;
|
|
289
|
+
allowlist: string[];
|
|
290
|
+
agentEnabled?: boolean;
|
|
291
|
+
owner?: string;
|
|
292
|
+
shareBranch?: string;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** One exact character range removed from the submitted artifact source. */
|
|
296
|
+
export interface TextCut {
|
|
297
|
+
start: number;
|
|
298
|
+
end: number;
|
|
299
|
+
quote: string;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** A Thread carries share policy and ownership on each entry in shares. */
|
|
303
|
+
export interface Thread {
|
|
87
304
|
schemaVersion: string;
|
|
88
305
|
id: string;
|
|
89
306
|
workspace: WorkspaceKey;
|
|
90
307
|
artifact: Artifact;
|
|
91
|
-
/** Revision history; artifact.content always equals the latest revision. */
|
|
92
308
|
revisions: Revision[];
|
|
93
309
|
annotations: Annotation[];
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
* Serializes as ONE unified diff against the submitted revision.
|
|
97
|
-
* Undefined = no direct edits.
|
|
98
|
-
*/
|
|
310
|
+
history?: SessionHistory;
|
|
311
|
+
curation?: HunkRejection[];
|
|
99
312
|
workingCopy?: string;
|
|
100
|
-
|
|
313
|
+
textCuts?: TextCut[];
|
|
314
|
+
viewedPaths?: string[];
|
|
315
|
+
messageOperations?: OperationReceipt<Message>[];
|
|
316
|
+
message: Message | null;
|
|
101
317
|
status: SessionStatus;
|
|
102
318
|
createdAt: string;
|
|
319
|
+
shelvedAnnotations?: Annotation[];
|
|
320
|
+
parentSessionId?: string;
|
|
321
|
+
shares?: ShareLink[];
|
|
322
|
+
participants?: Identity[];
|
|
103
323
|
}
|
|
104
324
|
|
|
105
|
-
/**
|
|
106
|
-
export function
|
|
107
|
-
return
|
|
325
|
+
/** Whether a Message releases a harness gate. */
|
|
326
|
+
export function messageAllows(outcome: MessageOutcome): boolean {
|
|
327
|
+
return outcome === "approved";
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
let messageSeq = 0;
|
|
331
|
+
|
|
332
|
+
/** A process-unique, time-sortable Message id for delivery deduplication. */
|
|
333
|
+
export function newMessageId(): string {
|
|
334
|
+
return `msg_${Date.now().toString(36)}${(messageSeq++).toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
|
108
335
|
}
|
|
109
336
|
|
|
110
337
|
/**
|
|
@@ -113,6 +340,7 @@ export function verdictAllows(kind: VerdictKind): boolean {
|
|
|
113
340
|
* annotators in different processes on the same millisecond.
|
|
114
341
|
*/
|
|
115
342
|
let annotationSeq = 0;
|
|
343
|
+
|
|
116
344
|
export function newAnnotationId(): string {
|
|
117
345
|
return `a_${Date.now().toString(36)}${(annotationSeq++).toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
|
|
118
346
|
}
|
package/src/working-copy.test.ts
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
applyTextCuts,
|
|
4
|
+
blockCutSourceRange,
|
|
5
|
+
cutBlock,
|
|
6
|
+
cutTextRange,
|
|
7
|
+
mergeTextCut,
|
|
8
|
+
restoreTextCut,
|
|
9
|
+
restoreBlock,
|
|
10
|
+
restoreLine,
|
|
11
|
+
sourceChunk,
|
|
12
|
+
} from "./working-copy";
|
|
3
13
|
import { parseBlocks, type Block } from "./markdown";
|
|
4
14
|
|
|
5
15
|
const BASE = `# Plan
|
|
@@ -18,92 +28,267 @@ const y = 2;
|
|
|
18
28
|
\`\`\`
|
|
19
29
|
`;
|
|
20
30
|
|
|
21
|
-
function block(
|
|
22
|
-
return parseBlocks(
|
|
31
|
+
function block(markdown: string, text: string): Block {
|
|
32
|
+
return parseBlocks(markdown).find((block) => block.text.includes(text))!;
|
|
23
33
|
}
|
|
24
34
|
|
|
25
35
|
describe("sourceChunk", () => {
|
|
26
36
|
test("returns the exact source lines a block occupies", () => {
|
|
27
37
|
expect(sourceChunk(BASE, block(BASE, "first item"))).toBe("- first item");
|
|
28
|
-
expect(sourceChunk(BASE, block(BASE, "spans two lines"))).toBe(
|
|
38
|
+
expect(sourceChunk(BASE, block(BASE, "spans two lines"))).toBe(
|
|
39
|
+
"A paragraph that\nspans two lines.",
|
|
40
|
+
);
|
|
29
41
|
});
|
|
30
42
|
|
|
31
43
|
test("code block chunk includes both fence lines", () => {
|
|
32
|
-
expect(sourceChunk(BASE, block(BASE, "const x"))).toBe(
|
|
44
|
+
expect(sourceChunk(BASE, block(BASE, "const x"))).toBe(
|
|
45
|
+
"```ts\nconst x = 1;\nconst y = 2;\n```",
|
|
46
|
+
);
|
|
33
47
|
});
|
|
34
48
|
});
|
|
35
49
|
|
|
36
50
|
describe("cutBlock", () => {
|
|
37
51
|
test("removes the block lines and the blank line before it", () => {
|
|
52
|
+
// Act
|
|
38
53
|
const cut = cutBlock(BASE, block(BASE, "spans two lines"));
|
|
54
|
+
|
|
55
|
+
// Assert
|
|
39
56
|
expect(cut).not.toContain("spans two lines");
|
|
40
57
|
expect(cut).toContain("## Context\n\n- first item");
|
|
41
58
|
});
|
|
42
59
|
|
|
43
60
|
test("cut at the start of the document leaves the leading blank line", () => {
|
|
61
|
+
// Act
|
|
44
62
|
const cut = cutBlock(BASE, block(BASE, "Plan"));
|
|
63
|
+
|
|
64
|
+
// Assert
|
|
45
65
|
expect(cut).not.toContain("# Plan");
|
|
46
66
|
expect(cut.trimStart().startsWith("## Context")).toBe(true);
|
|
47
67
|
});
|
|
48
68
|
|
|
49
69
|
test("cut at the end of the document keeps the rest intact", () => {
|
|
70
|
+
// Act
|
|
50
71
|
const cut = cutBlock(BASE, block(BASE, "const x"));
|
|
72
|
+
|
|
73
|
+
// Assert
|
|
51
74
|
expect(cut).not.toContain("const x");
|
|
52
75
|
expect(cut).toContain("- second item");
|
|
53
76
|
});
|
|
77
|
+
|
|
78
|
+
test.each(["Plan", "spans two lines", "first item", "const x"])(
|
|
79
|
+
"reports the exact source interval cut from %s",
|
|
80
|
+
(text) => {
|
|
81
|
+
const target = block(BASE, text);
|
|
82
|
+
const range = blockCutSourceRange(BASE, target);
|
|
83
|
+
|
|
84
|
+
expect(BASE.slice(0, range.start) + BASE.slice(range.end)).toBe(cutBlock(BASE, target));
|
|
85
|
+
},
|
|
86
|
+
);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe("cutTextRange", () => {
|
|
90
|
+
test("cuts only the selected characters inside a block", () => {
|
|
91
|
+
const paragraph = block(BASE, "spans two lines");
|
|
92
|
+
const start = paragraph.text.indexOf("paragraph");
|
|
93
|
+
const cut = cutTextRange(BASE, paragraph, start, paragraph, start + "paragraph".length);
|
|
94
|
+
|
|
95
|
+
expect(cut).toContain("A that\nspans two lines.");
|
|
96
|
+
expect(cut).toContain("## Context");
|
|
97
|
+
expect(cut).toContain("- first item");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("preserves a list marker around a partial cut", () => {
|
|
101
|
+
const item = block(BASE, "first item");
|
|
102
|
+
const cut = cutTextRange(BASE, item, 0, item, "first ".length);
|
|
103
|
+
|
|
104
|
+
expect(cut).toContain("- item");
|
|
105
|
+
expect(cut).not.toContain("- first item");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test.each([
|
|
109
|
+
["heading", "# remove keep", "# keep"],
|
|
110
|
+
["ordered list", "12. remove keep", "12. keep"],
|
|
111
|
+
["fenced code", "```ts\nremove keep\n```", "```ts\n keep\n```"],
|
|
112
|
+
["frontmatter", "---\nname: remove keep\n---", "---\nname: keep\n---"],
|
|
113
|
+
["table", "| remove | keep |\n| --- | --- |", "| | keep |\n| --- | --- |"],
|
|
114
|
+
])("preserves %s source markers", (_label, source, expected) => {
|
|
115
|
+
const target = block(source, "remove");
|
|
116
|
+
const start = target.text.indexOf("remove");
|
|
117
|
+
|
|
118
|
+
expect(cutTextRange(source, target, start, target, start + "remove".length)).toBe(expected);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("cuts an exact range across marked quote lines", () => {
|
|
122
|
+
const source = "> keep alpha\n> remove beta\n> keep gamma";
|
|
123
|
+
const quote = parseBlocks(source)[0]!;
|
|
124
|
+
const start = quote.text.indexOf("alpha");
|
|
125
|
+
const end = quote.text.indexOf("keep gamma");
|
|
126
|
+
|
|
127
|
+
const cut = cutTextRange(source, quote, start, quote, end);
|
|
128
|
+
|
|
129
|
+
expect(cut).toBe("> keep keep gamma");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("cuts an exact range across separate blocks", () => {
|
|
133
|
+
const source = "Keep before remove this.\n\n- remove that keep after";
|
|
134
|
+
const blocks = parseBlocks(source);
|
|
135
|
+
const first = blocks[0]!;
|
|
136
|
+
const second = blocks[1]!;
|
|
137
|
+
const start = first.text.indexOf("remove");
|
|
138
|
+
const end = second.text.indexOf("keep after");
|
|
139
|
+
|
|
140
|
+
const cut = cutTextRange(source, first, start, second, end);
|
|
141
|
+
|
|
142
|
+
expect(cut).toBe("Keep before keep after");
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe("exact text Cuts", () => {
|
|
147
|
+
test("stores and applies the selected source characters exactly", () => {
|
|
148
|
+
const source = "It lands under unin Threads then in the sidebar.";
|
|
149
|
+
const start = source.indexOf("under");
|
|
150
|
+
const end = source.indexOf(" in the sidebar");
|
|
151
|
+
const cuts = mergeTextCut(source, [], start, end);
|
|
152
|
+
|
|
153
|
+
expect(cuts).toEqual([{ start, end, quote: "under unin Threads then" }]);
|
|
154
|
+
expect(applyTextCuts(source, cuts)).toBe("It lands in the sidebar.");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("merges overlapping and adjacent ranges in one linear source interval", () => {
|
|
158
|
+
const source = "alpha beta gamma delta";
|
|
159
|
+
const first = mergeTextCut(source, [], 6, 10);
|
|
160
|
+
const merged = mergeTextCut(source, first, 10, 16);
|
|
161
|
+
|
|
162
|
+
expect(merged).toEqual([{ start: 6, end: 16, quote: "beta gamma" }]);
|
|
163
|
+
expect(applyTextCuts(source, merged)).toBe("alpha delta");
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("returns the original references for no-op ranges", () => {
|
|
167
|
+
const source = "unchanged";
|
|
168
|
+
const cuts = [{ start: 0, end: 2, quote: "un" }];
|
|
169
|
+
|
|
170
|
+
expect(mergeTextCut(source, cuts, 4, 4)).toBe(cuts);
|
|
171
|
+
expect(applyTextCuts(source, [])).toBe(source);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("applies out-of-order Cuts and ignores stale or overlapping records", () => {
|
|
175
|
+
const source = "alpha beta gamma";
|
|
176
|
+
|
|
177
|
+
expect(
|
|
178
|
+
applyTextCuts(source, [
|
|
179
|
+
{ start: 11, end: 16, quote: "gamma" },
|
|
180
|
+
{ start: 0, end: 5, quote: "alpha" },
|
|
181
|
+
{ start: 2, end: 8, quote: "stale" },
|
|
182
|
+
{ start: 6, end: 10, quote: "beta" },
|
|
183
|
+
]),
|
|
184
|
+
).toBe(" ");
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("restores an exact subrange without disturbing the rest of its Cut", () => {
|
|
188
|
+
const source = "alpha beta gamma delta";
|
|
189
|
+
const cuts = mergeTextCut(source, [], 6, 16);
|
|
190
|
+
const restored = restoreTextCut(source, cuts, 11, 16);
|
|
191
|
+
|
|
192
|
+
expect(restored).toEqual([{ start: 6, end: 11, quote: "beta " }]);
|
|
193
|
+
expect(applyTextCuts(source, restored ?? [])).toBe("alpha gamma delta");
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("does not restore across separate Cuts", () => {
|
|
197
|
+
const source = "alpha beta gamma delta";
|
|
198
|
+
const cuts = [
|
|
199
|
+
{ start: 6, end: 10, quote: "beta" },
|
|
200
|
+
{ start: 11, end: 16, quote: "gamma" },
|
|
201
|
+
];
|
|
202
|
+
|
|
203
|
+
expect(restoreTextCut(source, cuts, 6, 16)).toBeNull();
|
|
204
|
+
});
|
|
54
205
|
});
|
|
55
206
|
|
|
56
207
|
describe("restoreBlock", () => {
|
|
57
208
|
test("restoring the only cut returns undefined - back to pristine", () => {
|
|
58
|
-
|
|
59
|
-
const
|
|
209
|
+
// Arrange
|
|
210
|
+
const listItem = block(BASE, "second item");
|
|
211
|
+
const cut = cutBlock(BASE, listItem);
|
|
60
212
|
// the cut block re-enters before the code fence, the next surviving block
|
|
61
|
-
const next = parseBlocks(cut).find((
|
|
62
|
-
|
|
213
|
+
const next = parseBlocks(cut).find((candidate) => candidate.kind === "code");
|
|
214
|
+
|
|
215
|
+
// Act
|
|
216
|
+
const restored = restoreBlock(BASE, cut, listItem, restoreLine(next, cut.split("\n").length));
|
|
217
|
+
|
|
218
|
+
// Assert
|
|
63
219
|
expect(restored).toBeUndefined();
|
|
64
220
|
});
|
|
65
221
|
|
|
66
222
|
test("restore after another edit returns the merged working copy", () => {
|
|
67
|
-
|
|
68
|
-
const
|
|
69
|
-
const
|
|
70
|
-
const
|
|
223
|
+
// Arrange
|
|
224
|
+
const listItem = block(BASE, "second item");
|
|
225
|
+
const edited = cutBlock(BASE, listItem).replace("first item", "first item reworded");
|
|
226
|
+
const next = parseBlocks(edited).find((candidate) => candidate.kind === "code");
|
|
227
|
+
|
|
228
|
+
// Act
|
|
229
|
+
const restored = restoreBlock(
|
|
230
|
+
BASE,
|
|
231
|
+
edited,
|
|
232
|
+
listItem,
|
|
233
|
+
restoreLine(next, edited.split("\n").length),
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
// Assert
|
|
71
237
|
expect(restored).toContain("- second item");
|
|
72
238
|
expect(restored).toContain("first item reworded");
|
|
73
239
|
});
|
|
74
240
|
|
|
75
241
|
test("restore at the end of the document (no next block)", () => {
|
|
242
|
+
// Arrange
|
|
76
243
|
const code = block(BASE, "const x");
|
|
77
244
|
const cut = cutBlock(BASE, code);
|
|
245
|
+
|
|
246
|
+
// Act
|
|
78
247
|
const restored = restoreBlock(BASE, cut, code, restoreLine(undefined, cut.split("\n").length));
|
|
248
|
+
|
|
249
|
+
// Assert
|
|
79
250
|
expect(restored).toBeUndefined();
|
|
80
251
|
});
|
|
81
252
|
|
|
82
253
|
test("restore a multi-line block round-trips to pristine", () => {
|
|
83
|
-
|
|
84
|
-
const
|
|
85
|
-
const
|
|
86
|
-
const
|
|
254
|
+
// Arrange
|
|
255
|
+
const paragraph = block(BASE, "spans two lines");
|
|
256
|
+
const cut = cutBlock(BASE, paragraph);
|
|
257
|
+
const next = parseBlocks(cut).find((block) => block.text === "first item");
|
|
258
|
+
|
|
259
|
+
// Act
|
|
260
|
+
const restored = restoreBlock(BASE, cut, paragraph, restoreLine(next, cut.split("\n").length));
|
|
261
|
+
|
|
262
|
+
// Assert
|
|
87
263
|
expect(restored).toBeUndefined();
|
|
88
264
|
});
|
|
89
265
|
|
|
90
266
|
test("signature rule ignores blank-line layout but not text changes", () => {
|
|
91
|
-
|
|
92
|
-
const
|
|
93
|
-
const
|
|
267
|
+
// Arrange
|
|
268
|
+
const listItem = block(BASE, "second item");
|
|
269
|
+
const cut = cutBlock(BASE, listItem);
|
|
270
|
+
const next = parseBlocks(cut).find((candidate) => candidate.kind === "code");
|
|
94
271
|
const line = restoreLine(next, cut.split("\n").length);
|
|
272
|
+
|
|
273
|
+
// Assert
|
|
95
274
|
// extra blank lines elsewhere do not block pristine detection
|
|
96
|
-
expect(
|
|
275
|
+
expect(
|
|
276
|
+
restoreBlock(BASE, cut.replace("## Context", "## Context\n"), listItem, line),
|
|
277
|
+
).toBeUndefined();
|
|
97
278
|
// a real text change keeps the working copy alive
|
|
98
|
-
const changed = restoreBlock(BASE, cut.replace("first", "1st"),
|
|
279
|
+
const changed = restoreBlock(BASE, cut.replace("first", "1st"), listItem, line);
|
|
280
|
+
|
|
99
281
|
expect(changed).toContain("1st item");
|
|
100
282
|
});
|
|
101
283
|
});
|
|
102
284
|
|
|
103
285
|
describe("restoreLine", () => {
|
|
104
286
|
test("next surviving block's start, or the line count at the end", () => {
|
|
105
|
-
|
|
106
|
-
|
|
287
|
+
// Arrange
|
|
288
|
+
const listItem = block(BASE, "first item");
|
|
289
|
+
|
|
290
|
+
// Assert
|
|
291
|
+
expect(restoreLine(listItem, 99)).toBe(listItem.lineStart);
|
|
107
292
|
expect(restoreLine(undefined, 99)).toBe(99);
|
|
108
293
|
});
|
|
109
294
|
});
|