@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/src/anchor.ts CHANGED
@@ -1,74 +1,386 @@
1
1
  /**
2
- * Anchor resolution: the quote-primary cascade (map #2, #22, verified in the
3
- * edit-mode deep-dive). Anchors resolve against the current block list:
4
- * 1. exact quote in a block (prefix/suffix selectors break ties,
5
- * position hint breaks remaining ties)
6
- * 2. trimmed quote
7
- * 3. orphan - keep the quote for display, drop the highlight.
8
- * Never silently bind to the wrong text.
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 { Block } from "./markdown";
20
+ import { type Block, stripLeadingBlockMarker } from "./markdown";
21
+ import { fuzzyFindBestMatch } from "./fuzzy";
13
22
 
14
- const CONTEXT = 24;
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 when the quote had to be trimmed to bind. */
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(blocks: Block[], blockIndex: number, start: number, end: number): Anchor {
25
- const t = blocks[blockIndex]?.text ?? "";
26
- return {
27
- quote: t.slice(start, end),
28
- prefix: t.slice(Math.max(0, start - CONTEXT), start),
29
- suffix: t.slice(end, end + CONTEXT),
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 extends ResolvedAnchor {
81
+ interface Candidate {
82
+ blockIndex: number;
83
+ start: number;
84
+ end: number;
37
85
  score: number;
38
86
  }
39
87
 
40
- function findCandidates(anchor: Anchor, blocks: Block[], quote: string): Candidate[] {
41
- const out: Candidate[] = [];
42
- blocks.forEach((b, blockIndex) => {
43
- const text = b.text;
44
- for (let i = text.indexOf(quote); i !== -1; i = text.indexOf(quote, i + 1)) {
45
- const pre = text.slice(Math.max(0, i - CONTEXT), i);
46
- const suf = text.slice(i + quote.length, i + quote.length + CONTEXT);
47
- let score = 0;
48
- if (pre === anchor.prefix) score += 2;
49
- if (suf === anchor.suffix) score += 2;
50
- if (anchor.blockIndex === blockIndex) score += 1;
51
- if (anchor.blockIndex === blockIndex && anchor.start === i) score += 1;
52
- out.push({ blockIndex, start: i, end: i + quote.length, approximate: false, score });
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
+ });
53
128
  }
54
129
  });
55
- return out;
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
+ /** The best fuzzy window per block, ranked by similarity with context as a tiebreak. */
143
+ function findFuzzyCandidates(
144
+ anchor: Anchor,
145
+ blocks: Block[],
146
+ needle: string,
147
+ minimumSimilarity: number,
148
+ ): Candidate[] {
149
+ const candidates: Candidate[] = [];
150
+
151
+ blocks.forEach((block, blockIndex) => {
152
+ const match = fuzzyFindBestMatch(needle, block.text, minimumSimilarity);
153
+
154
+ if (match === null) return;
155
+ const score =
156
+ match.similarity +
157
+ contextScore(anchor, blockIndex, block, match.start, match.end) * FUZZY_CONTEXT_TIE_BREAK;
158
+
159
+ candidates.push({ blockIndex, start: match.start, end: match.end, score });
160
+ });
161
+
162
+ return candidates;
163
+ }
164
+
165
+ /** Highest-scoring candidate, or null when there are none. */
166
+ function pickBest(candidates: Candidate[]): Candidate | null {
167
+ if (candidates.length === 0) return null;
168
+ candidates.sort((left, right) => right.score - left.score);
169
+
170
+ return candidates[0]!;
171
+ }
172
+
173
+ function resolved(candidate: Candidate, strategy: AnchorMatchStrategy): ResolvedAnchor {
174
+ return {
175
+ blockIndex: candidate.blockIndex,
176
+ start: candidate.start,
177
+ endBlockIndex: candidate.blockIndex,
178
+ end: candidate.end,
179
+ approximate: strategy !== "exact",
180
+ strategy,
181
+ };
182
+ }
183
+
184
+ /* ------------------------------------------------ quotes spanning blocks */
185
+
186
+ /** The blocks as one document, with where each block's text starts in it. */
187
+ interface JoinedDocument {
188
+ text: string;
189
+ starts: number[];
190
+ }
191
+
192
+ function joinBlocks(blocks: Block[]): JoinedDocument {
193
+ const starts: number[] = [];
194
+ let text = "";
195
+
196
+ blocks.forEach((block, blockIndex) => {
197
+ if (blockIndex > 0) text += BLOCK_SEPARATOR;
198
+ starts.push(text.length);
199
+ text += block.text;
200
+ });
201
+
202
+ return { text, starts };
203
+ }
204
+
205
+ /**
206
+ * The block and in-block offset of a document offset. An offset inside a
207
+ * separator belongs to the block after it when it opens a range and to the
208
+ * block before it when it closes one, so a span never starts or ends on the
209
+ * blank line between blocks.
210
+ */
211
+ interface BlockOffset {
212
+ blockIndex: number;
213
+ offset: number;
214
+ }
215
+
216
+ function locateInBlocks(
217
+ blocks: Block[],
218
+ starts: number[],
219
+ offset: number,
220
+ edge: "start" | "end",
221
+ ): BlockOffset {
222
+ let blockIndex = starts.findLastIndex((start) => start <= offset);
223
+
224
+ if (blockIndex < 0) blockIndex = 0;
225
+ const blockEnd = starts[blockIndex]! + blocks[blockIndex]!.text.length;
226
+
227
+ if (edge === "start" && offset >= blockEnd && blockIndex < blocks.length - 1) {
228
+ return { blockIndex: blockIndex + 1, offset: 0 };
229
+ }
230
+ if (edge === "end" && offset === starts[blockIndex] && blockIndex > 0) {
231
+ return {
232
+ blockIndex: blockIndex - 1,
233
+ offset: blocks[blockIndex - 1]!.text.length,
234
+ };
235
+ }
236
+
237
+ return {
238
+ blockIndex,
239
+ offset: Math.min(offset, blockEnd) - starts[blockIndex]!,
240
+ };
241
+ }
242
+
243
+ /** Context agreement plus position hints for a window of the joined document. */
244
+ function documentScore(
245
+ anchor: Anchor,
246
+ documentText: string,
247
+ start: number,
248
+ end: number,
249
+ startBlockIndex: number,
250
+ ): number {
251
+ const prefix = documentText.slice(Math.max(0, start - ANCHOR_CONTEXT_CHARS), start);
252
+ const suffix = documentText.slice(end, end + ANCHOR_CONTEXT_CHARS);
253
+ let score = 0;
254
+
255
+ if (prefix === anchor.prefix) score += 2;
256
+ if (suffix === anchor.suffix) score += 2;
257
+ if (anchor.blockIndex === startBlockIndex) score += 1;
258
+
259
+ return score;
260
+ }
261
+
262
+ interface DocumentCandidate {
263
+ start: number;
264
+ end: number;
265
+ score: number;
266
+ }
267
+
268
+ function findSpanningExact(
269
+ anchor: Anchor,
270
+ documentText: string,
271
+ quote: string,
272
+ starts: number[],
273
+ blocks: Block[],
274
+ ): DocumentCandidate[] {
275
+ const candidates: DocumentCandidate[] = [];
276
+
277
+ for (
278
+ let matchStart = documentText.indexOf(quote);
279
+ matchStart !== -1;
280
+ matchStart = documentText.indexOf(quote, matchStart + 1)
281
+ ) {
282
+ const matchEnd = matchStart + quote.length;
283
+ const startBlockIndex = locateInBlocks(blocks, starts, matchStart, "start").blockIndex;
284
+
285
+ candidates.push({
286
+ start: matchStart,
287
+ end: matchEnd,
288
+ score: documentScore(anchor, documentText, matchStart, matchEnd, startBlockIndex),
289
+ });
290
+ }
291
+
292
+ return candidates;
293
+ }
294
+
295
+ function resolvedSpan(
296
+ blocks: Block[],
297
+ starts: number[],
298
+ candidate: DocumentCandidate,
299
+ strategy: AnchorMatchStrategy,
300
+ ): ResolvedAnchor {
301
+ const startPosition = locateInBlocks(blocks, starts, candidate.start, "start");
302
+ const endPosition = locateInBlocks(blocks, starts, candidate.end, "end");
303
+
304
+ return {
305
+ blockIndex: startPosition.blockIndex,
306
+ start: startPosition.offset,
307
+ endBlockIndex: endPosition.blockIndex,
308
+ end: endPosition.offset,
309
+ approximate: strategy !== "exact",
310
+ strategy,
311
+ };
312
+ }
313
+
314
+ /** The cascade for a quote that spans blocks, run over the joined document. */
315
+ function resolveSpanningAnchor(anchor: Anchor, blocks: Block[]): ResolvedAnchor | null {
316
+ const document = joinBlocks(blocks);
317
+ const best = (candidates: DocumentCandidate[]): DocumentCandidate | null =>
318
+ candidates.toSorted((left, right) => right.score - left.score)[0] ?? null;
319
+
320
+ const exact = best(
321
+ findSpanningExact(anchor, document.text, anchor.quote, document.starts, blocks),
322
+ );
323
+
324
+ if (exact) return resolvedSpan(blocks, document.starts, exact, "exact");
325
+
326
+ const trimmedQuote = anchor.quote.trim();
327
+
328
+ if (trimmedQuote !== anchor.quote) {
329
+ const trimmed = best(
330
+ findSpanningExact(anchor, document.text, trimmedQuote, document.starts, blocks),
331
+ );
332
+
333
+ if (trimmed) return resolvedSpan(blocks, document.starts, trimmed, "trimmed");
334
+ }
335
+
336
+ const fuzzy = fuzzyFindBestMatch(trimmedQuote, document.text, FUZZY_MINIMUM_SIMILARITY);
337
+
338
+ if (fuzzy) {
339
+ return resolvedSpan(
340
+ blocks,
341
+ document.starts,
342
+ { start: fuzzy.start, end: fuzzy.end, score: 0 },
343
+ "fuzzy",
344
+ );
345
+ }
346
+
347
+ return null;
56
348
  }
57
349
 
58
350
  /** Resolve an anchor against the current blocks, or null when orphaned. */
59
351
  export function resolveAnchor(anchor: Anchor, blocks: Block[]): ResolvedAnchor | null {
60
352
  if (anchor.quote === "") return null;
61
- let candidates = findCandidates(anchor, blocks, anchor.quote);
62
- let approximate = false;
63
- if (candidates.length === 0) {
64
- const trimmed = anchor.quote.trim();
65
- if (trimmed !== "" && trimmed !== anchor.quote) {
66
- candidates = findCandidates(anchor, blocks, trimmed);
67
- approximate = true;
68
- }
353
+ if (anchor.quote.includes(BLOCK_SEPARATOR)) return resolveSpanningAnchor(anchor, blocks);
354
+
355
+ const exact = pickBest(findExactCandidates(anchor, blocks, anchor.quote));
356
+
357
+ if (exact) return resolved(exact, "exact");
358
+
359
+ const trimmedQuote = anchor.quote.trim();
360
+
361
+ if (trimmedQuote !== "" && trimmedQuote !== anchor.quote) {
362
+ const trimmed = pickBest(findExactCandidates(anchor, blocks, trimmedQuote));
363
+
364
+ if (trimmed) return resolved(trimmed, "trimmed");
69
365
  }
70
- if (candidates.length === 0) return null;
71
- candidates.sort((a, b) => b.score - a.score);
72
- const best = candidates[0]!;
73
- return { ...best, approximate };
366
+
367
+ const normalizedQuote = stripLeadingBlockMarker(anchor.quote).trim();
368
+
369
+ if (normalizedQuote !== "" && normalizedQuote !== trimmedQuote) {
370
+ const normalized = pickBest(findExactCandidates(anchor, blocks, normalizedQuote));
371
+
372
+ if (normalized) return resolved(normalized, "normalized");
373
+ }
374
+
375
+ const fuzzyNeedle = normalizedQuote !== "" ? normalizedQuote : trimmedQuote;
376
+
377
+ if (fuzzyNeedle !== "") {
378
+ const fuzzy = pickBest(
379
+ findFuzzyCandidates(anchor, blocks, fuzzyNeedle, FUZZY_MINIMUM_SIMILARITY),
380
+ );
381
+
382
+ if (fuzzy) return resolved(fuzzy, "fuzzy");
383
+ }
384
+
385
+ return null;
74
386
  }
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, wordDiff } from "./diff";
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
- const d = unifiedDiff(oldText, newText, 1)!;
13
- expect(d[0]!.t).toBe("hunk");
14
- expect(d[0]!.text).toBe("@@ -3,3 +3,3 @@");
15
- expect(d.map((l) => l.text)).toEqual([
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
- const d = unifiedDiff(oldLines.join("\n"), newLines.join("\n"), 2)!;
30
- expect(d.filter((l) => l.t === "hunk").length).toBe(2);
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
- const d = unifiedDiff(oldText, newText, 50)!;
48
+
49
+ // Act
50
+ const diffLines = unifiedDiff(oldText, newText, 50)!;
51
+
52
+ // Assert
37
53
  const rebuilt: string[] = [];
38
- for (const l of d) {
39
- if (l.t === "hunk") continue;
40
- if (l.t === "ctx" || l.t === "add") rebuilt.push(l.text.slice(1));
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
- const t = unifiedDiffText("a", "b", "docs/plan.md")!;
47
- expect(t.startsWith("--- a/docs/plan.md\n+++ b/docs/plan.md\n@@")).toBe(true);
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
- const ops = lcsDiff([{ v: 1 }, { v: 2 }], [{ v: 2 }, { v: 3 }], (x, y) => x.v === y.v);
70
- expect(ops.map((o) => o.t)).toEqual(["del", "ctx", "add"]);
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
  });