@cueloop/schema 0.1.0-alpha.9 → 0.1.0-alpha.91
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 +340 -40
- package/src/anchor.ts +378 -43
- package/src/diff.test.ts +42 -25
- package/src/diff.ts +112 -77
- package/src/feedback.test.ts +318 -41
- package/src/feedback.ts +224 -54
- package/src/fuzzy.test.ts +110 -0
- package/src/fuzzy.ts +132 -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 +425 -0
- package/src/index.ts +9 -0
- package/src/inline.test.ts +195 -0
- package/src/inline.ts +240 -0
- package/src/markdown.test.ts +138 -22
- package/src/markdown.ts +333 -84
- package/src/participants.test.ts +105 -0
- package/src/participants.ts +51 -0
- package/src/path-view.test.ts +206 -0
- package/src/path-view.ts +90 -0
- package/src/share-access.test.ts +20 -0
- package/src/share-access.ts +13 -0
- package/src/share-links.test.ts +63 -0
- package/src/share-links.ts +29 -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 +346 -24
- package/src/working-copy.test.ts +209 -24
- package/src/working-copy.ts +148 -12
package/src/anchor.ts
CHANGED
|
@@ -1,74 +1,409 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Anchor resolution: the quote-primary cascade
|
|
3
|
-
*
|
|
4
|
-
* 1. exact quote in a block
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
2
|
+
* Anchor resolution: the quote-primary cascade. Anchors resolve against the
|
|
3
|
+
* current block list, each tier tried only when the one above it misses:
|
|
4
|
+
* 1. exact - the quote appears verbatim in a block
|
|
5
|
+
* 2. trimmed - the quote appears once surrounding whitespace is dropped
|
|
6
|
+
* 3. normalized - the quote appears once a leading markdown block marker
|
|
7
|
+
* (bullet, heading, ordered, quote) is stripped, matching how
|
|
8
|
+
* the parser strips block text
|
|
9
|
+
* 4. fuzzy - the quote was lightly edited but is still recognizably there
|
|
10
|
+
* 5. orphan - none matched; keep the quote for display, drop the highlight
|
|
11
|
+
* Prefix/suffix selectors and the recorded position break ties within a tier.
|
|
12
|
+
* Never silently bind to the wrong text: fuzzy needs a high similarity floor.
|
|
13
|
+
*
|
|
14
|
+
* A quote may span consecutive blocks: its text is the blocks' text joined by
|
|
15
|
+
* BLOCK_SEPARATOR, and it resolves against the document joined the same way,
|
|
16
|
+
* so one comment can cover the tail of a paragraph and the bullets under it.
|
|
9
17
|
*/
|
|
10
18
|
|
|
11
19
|
import type { Anchor } from "./types";
|
|
12
|
-
import type
|
|
20
|
+
import { type Block, stripLeadingBlockMarker } from "./markdown";
|
|
21
|
+
import { fuzzyFindBestMatch, newFuzzyBudget } from "./fuzzy";
|
|
13
22
|
|
|
14
|
-
|
|
23
|
+
/** Characters of surrounding text captured as prefix/suffix selectors. */
|
|
24
|
+
const ANCHOR_CONTEXT_CHARS = 24;
|
|
25
|
+
|
|
26
|
+
/** Minimum similarity a fuzzy match needs before the resolver will trust it. */
|
|
27
|
+
const FUZZY_MINIMUM_SIMILARITY = 0.75;
|
|
28
|
+
|
|
29
|
+
/** Joins block texts in a spanning quote and in the document it resolves against. */
|
|
30
|
+
export const BLOCK_SEPARATOR = "\n\n";
|
|
31
|
+
|
|
32
|
+
/** How the quote bound to the block text, weakest binding named last. */
|
|
33
|
+
export type AnchorMatchStrategy = "exact" | "trimmed" | "normalized" | "fuzzy";
|
|
15
34
|
|
|
16
35
|
export interface ResolvedAnchor {
|
|
17
36
|
blockIndex: number;
|
|
18
37
|
start: number;
|
|
38
|
+
/** Last block of the quote; equals blockIndex unless the quote spans blocks. */
|
|
39
|
+
endBlockIndex: number;
|
|
40
|
+
/** Offset within the end block. */
|
|
19
41
|
end: number;
|
|
20
|
-
/** True
|
|
42
|
+
/** True whenever the binding was not an exact quote match. */
|
|
21
43
|
approximate: boolean;
|
|
44
|
+
/** Which cascade tier bound the quote. */
|
|
45
|
+
strategy: AnchorMatchStrategy;
|
|
22
46
|
}
|
|
23
47
|
|
|
24
|
-
export function makeAnchor(
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
48
|
+
export function makeAnchor(
|
|
49
|
+
blocks: Block[],
|
|
50
|
+
blockIndex: number,
|
|
51
|
+
start: number,
|
|
52
|
+
end: number,
|
|
53
|
+
endBlockIndex: number = blockIndex,
|
|
54
|
+
): Anchor {
|
|
55
|
+
const startText = blocks[blockIndex]?.text ?? "";
|
|
56
|
+
const endText = blocks[endBlockIndex]?.text ?? "";
|
|
57
|
+
const quote = blocks
|
|
58
|
+
.slice(blockIndex, endBlockIndex + 1)
|
|
59
|
+
.map((block, offset, spanned) => {
|
|
60
|
+
const isFirst = offset === 0;
|
|
61
|
+
const isLast = offset === spanned.length - 1;
|
|
62
|
+
|
|
63
|
+
return block.text.slice(isFirst ? start : 0, isLast ? end : block.text.length);
|
|
64
|
+
})
|
|
65
|
+
.join(BLOCK_SEPARATOR);
|
|
66
|
+
|
|
67
|
+
const anchor: Anchor = {
|
|
68
|
+
quote,
|
|
69
|
+
prefix: startText.slice(Math.max(0, start - ANCHOR_CONTEXT_CHARS), start),
|
|
70
|
+
suffix: endText.slice(end, end + ANCHOR_CONTEXT_CHARS),
|
|
30
71
|
blockIndex,
|
|
31
72
|
start,
|
|
32
73
|
end,
|
|
33
74
|
};
|
|
75
|
+
|
|
76
|
+
if (endBlockIndex !== blockIndex) anchor.endBlockIndex = endBlockIndex;
|
|
77
|
+
|
|
78
|
+
return anchor;
|
|
34
79
|
}
|
|
35
80
|
|
|
36
|
-
interface Candidate
|
|
81
|
+
interface Candidate {
|
|
82
|
+
blockIndex: number;
|
|
83
|
+
start: number;
|
|
84
|
+
end: number;
|
|
37
85
|
score: number;
|
|
38
86
|
}
|
|
39
87
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
88
|
+
/** Score a candidate window by how well its context and position agree. */
|
|
89
|
+
function contextScore(
|
|
90
|
+
anchor: Anchor,
|
|
91
|
+
blockIndex: number,
|
|
92
|
+
block: Block,
|
|
93
|
+
start: number,
|
|
94
|
+
end: number,
|
|
95
|
+
): number {
|
|
96
|
+
const prefix = block.text.slice(Math.max(0, start - ANCHOR_CONTEXT_CHARS), start);
|
|
97
|
+
const suffix = block.text.slice(end, end + ANCHOR_CONTEXT_CHARS);
|
|
98
|
+
let score = 0;
|
|
99
|
+
|
|
100
|
+
if (prefix === anchor.prefix) score += 2;
|
|
101
|
+
if (suffix === anchor.suffix) score += 2;
|
|
102
|
+
if (anchor.blockIndex === blockIndex) score += 1;
|
|
103
|
+
if (anchor.blockIndex === blockIndex && anchor.start === start) score += 1;
|
|
104
|
+
|
|
105
|
+
return score;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Every verbatim occurrence of `quote` across the blocks, scored by context. */
|
|
109
|
+
function findExactCandidates(anchor: Anchor, blocks: Block[], quote: string): Candidate[] {
|
|
110
|
+
const candidates: Candidate[] = [];
|
|
111
|
+
|
|
112
|
+
blocks.forEach((block, blockIndex) => {
|
|
113
|
+
const text = block.text;
|
|
114
|
+
|
|
115
|
+
for (
|
|
116
|
+
let matchStart = text.indexOf(quote);
|
|
117
|
+
matchStart !== -1;
|
|
118
|
+
matchStart = text.indexOf(quote, matchStart + 1)
|
|
119
|
+
) {
|
|
120
|
+
const matchEnd = matchStart + quote.length;
|
|
121
|
+
|
|
122
|
+
candidates.push({
|
|
123
|
+
blockIndex,
|
|
124
|
+
start: matchStart,
|
|
125
|
+
end: matchEnd,
|
|
126
|
+
score: contextScore(anchor, blockIndex, block, matchStart, matchEnd),
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
return candidates;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* How much a full context agreement (0-6) may nudge the fuzzy ranking. Kept far
|
|
136
|
+
* below any real similarity gap so text quality always leads and context only
|
|
137
|
+
* breaks near-ties - a stale position hint must never pull the anchor onto a
|
|
138
|
+
* meaningfully less-similar passage.
|
|
139
|
+
*/
|
|
140
|
+
const FUZZY_CONTEXT_TIE_BREAK = 0.01;
|
|
141
|
+
|
|
142
|
+
/** Block indices to scan, spiralling out from the anchor's hinted block: hint, hint+1, hint-1, ... */
|
|
143
|
+
function blockScanOrder(hint: number | undefined, count: number): number[] {
|
|
144
|
+
if (count <= 0) return [];
|
|
145
|
+
const first = Number.isInteger(hint) ? Math.min(Math.max(hint!, 0), count - 1) : 0;
|
|
146
|
+
const order = [first];
|
|
147
|
+
|
|
148
|
+
for (const step of Array.from({ length: count - 1 }, (_unused, index) => index + 1)) {
|
|
149
|
+
if (first + step < count) order.push(first + step);
|
|
150
|
+
if (first - step >= 0) order.push(first - step);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return order;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* The best fuzzy window per block, ranked by similarity with context as a tiebreak. One work budget
|
|
158
|
+
* is shared across every block scan, so a stale anchor costs a single budget total, not one per
|
|
159
|
+
* block. Blocks are scanned outward from the anchor's hint, so a quote nudged a few blocks by an
|
|
160
|
+
* edit is reached before the budget runs out; a passage that moved far or is truly gone orphans.
|
|
161
|
+
*/
|
|
162
|
+
function findFuzzyCandidates(
|
|
163
|
+
anchor: Anchor,
|
|
164
|
+
blocks: Block[],
|
|
165
|
+
needle: string,
|
|
166
|
+
minimumSimilarity: number,
|
|
167
|
+
): Candidate[] {
|
|
168
|
+
const candidates: Candidate[] = [];
|
|
169
|
+
const budget = newFuzzyBudget();
|
|
170
|
+
|
|
171
|
+
for (const blockIndex of blockScanOrder(anchor.blockIndex, blocks.length)) {
|
|
172
|
+
const block = blocks[blockIndex]!;
|
|
173
|
+
const match = fuzzyFindBestMatch(needle, block.text, minimumSimilarity, budget);
|
|
174
|
+
|
|
175
|
+
if (match !== null) {
|
|
176
|
+
const score =
|
|
177
|
+
match.similarity +
|
|
178
|
+
contextScore(anchor, blockIndex, block, match.start, match.end) * FUZZY_CONTEXT_TIE_BREAK;
|
|
179
|
+
|
|
180
|
+
candidates.push({ blockIndex, start: match.start, end: match.end, score });
|
|
53
181
|
}
|
|
182
|
+
if (budget.remaining <= 0) break;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return candidates;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Highest-scoring candidate, or null when there are none. */
|
|
189
|
+
function pickBest(candidates: Candidate[]): Candidate | null {
|
|
190
|
+
if (candidates.length === 0) return null;
|
|
191
|
+
candidates.sort((left, right) => right.score - left.score);
|
|
192
|
+
|
|
193
|
+
return candidates[0]!;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function resolved(candidate: Candidate, strategy: AnchorMatchStrategy): ResolvedAnchor {
|
|
197
|
+
return {
|
|
198
|
+
blockIndex: candidate.blockIndex,
|
|
199
|
+
start: candidate.start,
|
|
200
|
+
endBlockIndex: candidate.blockIndex,
|
|
201
|
+
end: candidate.end,
|
|
202
|
+
approximate: strategy !== "exact",
|
|
203
|
+
strategy,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/* ------------------------------------------------ quotes spanning blocks */
|
|
208
|
+
|
|
209
|
+
/** The blocks as one document, with where each block's text starts in it. */
|
|
210
|
+
interface JoinedDocument {
|
|
211
|
+
text: string;
|
|
212
|
+
starts: number[];
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function joinBlocks(blocks: Block[]): JoinedDocument {
|
|
216
|
+
const starts: number[] = [];
|
|
217
|
+
let text = "";
|
|
218
|
+
|
|
219
|
+
blocks.forEach((block, blockIndex) => {
|
|
220
|
+
if (blockIndex > 0) text += BLOCK_SEPARATOR;
|
|
221
|
+
starts.push(text.length);
|
|
222
|
+
text += block.text;
|
|
54
223
|
});
|
|
55
|
-
|
|
224
|
+
|
|
225
|
+
return { text, starts };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* The block and in-block offset of a document offset. An offset inside a
|
|
230
|
+
* separator belongs to the block after it when it opens a range and to the
|
|
231
|
+
* block before it when it closes one, so a span never starts or ends on the
|
|
232
|
+
* blank line between blocks.
|
|
233
|
+
*/
|
|
234
|
+
interface BlockOffset {
|
|
235
|
+
blockIndex: number;
|
|
236
|
+
offset: number;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function locateInBlocks(
|
|
240
|
+
blocks: Block[],
|
|
241
|
+
starts: number[],
|
|
242
|
+
offset: number,
|
|
243
|
+
edge: "start" | "end",
|
|
244
|
+
): BlockOffset {
|
|
245
|
+
let blockIndex = starts.findLastIndex((start) => start <= offset);
|
|
246
|
+
|
|
247
|
+
if (blockIndex < 0) blockIndex = 0;
|
|
248
|
+
const blockEnd = starts[blockIndex]! + blocks[blockIndex]!.text.length;
|
|
249
|
+
|
|
250
|
+
if (edge === "start" && offset >= blockEnd && blockIndex < blocks.length - 1) {
|
|
251
|
+
return { blockIndex: blockIndex + 1, offset: 0 };
|
|
252
|
+
}
|
|
253
|
+
if (edge === "end" && offset === starts[blockIndex] && blockIndex > 0) {
|
|
254
|
+
return {
|
|
255
|
+
blockIndex: blockIndex - 1,
|
|
256
|
+
offset: blocks[blockIndex - 1]!.text.length,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return {
|
|
261
|
+
blockIndex,
|
|
262
|
+
offset: Math.min(offset, blockEnd) - starts[blockIndex]!,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Context agreement plus position hints for a window of the joined document. */
|
|
267
|
+
function documentScore(
|
|
268
|
+
anchor: Anchor,
|
|
269
|
+
documentText: string,
|
|
270
|
+
start: number,
|
|
271
|
+
end: number,
|
|
272
|
+
startBlockIndex: number,
|
|
273
|
+
): number {
|
|
274
|
+
const prefix = documentText.slice(Math.max(0, start - ANCHOR_CONTEXT_CHARS), start);
|
|
275
|
+
const suffix = documentText.slice(end, end + ANCHOR_CONTEXT_CHARS);
|
|
276
|
+
let score = 0;
|
|
277
|
+
|
|
278
|
+
if (prefix === anchor.prefix) score += 2;
|
|
279
|
+
if (suffix === anchor.suffix) score += 2;
|
|
280
|
+
if (anchor.blockIndex === startBlockIndex) score += 1;
|
|
281
|
+
|
|
282
|
+
return score;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
interface DocumentCandidate {
|
|
286
|
+
start: number;
|
|
287
|
+
end: number;
|
|
288
|
+
score: number;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function findSpanningExact(
|
|
292
|
+
anchor: Anchor,
|
|
293
|
+
documentText: string,
|
|
294
|
+
quote: string,
|
|
295
|
+
starts: number[],
|
|
296
|
+
blocks: Block[],
|
|
297
|
+
): DocumentCandidate[] {
|
|
298
|
+
const candidates: DocumentCandidate[] = [];
|
|
299
|
+
|
|
300
|
+
for (
|
|
301
|
+
let matchStart = documentText.indexOf(quote);
|
|
302
|
+
matchStart !== -1;
|
|
303
|
+
matchStart = documentText.indexOf(quote, matchStart + 1)
|
|
304
|
+
) {
|
|
305
|
+
const matchEnd = matchStart + quote.length;
|
|
306
|
+
const startBlockIndex = locateInBlocks(blocks, starts, matchStart, "start").blockIndex;
|
|
307
|
+
|
|
308
|
+
candidates.push({
|
|
309
|
+
start: matchStart,
|
|
310
|
+
end: matchEnd,
|
|
311
|
+
score: documentScore(anchor, documentText, matchStart, matchEnd, startBlockIndex),
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return candidates;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function resolvedSpan(
|
|
319
|
+
blocks: Block[],
|
|
320
|
+
starts: number[],
|
|
321
|
+
candidate: DocumentCandidate,
|
|
322
|
+
strategy: AnchorMatchStrategy,
|
|
323
|
+
): ResolvedAnchor {
|
|
324
|
+
const startPosition = locateInBlocks(blocks, starts, candidate.start, "start");
|
|
325
|
+
const endPosition = locateInBlocks(blocks, starts, candidate.end, "end");
|
|
326
|
+
|
|
327
|
+
return {
|
|
328
|
+
blockIndex: startPosition.blockIndex,
|
|
329
|
+
start: startPosition.offset,
|
|
330
|
+
endBlockIndex: endPosition.blockIndex,
|
|
331
|
+
end: endPosition.offset,
|
|
332
|
+
approximate: strategy !== "exact",
|
|
333
|
+
strategy,
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** The cascade for a quote that spans blocks, run over the joined document. */
|
|
338
|
+
function resolveSpanningAnchor(anchor: Anchor, blocks: Block[]): ResolvedAnchor | null {
|
|
339
|
+
const document = joinBlocks(blocks);
|
|
340
|
+
const best = (candidates: DocumentCandidate[]): DocumentCandidate | null =>
|
|
341
|
+
candidates.toSorted((left, right) => right.score - left.score)[0] ?? null;
|
|
342
|
+
|
|
343
|
+
const exact = best(
|
|
344
|
+
findSpanningExact(anchor, document.text, anchor.quote, document.starts, blocks),
|
|
345
|
+
);
|
|
346
|
+
|
|
347
|
+
if (exact) return resolvedSpan(blocks, document.starts, exact, "exact");
|
|
348
|
+
|
|
349
|
+
const trimmedQuote = anchor.quote.trim();
|
|
350
|
+
|
|
351
|
+
if (trimmedQuote !== anchor.quote) {
|
|
352
|
+
const trimmed = best(
|
|
353
|
+
findSpanningExact(anchor, document.text, trimmedQuote, document.starts, blocks),
|
|
354
|
+
);
|
|
355
|
+
|
|
356
|
+
if (trimmed) return resolvedSpan(blocks, document.starts, trimmed, "trimmed");
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const fuzzy = fuzzyFindBestMatch(trimmedQuote, document.text, FUZZY_MINIMUM_SIMILARITY);
|
|
360
|
+
|
|
361
|
+
if (fuzzy) {
|
|
362
|
+
return resolvedSpan(
|
|
363
|
+
blocks,
|
|
364
|
+
document.starts,
|
|
365
|
+
{ start: fuzzy.start, end: fuzzy.end, score: 0 },
|
|
366
|
+
"fuzzy",
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
return null;
|
|
56
371
|
}
|
|
57
372
|
|
|
58
373
|
/** Resolve an anchor against the current blocks, or null when orphaned. */
|
|
59
374
|
export function resolveAnchor(anchor: Anchor, blocks: Block[]): ResolvedAnchor | null {
|
|
60
375
|
if (anchor.quote === "") return null;
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
376
|
+
if (anchor.quote.includes(BLOCK_SEPARATOR)) return resolveSpanningAnchor(anchor, blocks);
|
|
377
|
+
|
|
378
|
+
const exact = pickBest(findExactCandidates(anchor, blocks, anchor.quote));
|
|
379
|
+
|
|
380
|
+
if (exact) return resolved(exact, "exact");
|
|
381
|
+
|
|
382
|
+
const trimmedQuote = anchor.quote.trim();
|
|
383
|
+
|
|
384
|
+
if (trimmedQuote !== "" && trimmedQuote !== anchor.quote) {
|
|
385
|
+
const trimmed = pickBest(findExactCandidates(anchor, blocks, trimmedQuote));
|
|
386
|
+
|
|
387
|
+
if (trimmed) return resolved(trimmed, "trimmed");
|
|
69
388
|
}
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
389
|
+
|
|
390
|
+
const normalizedQuote = stripLeadingBlockMarker(anchor.quote).trim();
|
|
391
|
+
|
|
392
|
+
if (normalizedQuote !== "" && normalizedQuote !== trimmedQuote) {
|
|
393
|
+
const normalized = pickBest(findExactCandidates(anchor, blocks, normalizedQuote));
|
|
394
|
+
|
|
395
|
+
if (normalized) return resolved(normalized, "normalized");
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const fuzzyNeedle = normalizedQuote !== "" ? normalizedQuote : trimmedQuote;
|
|
399
|
+
|
|
400
|
+
if (fuzzyNeedle !== "") {
|
|
401
|
+
const fuzzy = pickBest(
|
|
402
|
+
findFuzzyCandidates(anchor, blocks, fuzzyNeedle, FUZZY_MINIMUM_SIMILARITY),
|
|
403
|
+
);
|
|
404
|
+
|
|
405
|
+
if (fuzzy) return resolved(fuzzy, "fuzzy");
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
return null;
|
|
74
409
|
}
|
package/src/diff.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { lcsDiff, unifiedDiff, unifiedDiffText, editStats
|
|
2
|
+
import { lcsDiff, unifiedDiff, unifiedDiffText, editStats } from "./diff";
|
|
3
3
|
|
|
4
4
|
describe("unifiedDiff", () => {
|
|
5
5
|
test("equal inputs return null, not an empty diff", () => {
|
|
@@ -7,12 +7,17 @@ describe("unifiedDiff", () => {
|
|
|
7
7
|
});
|
|
8
8
|
|
|
9
9
|
test("produces hunk headers with correct line numbers", () => {
|
|
10
|
+
// Arrange
|
|
10
11
|
const oldText = ["l1", "l2", "l3", "l4", "l5", "l6", "l7", "l8"].join("\n");
|
|
11
12
|
const newText = ["l1", "l2", "l3", "CHANGED", "l5", "l6", "l7", "l8"].join("\n");
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
|
|
14
|
+
// Act
|
|
15
|
+
const diffLines = unifiedDiff(oldText, newText, 1)!;
|
|
16
|
+
|
|
17
|
+
// Assert
|
|
18
|
+
expect(diffLines[0]!.kind).toBe("hunk");
|
|
19
|
+
expect(diffLines[0]!.text).toBe("@@ -3,3 +3,3 @@");
|
|
20
|
+
expect(diffLines.map((line) => line.text)).toEqual([
|
|
16
21
|
"@@ -3,3 +3,3 @@",
|
|
17
22
|
" l3",
|
|
18
23
|
"-l4",
|
|
@@ -22,29 +27,44 @@ describe("unifiedDiff", () => {
|
|
|
22
27
|
});
|
|
23
28
|
|
|
24
29
|
test("distant changes split into separate hunks", () => {
|
|
30
|
+
// Arrange
|
|
25
31
|
const oldLines = Array.from({ length: 30 }, (_, i) => `line ${i + 1}`);
|
|
26
32
|
const newLines = [...oldLines];
|
|
33
|
+
|
|
27
34
|
newLines[1] = "first change";
|
|
28
35
|
newLines[27] = "second change";
|
|
29
|
-
|
|
30
|
-
|
|
36
|
+
|
|
37
|
+
// Act
|
|
38
|
+
const diffLines = unifiedDiff(oldLines.join("\n"), newLines.join("\n"), 2)!;
|
|
39
|
+
|
|
40
|
+
// Assert
|
|
41
|
+
expect(diffLines.filter((line) => line.kind === "hunk").length).toBe(2);
|
|
31
42
|
});
|
|
32
43
|
|
|
33
44
|
test("applies cleanly: reconstructing new text from old + diff", () => {
|
|
45
|
+
// Arrange
|
|
34
46
|
const oldText = "keep\ndrop\nkeep2\nkeep3";
|
|
35
47
|
const newText = "keep\nadded\nkeep2\nkeep3\ntail";
|
|
36
|
-
|
|
48
|
+
|
|
49
|
+
// Act
|
|
50
|
+
const diffLines = unifiedDiff(oldText, newText, 50)!;
|
|
51
|
+
|
|
52
|
+
// Assert
|
|
37
53
|
const rebuilt: string[] = [];
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
if (
|
|
54
|
+
|
|
55
|
+
for (const line of diffLines) {
|
|
56
|
+
if (line.kind === "hunk") continue;
|
|
57
|
+
if (line.kind === "ctx" || line.kind === "add") rebuilt.push(line.text.slice(1));
|
|
41
58
|
}
|
|
42
59
|
expect(rebuilt.join("\n")).toBe(newText);
|
|
43
60
|
});
|
|
44
61
|
|
|
45
62
|
test("unifiedDiffText carries file headers and path", () => {
|
|
46
|
-
|
|
47
|
-
|
|
63
|
+
// Act
|
|
64
|
+
const diffText = unifiedDiffText("a", "b", "docs/plan.md")!;
|
|
65
|
+
|
|
66
|
+
// Assert
|
|
67
|
+
expect(diffText.startsWith("--- a/docs/plan.md\n+++ b/docs/plan.md\n@@")).toBe(true);
|
|
48
68
|
});
|
|
49
69
|
});
|
|
50
70
|
|
|
@@ -54,19 +74,16 @@ describe("editStats", () => {
|
|
|
54
74
|
});
|
|
55
75
|
});
|
|
56
76
|
|
|
57
|
-
describe("wordDiff", () => {
|
|
58
|
-
test("preserves whitespace tokens and reconstructs both sides", () => {
|
|
59
|
-
const ops = wordDiff("the quick brown fox", "the slow brown foxes");
|
|
60
|
-
const oldSide = ops.filter((o) => o.t !== "add").map((o) => o.a).join("");
|
|
61
|
-
const newSide = ops.filter((o) => o.t !== "del").map((o) => o.b).join("");
|
|
62
|
-
expect(oldSide).toBe("the quick brown fox");
|
|
63
|
-
expect(newSide).toBe("the slow brown foxes");
|
|
64
|
-
});
|
|
65
|
-
});
|
|
66
|
-
|
|
67
77
|
describe("lcsDiff", () => {
|
|
68
78
|
test("custom equality", () => {
|
|
69
|
-
|
|
70
|
-
|
|
79
|
+
// Act
|
|
80
|
+
const ops = lcsDiff(
|
|
81
|
+
[{ v: 1 }, { v: 2 }],
|
|
82
|
+
[{ v: 2 }, { v: 3 }],
|
|
83
|
+
(oldItem, newItem) => oldItem.v === newItem.v,
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
// Assert
|
|
87
|
+
expect(ops.map((op) => op.kind)).toEqual(["del", "ctx", "add"]);
|
|
71
88
|
});
|
|
72
89
|
});
|