@cueloop/schema 0.1.0-alpha.7 → 0.1.0-alpha.71
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/anchor.test.ts +268 -40
- package/src/anchor.ts +355 -43
- package/src/diff.test.ts +42 -25
- package/src/diff.ts +112 -77
- package/src/feedback.test.ts +313 -36
- package/src/feedback.ts +219 -50
- package/src/fuzzy.test.ts +110 -0
- package/src/fuzzy.ts +115 -0
- package/src/herdr.test.ts +50 -0
- package/src/herdr.ts +54 -0
- package/src/history.test.ts +395 -0
- package/src/history.ts +419 -0
- package/src/index.ts +6 -0
- package/src/inline.test.ts +195 -0
- package/src/inline.ts +240 -0
- package/src/markdown.test.ts +76 -21
- package/src/markdown.ts +123 -72
- package/src/participants.test.ts +58 -0
- package/src/participants.ts +34 -0
- package/src/path-view.test.ts +201 -0
- package/src/path-view.ts +85 -0
- package/src/types.test.ts +45 -1
- package/src/types.ts +198 -12
- package/src/working-copy.test.ts +70 -23
- package/src/working-copy.ts +24 -5
package/src/types.ts
CHANGED
|
@@ -1,38 +1,107 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* The Thread primitive. Everything in cueloop renders, annotates, or
|
|
3
|
+
* resolves 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
11
|
/** A workspace is a repo/branch context holding review sessions. */
|
|
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 review session 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
|
+
/** Trust-boundary guard: is this string one of the artifact primitives? */
|
|
37
|
+
export function isArtifactType(value: string): value is ArtifactType {
|
|
38
|
+
return ARTIFACT_TYPES.some((candidate) => candidate === value);
|
|
13
39
|
}
|
|
14
40
|
|
|
15
|
-
|
|
41
|
+
/**
|
|
42
|
+
* Markdown artifacts (plan, reply, prototype) are block-parsed and quote-anchored,
|
|
43
|
+
* so they share the plan render path, first-heading title derivation, and revision
|
|
44
|
+
* drift-assist. A prototype is a component design doc (API / Composition / Callstack)
|
|
45
|
+
* by default; its opt-in experimental pixel mode is a client render override, not a
|
|
46
|
+
* different artifact. A diff (a patch) does not - keep this the one place that names
|
|
47
|
+
* the set, so a new markdown primitive joins here once.
|
|
48
|
+
*/
|
|
49
|
+
export function isMarkdownArtifact(type: ArtifactType): boolean {
|
|
50
|
+
return type === "plan" || type === "reply" || type === "prototype";
|
|
51
|
+
}
|
|
16
52
|
|
|
17
53
|
export interface ArtifactMeta {
|
|
18
54
|
cwd?: string;
|
|
19
55
|
agent?: string;
|
|
20
56
|
/** Agent-native session id, for resume/fork context. */
|
|
21
57
|
agentSessionId?: string;
|
|
22
|
-
/** Path to the plan file on disk, so feedback can reference it. */
|
|
58
|
+
/** Path to the plan or reply markdown file on disk, so feedback can reference it. */
|
|
23
59
|
planPath?: string;
|
|
60
|
+
/** Path to the prototype's entry HTML file on disk. */
|
|
61
|
+
prototypePath?: string;
|
|
24
62
|
/** Pull request reference the diff came from, so the verdict can be posted back. */
|
|
25
63
|
pr?: string;
|
|
26
64
|
/** herdr pane the submitting agent runs in - the review returns focus there. */
|
|
27
65
|
herdrPane?: string;
|
|
28
66
|
title?: string;
|
|
67
|
+
/** A self-initiated per-repo workbench thread (a bare launch's first comment), not an agent submission. */
|
|
68
|
+
workbench?: boolean;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Full old/new contents of one changed file, keyed by its repo-relative path. */
|
|
72
|
+
/** How a file changed, so curation emits the right create/delete headers. */
|
|
73
|
+
export type DiffFileStatus = "added" | "modified" | "deleted";
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* One reject decision of a diff review: a whole hunk, or one change block of
|
|
77
|
+
* it when `changeIndex` is set. The daemon curates the working copy from the
|
|
78
|
+
* full set, so every client sees the same patch.
|
|
79
|
+
*/
|
|
80
|
+
export interface HunkRejection {
|
|
81
|
+
path: string;
|
|
82
|
+
hunkIndex: number;
|
|
83
|
+
changeIndex?: number;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface DiffFileContents {
|
|
87
|
+
path: string;
|
|
88
|
+
oldContents: string;
|
|
89
|
+
newContents: string;
|
|
90
|
+
/** git's own classification - not inferred from empty contents, so an
|
|
91
|
+
* existing-empty-file edit is a modify, not a create/delete. */
|
|
92
|
+
status: DiffFileStatus;
|
|
29
93
|
}
|
|
30
94
|
|
|
31
95
|
export interface Artifact {
|
|
32
96
|
type: ArtifactType;
|
|
33
|
-
/** Markdown source for plans; unified-diff text for diffs. */
|
|
97
|
+
/** Markdown source for plans and replies; unified-diff text for diffs. */
|
|
34
98
|
content: string;
|
|
35
99
|
meta: ArtifactMeta;
|
|
100
|
+
/**
|
|
101
|
+
* Full file contents for a diff artifact, so hunk curation produces an
|
|
102
|
+
* exactly applyable patch; absent for legacy or partial (PR) diffs.
|
|
103
|
+
*/
|
|
104
|
+
files?: DiffFileContents[];
|
|
36
105
|
}
|
|
37
106
|
|
|
38
107
|
/**
|
|
@@ -44,27 +113,89 @@ export interface Anchor {
|
|
|
44
113
|
quote: string;
|
|
45
114
|
prefix: string;
|
|
46
115
|
suffix: string;
|
|
47
|
-
/** Index of the block the anchor
|
|
116
|
+
/** Index of the block the anchor starts in (hint). */
|
|
48
117
|
blockIndex?: number;
|
|
49
|
-
/**
|
|
118
|
+
/** Last block of a quote that spans blocks (hint); absent for one block. */
|
|
119
|
+
endBlockIndex?: number;
|
|
120
|
+
/** Character offsets: `start` within the first block, `end` within the last (hint). */
|
|
50
121
|
start?: number;
|
|
51
122
|
end?: number;
|
|
123
|
+
/** Prototype anchors: the CSS selector of the annotated element (authority). */
|
|
124
|
+
selector?: string;
|
|
52
125
|
}
|
|
53
126
|
|
|
54
|
-
/** The annotation kind set is open
|
|
55
|
-
export type AnnotationKind = "comment" |
|
|
127
|
+
/** The annotation kind set is open; these are the built-ins. */
|
|
128
|
+
export type AnnotationKind = "comment" | (string & {});
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The surface an annotation was made on. Name-only: the ref names the surface and the
|
|
132
|
+
* client loads that surface's text to resolve the quote anchor. Absent means the session's
|
|
133
|
+
* reviewed artifact, so every existing annotation keeps its meaning with no data change.
|
|
134
|
+
* A `file` target's `rev` doubles as the diff side: worktree for an added or context row,
|
|
135
|
+
* head for a deletion.
|
|
136
|
+
*/
|
|
137
|
+
export type AnnotationTarget =
|
|
138
|
+
| { kind: "artifact" }
|
|
139
|
+
| { kind: "file"; path: string; rev: "worktree" | "head" }
|
|
140
|
+
| { kind: "welcome" };
|
|
141
|
+
|
|
142
|
+
/** The target an annotation resolves against; absent records mean the reviewed artifact. */
|
|
143
|
+
export function annotationTarget(annotation: Pick<Annotation, "target">): AnnotationTarget {
|
|
144
|
+
return annotation.target ?? { kind: "artifact" };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Agent-authored context, not reviewer feedback: the guided walk's per-file
|
|
149
|
+
* notes (kind "note", anchored by the file path). Excluded from the feedback
|
|
150
|
+
* document and the reviewer's pending counts - an agent must never receive
|
|
151
|
+
* its own notes back as items to address.
|
|
152
|
+
*/
|
|
153
|
+
export function isAgentNote(annotation: Pick<Annotation, "kind">): boolean {
|
|
154
|
+
return annotation.kind === "note";
|
|
155
|
+
}
|
|
56
156
|
|
|
57
157
|
export interface Annotation {
|
|
58
158
|
id: string;
|
|
59
159
|
kind: AnnotationKind;
|
|
60
160
|
anchor: Anchor;
|
|
61
|
-
/**
|
|
161
|
+
/** The surface this note was made on; absent means the session's reviewed artifact. */
|
|
162
|
+
target?: AnnotationTarget;
|
|
163
|
+
/** Comment body. */
|
|
62
164
|
body: string;
|
|
63
165
|
/** Set by resolution when the quote can no longer be found. */
|
|
64
166
|
orphan?: boolean;
|
|
167
|
+
/**
|
|
168
|
+
* SSH key fingerprint of a share collaborator who authored this note. Absent
|
|
169
|
+
* on the planner's own annotations; the sharing gateway stamps it so the
|
|
170
|
+
* planner can tell whose note is whose and never overwrite a collaborator's.
|
|
171
|
+
*/
|
|
172
|
+
author?: string;
|
|
173
|
+
/**
|
|
174
|
+
* The root comment this one replies to. Absent on a root. A reply shares its
|
|
175
|
+
* root's anchor, so a discussion stays one conversation when the text moves.
|
|
176
|
+
*/
|
|
177
|
+
replyTo?: string;
|
|
178
|
+
/**
|
|
179
|
+
* Set when a revision addressed this annotation: the agent reported the id
|
|
180
|
+
* on resubmit ("agent"), or the quoted text disappeared from the revised
|
|
181
|
+
* plan ("drift"). Addressed annotations leave the default rail view and the
|
|
182
|
+
* next feedback document, but are never deleted.
|
|
183
|
+
*/
|
|
184
|
+
resolution?: AnnotationResolution;
|
|
65
185
|
createdAt: string;
|
|
66
186
|
}
|
|
67
187
|
|
|
188
|
+
export interface AnnotationResolution {
|
|
189
|
+
/** The revision number whose submission addressed this annotation. */
|
|
190
|
+
revision: number;
|
|
191
|
+
source: "agent" | "drift";
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** An annotation a revision has addressed; it leaves the default views. */
|
|
195
|
+
export function isAddressed(annotation: Annotation): boolean {
|
|
196
|
+
return annotation.resolution !== undefined;
|
|
197
|
+
}
|
|
198
|
+
|
|
68
199
|
export type VerdictKind = "comment" | "approve" | "request_changes";
|
|
69
200
|
|
|
70
201
|
export interface Verdict {
|
|
@@ -83,7 +214,23 @@ export interface Revision {
|
|
|
83
214
|
|
|
84
215
|
export type SessionStatus = "pending" | "resolved";
|
|
85
216
|
|
|
86
|
-
|
|
217
|
+
/**
|
|
218
|
+
* A person who authored annotations on a session. `id` is the stable key an
|
|
219
|
+
* annotation's `author` points at - the SSH fingerprint today, an OAuth id
|
|
220
|
+
* ("github:…") later. Provider-agnostic so those identities slot in unchanged.
|
|
221
|
+
*/
|
|
222
|
+
export interface Identity {
|
|
223
|
+
/** Stable identity key; equals an annotation's `author`. */
|
|
224
|
+
id: string;
|
|
225
|
+
/** Identity source. One value today; widen the union when OAuth lands. */
|
|
226
|
+
provider: "ssh";
|
|
227
|
+
/** Display name; absent = the collaborator stayed anonymous. */
|
|
228
|
+
name?: string;
|
|
229
|
+
/** Provider handle: a github login, an email, or a short fingerprint. */
|
|
230
|
+
handle?: string;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export interface Thread {
|
|
87
234
|
schemaVersion: string;
|
|
88
235
|
id: string;
|
|
89
236
|
workspace: WorkspaceKey;
|
|
@@ -91,17 +238,55 @@ export interface ReviewSession {
|
|
|
91
238
|
/** Revision history; artifact.content always equals the latest revision. */
|
|
92
239
|
revisions: Revision[];
|
|
93
240
|
annotations: Annotation[];
|
|
241
|
+
/**
|
|
242
|
+
* The session's history as a tree of entries with named branches; the
|
|
243
|
+
* artifact text and the open comments derive from the active path. Absent
|
|
244
|
+
* only on records written before histories existed; the store migrates
|
|
245
|
+
* those on read.
|
|
246
|
+
*/
|
|
247
|
+
history?: SessionHistory;
|
|
248
|
+
/** A diff review's reject decisions; the working copy is the patch they leave. */
|
|
249
|
+
curation?: HunkRejection[];
|
|
94
250
|
/**
|
|
95
251
|
* The reviewer's working copy of the artifact source (plan edits).
|
|
96
252
|
* Serializes as ONE unified diff against the submitted revision.
|
|
97
253
|
* Undefined = no direct edits.
|
|
98
254
|
*/
|
|
99
255
|
workingCopy?: string;
|
|
256
|
+
/**
|
|
257
|
+
* File paths the reviewer marked viewed during the guided walk (diff
|
|
258
|
+
* sessions). Persisting with the session means a resumed review keeps its
|
|
259
|
+
* progress. Undefined = the walk never started.
|
|
260
|
+
*/
|
|
261
|
+
viewedPaths?: string[];
|
|
100
262
|
verdict: Verdict | null;
|
|
101
263
|
status: SessionStatus;
|
|
102
264
|
createdAt: string;
|
|
265
|
+
/**
|
|
266
|
+
* Comments off the active path - removed, or made on a segment a tip moved
|
|
267
|
+
* away from. Nothing is deleted: a navigate or switch that brings their
|
|
268
|
+
* entries back onto the path shows them again.
|
|
269
|
+
*/
|
|
270
|
+
shelvedAnnotations?: Annotation[];
|
|
271
|
+
/** The session this one was forked from. */
|
|
272
|
+
parentSessionId?: string;
|
|
273
|
+
/** Share id once published; lets the planner pull collaborator notes back. */
|
|
274
|
+
shareId?: string;
|
|
275
|
+
/** The branch the share follows and shows collaborators; `main` when absent. */
|
|
276
|
+
shareBranch?: string;
|
|
277
|
+
/** SSH fingerprint that created the share; the gateway stamps it to gate pulls. */
|
|
278
|
+
owner?: string;
|
|
279
|
+
/**
|
|
280
|
+
* Identities that authored annotations here, keyed by id (union-by-id, like
|
|
281
|
+
* annotations). The gateway records a collaborator's identity and chosen name;
|
|
282
|
+
* the rail resolves an annotation's `author` against this registry.
|
|
283
|
+
*/
|
|
284
|
+
participants?: Identity[];
|
|
103
285
|
}
|
|
104
286
|
|
|
287
|
+
/** @deprecated use Thread */
|
|
288
|
+
export type ReviewSession = Thread;
|
|
289
|
+
|
|
105
290
|
/** comment and request_changes both map to deny in agent-native contracts. */
|
|
106
291
|
export function verdictAllows(kind: VerdictKind): boolean {
|
|
107
292
|
return kind === "approve";
|
|
@@ -113,6 +298,7 @@ export function verdictAllows(kind: VerdictKind): boolean {
|
|
|
113
298
|
* annotators in different processes on the same millisecond.
|
|
114
299
|
*/
|
|
115
300
|
let annotationSeq = 0;
|
|
301
|
+
|
|
116
302
|
export function newAnnotationId(): string {
|
|
117
303
|
return `a_${Date.now().toString(36)}${(annotationSeq++).toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
|
|
118
304
|
}
|
package/src/working-copy.test.ts
CHANGED
|
@@ -18,36 +18,49 @@ const y = 2;
|
|
|
18
18
|
\`\`\`
|
|
19
19
|
`;
|
|
20
20
|
|
|
21
|
-
function block(
|
|
22
|
-
return parseBlocks(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
59
|
-
const
|
|
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((
|
|
62
|
-
|
|
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
|
-
|
|
68
|
-
const
|
|
69
|
-
const
|
|
70
|
-
const
|
|
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
|
-
|
|
84
|
-
const
|
|
85
|
-
const
|
|
86
|
-
const
|
|
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
|
-
|
|
92
|
-
const
|
|
93
|
-
const
|
|
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(
|
|
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"),
|
|
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
|
-
|
|
106
|
-
|
|
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
|
});
|
package/src/working-copy.ts
CHANGED
|
@@ -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((
|
|
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
|
|
57
|
-
|
|
58
|
-
if (
|
|
59
|
-
|
|
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
|
}
|