@cueloop/schema 0.1.0-alpha.7 → 0.1.0-alpha.70
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/history.ts
ADDED
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A review session's history as an append-only tree of entries. Each entry
|
|
3
|
+
* points at its parent; a branch is a named tip; the active path is the chain
|
|
4
|
+
* from the current branch's tip back to the root. Nothing is ever deleted:
|
|
5
|
+
* navigating moves a tip, a comment removal is its own entry, a fork copies a
|
|
6
|
+
* path into a new history. Every view of the session - the artifact text,
|
|
7
|
+
* the open comments, the verdict history - derives from the active path.
|
|
8
|
+
*
|
|
9
|
+
* Pure: no store, no daemon. The daemon owns persistence; the client and the
|
|
10
|
+
* feedback document read what this module derives.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { Annotation, Thread, Verdict } from "./types";
|
|
14
|
+
|
|
15
|
+
export type EntryAuthor = "agent" | "reviewer";
|
|
16
|
+
|
|
17
|
+
interface EntryBase {
|
|
18
|
+
id: string;
|
|
19
|
+
/** null on the root entry. */
|
|
20
|
+
parentId: string | null;
|
|
21
|
+
createdAt: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type SessionEntry =
|
|
25
|
+
| (EntryBase & { type: "revision"; by: EntryAuthor; content: string })
|
|
26
|
+
| (EntryBase & { type: "comment"; annotationId: string })
|
|
27
|
+
| (EntryBase & { type: "comment-removed"; annotationId: string })
|
|
28
|
+
| (EntryBase & { type: "verdict"; verdict: Verdict })
|
|
29
|
+
| (EntryBase & { type: "branch-summary"; text: string; abandoned: string[] });
|
|
30
|
+
|
|
31
|
+
export type EntryType = SessionEntry["type"];
|
|
32
|
+
|
|
33
|
+
/** Omit that keeps a discriminated union's members apart. */
|
|
34
|
+
type DistributiveOmit<T, K extends keyof T> = T extends unknown ? Omit<T, K> : never;
|
|
35
|
+
|
|
36
|
+
/** An entry as a caller describes it: the tree pointers are the history's business. */
|
|
37
|
+
export type NewEntry = DistributiveOmit<SessionEntry, "id" | "parentId"> & { id?: string };
|
|
38
|
+
|
|
39
|
+
export interface AppendResult {
|
|
40
|
+
history: SessionHistory;
|
|
41
|
+
entry: SessionEntry;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The agent's line; every other branch is the reviewer's. */
|
|
45
|
+
export const MAIN_BRANCH = "main";
|
|
46
|
+
|
|
47
|
+
export interface SessionHistory {
|
|
48
|
+
entries: SessionEntry[];
|
|
49
|
+
/** Named tips; `main` always exists. */
|
|
50
|
+
tips: Record<string, string>;
|
|
51
|
+
/** The branch whose path the session shows. */
|
|
52
|
+
branch: string;
|
|
53
|
+
/** Checkpoints: an entry id to the name a reviewer gave it. */
|
|
54
|
+
labels: Record<string, string>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
let entrySeq = 0;
|
|
58
|
+
|
|
59
|
+
export function newEntryId(): string {
|
|
60
|
+
return `e_${Date.now().toString(36)}${(entrySeq++).toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export class HistoryError extends Error {
|
|
64
|
+
constructor(
|
|
65
|
+
readonly code:
|
|
66
|
+
| "unknown-entry"
|
|
67
|
+
| "unknown-branch"
|
|
68
|
+
| "branch-exists"
|
|
69
|
+
| "off-path"
|
|
70
|
+
| "already-there"
|
|
71
|
+
| "not-a-revision"
|
|
72
|
+
| "cycle",
|
|
73
|
+
message: string,
|
|
74
|
+
) {
|
|
75
|
+
super(message);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/* ------------------------------------------------------------------ reads */
|
|
80
|
+
|
|
81
|
+
export function entryOf(history: SessionHistory, id: string): SessionEntry | undefined {
|
|
82
|
+
return history.entries.find((entry) => entry.id === id);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function tipOf(history: SessionHistory, branch: string = history.branch): string {
|
|
86
|
+
const tip = history.tips[branch];
|
|
87
|
+
|
|
88
|
+
if (tip === undefined) throw new HistoryError("unknown-branch", `no branch "${branch}"`);
|
|
89
|
+
|
|
90
|
+
return tip;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The entries from `fromId` (default: the current tip) back to the root, root first. */
|
|
94
|
+
export function pathOf(history: SessionHistory, fromId: string = tipOf(history)): SessionEntry[] {
|
|
95
|
+
const byId = new Map(history.entries.map((entry) => [entry.id, entry]));
|
|
96
|
+
const path: SessionEntry[] = [];
|
|
97
|
+
const seen = new Set<string>();
|
|
98
|
+
|
|
99
|
+
for (
|
|
100
|
+
let entry = byId.get(fromId);
|
|
101
|
+
entry;
|
|
102
|
+
entry = entry.parentId ? byId.get(entry.parentId) : undefined
|
|
103
|
+
) {
|
|
104
|
+
if (seen.has(entry.id))
|
|
105
|
+
throw new HistoryError("cycle", `entry "${entry.id}" is its own ancestor`);
|
|
106
|
+
seen.add(entry.id);
|
|
107
|
+
path.unshift(entry);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return path;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The graph invariants every history must hold, as one message or null: ids
|
|
115
|
+
* are unique, every parent exists, no entry is its own ancestor, every tip
|
|
116
|
+
* names an entry, `main` and the current branch exist, and every tip's path
|
|
117
|
+
* reaches a revision. Run at every boundary a history crosses.
|
|
118
|
+
*/
|
|
119
|
+
export function validateHistory(history: SessionHistory): string | null {
|
|
120
|
+
const ids = new Set<string>();
|
|
121
|
+
|
|
122
|
+
for (const entry of history.entries) {
|
|
123
|
+
if (ids.has(entry.id)) return `duplicate entry id "${entry.id}"`;
|
|
124
|
+
ids.add(entry.id);
|
|
125
|
+
}
|
|
126
|
+
for (const entry of history.entries) {
|
|
127
|
+
if (entry.parentId !== null && !ids.has(entry.parentId)) {
|
|
128
|
+
return `entry "${entry.id}" points at missing parent "${entry.parentId}"`;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
if (history.tips[MAIN_BRANCH] === undefined) return `no "${MAIN_BRANCH}" branch`;
|
|
132
|
+
if (history.tips[history.branch] === undefined)
|
|
133
|
+
return `current branch "${history.branch}" has no tip`;
|
|
134
|
+
for (const [branch, tip] of Object.entries(history.tips)) {
|
|
135
|
+
if (!ids.has(tip)) return `branch "${branch}" points at missing entry "${tip}"`;
|
|
136
|
+
try {
|
|
137
|
+
if (!pathOf(history, tip).some((entry) => entry.type === "revision")) {
|
|
138
|
+
return `branch "${branch}" has no revision on its path`;
|
|
139
|
+
}
|
|
140
|
+
} catch (error) {
|
|
141
|
+
return error instanceof HistoryError ? error.message : String(error);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
for (const entry of history.entries) {
|
|
145
|
+
// a cycle off every path still hangs a walk that starts inside it
|
|
146
|
+
try {
|
|
147
|
+
pathOf(history, entry.id);
|
|
148
|
+
} catch (error) {
|
|
149
|
+
return error instanceof HistoryError ? error.message : String(error);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export interface DerivedPath {
|
|
157
|
+
/** The last revision on the path: what the artifact shows. */
|
|
158
|
+
head: SessionEntry & { type: "revision" };
|
|
159
|
+
/**
|
|
160
|
+
* Comments added on the path and not removed after, in order. Whether one
|
|
161
|
+
* is addressed by a revision is the annotation's own state, not the path's.
|
|
162
|
+
*/
|
|
163
|
+
annotationIds: string[];
|
|
164
|
+
/** Verdicts on the path, oldest first. */
|
|
165
|
+
verdicts: Verdict[];
|
|
166
|
+
summaries: Array<SessionEntry & { type: "branch-summary" }>;
|
|
167
|
+
/** Agent revisions on the path: one per round. */
|
|
168
|
+
rounds: number;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Everything a viewer sees on the current path, folded from its entries. */
|
|
172
|
+
export function derivePath(history: SessionHistory, fromId?: string): DerivedPath {
|
|
173
|
+
const path = pathOf(history, fromId);
|
|
174
|
+
const revisions = path.filter((entry) => entry.type === "revision");
|
|
175
|
+
const head = revisions.at(-1);
|
|
176
|
+
|
|
177
|
+
if (!head) throw new HistoryError("not-a-revision", "a path without a revision has no head");
|
|
178
|
+
const open = new Set<string>();
|
|
179
|
+
const verdicts: Verdict[] = [];
|
|
180
|
+
const summaries: Array<SessionEntry & { type: "branch-summary" }> = [];
|
|
181
|
+
|
|
182
|
+
for (const entry of path) {
|
|
183
|
+
if (entry.type === "comment") open.add(entry.annotationId);
|
|
184
|
+
if (entry.type === "comment-removed") open.delete(entry.annotationId);
|
|
185
|
+
if (entry.type === "verdict") verdicts.push(entry.verdict);
|
|
186
|
+
if (entry.type === "branch-summary") summaries.push(entry);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return {
|
|
190
|
+
head,
|
|
191
|
+
annotationIds: [...open],
|
|
192
|
+
verdicts,
|
|
193
|
+
summaries,
|
|
194
|
+
rounds: revisions.filter((entry) => entry.by === "agent").length,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/* ----------------------------------------------------------------- writes */
|
|
199
|
+
|
|
200
|
+
/** Append an entry after the current branch's tip and move the tip onto it. */
|
|
201
|
+
export function appendEntry(history: SessionHistory, entry: NewEntry): AppendResult {
|
|
202
|
+
const parentId = tipOf(history);
|
|
203
|
+
// SAFETY: the spread keeps `type` and its fields intact; only the tree
|
|
204
|
+
// pointers are added, which the union's every member carries
|
|
205
|
+
const full = { ...entry, id: entry.id ?? newEntryId(), parentId } as SessionEntry;
|
|
206
|
+
|
|
207
|
+
return {
|
|
208
|
+
history: {
|
|
209
|
+
...history,
|
|
210
|
+
entries: [...history.entries, full],
|
|
211
|
+
tips: { ...history.tips, [history.branch]: full.id },
|
|
212
|
+
},
|
|
213
|
+
entry: full,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Replace the text of the agent's current revision on `main` in place. A
|
|
219
|
+
* re-capture of a working tree is the revision the agent is still working on,
|
|
220
|
+
* not a new one, so nothing is appended.
|
|
221
|
+
*/
|
|
222
|
+
export function recaptureMainHead(history: SessionHistory, content: string): SessionHistory {
|
|
223
|
+
const head = pathOf(history, tipOf(history, MAIN_BRANCH))
|
|
224
|
+
.filter((entry) => entry.type === "revision" && entry.by === "agent")
|
|
225
|
+
.at(-1);
|
|
226
|
+
|
|
227
|
+
if (!head) throw new HistoryError("not-a-revision", `"${MAIN_BRANCH}" has no agent revision`);
|
|
228
|
+
|
|
229
|
+
return {
|
|
230
|
+
...history,
|
|
231
|
+
entries: history.entries.map((entry) => (entry.id === head.id ? { ...entry, content } : entry)),
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Name the current tip as a checkpoint. */
|
|
236
|
+
export function labelTip(history: SessionHistory, label: string): SessionHistory {
|
|
237
|
+
return { ...history, labels: { ...history.labels, [tipOf(history)]: label } };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Start a branch at the current tip and switch to it; `main` is untouched. */
|
|
241
|
+
export function createBranch(history: SessionHistory, name: string): SessionHistory {
|
|
242
|
+
if (history.tips[name] !== undefined) {
|
|
243
|
+
throw new HistoryError("branch-exists", `branch "${name}" exists`);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return { ...history, tips: { ...history.tips, [name]: tipOf(history) }, branch: name };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export function switchBranch(history: SessionHistory, name: string): SessionHistory {
|
|
250
|
+
tipOf(history, name);
|
|
251
|
+
|
|
252
|
+
return { ...history, branch: name };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export interface NavigateOptions {
|
|
256
|
+
/** Record the abandoned segment as a branch summary parented at the target. */
|
|
257
|
+
summary?: string;
|
|
258
|
+
createdAt?: string;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Move the current branch's tip to an earlier entry on its path. The entries
|
|
263
|
+
* after it stay in the tree; with a summary, a branch-summary entry that
|
|
264
|
+
* names them is appended at the target and becomes the tip.
|
|
265
|
+
*/
|
|
266
|
+
export function navigateTo(
|
|
267
|
+
history: SessionHistory,
|
|
268
|
+
targetId: string,
|
|
269
|
+
options: NavigateOptions = {},
|
|
270
|
+
): SessionHistory {
|
|
271
|
+
if (!entryOf(history, targetId)) {
|
|
272
|
+
throw new HistoryError("unknown-entry", `no entry "${targetId}"`);
|
|
273
|
+
}
|
|
274
|
+
const tip = tipOf(history);
|
|
275
|
+
|
|
276
|
+
if (targetId === tip) throw new HistoryError("already-there", "already at that entry");
|
|
277
|
+
const path = pathOf(history);
|
|
278
|
+
const index = path.findIndex((entry) => entry.id === targetId);
|
|
279
|
+
|
|
280
|
+
if (index === -1) {
|
|
281
|
+
throw new HistoryError(
|
|
282
|
+
"off-path",
|
|
283
|
+
`"${targetId}" is not on branch "${history.branch}" - switch branch first`,
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
const abandoned = path.slice(index + 1).map((entry) => entry.id);
|
|
287
|
+
const moved: SessionHistory = {
|
|
288
|
+
...history,
|
|
289
|
+
tips: { ...history.tips, [history.branch]: targetId },
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
if (options.summary === undefined) return moved;
|
|
293
|
+
|
|
294
|
+
return appendEntry(moved, {
|
|
295
|
+
type: "branch-summary",
|
|
296
|
+
text: options.summary,
|
|
297
|
+
abandoned,
|
|
298
|
+
createdAt: options.createdAt ?? new Date().toISOString(),
|
|
299
|
+
}).history;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* A fork's history: the current path copied as one branch, keeping comments
|
|
304
|
+
* and labels on it, dropping verdicts and the reviewer's edits, with the tip
|
|
305
|
+
* at the copied head. The kept entries are chained anew so no entry points at
|
|
306
|
+
* one that was dropped.
|
|
307
|
+
*/
|
|
308
|
+
export function forkHistory(history: SessionHistory): SessionHistory {
|
|
309
|
+
const kept: SessionEntry[] = [];
|
|
310
|
+
const labels: Record<string, string> = {};
|
|
311
|
+
|
|
312
|
+
for (const entry of pathOf(history)) {
|
|
313
|
+
if (entry.type === "verdict" || (entry.type === "revision" && entry.by === "reviewer"))
|
|
314
|
+
continue;
|
|
315
|
+
kept.push({ ...entry, parentId: kept.at(-1)?.id ?? null });
|
|
316
|
+
const label = history.labels[entry.id];
|
|
317
|
+
|
|
318
|
+
if (label !== undefined) labels[entry.id] = label;
|
|
319
|
+
}
|
|
320
|
+
const tip = kept.at(-1);
|
|
321
|
+
|
|
322
|
+
if (!tip) throw new HistoryError("not-a-revision", "nothing to fork");
|
|
323
|
+
|
|
324
|
+
return { entries: kept, tips: { [MAIN_BRANCH]: tip.id }, branch: MAIN_BRANCH, labels };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* The history as a share carries it: one branch's path and nothing else, so a
|
|
329
|
+
* collaborator sees that branch wherever the owner stands. Labels on the path
|
|
330
|
+
* travel; other branches and their entries do not.
|
|
331
|
+
*/
|
|
332
|
+
export function followBranch(history: SessionHistory, branch: string): SessionHistory {
|
|
333
|
+
const entries = pathOf(history, tipOf(history, branch));
|
|
334
|
+
const labels: Record<string, string> = {};
|
|
335
|
+
|
|
336
|
+
for (const entry of entries) {
|
|
337
|
+
const label = history.labels[entry.id];
|
|
338
|
+
|
|
339
|
+
if (label !== undefined) labels[entry.id] = label;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
return { entries, tips: { [branch]: entries.at(-1)!.id }, branch, labels };
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** The comment removals a history records, in entry order: what a merge carries across a share. */
|
|
346
|
+
export function removalEntries(
|
|
347
|
+
history: SessionHistory,
|
|
348
|
+
): Array<SessionEntry & { type: "comment-removed" }> {
|
|
349
|
+
return history.entries.filter(
|
|
350
|
+
(entry): entry is SessionEntry & { type: "comment-removed" } =>
|
|
351
|
+
entry.type === "comment-removed",
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/* -------------------------------------------------------------- migration */
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* A linear session as a one-branch tree: its revisions chained on `main` as
|
|
359
|
+
* agent revisions, its comments attached after the revision that was current
|
|
360
|
+
* when they were made, its verdict (when resolved) after the last revision.
|
|
361
|
+
* Deterministic: the same record migrates to the same ids.
|
|
362
|
+
*/
|
|
363
|
+
export function historyFromLinear(
|
|
364
|
+
session: Pick<Thread, "id" | "revisions" | "annotations" | "verdict" | "createdAt">,
|
|
365
|
+
): SessionHistory {
|
|
366
|
+
const entries: SessionEntry[] = [];
|
|
367
|
+
let parentId: string | null = null;
|
|
368
|
+
const push = (entry: DistributiveOmit<SessionEntry, "parentId">): void => {
|
|
369
|
+
// SAFETY: only the parent pointer is added; the union member is untouched
|
|
370
|
+
const full = { ...entry, parentId } as SessionEntry;
|
|
371
|
+
|
|
372
|
+
entries.push(full);
|
|
373
|
+
parentId = full.id;
|
|
374
|
+
};
|
|
375
|
+
const revisions = [...session.revisions].sort((left, right) => left.revision - right.revision);
|
|
376
|
+
const comments = [...session.annotations].sort((left, right) =>
|
|
377
|
+
left.createdAt.localeCompare(right.createdAt),
|
|
378
|
+
);
|
|
379
|
+
let nextComment = 0;
|
|
380
|
+
|
|
381
|
+
revisions.forEach((revision, index) => {
|
|
382
|
+
push({
|
|
383
|
+
id: `${session.id}_rev${revision.revision}`,
|
|
384
|
+
type: "revision",
|
|
385
|
+
by: "agent",
|
|
386
|
+
content: revision.content,
|
|
387
|
+
createdAt: revision.submittedAt,
|
|
388
|
+
});
|
|
389
|
+
const until = revisions[index + 1]?.submittedAt;
|
|
390
|
+
|
|
391
|
+
while (
|
|
392
|
+
nextComment < comments.length &&
|
|
393
|
+
(until === undefined || comments[nextComment]!.createdAt < until)
|
|
394
|
+
) {
|
|
395
|
+
const annotation: Annotation = comments[nextComment]!;
|
|
396
|
+
|
|
397
|
+
push({
|
|
398
|
+
id: `${session.id}_com_${annotation.id}`,
|
|
399
|
+
type: "comment",
|
|
400
|
+
annotationId: annotation.id,
|
|
401
|
+
createdAt: annotation.createdAt,
|
|
402
|
+
});
|
|
403
|
+
nextComment++;
|
|
404
|
+
}
|
|
405
|
+
});
|
|
406
|
+
if (session.verdict) {
|
|
407
|
+
push({
|
|
408
|
+
id: `${session.id}_verdict`,
|
|
409
|
+
type: "verdict",
|
|
410
|
+
verdict: session.verdict,
|
|
411
|
+
createdAt: session.verdict.resolvedAt,
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
const tip = entries.at(-1);
|
|
415
|
+
|
|
416
|
+
if (!tip) throw new HistoryError("not-a-revision", `session ${session.id} has no revision`);
|
|
417
|
+
|
|
418
|
+
return { entries, tips: { [MAIN_BRANCH]: tip.id }, branch: MAIN_BRANCH, labels: {} };
|
|
419
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
export * from "./types";
|
|
2
|
+
export * from "./participants";
|
|
2
3
|
export * from "./markdown";
|
|
4
|
+
export * from "./inline";
|
|
3
5
|
export * from "./working-copy";
|
|
4
6
|
export * from "./diff";
|
|
5
7
|
export * from "./anchor";
|
|
8
|
+
export * from "./fuzzy";
|
|
6
9
|
export * from "./feedback";
|
|
10
|
+
export * from "./herdr";
|
|
11
|
+
export * from "./history";
|
|
12
|
+
export * from "./path-view";
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { inlineRuns, type InlineRun } from "./inline";
|
|
3
|
+
|
|
4
|
+
/** Every run concatenated back in order must equal the source (nothing dropped). */
|
|
5
|
+
function reconstruct(runs: InlineRun[]): string {
|
|
6
|
+
return runs.map((run) => run.text).join("");
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Each visible run's text must sit at its claimed source offset. */
|
|
10
|
+
function offsetsAreExact(source: string, runs: InlineRun[]): boolean {
|
|
11
|
+
return runs.every(
|
|
12
|
+
(run) =>
|
|
13
|
+
run.start === null || source.slice(run.start, run.start + run.text.length) === run.text,
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** The visible (non-marker) runs, which are what an anchor can resolve against. */
|
|
18
|
+
function visible(runs: InlineRun[]): InlineRun[] {
|
|
19
|
+
return runs.filter((run) => run.start !== null);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe("inlineRuns", () => {
|
|
23
|
+
test("plain text is a single positioned run", () => {
|
|
24
|
+
// Arrange
|
|
25
|
+
const source = "just plain words";
|
|
26
|
+
|
|
27
|
+
// Act
|
|
28
|
+
const runs = inlineRuns(source);
|
|
29
|
+
|
|
30
|
+
// Assert
|
|
31
|
+
expect(runs).toEqual([{ text: "just plain words", role: "text", start: 0 }]);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("strong emphasis: markers concealed, content positioned", () => {
|
|
35
|
+
// Arrange
|
|
36
|
+
const source = "a **bold** b";
|
|
37
|
+
|
|
38
|
+
// Act
|
|
39
|
+
const runs = inlineRuns(source);
|
|
40
|
+
|
|
41
|
+
// Assert
|
|
42
|
+
expect(runs).toEqual([
|
|
43
|
+
{ text: "a ", role: "text", start: 0 },
|
|
44
|
+
{ text: "**", role: "marker", start: null },
|
|
45
|
+
{ text: "bold", role: "strong", start: 4 },
|
|
46
|
+
{ text: "**", role: "marker", start: null },
|
|
47
|
+
{ text: " b", role: "text", start: 10 },
|
|
48
|
+
]);
|
|
49
|
+
expect(reconstruct(runs)).toBe(source);
|
|
50
|
+
expect(offsetsAreExact(source, runs)).toBe(true);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("em, code, and strike each conceal their markers and keep offsets", () => {
|
|
54
|
+
// Assert
|
|
55
|
+
for (const [source, role, inner] of [
|
|
56
|
+
["x *em* y", "em", "em"],
|
|
57
|
+
["x `code` y", "code", "code"],
|
|
58
|
+
["x ~~gone~~ y", "strike", "gone"],
|
|
59
|
+
] as const) {
|
|
60
|
+
const runs = inlineRuns(source);
|
|
61
|
+
const content = runs.find((run) => run.role === role)!;
|
|
62
|
+
|
|
63
|
+
expect(content.text).toBe(inner);
|
|
64
|
+
expect(source.slice(content.start!, content.start! + inner.length)).toBe(inner);
|
|
65
|
+
expect(reconstruct(runs)).toBe(source);
|
|
66
|
+
expect(runs.filter((run) => run.role === "marker").every((run) => run.start === null)).toBe(
|
|
67
|
+
true,
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("code spans do not parse markup inside them", () => {
|
|
73
|
+
// Act
|
|
74
|
+
const runs = inlineRuns("call `a**b**c` now");
|
|
75
|
+
|
|
76
|
+
// Assert - the backtick content is one literal code run, no strong inside
|
|
77
|
+
const code = runs.find((run) => run.role === "code")!;
|
|
78
|
+
|
|
79
|
+
expect(code.text).toBe("a**b**c");
|
|
80
|
+
expect(runs.some((run) => run.role === "strong")).toBe(false);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("links: label positioned with href, brackets and url concealed", () => {
|
|
84
|
+
// Arrange
|
|
85
|
+
const source = "see [the docs](https://example.com) here";
|
|
86
|
+
|
|
87
|
+
// Act
|
|
88
|
+
const runs = inlineRuns(source);
|
|
89
|
+
const label = runs.find((run) => run.role === "link")!;
|
|
90
|
+
|
|
91
|
+
// Assert
|
|
92
|
+
expect(label).toEqual({
|
|
93
|
+
text: "the docs",
|
|
94
|
+
role: "link",
|
|
95
|
+
start: 5,
|
|
96
|
+
href: "https://example.com",
|
|
97
|
+
});
|
|
98
|
+
expect(reconstruct(runs)).toBe(source);
|
|
99
|
+
// the url text is a concealed marker, never a visible run
|
|
100
|
+
expect(visible(runs).some((run) => run.text.includes("https"))).toBe(false);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("links: a destination with balanced parentheses stays whole", () => {
|
|
104
|
+
// Arrange
|
|
105
|
+
const source = "see [foo](https://en.wikipedia.org/wiki/Foo_(bar)) here";
|
|
106
|
+
|
|
107
|
+
// Act
|
|
108
|
+
const runs = inlineRuns(source);
|
|
109
|
+
const label = runs.find((run) => run.role === "link")!;
|
|
110
|
+
|
|
111
|
+
// Assert
|
|
112
|
+
expect(label.href).toBe("https://en.wikipedia.org/wiki/Foo_(bar)");
|
|
113
|
+
expect(reconstruct(runs)).toBe(source);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("links: an unclosed destination never becomes a link", () => {
|
|
117
|
+
// Arrange
|
|
118
|
+
const source = "see [foo](https://example.com/(open here";
|
|
119
|
+
|
|
120
|
+
// Act
|
|
121
|
+
const runs = inlineRuns(source);
|
|
122
|
+
|
|
123
|
+
// Assert
|
|
124
|
+
expect(runs.some((run) => run.role === "link")).toBe(false);
|
|
125
|
+
expect(reconstruct(runs)).toBe(source);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("nested emphasis: inner marker wins, both markers concealed", () => {
|
|
129
|
+
// Act
|
|
130
|
+
const runs = inlineRuns("**a *b* c**");
|
|
131
|
+
|
|
132
|
+
// Assert
|
|
133
|
+
expect(visible(runs)).toEqual([
|
|
134
|
+
{ text: "a ", role: "strong", start: 2 },
|
|
135
|
+
{ text: "b", role: "em", start: 5 },
|
|
136
|
+
{ text: " c", role: "strong", start: 7 },
|
|
137
|
+
]);
|
|
138
|
+
expect(reconstruct(runs)).toBe("**a *b* c**");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("a lone em close is not stolen by an inner ** run", () => {
|
|
142
|
+
// Act - the em must span the whole thing, with **b** strong nested inside
|
|
143
|
+
const runs = inlineRuns("*a **b** c*");
|
|
144
|
+
|
|
145
|
+
// Assert
|
|
146
|
+
expect(runs.find((run) => run.role === "em" && run.text === "a ")).toBeDefined();
|
|
147
|
+
expect(runs.find((run) => run.role === "strong" && run.text === "b")).toBeDefined();
|
|
148
|
+
expect(reconstruct(runs)).toBe("*a **b** c*");
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("unbalanced markers fall back to literal text", () => {
|
|
152
|
+
// Assert
|
|
153
|
+
for (const source of ["**not closed", "a * b", "`open code", "[label](noclose"]) {
|
|
154
|
+
const runs = inlineRuns(source);
|
|
155
|
+
|
|
156
|
+
expect(reconstruct(runs)).toBe(source);
|
|
157
|
+
expect(runs.every((run) => run.role === "text" || run.role === "marker")).toBe(true);
|
|
158
|
+
expect(offsetsAreExact(source, runs)).toBe(true);
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("empty spans do not match", () => {
|
|
163
|
+
// Act
|
|
164
|
+
const runs = inlineRuns("a **** b `` c [](x)");
|
|
165
|
+
|
|
166
|
+
// Assert - nothing emphasized; all reconstructs
|
|
167
|
+
expect(reconstruct(runs)).toBe("a **** b `` c [](x)");
|
|
168
|
+
expect(runs.some((run) => ["strong", "em", "code", "link"].includes(run.role))).toBe(false);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("backslash escapes render the punctuation literally, marker concealed", () => {
|
|
172
|
+
// Act
|
|
173
|
+
const runs = inlineRuns("not \\*emph\\* here");
|
|
174
|
+
|
|
175
|
+
// Assert - no em; the escaped stars are positioned text, backslashes concealed
|
|
176
|
+
expect(runs.some((run) => run.role === "em")).toBe(false);
|
|
177
|
+
expect(reconstruct(runs)).toBe("not \\*emph\\* here");
|
|
178
|
+
expect(offsetsAreExact("not \\*emph\\* here", runs)).toBe(true);
|
|
179
|
+
const star = runs.find((run) => run.text === "*" && run.start !== null)!;
|
|
180
|
+
|
|
181
|
+
expect("not \\*emph\\* here"[star.start!]).toBe("*");
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("reconstruct + exact offsets hold on a mixed line", () => {
|
|
185
|
+
// Arrange
|
|
186
|
+
const source = "The **daemon** persists `sessions` to [disk](d) ~~fast~~";
|
|
187
|
+
|
|
188
|
+
// Act
|
|
189
|
+
const runs = inlineRuns(source);
|
|
190
|
+
|
|
191
|
+
// Assert
|
|
192
|
+
expect(reconstruct(runs)).toBe(source);
|
|
193
|
+
expect(offsetsAreExact(source, runs)).toBe(true);
|
|
194
|
+
});
|
|
195
|
+
});
|