@cueloop/schema 0.1.0-alpha.42 → 0.1.0-alpha.43
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 +1 -1
- package/src/anchor.test.ts +61 -0
- package/src/anchor.ts +116 -35
- package/src/fuzzy.test.ts +92 -0
- package/src/fuzzy.ts +94 -0
- package/src/index.ts +1 -0
- package/src/markdown.test.ts +28 -1
- package/src/markdown.ts +39 -14
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cueloop/schema",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.43",
|
|
4
4
|
"description": "The ReviewSession primitive: types, markdown block model, quote-primary anchors, feedback serialization",
|
|
5
5
|
"homepage": "https://github.com/mmurakaru/cueloop#readme",
|
|
6
6
|
"bugs": {
|
package/src/anchor.test.ts
CHANGED
|
@@ -103,6 +103,67 @@ describe("anchor cascade", () => {
|
|
|
103
103
|
expect(resolved.start).toBe(5);
|
|
104
104
|
});
|
|
105
105
|
|
|
106
|
+
test("binds a quote that still carries its leading list marker", () => {
|
|
107
|
+
// Arrange
|
|
108
|
+
// an agent copied the source line verbatim, including the "- " bullet, but
|
|
109
|
+
// the parsed list block strips the marker from its text
|
|
110
|
+
const blocks = blocksOf("## Notes\n\n- This is a dummy plan - confirm the item");
|
|
111
|
+
const anchor = { quote: "- This is a dummy plan", prefix: "", suffix: "" };
|
|
112
|
+
|
|
113
|
+
// Act
|
|
114
|
+
const resolved = resolveAnchor(anchor, blocks)!;
|
|
115
|
+
|
|
116
|
+
// Assert
|
|
117
|
+
expect(resolved).not.toBeNull();
|
|
118
|
+
expect(resolved.strategy).toBe("normalized");
|
|
119
|
+
expect(blocks[resolved.blockIndex]!.text.slice(resolved.start, resolved.end)).toBe(
|
|
120
|
+
"This is a dummy plan",
|
|
121
|
+
);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("binds a heading quote that still carries its leading hashes", () => {
|
|
125
|
+
// Arrange
|
|
126
|
+
const blocks = blocksOf(DOC);
|
|
127
|
+
const anchor = { quote: "## Storage", prefix: "", suffix: "" };
|
|
128
|
+
|
|
129
|
+
// Act
|
|
130
|
+
const resolved = resolveAnchor(anchor, blocks)!;
|
|
131
|
+
|
|
132
|
+
// Assert
|
|
133
|
+
expect(resolved.strategy).toBe("normalized");
|
|
134
|
+
expect(blocks[resolved.blockIndex]!.text).toBe("Storage");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("fuzzy tier binds a lightly edited quote", () => {
|
|
138
|
+
// Arrange
|
|
139
|
+
const blocks = blocksOf(DOC);
|
|
140
|
+
const contextBlockIndex = blocks.findIndex((block) => block.text.startsWith("Review sessions"));
|
|
141
|
+
// a typo the reviewer never saw: "sesssions"
|
|
142
|
+
const anchor = { quote: "Review sesssions currently live", prefix: "", suffix: "" };
|
|
143
|
+
|
|
144
|
+
// Act
|
|
145
|
+
const resolved = resolveAnchor(anchor, blocks)!;
|
|
146
|
+
|
|
147
|
+
// Assert
|
|
148
|
+
expect(resolved.strategy).toBe("fuzzy");
|
|
149
|
+
expect(resolved.blockIndex).toBe(contextBlockIndex);
|
|
150
|
+
expect(resolved.approximate).toBe(true);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("exact matches report the exact strategy", () => {
|
|
154
|
+
// Arrange
|
|
155
|
+
const blocks = blocksOf(DOC);
|
|
156
|
+
const contextBlockIndex = blocks.findIndex((block) => block.text.startsWith("Review sessions"));
|
|
157
|
+
const anchor = makeAnchor(blocks, contextBlockIndex, 0, 15);
|
|
158
|
+
|
|
159
|
+
// Act
|
|
160
|
+
const resolved = resolveAnchor(anchor, blocks)!;
|
|
161
|
+
|
|
162
|
+
// Assert
|
|
163
|
+
expect(resolved.strategy).toBe("exact");
|
|
164
|
+
expect(resolved.approximate).toBe(false);
|
|
165
|
+
});
|
|
166
|
+
|
|
106
167
|
test("trimmed-quote fallback marks the resolution approximate", () => {
|
|
107
168
|
// Arrange
|
|
108
169
|
const anchor = {
|
package/src/anchor.ts
CHANGED
|
@@ -1,25 +1,38 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Anchor resolution: the quote-primary cascade. Anchors resolve against the
|
|
3
|
-
* current block list:
|
|
4
|
-
* 1. exact quote in a block
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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. */
|
|
15
20
|
const ANCHOR_CONTEXT_CHARS = 24;
|
|
16
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";
|
|
27
|
+
|
|
17
28
|
export interface ResolvedAnchor {
|
|
18
29
|
blockIndex: number;
|
|
19
30
|
start: number;
|
|
20
31
|
end: number;
|
|
21
|
-
/** True
|
|
32
|
+
/** True whenever the binding was not an exact quote match. */
|
|
22
33
|
approximate: boolean;
|
|
34
|
+
/** Which cascade tier bound the quote. */
|
|
35
|
+
strategy: AnchorMatchStrategy;
|
|
23
36
|
}
|
|
24
37
|
|
|
25
38
|
export function makeAnchor(
|
|
@@ -39,11 +52,33 @@ export function makeAnchor(
|
|
|
39
52
|
};
|
|
40
53
|
}
|
|
41
54
|
|
|
42
|
-
interface Candidate
|
|
55
|
+
interface Candidate {
|
|
56
|
+
blockIndex: number;
|
|
57
|
+
start: number;
|
|
58
|
+
end: number;
|
|
43
59
|
score: number;
|
|
44
60
|
}
|
|
45
61
|
|
|
46
|
-
|
|
62
|
+
/** Score a candidate window by how well its context and position agree. */
|
|
63
|
+
function contextScore(
|
|
64
|
+
anchor: Anchor,
|
|
65
|
+
blockIndex: number,
|
|
66
|
+
block: Block,
|
|
67
|
+
start: number,
|
|
68
|
+
end: number,
|
|
69
|
+
): number {
|
|
70
|
+
const prefix = block.text.slice(Math.max(0, start - ANCHOR_CONTEXT_CHARS), start);
|
|
71
|
+
const suffix = block.text.slice(end, end + ANCHOR_CONTEXT_CHARS);
|
|
72
|
+
let score = 0;
|
|
73
|
+
if (prefix === anchor.prefix) score += 2;
|
|
74
|
+
if (suffix === anchor.suffix) score += 2;
|
|
75
|
+
if (anchor.blockIndex === blockIndex) score += 1;
|
|
76
|
+
if (anchor.blockIndex === blockIndex && anchor.start === start) score += 1;
|
|
77
|
+
return score;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Every verbatim occurrence of `quote` across the blocks, scored by context. */
|
|
81
|
+
function findExactCandidates(anchor: Anchor, blocks: Block[], quote: string): Candidate[] {
|
|
47
82
|
const candidates: Candidate[] = [];
|
|
48
83
|
blocks.forEach((block, blockIndex) => {
|
|
49
84
|
const text = block.text;
|
|
@@ -52,42 +87,88 @@ function findCandidates(anchor: Anchor, blocks: Block[], quote: string): Candida
|
|
|
52
87
|
matchStart !== -1;
|
|
53
88
|
matchStart = text.indexOf(quote, matchStart + 1)
|
|
54
89
|
) {
|
|
55
|
-
const
|
|
56
|
-
const suffix = text.slice(
|
|
57
|
-
matchStart + quote.length,
|
|
58
|
-
matchStart + quote.length + ANCHOR_CONTEXT_CHARS,
|
|
59
|
-
);
|
|
60
|
-
let score = 0;
|
|
61
|
-
if (prefix === anchor.prefix) score += 2;
|
|
62
|
-
if (suffix === anchor.suffix) score += 2;
|
|
63
|
-
if (anchor.blockIndex === blockIndex) score += 1;
|
|
64
|
-
if (anchor.blockIndex === blockIndex && anchor.start === matchStart) score += 1;
|
|
90
|
+
const matchEnd = matchStart + quote.length;
|
|
65
91
|
candidates.push({
|
|
66
92
|
blockIndex,
|
|
67
93
|
start: matchStart,
|
|
68
|
-
end:
|
|
69
|
-
|
|
70
|
-
score,
|
|
94
|
+
end: matchEnd,
|
|
95
|
+
score: contextScore(anchor, blockIndex, block, matchStart, matchEnd),
|
|
71
96
|
});
|
|
72
97
|
}
|
|
73
98
|
});
|
|
74
99
|
return candidates;
|
|
75
100
|
}
|
|
76
101
|
|
|
102
|
+
/**
|
|
103
|
+
* How much a full context agreement (0-6) may nudge the fuzzy ranking. Kept far
|
|
104
|
+
* below any real similarity gap so text quality always leads and context only
|
|
105
|
+
* breaks near-ties - a stale position hint must never pull the anchor onto a
|
|
106
|
+
* meaningfully less-similar passage.
|
|
107
|
+
*/
|
|
108
|
+
const FUZZY_CONTEXT_TIE_BREAK = 0.01;
|
|
109
|
+
|
|
110
|
+
/** The best fuzzy window per block, ranked by similarity with context as a tiebreak. */
|
|
111
|
+
function findFuzzyCandidates(
|
|
112
|
+
anchor: Anchor,
|
|
113
|
+
blocks: Block[],
|
|
114
|
+
needle: string,
|
|
115
|
+
minimumSimilarity: number,
|
|
116
|
+
): Candidate[] {
|
|
117
|
+
const candidates: Candidate[] = [];
|
|
118
|
+
blocks.forEach((block, blockIndex) => {
|
|
119
|
+
const match = fuzzyFindBestMatch(needle, block.text, minimumSimilarity);
|
|
120
|
+
if (match === null) return;
|
|
121
|
+
const score =
|
|
122
|
+
match.similarity +
|
|
123
|
+
contextScore(anchor, blockIndex, block, match.start, match.end) * FUZZY_CONTEXT_TIE_BREAK;
|
|
124
|
+
candidates.push({ blockIndex, start: match.start, end: match.end, score });
|
|
125
|
+
});
|
|
126
|
+
return candidates;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Highest-scoring candidate, or null when there are none. */
|
|
130
|
+
function pickBest(candidates: Candidate[]): Candidate | null {
|
|
131
|
+
if (candidates.length === 0) return null;
|
|
132
|
+
candidates.sort((left, right) => right.score - left.score);
|
|
133
|
+
return candidates[0]!;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function resolved(candidate: Candidate, strategy: AnchorMatchStrategy): ResolvedAnchor {
|
|
137
|
+
return {
|
|
138
|
+
blockIndex: candidate.blockIndex,
|
|
139
|
+
start: candidate.start,
|
|
140
|
+
end: candidate.end,
|
|
141
|
+
approximate: strategy !== "exact",
|
|
142
|
+
strategy,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
77
146
|
/** Resolve an anchor against the current blocks, or null when orphaned. */
|
|
78
147
|
export function resolveAnchor(anchor: Anchor, blocks: Block[]): ResolvedAnchor | null {
|
|
79
148
|
if (anchor.quote === "") return null;
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
if (
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
149
|
+
|
|
150
|
+
const exact = pickBest(findExactCandidates(anchor, blocks, anchor.quote));
|
|
151
|
+
if (exact) return resolved(exact, "exact");
|
|
152
|
+
|
|
153
|
+
const trimmedQuote = anchor.quote.trim();
|
|
154
|
+
if (trimmedQuote !== "" && trimmedQuote !== anchor.quote) {
|
|
155
|
+
const trimmed = pickBest(findExactCandidates(anchor, blocks, trimmedQuote));
|
|
156
|
+
if (trimmed) return resolved(trimmed, "trimmed");
|
|
88
157
|
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
158
|
+
|
|
159
|
+
const normalizedQuote = stripLeadingBlockMarker(anchor.quote).trim();
|
|
160
|
+
if (normalizedQuote !== "" && normalizedQuote !== trimmedQuote) {
|
|
161
|
+
const normalized = pickBest(findExactCandidates(anchor, blocks, normalizedQuote));
|
|
162
|
+
if (normalized) return resolved(normalized, "normalized");
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const fuzzyNeedle = normalizedQuote !== "" ? normalizedQuote : trimmedQuote;
|
|
166
|
+
if (fuzzyNeedle !== "") {
|
|
167
|
+
const fuzzy = pickBest(
|
|
168
|
+
findFuzzyCandidates(anchor, blocks, fuzzyNeedle, FUZZY_MINIMUM_SIMILARITY),
|
|
169
|
+
);
|
|
170
|
+
if (fuzzy) return resolved(fuzzy, "fuzzy");
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return null;
|
|
93
174
|
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { fuzzyFindBestMatch, levenshteinDistance, similarityRatio } from "./fuzzy";
|
|
3
|
+
|
|
4
|
+
describe("levenshteinDistance", () => {
|
|
5
|
+
test("identical strings cost nothing", () => {
|
|
6
|
+
// Arrange / Act / Assert
|
|
7
|
+
expect(levenshteinDistance("anchor", "anchor")).toBe(0);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
test("counts a single substitution", () => {
|
|
11
|
+
// Arrange / Act / Assert
|
|
12
|
+
expect(levenshteinDistance("plan", "plane")).toBe(1);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("empty against non-empty costs the whole length", () => {
|
|
16
|
+
// Arrange / Act / Assert
|
|
17
|
+
expect(levenshteinDistance("", "quote")).toBe(5);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("is symmetric", () => {
|
|
21
|
+
// Arrange / Act / Assert
|
|
22
|
+
expect(levenshteinDistance("kitten", "sitting")).toBe(levenshteinDistance("sitting", "kitten"));
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe("similarityRatio", () => {
|
|
27
|
+
test("two empty strings are identical", () => {
|
|
28
|
+
// Arrange / Act / Assert
|
|
29
|
+
expect(similarityRatio("", "")).toBe(1);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("identical strings score one", () => {
|
|
33
|
+
// Arrange / Act / Assert
|
|
34
|
+
expect(similarityRatio("review", "review")).toBe(1);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("a one-character typo in a long string stays close to one", () => {
|
|
38
|
+
// Arrange
|
|
39
|
+
const original = "demonstration plan";
|
|
40
|
+
|
|
41
|
+
// Act
|
|
42
|
+
const ratio = similarityRatio(original, "demonstration plann");
|
|
43
|
+
|
|
44
|
+
// Assert
|
|
45
|
+
expect(ratio).toBeGreaterThan(0.9);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("unrelated strings score low", () => {
|
|
49
|
+
// Arrange / Act / Assert
|
|
50
|
+
expect(similarityRatio("abcdef", "zyxwvu")).toBeLessThan(0.2);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe("fuzzyFindBestMatch", () => {
|
|
55
|
+
test("finds a lightly edited window and reports its offsets", () => {
|
|
56
|
+
// Arrange
|
|
57
|
+
const haystack = "the quick brown fox jumps";
|
|
58
|
+
|
|
59
|
+
// Act
|
|
60
|
+
const match = fuzzyFindBestMatch("quikc brown", haystack, 0.75)!;
|
|
61
|
+
|
|
62
|
+
// Assert
|
|
63
|
+
expect(match).not.toBeNull();
|
|
64
|
+
expect(haystack.slice(match.start, match.end)).toBe("quick brown");
|
|
65
|
+
expect(match.similarity).toBeGreaterThan(0.75);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("returns null when nothing clears the similarity floor", () => {
|
|
69
|
+
// Arrange / Act
|
|
70
|
+
const match = fuzzyFindBestMatch("completely unrelated", "short text", 0.75);
|
|
71
|
+
|
|
72
|
+
// Assert
|
|
73
|
+
expect(match).toBeNull();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("an empty needle or haystack never matches", () => {
|
|
77
|
+
// Arrange / Act / Assert
|
|
78
|
+
expect(fuzzyFindBestMatch("", "text", 0.75)).toBeNull();
|
|
79
|
+
expect(fuzzyFindBestMatch("text", "", 0.75)).toBeNull();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("ties keep the earliest window", () => {
|
|
83
|
+
// Arrange
|
|
84
|
+
const haystack = "word word";
|
|
85
|
+
|
|
86
|
+
// Act
|
|
87
|
+
const match = fuzzyFindBestMatch("word", haystack, 0.9)!;
|
|
88
|
+
|
|
89
|
+
// Assert
|
|
90
|
+
expect(match.start).toBe(0);
|
|
91
|
+
});
|
|
92
|
+
});
|
package/src/fuzzy.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Approximate string matching for anchor re-binding. The anchor resolver falls
|
|
3
|
+
* back to these when exact and marker-normalized quote lookups both miss - the
|
|
4
|
+
* quoted text is still present but was lightly edited (a word changed, a typo
|
|
5
|
+
* fixed, punctuation adjusted). Everything here is pure and offset-exact so it
|
|
6
|
+
* can be tested in isolation from the resolver.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** A window of the haystack that best matches the needle. */
|
|
10
|
+
export interface FuzzyMatch {
|
|
11
|
+
/** Inclusive start offset into the haystack. */
|
|
12
|
+
start: number;
|
|
13
|
+
/** Exclusive end offset into the haystack. */
|
|
14
|
+
end: number;
|
|
15
|
+
/** Similarity of the matched window to the needle, in [0, 1]. */
|
|
16
|
+
similarity: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Levenshtein edit distance: the fewest single-character insertions, deletions,
|
|
21
|
+
* or substitutions that turn `left` into `right`. Uses two rolling rows, so it
|
|
22
|
+
* costs O(left.length * right.length) time and O(right.length) space.
|
|
23
|
+
*/
|
|
24
|
+
export function levenshteinDistance(left: string, right: string): number {
|
|
25
|
+
if (left === right) return 0;
|
|
26
|
+
if (left.length === 0) return right.length;
|
|
27
|
+
if (right.length === 0) return left.length;
|
|
28
|
+
|
|
29
|
+
let previousRow: number[] = Array.from({ length: right.length + 1 }, (_unused, column) => column);
|
|
30
|
+
let currentRow: number[] = Array.from({ length: right.length + 1 }, () => 0);
|
|
31
|
+
|
|
32
|
+
for (let leftIndex = 1; leftIndex <= left.length; leftIndex++) {
|
|
33
|
+
currentRow[0] = leftIndex;
|
|
34
|
+
for (let rightIndex = 1; rightIndex <= right.length; rightIndex++) {
|
|
35
|
+
const substitutionCost = left[leftIndex - 1] === right[rightIndex - 1] ? 0 : 1;
|
|
36
|
+
currentRow[rightIndex] = Math.min(
|
|
37
|
+
currentRow[rightIndex - 1]! + 1, // insertion
|
|
38
|
+
previousRow[rightIndex]! + 1, // deletion
|
|
39
|
+
previousRow[rightIndex - 1]! + substitutionCost, // substitution
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
const swap = previousRow;
|
|
43
|
+
previousRow = currentRow;
|
|
44
|
+
currentRow = swap;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return previousRow[right.length]!;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Similarity of two strings in [0, 1]: 1 is identical, 0 shares nothing. Derived
|
|
52
|
+
* from the edit distance normalized by the longer string. Two empty strings are
|
|
53
|
+
* defined as identical (1).
|
|
54
|
+
*/
|
|
55
|
+
export function similarityRatio(left: string, right: string): number {
|
|
56
|
+
const longestLength = Math.max(left.length, right.length);
|
|
57
|
+
if (longestLength === 0) return 1;
|
|
58
|
+
return 1 - levenshteinDistance(left, right) / longestLength;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Find the haystack window most similar to the needle, or null when nothing
|
|
63
|
+
* clears `minimumSimilarity`. Windows range around the needle length so the
|
|
64
|
+
* match tolerates a few inserted or deleted characters, not only substitutions.
|
|
65
|
+
* Ties keep the earliest window. Offsets index the haystack directly.
|
|
66
|
+
*/
|
|
67
|
+
export function fuzzyFindBestMatch(
|
|
68
|
+
needle: string,
|
|
69
|
+
haystack: string,
|
|
70
|
+
minimumSimilarity: number,
|
|
71
|
+
): FuzzyMatch | null {
|
|
72
|
+
if (needle === "" || haystack === "") return null;
|
|
73
|
+
|
|
74
|
+
const lengthTolerance = Math.max(2, Math.round(needle.length * 0.25));
|
|
75
|
+
const minimumWindowLength = Math.max(1, needle.length - lengthTolerance);
|
|
76
|
+
const maximumWindowLength = needle.length + lengthTolerance;
|
|
77
|
+
|
|
78
|
+
let bestMatch: FuzzyMatch | null = null;
|
|
79
|
+
for (let start = 0; start < haystack.length; start++) {
|
|
80
|
+
for (
|
|
81
|
+
let windowLength = minimumWindowLength;
|
|
82
|
+
windowLength <= maximumWindowLength && start + windowLength <= haystack.length;
|
|
83
|
+
windowLength++
|
|
84
|
+
) {
|
|
85
|
+
const window = haystack.slice(start, start + windowLength);
|
|
86
|
+
const similarity = similarityRatio(needle, window);
|
|
87
|
+
if (similarity < minimumSimilarity) continue;
|
|
88
|
+
if (bestMatch === null || similarity > bestMatch.similarity) {
|
|
89
|
+
bestMatch = { start, end: start + windowLength, similarity };
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return bestMatch;
|
|
94
|
+
}
|
package/src/index.ts
CHANGED
package/src/markdown.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { parseBlocks, blockToMd, sectionOf } from "./markdown";
|
|
2
|
+
import { parseBlocks, blockToMd, sectionOf, stripLeadingBlockMarker } from "./markdown";
|
|
3
3
|
|
|
4
4
|
const SAMPLE = `# Title
|
|
5
5
|
|
|
@@ -122,3 +122,30 @@ describe("sectionOf", () => {
|
|
|
122
122
|
expect(sectionOf(blocks, paragraphIndex)).toBe("Context");
|
|
123
123
|
});
|
|
124
124
|
});
|
|
125
|
+
|
|
126
|
+
describe("stripLeadingBlockMarker", () => {
|
|
127
|
+
test("strips the marker the parser strips for every marked block kind", () => {
|
|
128
|
+
// Assert
|
|
129
|
+
expect(stripLeadingBlockMarker("# Heading")).toBe("Heading");
|
|
130
|
+
expect(stripLeadingBlockMarker("### Heading")).toBe("Heading");
|
|
131
|
+
expect(stripLeadingBlockMarker("- bullet item")).toBe("bullet item");
|
|
132
|
+
expect(stripLeadingBlockMarker("3. ordered item")).toBe("ordered item");
|
|
133
|
+
expect(stripLeadingBlockMarker("> quoted line")).toBe("quoted line");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("agrees with the text the parser produces", () => {
|
|
137
|
+
// Arrange
|
|
138
|
+
const source = "- This is a dummy plan - confirm the item";
|
|
139
|
+
|
|
140
|
+
// Act
|
|
141
|
+
const [listBlock] = parseBlocks(source);
|
|
142
|
+
|
|
143
|
+
// Assert
|
|
144
|
+
expect(stripLeadingBlockMarker(source)).toBe(listBlock!.text);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("leaves an unmarked line untouched", () => {
|
|
148
|
+
// Assert
|
|
149
|
+
expect(stripLeadingBlockMarker("a plain paragraph")).toBe("a plain paragraph");
|
|
150
|
+
});
|
|
151
|
+
});
|
package/src/markdown.ts
CHANGED
|
@@ -19,16 +19,21 @@ export interface Block {
|
|
|
19
19
|
lineEnd: number;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
/**
|
|
23
|
+
* The leading block markers the parser strips from a line: headings (h1-h3),
|
|
24
|
+
* bullet list, ordered list, and blockquote. Single source of truth so the
|
|
25
|
+
* anchor resolver strips exactly the same set and the two never drift.
|
|
26
|
+
*/
|
|
27
|
+
export const LEADING_BLOCK_MARKER = /^(?:#{1,3} |- |\d+\. |> )/;
|
|
28
|
+
|
|
29
|
+
/** Remove one leading markdown block marker, or return the line unchanged. */
|
|
30
|
+
export function stripLeadingBlockMarker(line: string): string {
|
|
31
|
+
return line.replace(LEADING_BLOCK_MARKER, "");
|
|
32
|
+
}
|
|
33
|
+
|
|
22
34
|
function isMarkerLine(line: string): boolean {
|
|
23
35
|
return (
|
|
24
|
-
line.startsWith("```") ||
|
|
25
|
-
line.startsWith("# ") ||
|
|
26
|
-
line.startsWith("## ") ||
|
|
27
|
-
line.startsWith("### ") ||
|
|
28
|
-
line.startsWith("> ") ||
|
|
29
|
-
line.startsWith("- ") ||
|
|
30
|
-
/^\d+\. /.test(line) ||
|
|
31
|
-
/^(---|\*\*\*|___)\s*$/.test(line)
|
|
36
|
+
line.startsWith("```") || LEADING_BLOCK_MARKER.test(line) || /^(---|\*\*\*|___)\s*$/.test(line)
|
|
32
37
|
);
|
|
33
38
|
}
|
|
34
39
|
|
|
@@ -55,13 +60,28 @@ export function parseBlocks(markdown: string): Block[] {
|
|
|
55
60
|
lineIndex++;
|
|
56
61
|
blocks.push({ kind: "code", text: body.join("\n"), lang, lineStart: start, lineEnd: end });
|
|
57
62
|
} else if (line.startsWith("### ")) {
|
|
58
|
-
blocks.push({
|
|
63
|
+
blocks.push({
|
|
64
|
+
kind: "h3",
|
|
65
|
+
text: stripLeadingBlockMarker(line),
|
|
66
|
+
lineStart: lineIndex,
|
|
67
|
+
lineEnd: lineIndex,
|
|
68
|
+
});
|
|
59
69
|
lineIndex++;
|
|
60
70
|
} else if (line.startsWith("## ")) {
|
|
61
|
-
blocks.push({
|
|
71
|
+
blocks.push({
|
|
72
|
+
kind: "h2",
|
|
73
|
+
text: stripLeadingBlockMarker(line),
|
|
74
|
+
lineStart: lineIndex,
|
|
75
|
+
lineEnd: lineIndex,
|
|
76
|
+
});
|
|
62
77
|
lineIndex++;
|
|
63
78
|
} else if (line.startsWith("# ")) {
|
|
64
|
-
blocks.push({
|
|
79
|
+
blocks.push({
|
|
80
|
+
kind: "h1",
|
|
81
|
+
text: stripLeadingBlockMarker(line),
|
|
82
|
+
lineStart: lineIndex,
|
|
83
|
+
lineEnd: lineIndex,
|
|
84
|
+
});
|
|
65
85
|
lineIndex++;
|
|
66
86
|
} else if (/^(---|\*\*\*|___)\s*$/.test(line)) {
|
|
67
87
|
blocks.push({ kind: "hr", text: "", lineStart: lineIndex, lineEnd: lineIndex });
|
|
@@ -70,7 +90,7 @@ export function parseBlocks(markdown: string): Block[] {
|
|
|
70
90
|
const start = lineIndex;
|
|
71
91
|
const body: string[] = [];
|
|
72
92
|
while (lineIndex < lines.length && lines[lineIndex]!.startsWith("> ")) {
|
|
73
|
-
body.push(lines[lineIndex]
|
|
93
|
+
body.push(stripLeadingBlockMarker(lines[lineIndex]!));
|
|
74
94
|
lineIndex++;
|
|
75
95
|
}
|
|
76
96
|
blocks.push({
|
|
@@ -80,12 +100,17 @@ export function parseBlocks(markdown: string): Block[] {
|
|
|
80
100
|
lineEnd: lineIndex - 1,
|
|
81
101
|
});
|
|
82
102
|
} else if (line.startsWith("- ")) {
|
|
83
|
-
blocks.push({
|
|
103
|
+
blocks.push({
|
|
104
|
+
kind: "li",
|
|
105
|
+
text: stripLeadingBlockMarker(line),
|
|
106
|
+
lineStart: lineIndex,
|
|
107
|
+
lineEnd: lineIndex,
|
|
108
|
+
});
|
|
84
109
|
lineIndex++;
|
|
85
110
|
} else if (/^\d+\. /.test(line)) {
|
|
86
111
|
blocks.push({
|
|
87
112
|
kind: "oli",
|
|
88
|
-
text: line
|
|
113
|
+
text: stripLeadingBlockMarker(line),
|
|
89
114
|
lineStart: lineIndex,
|
|
90
115
|
lineEnd: lineIndex,
|
|
91
116
|
});
|