@cueloop/schema 0.1.0-alpha.6 → 0.1.0-alpha.61
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 +154 -39
- package/src/anchor.ts +161 -42
- package/src/diff.test.ts +42 -25
- package/src/diff.ts +112 -77
- package/src/feedback.test.ts +170 -40
- package/src/feedback.ts +124 -47
- package/src/fuzzy.test.ts +92 -0
- package/src/fuzzy.ts +101 -0
- package/src/herdr.test.ts +50 -0
- package/src/herdr.ts +54 -0
- package/src/index.ts +5 -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 +38 -0
- package/src/types.test.ts +30 -0
- package/src/types.ts +124 -9
- package/src/working-copy.test.ts +156 -0
- package/src/working-copy.ts +66 -0
package/package.json
CHANGED
|
@@ -1,7 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cueloop/schema",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.61",
|
|
4
|
+
"description": "The ReviewSession primitive: types, markdown block model, quote-primary anchors, feedback serialization",
|
|
5
|
+
"homepage": "https://github.com/mmurakaru/cueloop#readme",
|
|
6
|
+
"bugs": {
|
|
7
|
+
"url": "https://github.com/mmurakaru/cueloop/issues"
|
|
8
|
+
},
|
|
4
9
|
"license": "Apache-2.0",
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "git+https://github.com/mmurakaru/cueloop.git",
|
|
13
|
+
"directory": "packages/schema"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"src",
|
|
17
|
+
"README.md"
|
|
18
|
+
],
|
|
5
19
|
"type": "module",
|
|
6
20
|
"exports": {
|
|
7
21
|
".": "./src/index.ts"
|
|
@@ -9,21 +23,7 @@
|
|
|
9
23
|
"publishConfig": {
|
|
10
24
|
"access": "public"
|
|
11
25
|
},
|
|
12
|
-
"files": [
|
|
13
|
-
"src",
|
|
14
|
-
"README.md"
|
|
15
|
-
],
|
|
16
26
|
"engines": {
|
|
17
27
|
"bun": ">=1.3.0"
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
"type": "git",
|
|
21
|
-
"url": "git+https://github.com/mmurakaru/cueloop.git",
|
|
22
|
-
"directory": "packages/schema"
|
|
23
|
-
},
|
|
24
|
-
"homepage": "https://github.com/mmurakaru/cueloop#readme",
|
|
25
|
-
"bugs": {
|
|
26
|
-
"url": "https://github.com/mmurakaru/cueloop/issues"
|
|
27
|
-
},
|
|
28
|
-
"description": "The ReviewSession primitive: types, markdown block model, quote-primary anchors, feedback serialization"
|
|
29
|
-
}
|
|
28
|
+
}
|
|
29
|
+
}
|
package/src/anchor.test.ts
CHANGED
|
@@ -15,80 +15,195 @@ Sessions are written as one JSON document per session. Sessions are written
|
|
|
15
15
|
atomically.
|
|
16
16
|
`;
|
|
17
17
|
|
|
18
|
-
function blocksOf(
|
|
19
|
-
return parseBlocks(
|
|
18
|
+
function blocksOf(markdown: string) {
|
|
19
|
+
return parseBlocks(markdown);
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
describe("anchor cascade", () => {
|
|
23
23
|
test("makeAnchor captures quote + context selectors", () => {
|
|
24
|
+
// Arrange
|
|
24
25
|
const blocks = blocksOf(DOC);
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
26
|
+
const contextBlockIndex = blocks.findIndex((block) => block.text.startsWith("Review sessions"));
|
|
27
|
+
|
|
28
|
+
// Act
|
|
29
|
+
const anchor = makeAnchor(blocks, contextBlockIndex, 0, 15); // "Review sessions"
|
|
30
|
+
|
|
31
|
+
// Assert
|
|
32
|
+
expect(anchor.quote).toBe("Review sessions");
|
|
33
|
+
expect(anchor.prefix).toBe("");
|
|
34
|
+
expect(anchor.suffix.startsWith(" currently")).toBe(true);
|
|
30
35
|
});
|
|
31
36
|
|
|
32
37
|
test("resolves at the recorded position when unchanged", () => {
|
|
38
|
+
// Arrange
|
|
33
39
|
const blocks = blocksOf(DOC);
|
|
34
|
-
const
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
+
const contextBlockIndex = blocks.findIndex((block) => block.text.startsWith("Review sessions"));
|
|
41
|
+
const anchor = makeAnchor(blocks, contextBlockIndex, 7, 15); // "sessions"
|
|
42
|
+
|
|
43
|
+
// Act
|
|
44
|
+
const resolved = resolveAnchor(anchor, blocks)!;
|
|
45
|
+
|
|
46
|
+
// Assert
|
|
47
|
+
expect(resolved.blockIndex).toBe(contextBlockIndex);
|
|
48
|
+
expect(resolved.start).toBe(7);
|
|
49
|
+
expect(resolved.approximate).toBe(false);
|
|
40
50
|
});
|
|
41
51
|
|
|
42
52
|
test("survives text changes before the quote (quote search)", () => {
|
|
53
|
+
// Arrange
|
|
43
54
|
const blocks = blocksOf(DOC);
|
|
44
|
-
const
|
|
45
|
-
const
|
|
46
|
-
const edited = blocksOf(
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
55
|
+
const contextBlockIndex = blocks.findIndex((block) => block.text.startsWith("Review sessions"));
|
|
56
|
+
const anchor = makeAnchor(blocks, contextBlockIndex, 0, 15);
|
|
57
|
+
const edited = blocksOf(
|
|
58
|
+
DOC.replace("Review sessions currently", "PREFIX. Review sessions now"),
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
// Act
|
|
62
|
+
const resolved = resolveAnchor(anchor, edited)!;
|
|
63
|
+
|
|
64
|
+
// Assert
|
|
65
|
+
const editedBlockIndex = edited.findIndex((block) => block.text.includes("Review sessions"));
|
|
66
|
+
|
|
67
|
+
expect(resolved.blockIndex).toBe(editedBlockIndex);
|
|
68
|
+
expect(edited[resolved.blockIndex]!.text.slice(resolved.start, resolved.end)).toBe(
|
|
69
|
+
"Review sessions",
|
|
70
|
+
);
|
|
51
71
|
});
|
|
52
72
|
|
|
53
73
|
test("prefix/suffix selectors disambiguate repeated quotes", () => {
|
|
74
|
+
// Arrange
|
|
54
75
|
const blocks = blocksOf(DOC);
|
|
55
|
-
const
|
|
76
|
+
const storageBlockIndex = blocks.findIndex((block) => block.text.includes("atomically"));
|
|
56
77
|
// anchor on the SECOND "Sessions are written" occurrence
|
|
57
|
-
const text = blocks[
|
|
78
|
+
const text = blocks[storageBlockIndex]!.text;
|
|
58
79
|
const second = text.indexOf("Sessions are written", text.indexOf("Sessions are written") + 1);
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
80
|
+
const anchor = makeAnchor(
|
|
81
|
+
blocks,
|
|
82
|
+
storageBlockIndex,
|
|
83
|
+
second,
|
|
84
|
+
second + "Sessions are written".length,
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
// Act
|
|
88
|
+
const resolved = resolveAnchor(anchor, blocks)!;
|
|
89
|
+
|
|
90
|
+
// Assert
|
|
91
|
+
expect(resolved.start).toBe(second);
|
|
62
92
|
});
|
|
63
93
|
|
|
64
94
|
test("position hint breaks ties when context also repeats", () => {
|
|
65
|
-
|
|
66
|
-
const
|
|
67
|
-
const
|
|
68
|
-
const
|
|
69
|
-
|
|
95
|
+
// Arrange
|
|
96
|
+
const repeatedWords = "word word word";
|
|
97
|
+
const blocks = blocksOf(repeatedWords);
|
|
98
|
+
const anchor = makeAnchor(blocks, 0, 5, 9); // middle "word"
|
|
99
|
+
|
|
100
|
+
// Act
|
|
101
|
+
const resolved = resolveAnchor(anchor, blocks)!;
|
|
102
|
+
|
|
103
|
+
// Assert
|
|
104
|
+
expect(resolved.start).toBe(5);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("binds a quote that still carries its leading list marker", () => {
|
|
108
|
+
// Arrange
|
|
109
|
+
// an agent copied the source line verbatim, including the "- " bullet, but
|
|
110
|
+
// the parsed list block strips the marker from its text
|
|
111
|
+
const blocks = blocksOf("## Notes\n\n- This is a dummy plan - confirm the item");
|
|
112
|
+
const anchor = { quote: "- This is a dummy plan", prefix: "", suffix: "" };
|
|
113
|
+
|
|
114
|
+
// Act
|
|
115
|
+
const resolved = resolveAnchor(anchor, blocks)!;
|
|
116
|
+
|
|
117
|
+
// Assert
|
|
118
|
+
expect(resolved).not.toBeNull();
|
|
119
|
+
expect(resolved.strategy).toBe("normalized");
|
|
120
|
+
expect(blocks[resolved.blockIndex]!.text.slice(resolved.start, resolved.end)).toBe(
|
|
121
|
+
"This is a dummy plan",
|
|
122
|
+
);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("binds a heading quote that still carries its leading hashes", () => {
|
|
126
|
+
// Arrange
|
|
127
|
+
const blocks = blocksOf(DOC);
|
|
128
|
+
const anchor = { quote: "## Storage", prefix: "", suffix: "" };
|
|
129
|
+
|
|
130
|
+
// Act
|
|
131
|
+
const resolved = resolveAnchor(anchor, blocks)!;
|
|
132
|
+
|
|
133
|
+
// Assert
|
|
134
|
+
expect(resolved.strategy).toBe("normalized");
|
|
135
|
+
expect(blocks[resolved.blockIndex]!.text).toBe("Storage");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("fuzzy tier binds a lightly edited quote", () => {
|
|
139
|
+
// Arrange
|
|
140
|
+
const blocks = blocksOf(DOC);
|
|
141
|
+
const contextBlockIndex = blocks.findIndex((block) => block.text.startsWith("Review sessions"));
|
|
142
|
+
// a typo the reviewer never saw: "sesssions"
|
|
143
|
+
const anchor = { quote: "Review sesssions currently live", prefix: "", suffix: "" };
|
|
144
|
+
|
|
145
|
+
// Act
|
|
146
|
+
const resolved = resolveAnchor(anchor, blocks)!;
|
|
147
|
+
|
|
148
|
+
// Assert
|
|
149
|
+
expect(resolved.strategy).toBe("fuzzy");
|
|
150
|
+
expect(resolved.blockIndex).toBe(contextBlockIndex);
|
|
151
|
+
expect(resolved.approximate).toBe(true);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("exact matches report the exact strategy", () => {
|
|
155
|
+
// Arrange
|
|
156
|
+
const blocks = blocksOf(DOC);
|
|
157
|
+
const contextBlockIndex = blocks.findIndex((block) => block.text.startsWith("Review sessions"));
|
|
158
|
+
const anchor = makeAnchor(blocks, contextBlockIndex, 0, 15);
|
|
159
|
+
|
|
160
|
+
// Act
|
|
161
|
+
const resolved = resolveAnchor(anchor, blocks)!;
|
|
162
|
+
|
|
163
|
+
// Assert
|
|
164
|
+
expect(resolved.strategy).toBe("exact");
|
|
165
|
+
expect(resolved.approximate).toBe(false);
|
|
70
166
|
});
|
|
71
167
|
|
|
72
168
|
test("trimmed-quote fallback marks the resolution approximate", () => {
|
|
73
|
-
|
|
74
|
-
const
|
|
169
|
+
// Arrange
|
|
170
|
+
const anchor = {
|
|
171
|
+
quote: " text ",
|
|
172
|
+
prefix: "some",
|
|
173
|
+
suffix: "here",
|
|
174
|
+
blockIndex: 0,
|
|
175
|
+
start: 4,
|
|
176
|
+
end: 10,
|
|
177
|
+
};
|
|
75
178
|
const edited = blocksOf("some\ntext\nhere");
|
|
76
179
|
// " text " (with spaces) is gone, "text" survives
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
180
|
+
|
|
181
|
+
// Act
|
|
182
|
+
const resolved = resolveAnchor(anchor, edited);
|
|
183
|
+
|
|
184
|
+
// Assert
|
|
185
|
+
expect(resolved).not.toBeNull();
|
|
186
|
+
expect(resolved!.approximate).toBe(true);
|
|
80
187
|
});
|
|
81
188
|
|
|
82
189
|
test("orphans when the quote is gone entirely", () => {
|
|
190
|
+
// Arrange
|
|
83
191
|
const blocks = blocksOf(DOC);
|
|
84
|
-
const
|
|
85
|
-
const
|
|
86
|
-
const edited = blocksOf(
|
|
87
|
-
|
|
192
|
+
const contextBlockIndex = blocks.findIndex((block) => block.text.startsWith("Review sessions"));
|
|
193
|
+
const anchor = makeAnchor(blocks, contextBlockIndex, 0, 15);
|
|
194
|
+
const edited = blocksOf(
|
|
195
|
+
DOC.replace(/Review sessions[^\n]*\n[^\n]*/, "Completely different text."),
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
// Assert
|
|
199
|
+
expect(resolveAnchor(anchor, edited)).toBeNull();
|
|
88
200
|
});
|
|
89
201
|
|
|
90
202
|
test("empty quote never binds", () => {
|
|
203
|
+
// Arrange
|
|
91
204
|
const blocks = blocksOf(DOC);
|
|
205
|
+
|
|
206
|
+
// Assert
|
|
92
207
|
expect(resolveAnchor({ quote: "", prefix: "", suffix: "" }, blocks)).toBeNull();
|
|
93
208
|
});
|
|
94
209
|
});
|
package/src/anchor.ts
CHANGED
|
@@ -1,74 +1,193 @@
|
|
|
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.
|
|
9
13
|
*/
|
|
10
14
|
|
|
11
15
|
import type { Anchor } from "./types";
|
|
12
|
-
import type
|
|
16
|
+
import { type Block, stripLeadingBlockMarker } from "./markdown";
|
|
17
|
+
import { fuzzyFindBestMatch } from "./fuzzy";
|
|
13
18
|
|
|
14
|
-
|
|
19
|
+
/** Characters of surrounding text captured as prefix/suffix selectors. */
|
|
20
|
+
const ANCHOR_CONTEXT_CHARS = 24;
|
|
21
|
+
|
|
22
|
+
/** Minimum similarity a fuzzy match needs before the resolver will trust it. */
|
|
23
|
+
const FUZZY_MINIMUM_SIMILARITY = 0.75;
|
|
24
|
+
|
|
25
|
+
/** How the quote bound to the block text, weakest binding named last. */
|
|
26
|
+
export type AnchorMatchStrategy = "exact" | "trimmed" | "normalized" | "fuzzy";
|
|
15
27
|
|
|
16
28
|
export interface ResolvedAnchor {
|
|
17
29
|
blockIndex: number;
|
|
18
30
|
start: number;
|
|
19
31
|
end: number;
|
|
20
|
-
/** True
|
|
32
|
+
/** True whenever the binding was not an exact quote match. */
|
|
21
33
|
approximate: boolean;
|
|
34
|
+
/** Which cascade tier bound the quote. */
|
|
35
|
+
strategy: AnchorMatchStrategy;
|
|
22
36
|
}
|
|
23
37
|
|
|
24
|
-
export function makeAnchor(
|
|
25
|
-
|
|
38
|
+
export function makeAnchor(
|
|
39
|
+
blocks: Block[],
|
|
40
|
+
blockIndex: number,
|
|
41
|
+
start: number,
|
|
42
|
+
end: number,
|
|
43
|
+
): Anchor {
|
|
44
|
+
const blockText = blocks[blockIndex]?.text ?? "";
|
|
45
|
+
|
|
26
46
|
return {
|
|
27
|
-
quote:
|
|
28
|
-
prefix:
|
|
29
|
-
suffix:
|
|
47
|
+
quote: blockText.slice(start, end),
|
|
48
|
+
prefix: blockText.slice(Math.max(0, start - ANCHOR_CONTEXT_CHARS), start),
|
|
49
|
+
suffix: blockText.slice(end, end + ANCHOR_CONTEXT_CHARS),
|
|
30
50
|
blockIndex,
|
|
31
51
|
start,
|
|
32
52
|
end,
|
|
33
53
|
};
|
|
34
54
|
}
|
|
35
55
|
|
|
36
|
-
interface Candidate
|
|
56
|
+
interface Candidate {
|
|
57
|
+
blockIndex: number;
|
|
58
|
+
start: number;
|
|
59
|
+
end: number;
|
|
37
60
|
score: number;
|
|
38
61
|
}
|
|
39
62
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
63
|
+
/** Score a candidate window by how well its context and position agree. */
|
|
64
|
+
function contextScore(
|
|
65
|
+
anchor: Anchor,
|
|
66
|
+
blockIndex: number,
|
|
67
|
+
block: Block,
|
|
68
|
+
start: number,
|
|
69
|
+
end: number,
|
|
70
|
+
): number {
|
|
71
|
+
const prefix = block.text.slice(Math.max(0, start - ANCHOR_CONTEXT_CHARS), start);
|
|
72
|
+
const suffix = block.text.slice(end, end + ANCHOR_CONTEXT_CHARS);
|
|
73
|
+
let score = 0;
|
|
74
|
+
|
|
75
|
+
if (prefix === anchor.prefix) score += 2;
|
|
76
|
+
if (suffix === anchor.suffix) score += 2;
|
|
77
|
+
if (anchor.blockIndex === blockIndex) score += 1;
|
|
78
|
+
if (anchor.blockIndex === blockIndex && anchor.start === start) score += 1;
|
|
79
|
+
|
|
80
|
+
return score;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Every verbatim occurrence of `quote` across the blocks, scored by context. */
|
|
84
|
+
function findExactCandidates(anchor: Anchor, blocks: Block[], quote: string): Candidate[] {
|
|
85
|
+
const candidates: Candidate[] = [];
|
|
86
|
+
|
|
87
|
+
blocks.forEach((block, blockIndex) => {
|
|
88
|
+
const text = block.text;
|
|
89
|
+
|
|
90
|
+
for (
|
|
91
|
+
let matchStart = text.indexOf(quote);
|
|
92
|
+
matchStart !== -1;
|
|
93
|
+
matchStart = text.indexOf(quote, matchStart + 1)
|
|
94
|
+
) {
|
|
95
|
+
const matchEnd = matchStart + quote.length;
|
|
96
|
+
|
|
97
|
+
candidates.push({
|
|
98
|
+
blockIndex,
|
|
99
|
+
start: matchStart,
|
|
100
|
+
end: matchEnd,
|
|
101
|
+
score: contextScore(anchor, blockIndex, block, matchStart, matchEnd),
|
|
102
|
+
});
|
|
53
103
|
}
|
|
54
104
|
});
|
|
55
|
-
|
|
105
|
+
|
|
106
|
+
return candidates;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* How much a full context agreement (0-6) may nudge the fuzzy ranking. Kept far
|
|
111
|
+
* below any real similarity gap so text quality always leads and context only
|
|
112
|
+
* breaks near-ties - a stale position hint must never pull the anchor onto a
|
|
113
|
+
* meaningfully less-similar passage.
|
|
114
|
+
*/
|
|
115
|
+
const FUZZY_CONTEXT_TIE_BREAK = 0.01;
|
|
116
|
+
|
|
117
|
+
/** The best fuzzy window per block, ranked by similarity with context as a tiebreak. */
|
|
118
|
+
function findFuzzyCandidates(
|
|
119
|
+
anchor: Anchor,
|
|
120
|
+
blocks: Block[],
|
|
121
|
+
needle: string,
|
|
122
|
+
minimumSimilarity: number,
|
|
123
|
+
): Candidate[] {
|
|
124
|
+
const candidates: Candidate[] = [];
|
|
125
|
+
|
|
126
|
+
blocks.forEach((block, blockIndex) => {
|
|
127
|
+
const match = fuzzyFindBestMatch(needle, block.text, minimumSimilarity);
|
|
128
|
+
|
|
129
|
+
if (match === null) return;
|
|
130
|
+
const score =
|
|
131
|
+
match.similarity +
|
|
132
|
+
contextScore(anchor, blockIndex, block, match.start, match.end) * FUZZY_CONTEXT_TIE_BREAK;
|
|
133
|
+
|
|
134
|
+
candidates.push({ blockIndex, start: match.start, end: match.end, score });
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
return candidates;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Highest-scoring candidate, or null when there are none. */
|
|
141
|
+
function pickBest(candidates: Candidate[]): Candidate | null {
|
|
142
|
+
if (candidates.length === 0) return null;
|
|
143
|
+
candidates.sort((left, right) => right.score - left.score);
|
|
144
|
+
|
|
145
|
+
return candidates[0]!;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function resolved(candidate: Candidate, strategy: AnchorMatchStrategy): ResolvedAnchor {
|
|
149
|
+
return {
|
|
150
|
+
blockIndex: candidate.blockIndex,
|
|
151
|
+
start: candidate.start,
|
|
152
|
+
end: candidate.end,
|
|
153
|
+
approximate: strategy !== "exact",
|
|
154
|
+
strategy,
|
|
155
|
+
};
|
|
56
156
|
}
|
|
57
157
|
|
|
58
158
|
/** Resolve an anchor against the current blocks, or null when orphaned. */
|
|
59
159
|
export function resolveAnchor(anchor: Anchor, blocks: Block[]): ResolvedAnchor | null {
|
|
60
160
|
if (anchor.quote === "") return null;
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
161
|
+
|
|
162
|
+
const exact = pickBest(findExactCandidates(anchor, blocks, anchor.quote));
|
|
163
|
+
|
|
164
|
+
if (exact) return resolved(exact, "exact");
|
|
165
|
+
|
|
166
|
+
const trimmedQuote = anchor.quote.trim();
|
|
167
|
+
|
|
168
|
+
if (trimmedQuote !== "" && trimmedQuote !== anchor.quote) {
|
|
169
|
+
const trimmed = pickBest(findExactCandidates(anchor, blocks, trimmedQuote));
|
|
170
|
+
|
|
171
|
+
if (trimmed) return resolved(trimmed, "trimmed");
|
|
69
172
|
}
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
173
|
+
|
|
174
|
+
const normalizedQuote = stripLeadingBlockMarker(anchor.quote).trim();
|
|
175
|
+
|
|
176
|
+
if (normalizedQuote !== "" && normalizedQuote !== trimmedQuote) {
|
|
177
|
+
const normalized = pickBest(findExactCandidates(anchor, blocks, normalizedQuote));
|
|
178
|
+
|
|
179
|
+
if (normalized) return resolved(normalized, "normalized");
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const fuzzyNeedle = normalizedQuote !== "" ? normalizedQuote : trimmedQuote;
|
|
183
|
+
|
|
184
|
+
if (fuzzyNeedle !== "") {
|
|
185
|
+
const fuzzy = pickBest(
|
|
186
|
+
findFuzzyCandidates(anchor, blocks, fuzzyNeedle, FUZZY_MINIMUM_SIMILARITY),
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
if (fuzzy) return resolved(fuzzy, "fuzzy");
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return null;
|
|
74
193
|
}
|
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
|
});
|