@cueloop/schema 0.1.0-alpha.4 → 0.1.0-alpha.40
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 +16 -11
- package/src/anchor.test.ts +92 -39
- package/src/anchor.ts +39 -20
- package/src/diff.test.ts +40 -25
- package/src/diff.ts +96 -77
- package/src/feedback.test.ts +127 -39
- package/src/feedback.ts +103 -42
- package/src/herdr.test.ts +50 -0
- package/src/herdr.ts +51 -0
- package/src/index.ts +3 -0
- package/src/markdown.test.ts +44 -20
- package/src/markdown.ts +87 -72
- package/src/participants.test.ts +58 -0
- package/src/participants.ts +36 -0
- package/src/types.test.ts +21 -0
- package/src/types.ts +102 -6
- package/src/working-copy.test.ts +155 -0
- package/src/working-copy.ts +61 -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.40",
|
|
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,16 +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
|
-
"repository": {
|
|
20
|
-
"type": "git",
|
|
21
|
-
"url": "git+https://github.com/mmurakaru/cueloop.git",
|
|
22
|
-
"directory": "packages/schema"
|
|
23
28
|
}
|
|
24
|
-
}
|
|
29
|
+
}
|
package/src/anchor.test.ts
CHANGED
|
@@ -15,80 +15,133 @@ 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
|
+
expect(resolved.blockIndex).toBe(editedBlockIndex);
|
|
67
|
+
expect(edited[resolved.blockIndex]!.text.slice(resolved.start, resolved.end)).toBe(
|
|
68
|
+
"Review sessions",
|
|
69
|
+
);
|
|
51
70
|
});
|
|
52
71
|
|
|
53
72
|
test("prefix/suffix selectors disambiguate repeated quotes", () => {
|
|
73
|
+
// Arrange
|
|
54
74
|
const blocks = blocksOf(DOC);
|
|
55
|
-
const
|
|
75
|
+
const storageBlockIndex = blocks.findIndex((block) => block.text.includes("atomically"));
|
|
56
76
|
// anchor on the SECOND "Sessions are written" occurrence
|
|
57
|
-
const text = blocks[
|
|
77
|
+
const text = blocks[storageBlockIndex]!.text;
|
|
58
78
|
const second = text.indexOf("Sessions are written", text.indexOf("Sessions are written") + 1);
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
79
|
+
const anchor = makeAnchor(
|
|
80
|
+
blocks,
|
|
81
|
+
storageBlockIndex,
|
|
82
|
+
second,
|
|
83
|
+
second + "Sessions are written".length,
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
// Act
|
|
87
|
+
const resolved = resolveAnchor(anchor, blocks)!;
|
|
88
|
+
|
|
89
|
+
// Assert
|
|
90
|
+
expect(resolved.start).toBe(second);
|
|
62
91
|
});
|
|
63
92
|
|
|
64
93
|
test("position hint breaks ties when context also repeats", () => {
|
|
65
|
-
|
|
66
|
-
const
|
|
67
|
-
const
|
|
68
|
-
const
|
|
69
|
-
|
|
94
|
+
// Arrange
|
|
95
|
+
const repeatedWords = "word word word";
|
|
96
|
+
const blocks = blocksOf(repeatedWords);
|
|
97
|
+
const anchor = makeAnchor(blocks, 0, 5, 9); // middle "word"
|
|
98
|
+
|
|
99
|
+
// Act
|
|
100
|
+
const resolved = resolveAnchor(anchor, blocks)!;
|
|
101
|
+
|
|
102
|
+
// Assert
|
|
103
|
+
expect(resolved.start).toBe(5);
|
|
70
104
|
});
|
|
71
105
|
|
|
72
106
|
test("trimmed-quote fallback marks the resolution approximate", () => {
|
|
73
|
-
|
|
74
|
-
const
|
|
107
|
+
// Arrange
|
|
108
|
+
const anchor = {
|
|
109
|
+
quote: " text ",
|
|
110
|
+
prefix: "some",
|
|
111
|
+
suffix: "here",
|
|
112
|
+
blockIndex: 0,
|
|
113
|
+
start: 4,
|
|
114
|
+
end: 10,
|
|
115
|
+
};
|
|
75
116
|
const edited = blocksOf("some\ntext\nhere");
|
|
76
117
|
// " text " (with spaces) is gone, "text" survives
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
118
|
+
|
|
119
|
+
// Act
|
|
120
|
+
const resolved = resolveAnchor(anchor, edited);
|
|
121
|
+
|
|
122
|
+
// Assert
|
|
123
|
+
expect(resolved).not.toBeNull();
|
|
124
|
+
expect(resolved!.approximate).toBe(true);
|
|
80
125
|
});
|
|
81
126
|
|
|
82
127
|
test("orphans when the quote is gone entirely", () => {
|
|
128
|
+
// Arrange
|
|
83
129
|
const blocks = blocksOf(DOC);
|
|
84
|
-
const
|
|
85
|
-
const
|
|
86
|
-
const edited = blocksOf(
|
|
87
|
-
|
|
130
|
+
const contextBlockIndex = blocks.findIndex((block) => block.text.startsWith("Review sessions"));
|
|
131
|
+
const anchor = makeAnchor(blocks, contextBlockIndex, 0, 15);
|
|
132
|
+
const edited = blocksOf(
|
|
133
|
+
DOC.replace(/Review sessions[^\n]*\n[^\n]*/, "Completely different text."),
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
// Assert
|
|
137
|
+
expect(resolveAnchor(anchor, edited)).toBeNull();
|
|
88
138
|
});
|
|
89
139
|
|
|
90
140
|
test("empty quote never binds", () => {
|
|
141
|
+
// Arrange
|
|
91
142
|
const blocks = blocksOf(DOC);
|
|
143
|
+
|
|
144
|
+
// Assert
|
|
92
145
|
expect(resolveAnchor({ quote: "", prefix: "", suffix: "" }, blocks)).toBeNull();
|
|
93
146
|
});
|
|
94
147
|
});
|
package/src/anchor.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Anchor resolution: the quote-primary cascade
|
|
3
|
-
*
|
|
2
|
+
* Anchor resolution: the quote-primary cascade. Anchors resolve against the
|
|
3
|
+
* current block list:
|
|
4
4
|
* 1. exact quote in a block (prefix/suffix selectors break ties,
|
|
5
5
|
* position hint breaks remaining ties)
|
|
6
6
|
* 2. trimmed quote
|
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
import type { Anchor } from "./types";
|
|
12
12
|
import type { Block } from "./markdown";
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
/** Characters of surrounding text captured as prefix/suffix selectors. */
|
|
15
|
+
const ANCHOR_CONTEXT_CHARS = 24;
|
|
15
16
|
|
|
16
17
|
export interface ResolvedAnchor {
|
|
17
18
|
blockIndex: number;
|
|
@@ -21,12 +22,17 @@ export interface ResolvedAnchor {
|
|
|
21
22
|
approximate: boolean;
|
|
22
23
|
}
|
|
23
24
|
|
|
24
|
-
export function makeAnchor(
|
|
25
|
-
|
|
25
|
+
export function makeAnchor(
|
|
26
|
+
blocks: Block[],
|
|
27
|
+
blockIndex: number,
|
|
28
|
+
start: number,
|
|
29
|
+
end: number,
|
|
30
|
+
): Anchor {
|
|
31
|
+
const blockText = blocks[blockIndex]?.text ?? "";
|
|
26
32
|
return {
|
|
27
|
-
quote:
|
|
28
|
-
prefix:
|
|
29
|
-
suffix:
|
|
33
|
+
quote: blockText.slice(start, end),
|
|
34
|
+
prefix: blockText.slice(Math.max(0, start - ANCHOR_CONTEXT_CHARS), start),
|
|
35
|
+
suffix: blockText.slice(end, end + ANCHOR_CONTEXT_CHARS),
|
|
30
36
|
blockIndex,
|
|
31
37
|
start,
|
|
32
38
|
end,
|
|
@@ -38,21 +44,34 @@ interface Candidate extends ResolvedAnchor {
|
|
|
38
44
|
}
|
|
39
45
|
|
|
40
46
|
function findCandidates(anchor: Anchor, blocks: Block[], quote: string): Candidate[] {
|
|
41
|
-
const
|
|
42
|
-
blocks.forEach((
|
|
43
|
-
const text =
|
|
44
|
-
for (
|
|
45
|
-
|
|
46
|
-
|
|
47
|
+
const candidates: Candidate[] = [];
|
|
48
|
+
blocks.forEach((block, blockIndex) => {
|
|
49
|
+
const text = block.text;
|
|
50
|
+
for (
|
|
51
|
+
let matchStart = text.indexOf(quote);
|
|
52
|
+
matchStart !== -1;
|
|
53
|
+
matchStart = text.indexOf(quote, matchStart + 1)
|
|
54
|
+
) {
|
|
55
|
+
const prefix = text.slice(Math.max(0, matchStart - ANCHOR_CONTEXT_CHARS), matchStart);
|
|
56
|
+
const suffix = text.slice(
|
|
57
|
+
matchStart + quote.length,
|
|
58
|
+
matchStart + quote.length + ANCHOR_CONTEXT_CHARS,
|
|
59
|
+
);
|
|
47
60
|
let score = 0;
|
|
48
|
-
if (
|
|
49
|
-
if (
|
|
61
|
+
if (prefix === anchor.prefix) score += 2;
|
|
62
|
+
if (suffix === anchor.suffix) score += 2;
|
|
50
63
|
if (anchor.blockIndex === blockIndex) score += 1;
|
|
51
|
-
if (anchor.blockIndex === blockIndex && anchor.start ===
|
|
52
|
-
|
|
64
|
+
if (anchor.blockIndex === blockIndex && anchor.start === matchStart) score += 1;
|
|
65
|
+
candidates.push({
|
|
66
|
+
blockIndex,
|
|
67
|
+
start: matchStart,
|
|
68
|
+
end: matchStart + quote.length,
|
|
69
|
+
approximate: false,
|
|
70
|
+
score,
|
|
71
|
+
});
|
|
53
72
|
}
|
|
54
73
|
});
|
|
55
|
-
return
|
|
74
|
+
return candidates;
|
|
56
75
|
}
|
|
57
76
|
|
|
58
77
|
/** Resolve an anchor against the current blocks, or null when orphaned. */
|
|
@@ -68,7 +87,7 @@ export function resolveAnchor(anchor: Anchor, blocks: Block[]): ResolvedAnchor |
|
|
|
68
87
|
}
|
|
69
88
|
}
|
|
70
89
|
if (candidates.length === 0) return null;
|
|
71
|
-
candidates.sort((
|
|
90
|
+
candidates.sort((left, right) => right.score - left.score);
|
|
72
91
|
const best = candidates[0]!;
|
|
73
92
|
return { ...best, approximate };
|
|
74
93
|
}
|
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,42 @@ 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];
|
|
27
33
|
newLines[1] = "first change";
|
|
28
34
|
newLines[27] = "second change";
|
|
29
|
-
|
|
30
|
-
|
|
35
|
+
|
|
36
|
+
// Act
|
|
37
|
+
const diffLines = unifiedDiff(oldLines.join("\n"), newLines.join("\n"), 2)!;
|
|
38
|
+
|
|
39
|
+
// Assert
|
|
40
|
+
expect(diffLines.filter((line) => line.kind === "hunk").length).toBe(2);
|
|
31
41
|
});
|
|
32
42
|
|
|
33
43
|
test("applies cleanly: reconstructing new text from old + diff", () => {
|
|
44
|
+
// Arrange
|
|
34
45
|
const oldText = "keep\ndrop\nkeep2\nkeep3";
|
|
35
46
|
const newText = "keep\nadded\nkeep2\nkeep3\ntail";
|
|
36
|
-
|
|
47
|
+
|
|
48
|
+
// Act
|
|
49
|
+
const diffLines = unifiedDiff(oldText, newText, 50)!;
|
|
50
|
+
|
|
51
|
+
// Assert
|
|
37
52
|
const rebuilt: string[] = [];
|
|
38
|
-
for (const
|
|
39
|
-
if (
|
|
40
|
-
if (
|
|
53
|
+
for (const line of diffLines) {
|
|
54
|
+
if (line.kind === "hunk") continue;
|
|
55
|
+
if (line.kind === "ctx" || line.kind === "add") rebuilt.push(line.text.slice(1));
|
|
41
56
|
}
|
|
42
57
|
expect(rebuilt.join("\n")).toBe(newText);
|
|
43
58
|
});
|
|
44
59
|
|
|
45
60
|
test("unifiedDiffText carries file headers and path", () => {
|
|
46
|
-
|
|
47
|
-
|
|
61
|
+
// Act
|
|
62
|
+
const diffText = unifiedDiffText("a", "b", "docs/plan.md")!;
|
|
63
|
+
|
|
64
|
+
// Assert
|
|
65
|
+
expect(diffText.startsWith("--- a/docs/plan.md\n+++ b/docs/plan.md\n@@")).toBe(true);
|
|
48
66
|
});
|
|
49
67
|
});
|
|
50
68
|
|
|
@@ -54,19 +72,16 @@ describe("editStats", () => {
|
|
|
54
72
|
});
|
|
55
73
|
});
|
|
56
74
|
|
|
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
75
|
describe("lcsDiff", () => {
|
|
68
76
|
test("custom equality", () => {
|
|
69
|
-
|
|
70
|
-
|
|
77
|
+
// Act
|
|
78
|
+
const ops = lcsDiff(
|
|
79
|
+
[{ v: 1 }, { v: 2 }],
|
|
80
|
+
[{ v: 2 }, { v: 3 }],
|
|
81
|
+
(oldItem, newItem) => oldItem.v === newItem.v,
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
// Assert
|
|
85
|
+
expect(ops.map((op) => op.kind)).toEqual(["del", "ctx", "add"]);
|
|
71
86
|
});
|
|
72
87
|
});
|
package/src/diff.ts
CHANGED
|
@@ -1,52 +1,63 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Line/token diffing and unified-diff serialization. The working copy
|
|
3
|
-
* serializes as ONE unified diff against the submitted revision
|
|
3
|
+
* serializes as ONE unified diff against the submitted revision.
|
|
4
4
|
* Pure algorithms, no IO.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
export type
|
|
7
|
+
export type DiffOpKind = "ctx" | "del" | "add";
|
|
8
8
|
|
|
9
9
|
export interface DiffOp<T = string> {
|
|
10
|
-
|
|
10
|
+
kind: DiffOpKind;
|
|
11
11
|
/** Present for ctx and del. */
|
|
12
|
-
|
|
12
|
+
oldValue?: T;
|
|
13
13
|
/** Present for ctx and add. */
|
|
14
|
-
|
|
14
|
+
newValue?: T;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
/** LCS diff over arrays. O(n*m) - fine for plan-sized documents. */
|
|
18
|
-
export function lcsDiff<T>(
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
18
|
+
export function lcsDiff<T>(
|
|
19
|
+
oldItems: T[],
|
|
20
|
+
newItems: T[],
|
|
21
|
+
equals: (oldItem: T, newItem: T) => boolean = (oldItem, newItem) => oldItem === newItem,
|
|
22
|
+
): DiffOp<T>[] {
|
|
23
|
+
const oldCount = oldItems.length;
|
|
24
|
+
const newCount = newItems.length;
|
|
25
|
+
const longestCommon: number[][] = Array.from({ length: oldCount + 1 }, () =>
|
|
26
|
+
Array.from({ length: newCount + 1 }, () => 0),
|
|
27
|
+
);
|
|
28
|
+
for (let oldIndex = oldCount - 1; oldIndex >= 0; oldIndex--) {
|
|
29
|
+
for (let newIndex = newCount - 1; newIndex >= 0; newIndex--) {
|
|
30
|
+
longestCommon[oldIndex]![newIndex] = equals(oldItems[oldIndex]!, newItems[newIndex]!)
|
|
31
|
+
? longestCommon[oldIndex + 1]![newIndex + 1]! + 1
|
|
32
|
+
: Math.max(
|
|
33
|
+
longestCommon[oldIndex + 1]![newIndex]!,
|
|
34
|
+
longestCommon[oldIndex]![newIndex + 1]!,
|
|
35
|
+
);
|
|
25
36
|
}
|
|
26
37
|
}
|
|
27
|
-
const
|
|
28
|
-
let
|
|
29
|
-
let
|
|
30
|
-
while (
|
|
31
|
-
if (
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
} else if (
|
|
36
|
-
|
|
37
|
-
|
|
38
|
+
const ops: DiffOp<T>[] = [];
|
|
39
|
+
let oldIndex = 0;
|
|
40
|
+
let newIndex = 0;
|
|
41
|
+
while (oldIndex < oldCount && newIndex < newCount) {
|
|
42
|
+
if (equals(oldItems[oldIndex]!, newItems[newIndex]!)) {
|
|
43
|
+
ops.push({ kind: "ctx", oldValue: oldItems[oldIndex], newValue: newItems[newIndex] });
|
|
44
|
+
oldIndex++;
|
|
45
|
+
newIndex++;
|
|
46
|
+
} else if (longestCommon[oldIndex + 1]![newIndex]! >= longestCommon[oldIndex]![newIndex + 1]!) {
|
|
47
|
+
ops.push({ kind: "del", oldValue: oldItems[oldIndex] });
|
|
48
|
+
oldIndex++;
|
|
38
49
|
} else {
|
|
39
|
-
|
|
40
|
-
|
|
50
|
+
ops.push({ kind: "add", newValue: newItems[newIndex] });
|
|
51
|
+
newIndex++;
|
|
41
52
|
}
|
|
42
53
|
}
|
|
43
|
-
while (
|
|
44
|
-
while (
|
|
45
|
-
return
|
|
54
|
+
while (oldIndex < oldCount) ops.push({ kind: "del", oldValue: oldItems[oldIndex++] });
|
|
55
|
+
while (newIndex < newCount) ops.push({ kind: "add", newValue: newItems[newIndex++] });
|
|
56
|
+
return ops;
|
|
46
57
|
}
|
|
47
58
|
|
|
48
59
|
export interface UnifiedLine {
|
|
49
|
-
|
|
60
|
+
kind: DiffOpKind | "hunk";
|
|
50
61
|
text: string;
|
|
51
62
|
}
|
|
52
63
|
|
|
@@ -56,58 +67,72 @@ export interface UnifiedLine {
|
|
|
56
67
|
*/
|
|
57
68
|
export function unifiedDiff(oldText: string, newText: string, context = 3): UnifiedLine[] | null {
|
|
58
69
|
const ops = lcsDiff(oldText.split("\n"), newText.split("\n"));
|
|
59
|
-
if (!ops.some((
|
|
60
|
-
let
|
|
61
|
-
let
|
|
62
|
-
const rows = ops.map((
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
text: (
|
|
66
|
-
|
|
67
|
-
|
|
70
|
+
if (!ops.some((op) => op.kind !== "ctx")) return null;
|
|
71
|
+
let oldLineNumber = 1;
|
|
72
|
+
let newLineNumber = 1;
|
|
73
|
+
const rows = ops.map((op) => {
|
|
74
|
+
const row = {
|
|
75
|
+
kind: op.kind,
|
|
76
|
+
text: (op.kind === "add" ? op.newValue : op.oldValue) ?? "",
|
|
77
|
+
oldLine: op.kind !== "add" ? oldLineNumber : null,
|
|
78
|
+
newLine: op.kind !== "del" ? newLineNumber : null,
|
|
68
79
|
};
|
|
69
|
-
if (
|
|
70
|
-
if (
|
|
71
|
-
return
|
|
80
|
+
if (op.kind !== "add") oldLineNumber++;
|
|
81
|
+
if (op.kind !== "del") newLineNumber++;
|
|
82
|
+
return row;
|
|
72
83
|
});
|
|
73
|
-
const keep =
|
|
74
|
-
rows.forEach((
|
|
75
|
-
if (
|
|
76
|
-
for (
|
|
84
|
+
const keep = Array.from({ length: rows.length }, () => false);
|
|
85
|
+
rows.forEach((row, rowIndex) => {
|
|
86
|
+
if (row.kind !== "ctx") {
|
|
87
|
+
for (
|
|
88
|
+
let keepIndex = Math.max(0, rowIndex - context);
|
|
89
|
+
keepIndex <= Math.min(rows.length - 1, rowIndex + context);
|
|
90
|
+
keepIndex++
|
|
91
|
+
) {
|
|
92
|
+
keep[keepIndex] = true;
|
|
93
|
+
}
|
|
77
94
|
}
|
|
78
95
|
});
|
|
79
96
|
const hunks: { rows: (typeof rows)[number][] }[] = [];
|
|
80
|
-
let
|
|
81
|
-
rows.forEach((
|
|
82
|
-
if (!keep[
|
|
83
|
-
|
|
97
|
+
let openHunk: { rows: (typeof rows)[number][] } | null = null;
|
|
98
|
+
rows.forEach((row, rowIndex) => {
|
|
99
|
+
if (!keep[rowIndex]) {
|
|
100
|
+
openHunk = null;
|
|
84
101
|
return;
|
|
85
102
|
}
|
|
86
|
-
if (!
|
|
87
|
-
|
|
88
|
-
hunks.push(
|
|
103
|
+
if (!openHunk) {
|
|
104
|
+
openHunk = { rows: [] };
|
|
105
|
+
hunks.push(openHunk);
|
|
89
106
|
}
|
|
90
|
-
|
|
107
|
+
openHunk.rows.push(row);
|
|
91
108
|
});
|
|
92
|
-
const
|
|
93
|
-
for (const
|
|
94
|
-
const
|
|
95
|
-
const
|
|
96
|
-
const
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
for (const
|
|
100
|
-
|
|
109
|
+
const lines: UnifiedLine[] = [];
|
|
110
|
+
for (const hunk of hunks) {
|
|
111
|
+
const oldStart = hunk.rows.find((row) => row.oldLine !== null)?.oldLine ?? 1;
|
|
112
|
+
const newStart = hunk.rows.find((row) => row.newLine !== null)?.newLine ?? 1;
|
|
113
|
+
const oldCount = hunk.rows.filter((row) => row.kind !== "add").length;
|
|
114
|
+
const newCount = hunk.rows.filter((row) => row.kind !== "del").length;
|
|
115
|
+
lines.push({ kind: "hunk", text: `@@ -${oldStart},${oldCount} +${newStart},${newCount} @@` });
|
|
116
|
+
for (const row of hunk.rows) {
|
|
117
|
+
lines.push({
|
|
118
|
+
kind: row.kind,
|
|
119
|
+
text: (row.kind === "add" ? "+" : row.kind === "del" ? "-" : " ") + row.text,
|
|
120
|
+
});
|
|
101
121
|
}
|
|
102
122
|
}
|
|
103
|
-
return
|
|
123
|
+
return lines;
|
|
104
124
|
}
|
|
105
125
|
|
|
106
126
|
/** Render a unified diff as text with file headers. */
|
|
107
|
-
export function unifiedDiffText(
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
127
|
+
export function unifiedDiffText(
|
|
128
|
+
oldText: string,
|
|
129
|
+
newText: string,
|
|
130
|
+
path = "plan.md",
|
|
131
|
+
context = 3,
|
|
132
|
+
): string | null {
|
|
133
|
+
const lines = unifiedDiff(oldText, newText, context);
|
|
134
|
+
if (!lines) return null;
|
|
135
|
+
return [`--- a/${path}`, `+++ b/${path}`, ...lines.map((line) => line.text)].join("\n");
|
|
111
136
|
}
|
|
112
137
|
|
|
113
138
|
export interface EditStats {
|
|
@@ -116,16 +141,10 @@ export interface EditStats {
|
|
|
116
141
|
}
|
|
117
142
|
|
|
118
143
|
export function editStats(oldText: string, newText: string): EditStats {
|
|
119
|
-
const
|
|
120
|
-
if (!
|
|
144
|
+
const lines = unifiedDiff(oldText, newText, 0);
|
|
145
|
+
if (!lines) return { added: 0, removed: 0 };
|
|
121
146
|
return {
|
|
122
|
-
added:
|
|
123
|
-
removed:
|
|
147
|
+
added: lines.filter((line) => line.kind === "add").length,
|
|
148
|
+
removed: lines.filter((line) => line.kind === "del").length,
|
|
124
149
|
};
|
|
125
150
|
}
|
|
126
|
-
|
|
127
|
-
/** Word-level diff ops for tracked-changes rendering. Whitespace-preserving. */
|
|
128
|
-
export function wordDiff(oldText: string, newText: string): DiffOp[] {
|
|
129
|
-
const tok = (s: string) => s.split(/(\s+)/).filter((x) => x !== "");
|
|
130
|
-
return lcsDiff(tok(oldText), tok(newText));
|
|
131
|
-
}
|