@cueloop/schema 0.1.0-alpha.15 → 0.1.0-alpha.17
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 +36 -36
- package/src/anchor.ts +20 -19
- package/src/diff.test.ts +16 -16
- package/src/diff.ts +83 -71
- package/src/feedback.test.ts +48 -29
- package/src/feedback.ts +24 -22
- package/src/markdown.test.ts +20 -20
- package/src/markdown.ts +72 -72
- package/src/types.ts +20 -4
- package/src/working-copy.test.ts +21 -21
- package/src/working-copy.ts +5 -5
package/package.json
CHANGED
package/src/anchor.test.ts
CHANGED
|
@@ -15,76 +15,76 @@ 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
24
|
const blocks = blocksOf(DOC);
|
|
25
|
-
const
|
|
26
|
-
const
|
|
27
|
-
expect(
|
|
28
|
-
expect(
|
|
29
|
-
expect(
|
|
25
|
+
const contextBlockIndex = blocks.findIndex((block) => block.text.startsWith("Review sessions"));
|
|
26
|
+
const anchor = makeAnchor(blocks, contextBlockIndex, 0, 15); // "Review sessions"
|
|
27
|
+
expect(anchor.quote).toBe("Review sessions");
|
|
28
|
+
expect(anchor.prefix).toBe("");
|
|
29
|
+
expect(anchor.suffix.startsWith(" currently")).toBe(true);
|
|
30
30
|
});
|
|
31
31
|
|
|
32
32
|
test("resolves at the recorded position when unchanged", () => {
|
|
33
33
|
const blocks = blocksOf(DOC);
|
|
34
|
-
const
|
|
35
|
-
const
|
|
36
|
-
const
|
|
37
|
-
expect(
|
|
38
|
-
expect(
|
|
39
|
-
expect(
|
|
34
|
+
const contextBlockIndex = blocks.findIndex((block) => block.text.startsWith("Review sessions"));
|
|
35
|
+
const anchor = makeAnchor(blocks, contextBlockIndex, 7, 15); // "sessions"
|
|
36
|
+
const resolved = resolveAnchor(anchor, blocks)!;
|
|
37
|
+
expect(resolved.blockIndex).toBe(contextBlockIndex);
|
|
38
|
+
expect(resolved.start).toBe(7);
|
|
39
|
+
expect(resolved.approximate).toBe(false);
|
|
40
40
|
});
|
|
41
41
|
|
|
42
42
|
test("survives text changes before the quote (quote search)", () => {
|
|
43
43
|
const blocks = blocksOf(DOC);
|
|
44
|
-
const
|
|
45
|
-
const
|
|
44
|
+
const contextBlockIndex = blocks.findIndex((block) => block.text.startsWith("Review sessions"));
|
|
45
|
+
const anchor = makeAnchor(blocks, contextBlockIndex, 0, 15);
|
|
46
46
|
const edited = blocksOf(DOC.replace("Review sessions currently", "PREFIX. Review sessions now"));
|
|
47
|
-
const
|
|
48
|
-
const
|
|
49
|
-
expect(
|
|
50
|
-
expect(edited[
|
|
47
|
+
const resolved = resolveAnchor(anchor, edited)!;
|
|
48
|
+
const editedBlockIndex = edited.findIndex((block) => block.text.includes("Review sessions"));
|
|
49
|
+
expect(resolved.blockIndex).toBe(editedBlockIndex);
|
|
50
|
+
expect(edited[resolved.blockIndex]!.text.slice(resolved.start, resolved.end)).toBe("Review sessions");
|
|
51
51
|
});
|
|
52
52
|
|
|
53
53
|
test("prefix/suffix selectors disambiguate repeated quotes", () => {
|
|
54
54
|
const blocks = blocksOf(DOC);
|
|
55
|
-
const
|
|
55
|
+
const storageBlockIndex = blocks.findIndex((block) => block.text.includes("atomically"));
|
|
56
56
|
// anchor on the SECOND "Sessions are written" occurrence
|
|
57
|
-
const text = blocks[
|
|
57
|
+
const text = blocks[storageBlockIndex]!.text;
|
|
58
58
|
const second = text.indexOf("Sessions are written", text.indexOf("Sessions are written") + 1);
|
|
59
|
-
const
|
|
60
|
-
const
|
|
61
|
-
expect(
|
|
59
|
+
const anchor = makeAnchor(blocks, storageBlockIndex, second, second + "Sessions are written".length);
|
|
60
|
+
const resolved = resolveAnchor(anchor, blocks)!;
|
|
61
|
+
expect(resolved.start).toBe(second);
|
|
62
62
|
});
|
|
63
63
|
|
|
64
64
|
test("position hint breaks ties when context also repeats", () => {
|
|
65
|
-
const
|
|
66
|
-
const blocks = blocksOf(
|
|
67
|
-
const
|
|
68
|
-
const
|
|
69
|
-
expect(
|
|
65
|
+
const repeatedWords = "word word word";
|
|
66
|
+
const blocks = blocksOf(repeatedWords);
|
|
67
|
+
const anchor = makeAnchor(blocks, 0, 5, 9); // middle "word"
|
|
68
|
+
const resolved = resolveAnchor(anchor, blocks)!;
|
|
69
|
+
expect(resolved.start).toBe(5);
|
|
70
70
|
});
|
|
71
71
|
|
|
72
72
|
test("trimmed-quote fallback marks the resolution approximate", () => {
|
|
73
73
|
const blocks = blocksOf("some text here");
|
|
74
|
-
const
|
|
74
|
+
const anchor = { quote: " text ", prefix: "some", suffix: "here", blockIndex: 0, start: 4, end: 10 };
|
|
75
75
|
const edited = blocksOf("some\ntext\nhere");
|
|
76
76
|
// " text " (with spaces) is gone, "text" survives
|
|
77
|
-
const
|
|
78
|
-
expect(
|
|
79
|
-
expect(
|
|
77
|
+
const resolved = resolveAnchor(anchor, edited);
|
|
78
|
+
expect(resolved).not.toBeNull();
|
|
79
|
+
expect(resolved!.approximate).toBe(true);
|
|
80
80
|
});
|
|
81
81
|
|
|
82
82
|
test("orphans when the quote is gone entirely", () => {
|
|
83
83
|
const blocks = blocksOf(DOC);
|
|
84
|
-
const
|
|
85
|
-
const
|
|
84
|
+
const contextBlockIndex = blocks.findIndex((block) => block.text.startsWith("Review sessions"));
|
|
85
|
+
const anchor = makeAnchor(blocks, contextBlockIndex, 0, 15);
|
|
86
86
|
const edited = blocksOf(DOC.replace(/Review sessions[^\n]*\n[^\n]*/, "Completely different text."));
|
|
87
|
-
expect(resolveAnchor(
|
|
87
|
+
expect(resolveAnchor(anchor, edited)).toBeNull();
|
|
88
88
|
});
|
|
89
89
|
|
|
90
90
|
test("empty quote never binds", () => {
|
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;
|
|
@@ -22,11 +23,11 @@ export interface ResolvedAnchor {
|
|
|
22
23
|
}
|
|
23
24
|
|
|
24
25
|
export function makeAnchor(blocks: Block[], blockIndex: number, start: number, end: number): Anchor {
|
|
25
|
-
const
|
|
26
|
+
const blockText = blocks[blockIndex]?.text ?? "";
|
|
26
27
|
return {
|
|
27
|
-
quote:
|
|
28
|
-
prefix:
|
|
29
|
-
suffix:
|
|
28
|
+
quote: blockText.slice(start, end),
|
|
29
|
+
prefix: blockText.slice(Math.max(0, start - ANCHOR_CONTEXT_CHARS), start),
|
|
30
|
+
suffix: blockText.slice(end, end + ANCHOR_CONTEXT_CHARS),
|
|
30
31
|
blockIndex,
|
|
31
32
|
start,
|
|
32
33
|
end,
|
|
@@ -38,21 +39,21 @@ interface Candidate extends ResolvedAnchor {
|
|
|
38
39
|
}
|
|
39
40
|
|
|
40
41
|
function findCandidates(anchor: Anchor, blocks: Block[], quote: string): Candidate[] {
|
|
41
|
-
const
|
|
42
|
-
blocks.forEach((
|
|
43
|
-
const text =
|
|
44
|
-
for (let
|
|
45
|
-
const
|
|
46
|
-
const
|
|
42
|
+
const candidates: Candidate[] = [];
|
|
43
|
+
blocks.forEach((block, blockIndex) => {
|
|
44
|
+
const text = block.text;
|
|
45
|
+
for (let matchStart = text.indexOf(quote); matchStart !== -1; matchStart = text.indexOf(quote, matchStart + 1)) {
|
|
46
|
+
const prefix = text.slice(Math.max(0, matchStart - ANCHOR_CONTEXT_CHARS), matchStart);
|
|
47
|
+
const suffix = text.slice(matchStart + quote.length, matchStart + quote.length + ANCHOR_CONTEXT_CHARS);
|
|
47
48
|
let score = 0;
|
|
48
|
-
if (
|
|
49
|
-
if (
|
|
49
|
+
if (prefix === anchor.prefix) score += 2;
|
|
50
|
+
if (suffix === anchor.suffix) score += 2;
|
|
50
51
|
if (anchor.blockIndex === blockIndex) score += 1;
|
|
51
|
-
if (anchor.blockIndex === blockIndex && anchor.start ===
|
|
52
|
-
|
|
52
|
+
if (anchor.blockIndex === blockIndex && anchor.start === matchStart) score += 1;
|
|
53
|
+
candidates.push({ blockIndex, start: matchStart, end: matchStart + quote.length, approximate: false, score });
|
|
53
54
|
}
|
|
54
55
|
});
|
|
55
|
-
return
|
|
56
|
+
return candidates;
|
|
56
57
|
}
|
|
57
58
|
|
|
58
59
|
/** Resolve an anchor against the current blocks, or null when orphaned. */
|
|
@@ -68,7 +69,7 @@ export function resolveAnchor(anchor: Anchor, blocks: Block[]): ResolvedAnchor |
|
|
|
68
69
|
}
|
|
69
70
|
}
|
|
70
71
|
if (candidates.length === 0) return null;
|
|
71
|
-
candidates.sort((
|
|
72
|
+
candidates.sort((left, right) => right.score - left.score);
|
|
72
73
|
const best = candidates[0]!;
|
|
73
74
|
return { ...best, approximate };
|
|
74
75
|
}
|
package/src/diff.test.ts
CHANGED
|
@@ -9,10 +9,10 @@ describe("unifiedDiff", () => {
|
|
|
9
9
|
test("produces hunk headers with correct line numbers", () => {
|
|
10
10
|
const oldText = ["l1", "l2", "l3", "l4", "l5", "l6", "l7", "l8"].join("\n");
|
|
11
11
|
const newText = ["l1", "l2", "l3", "CHANGED", "l5", "l6", "l7", "l8"].join("\n");
|
|
12
|
-
const
|
|
13
|
-
expect(
|
|
14
|
-
expect(
|
|
15
|
-
expect(
|
|
12
|
+
const diffLines = unifiedDiff(oldText, newText, 1)!;
|
|
13
|
+
expect(diffLines[0]!.kind).toBe("hunk");
|
|
14
|
+
expect(diffLines[0]!.text).toBe("@@ -3,3 +3,3 @@");
|
|
15
|
+
expect(diffLines.map((line) => line.text)).toEqual([
|
|
16
16
|
"@@ -3,3 +3,3 @@",
|
|
17
17
|
" l3",
|
|
18
18
|
"-l4",
|
|
@@ -26,25 +26,25 @@ describe("unifiedDiff", () => {
|
|
|
26
26
|
const newLines = [...oldLines];
|
|
27
27
|
newLines[1] = "first change";
|
|
28
28
|
newLines[27] = "second change";
|
|
29
|
-
const
|
|
30
|
-
expect(
|
|
29
|
+
const diffLines = unifiedDiff(oldLines.join("\n"), newLines.join("\n"), 2)!;
|
|
30
|
+
expect(diffLines.filter((line) => line.kind === "hunk").length).toBe(2);
|
|
31
31
|
});
|
|
32
32
|
|
|
33
33
|
test("applies cleanly: reconstructing new text from old + diff", () => {
|
|
34
34
|
const oldText = "keep\ndrop\nkeep2\nkeep3";
|
|
35
35
|
const newText = "keep\nadded\nkeep2\nkeep3\ntail";
|
|
36
|
-
const
|
|
36
|
+
const diffLines = unifiedDiff(oldText, newText, 50)!;
|
|
37
37
|
const rebuilt: string[] = [];
|
|
38
|
-
for (const
|
|
39
|
-
if (
|
|
40
|
-
if (
|
|
38
|
+
for (const line of diffLines) {
|
|
39
|
+
if (line.kind === "hunk") continue;
|
|
40
|
+
if (line.kind === "ctx" || line.kind === "add") rebuilt.push(line.text.slice(1));
|
|
41
41
|
}
|
|
42
42
|
expect(rebuilt.join("\n")).toBe(newText);
|
|
43
43
|
});
|
|
44
44
|
|
|
45
45
|
test("unifiedDiffText carries file headers and path", () => {
|
|
46
|
-
const
|
|
47
|
-
expect(
|
|
46
|
+
const diffText = unifiedDiffText("a", "b", "docs/plan.md")!;
|
|
47
|
+
expect(diffText.startsWith("--- a/docs/plan.md\n+++ b/docs/plan.md\n@@")).toBe(true);
|
|
48
48
|
});
|
|
49
49
|
});
|
|
50
50
|
|
|
@@ -57,8 +57,8 @@ describe("editStats", () => {
|
|
|
57
57
|
describe("wordDiff", () => {
|
|
58
58
|
test("preserves whitespace tokens and reconstructs both sides", () => {
|
|
59
59
|
const ops = wordDiff("the quick brown fox", "the slow brown foxes");
|
|
60
|
-
const oldSide = ops.filter((
|
|
61
|
-
const newSide = ops.filter((
|
|
60
|
+
const oldSide = ops.filter((op) => op.kind !== "add").map((op) => op.oldValue).join("");
|
|
61
|
+
const newSide = ops.filter((op) => op.kind !== "del").map((op) => op.newValue).join("");
|
|
62
62
|
expect(oldSide).toBe("the quick brown fox");
|
|
63
63
|
expect(newSide).toBe("the slow brown foxes");
|
|
64
64
|
});
|
|
@@ -66,7 +66,7 @@ describe("wordDiff", () => {
|
|
|
66
66
|
|
|
67
67
|
describe("lcsDiff", () => {
|
|
68
68
|
test("custom equality", () => {
|
|
69
|
-
const ops = lcsDiff([{ v: 1 }, { v: 2 }], [{ v: 2 }, { v: 3 }], (
|
|
70
|
-
expect(ops.map((
|
|
69
|
+
const ops = lcsDiff([{ v: 1 }, { v: 2 }], [{ v: 2 }, { v: 3 }], (oldItem, newItem) => oldItem.v === newItem.v);
|
|
70
|
+
expect(ops.map((op) => op.kind)).toEqual(["del", "ctx", "add"]);
|
|
71
71
|
});
|
|
72
72
|
});
|
package/src/diff.ts
CHANGED
|
@@ -1,52 +1,58 @@
|
|
|
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 }, () => new Array<number>(newCount + 1).fill(0));
|
|
26
|
+
for (let oldIndex = oldCount - 1; oldIndex >= 0; oldIndex--) {
|
|
27
|
+
for (let newIndex = newCount - 1; newIndex >= 0; newIndex--) {
|
|
28
|
+
longestCommon[oldIndex]![newIndex] = equals(oldItems[oldIndex]!, newItems[newIndex]!)
|
|
29
|
+
? longestCommon[oldIndex + 1]![newIndex + 1]! + 1
|
|
30
|
+
: Math.max(longestCommon[oldIndex + 1]![newIndex]!, longestCommon[oldIndex]![newIndex + 1]!);
|
|
25
31
|
}
|
|
26
32
|
}
|
|
27
|
-
const
|
|
28
|
-
let
|
|
29
|
-
let
|
|
30
|
-
while (
|
|
31
|
-
if (
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
} else if (
|
|
36
|
-
|
|
37
|
-
|
|
33
|
+
const ops: DiffOp<T>[] = [];
|
|
34
|
+
let oldIndex = 0;
|
|
35
|
+
let newIndex = 0;
|
|
36
|
+
while (oldIndex < oldCount && newIndex < newCount) {
|
|
37
|
+
if (equals(oldItems[oldIndex]!, newItems[newIndex]!)) {
|
|
38
|
+
ops.push({ kind: "ctx", oldValue: oldItems[oldIndex], newValue: newItems[newIndex] });
|
|
39
|
+
oldIndex++;
|
|
40
|
+
newIndex++;
|
|
41
|
+
} else if (longestCommon[oldIndex + 1]![newIndex]! >= longestCommon[oldIndex]![newIndex + 1]!) {
|
|
42
|
+
ops.push({ kind: "del", oldValue: oldItems[oldIndex] });
|
|
43
|
+
oldIndex++;
|
|
38
44
|
} else {
|
|
39
|
-
|
|
40
|
-
|
|
45
|
+
ops.push({ kind: "add", newValue: newItems[newIndex] });
|
|
46
|
+
newIndex++;
|
|
41
47
|
}
|
|
42
48
|
}
|
|
43
|
-
while (
|
|
44
|
-
while (
|
|
45
|
-
return
|
|
49
|
+
while (oldIndex < oldCount) ops.push({ kind: "del", oldValue: oldItems[oldIndex++] });
|
|
50
|
+
while (newIndex < newCount) ops.push({ kind: "add", newValue: newItems[newIndex++] });
|
|
51
|
+
return ops;
|
|
46
52
|
}
|
|
47
53
|
|
|
48
54
|
export interface UnifiedLine {
|
|
49
|
-
|
|
55
|
+
kind: DiffOpKind | "hunk";
|
|
50
56
|
text: string;
|
|
51
57
|
}
|
|
52
58
|
|
|
@@ -56,58 +62,64 @@ export interface UnifiedLine {
|
|
|
56
62
|
*/
|
|
57
63
|
export function unifiedDiff(oldText: string, newText: string, context = 3): UnifiedLine[] | null {
|
|
58
64
|
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
|
-
|
|
65
|
+
if (!ops.some((op) => op.kind !== "ctx")) return null;
|
|
66
|
+
let oldLineNumber = 1;
|
|
67
|
+
let newLineNumber = 1;
|
|
68
|
+
const rows = ops.map((op) => {
|
|
69
|
+
const row = {
|
|
70
|
+
kind: op.kind,
|
|
71
|
+
text: (op.kind === "add" ? op.newValue : op.oldValue) ?? "",
|
|
72
|
+
oldLine: op.kind !== "add" ? oldLineNumber : null,
|
|
73
|
+
newLine: op.kind !== "del" ? newLineNumber : null,
|
|
68
74
|
};
|
|
69
|
-
if (
|
|
70
|
-
if (
|
|
71
|
-
return
|
|
75
|
+
if (op.kind !== "add") oldLineNumber++;
|
|
76
|
+
if (op.kind !== "del") newLineNumber++;
|
|
77
|
+
return row;
|
|
72
78
|
});
|
|
73
79
|
const keep = new Array<boolean>(rows.length).fill(false);
|
|
74
|
-
rows.forEach((
|
|
75
|
-
if (
|
|
76
|
-
for (
|
|
80
|
+
rows.forEach((row, rowIndex) => {
|
|
81
|
+
if (row.kind !== "ctx") {
|
|
82
|
+
for (
|
|
83
|
+
let keepIndex = Math.max(0, rowIndex - context);
|
|
84
|
+
keepIndex <= Math.min(rows.length - 1, rowIndex + context);
|
|
85
|
+
keepIndex++
|
|
86
|
+
) {
|
|
87
|
+
keep[keepIndex] = true;
|
|
88
|
+
}
|
|
77
89
|
}
|
|
78
90
|
});
|
|
79
91
|
const hunks: { rows: (typeof rows)[number][] }[] = [];
|
|
80
|
-
let
|
|
81
|
-
rows.forEach((
|
|
82
|
-
if (!keep[
|
|
83
|
-
|
|
92
|
+
let openHunk: { rows: (typeof rows)[number][] } | null = null;
|
|
93
|
+
rows.forEach((row, rowIndex) => {
|
|
94
|
+
if (!keep[rowIndex]) {
|
|
95
|
+
openHunk = null;
|
|
84
96
|
return;
|
|
85
97
|
}
|
|
86
|
-
if (!
|
|
87
|
-
|
|
88
|
-
hunks.push(
|
|
98
|
+
if (!openHunk) {
|
|
99
|
+
openHunk = { rows: [] };
|
|
100
|
+
hunks.push(openHunk);
|
|
89
101
|
}
|
|
90
|
-
|
|
102
|
+
openHunk.rows.push(row);
|
|
91
103
|
});
|
|
92
|
-
const
|
|
93
|
-
for (const
|
|
94
|
-
const
|
|
95
|
-
const
|
|
96
|
-
const
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
for (const
|
|
100
|
-
|
|
104
|
+
const lines: UnifiedLine[] = [];
|
|
105
|
+
for (const hunk of hunks) {
|
|
106
|
+
const oldStart = hunk.rows.find((row) => row.oldLine !== null)?.oldLine ?? 1;
|
|
107
|
+
const newStart = hunk.rows.find((row) => row.newLine !== null)?.newLine ?? 1;
|
|
108
|
+
const oldCount = hunk.rows.filter((row) => row.kind !== "add").length;
|
|
109
|
+
const newCount = hunk.rows.filter((row) => row.kind !== "del").length;
|
|
110
|
+
lines.push({ kind: "hunk", text: `@@ -${oldStart},${oldCount} +${newStart},${newCount} @@` });
|
|
111
|
+
for (const row of hunk.rows) {
|
|
112
|
+
lines.push({ kind: row.kind, text: (row.kind === "add" ? "+" : row.kind === "del" ? "-" : " ") + row.text });
|
|
101
113
|
}
|
|
102
114
|
}
|
|
103
|
-
return
|
|
115
|
+
return lines;
|
|
104
116
|
}
|
|
105
117
|
|
|
106
118
|
/** Render a unified diff as text with file headers. */
|
|
107
119
|
export function unifiedDiffText(oldText: string, newText: string, path = "plan.md", context = 3): string | null {
|
|
108
|
-
const
|
|
109
|
-
if (!
|
|
110
|
-
return [`--- a/${path}`, `+++ b/${path}`, ...
|
|
120
|
+
const lines = unifiedDiff(oldText, newText, context);
|
|
121
|
+
if (!lines) return null;
|
|
122
|
+
return [`--- a/${path}`, `+++ b/${path}`, ...lines.map((line) => line.text)].join("\n");
|
|
111
123
|
}
|
|
112
124
|
|
|
113
125
|
export interface EditStats {
|
|
@@ -116,16 +128,16 @@ export interface EditStats {
|
|
|
116
128
|
}
|
|
117
129
|
|
|
118
130
|
export function editStats(oldText: string, newText: string): EditStats {
|
|
119
|
-
const
|
|
120
|
-
if (!
|
|
131
|
+
const lines = unifiedDiff(oldText, newText, 0);
|
|
132
|
+
if (!lines) return { added: 0, removed: 0 };
|
|
121
133
|
return {
|
|
122
|
-
added:
|
|
123
|
-
removed:
|
|
134
|
+
added: lines.filter((line) => line.kind === "add").length,
|
|
135
|
+
removed: lines.filter((line) => line.kind === "del").length,
|
|
124
136
|
};
|
|
125
137
|
}
|
|
126
138
|
|
|
127
139
|
/** Word-level diff ops for tracked-changes rendering. Whitespace-preserving. */
|
|
128
140
|
export function wordDiff(oldText: string, newText: string): DiffOp[] {
|
|
129
|
-
const
|
|
130
|
-
return lcsDiff(
|
|
141
|
+
const tokenize = (text: string) => text.split(/(\s+)/).filter((token) => token !== "");
|
|
142
|
+
return lcsDiff(tokenize(oldText), tokenize(newText));
|
|
131
143
|
}
|
package/src/feedback.test.ts
CHANGED
|
@@ -13,7 +13,7 @@ Review sessions live only in daemon memory today.
|
|
|
13
13
|
Sessions are written as one JSON document per session.
|
|
14
14
|
`;
|
|
15
15
|
|
|
16
|
-
function
|
|
16
|
+
function makeAnnotation(partial: Partial<Annotation> & { anchor: Annotation["anchor"] }): Annotation {
|
|
17
17
|
return {
|
|
18
18
|
id: "a1",
|
|
19
19
|
kind: "comment",
|
|
@@ -25,7 +25,7 @@ function ann(partial: Partial<Annotation> & { anchor: Annotation["anchor"] }): A
|
|
|
25
25
|
|
|
26
26
|
describe("renderFeedback", () => {
|
|
27
27
|
test("plan edits section carries the one unified diff, directive framing first", () => {
|
|
28
|
-
const
|
|
28
|
+
const feedback = renderFeedback({
|
|
29
29
|
verdictKind: "request_changes",
|
|
30
30
|
summary: "Tighten the storage section.",
|
|
31
31
|
artifactContent: PLAN,
|
|
@@ -33,83 +33,102 @@ describe("renderFeedback", () => {
|
|
|
33
33
|
annotations: [],
|
|
34
34
|
planPath: "docs/plan.md",
|
|
35
35
|
});
|
|
36
|
-
expect(
|
|
37
|
-
expect(
|
|
38
|
-
expect(
|
|
39
|
-
expect(
|
|
40
|
-
expect(
|
|
41
|
-
expect(
|
|
42
|
-
expect(
|
|
36
|
+
expect(feedback).toContain("# Review: request changes");
|
|
37
|
+
expect(feedback).toContain("Tighten the storage section.");
|
|
38
|
+
expect(feedback).toContain("## Plan edits");
|
|
39
|
+
expect(feedback).toContain("Apply this exact diff first");
|
|
40
|
+
expect(feedback).toContain("--- a/docs/plan.md");
|
|
41
|
+
expect(feedback).toContain("-Sessions are written as one JSON document per session.");
|
|
42
|
+
expect(feedback).toContain("+Sessions are written as one JSON record per session.");
|
|
43
43
|
});
|
|
44
44
|
|
|
45
45
|
test("annotations are located by quote and section", () => {
|
|
46
|
-
const
|
|
46
|
+
const feedback = renderFeedback({
|
|
47
47
|
verdictKind: "comment",
|
|
48
48
|
summary: "",
|
|
49
49
|
artifactContent: PLAN,
|
|
50
50
|
annotations: [
|
|
51
|
-
|
|
51
|
+
makeAnnotation({
|
|
52
52
|
anchor: { quote: "daemon memory", prefix: "live only in ", suffix: " today." },
|
|
53
53
|
}),
|
|
54
54
|
],
|
|
55
55
|
});
|
|
56
|
-
expect(
|
|
57
|
-
expect(
|
|
58
|
-
expect(
|
|
59
|
-
expect(
|
|
56
|
+
expect(feedback).toContain("## Annotations (1)");
|
|
57
|
+
expect(feedback).toContain("### 1. Comment (§ Context)");
|
|
58
|
+
expect(feedback).toContain("> daemon memory");
|
|
59
|
+
expect(feedback).toContain("Please clarify.");
|
|
60
60
|
});
|
|
61
61
|
|
|
62
62
|
test("suggestions render as replace/with pairs", () => {
|
|
63
|
-
const
|
|
63
|
+
const feedback = renderFeedback({
|
|
64
64
|
verdictKind: "request_changes",
|
|
65
65
|
summary: "",
|
|
66
66
|
artifactContent: PLAN,
|
|
67
67
|
annotations: [
|
|
68
|
-
|
|
68
|
+
makeAnnotation({
|
|
69
69
|
kind: "suggestion",
|
|
70
70
|
body: "one durable JSON record",
|
|
71
71
|
anchor: { quote: "one JSON document", prefix: "written as ", suffix: " per session." },
|
|
72
72
|
}),
|
|
73
73
|
],
|
|
74
74
|
});
|
|
75
|
-
expect(
|
|
76
|
-
expect(
|
|
77
|
-
expect(
|
|
75
|
+
expect(feedback).toContain("### 1. Suggested change (§ Storage)");
|
|
76
|
+
expect(feedback).toContain("Replace:\n> one JSON document");
|
|
77
|
+
expect(feedback).toContain("With:\n> one durable JSON record");
|
|
78
78
|
});
|
|
79
79
|
|
|
80
80
|
test("orphaned anchors are flagged, never dropped", () => {
|
|
81
|
-
const
|
|
81
|
+
const feedback = renderFeedback({
|
|
82
82
|
verdictKind: "comment",
|
|
83
83
|
summary: "",
|
|
84
84
|
artifactContent: PLAN,
|
|
85
|
-
annotations: [
|
|
85
|
+
annotations: [makeAnnotation({ anchor: { quote: "text that is gone", prefix: "", suffix: "" } })],
|
|
86
86
|
});
|
|
87
|
-
expect(
|
|
88
|
-
expect(
|
|
87
|
+
expect(feedback).toContain("[orphaned anchor: the quoted text is no longer present]");
|
|
88
|
+
expect(feedback).toContain("> text that is gone");
|
|
89
89
|
});
|
|
90
90
|
|
|
91
91
|
test("empty review says so explicitly", () => {
|
|
92
|
-
const
|
|
92
|
+
const feedback = renderFeedback({
|
|
93
93
|
verdictKind: "approve",
|
|
94
94
|
summary: "",
|
|
95
95
|
artifactContent: PLAN,
|
|
96
96
|
annotations: [],
|
|
97
97
|
});
|
|
98
|
-
expect(
|
|
98
|
+
expect(feedback).toContain("_No edits or annotations._");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("agent notes never come back as feedback items", () => {
|
|
102
|
+
const note = makeAnnotation({
|
|
103
|
+
kind: "note",
|
|
104
|
+
anchor: { quote: "src/a.ts", prefix: "", suffix: "" },
|
|
105
|
+
body: "The agent's own explanation of the change.",
|
|
106
|
+
});
|
|
107
|
+
const noteOnly = renderFeedback({ verdictKind: "approve", summary: "", artifactContent: PLAN, annotations: [note] });
|
|
108
|
+
expect(noteOnly).toContain("_No edits or annotations._");
|
|
109
|
+
expect(noteOnly).not.toContain("The agent's own explanation of the change.");
|
|
110
|
+
const mixed = renderFeedback({
|
|
111
|
+
verdictKind: "request_changes",
|
|
112
|
+
summary: "",
|
|
113
|
+
artifactContent: PLAN,
|
|
114
|
+
annotations: [note, makeAnnotation({ anchor: { quote: "one JSON document", prefix: "written as ", suffix: " per session." } })],
|
|
115
|
+
});
|
|
116
|
+
expect(mixed).toContain("## Annotations (1)");
|
|
117
|
+
expect(mixed).not.toContain("The agent's own explanation of the change.");
|
|
99
118
|
});
|
|
100
119
|
|
|
101
120
|
test("anchors resolve against the working copy when present", () => {
|
|
102
121
|
// annotation made on text that only exists in the edited copy
|
|
103
122
|
const working = PLAN.replace("## Storage", "## Persistence");
|
|
104
|
-
const
|
|
123
|
+
const feedback = renderFeedback({
|
|
105
124
|
verdictKind: "request_changes",
|
|
106
125
|
summary: "",
|
|
107
126
|
artifactContent: PLAN,
|
|
108
127
|
workingCopy: working,
|
|
109
128
|
annotations: [
|
|
110
|
-
|
|
129
|
+
makeAnnotation({ anchor: { quote: "one JSON document", prefix: "written as ", suffix: " per session." } }),
|
|
111
130
|
],
|
|
112
131
|
});
|
|
113
|
-
expect(
|
|
132
|
+
expect(feedback).toContain("(§ Persistence)");
|
|
114
133
|
});
|
|
115
134
|
});
|
package/src/feedback.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* feedback.md: the ONE structured markdown document the agent receives on
|
|
3
|
-
* resolve
|
|
4
|
-
*
|
|
5
|
-
*
|
|
3
|
+
* resolve. Directive framing - soft phrasing gets ignored by models: the plan
|
|
4
|
+
* diff is applied verbatim first, then every annotation is addressed, located
|
|
5
|
+
* by quoted text.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import type
|
|
8
|
+
import { isAgentNote, type Annotation, type ReviewSession, type VerdictKind } from "./types";
|
|
9
9
|
import { parseBlocks, sectionOf } from "./markdown";
|
|
10
10
|
import { resolveAnchor } from "./anchor";
|
|
11
11
|
import { unifiedDiffText } from "./diff";
|
|
@@ -22,10 +22,12 @@ export interface FeedbackInput {
|
|
|
22
22
|
planPath?: string;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
const quoteLines = (
|
|
25
|
+
const quoteLines = (text: string) => "> " + text.replace(/\n/g, "\n> ");
|
|
26
26
|
|
|
27
27
|
export function renderFeedback(input: FeedbackInput): string {
|
|
28
28
|
const path = input.planPath ?? "plan.md";
|
|
29
|
+
// agent notes are the submitter's own context - never echoed back as feedback
|
|
30
|
+
const annotations = input.annotations.filter((annotation) => !isAgentNote(annotation));
|
|
29
31
|
const blocks = parseBlocks(input.workingCopy ?? input.artifactContent);
|
|
30
32
|
const lines: string[] = [];
|
|
31
33
|
lines.push("# Review: " + input.verdictKind.replace("_", " "));
|
|
@@ -49,35 +51,35 @@ export function renderFeedback(input: FeedbackInput): string {
|
|
|
49
51
|
lines.push("");
|
|
50
52
|
}
|
|
51
53
|
|
|
52
|
-
if (
|
|
53
|
-
lines.push(`## Annotations (${
|
|
54
|
+
if (annotations.length) {
|
|
55
|
+
lines.push(`## Annotations (${annotations.length})`);
|
|
54
56
|
lines.push("");
|
|
55
57
|
lines.push(`Address every item. Locate each one in ${path} by its quoted text.`);
|
|
56
58
|
lines.push("");
|
|
57
|
-
|
|
58
|
-
const
|
|
59
|
-
const
|
|
60
|
-
const
|
|
61
|
-
const
|
|
62
|
-
if (
|
|
63
|
-
lines.push(`### ${
|
|
59
|
+
annotations.forEach((annotation, annotationIndex) => {
|
|
60
|
+
const resolved = resolveAnchor(annotation.anchor, blocks);
|
|
61
|
+
const sectionTitle = resolved ? sectionOf(blocks, resolved.blockIndex) : "";
|
|
62
|
+
const location = sectionTitle ? ` (§ ${sectionTitle})` : "";
|
|
63
|
+
const orphanFlag = resolved === null ? " [orphaned anchor: the quoted text is no longer present]" : "";
|
|
64
|
+
if (annotation.kind === "suggestion") {
|
|
65
|
+
lines.push(`### ${annotationIndex + 1}. Suggested change${location}${orphanFlag}`);
|
|
64
66
|
lines.push("");
|
|
65
67
|
lines.push("Replace:");
|
|
66
|
-
lines.push(quoteLines(
|
|
68
|
+
lines.push(quoteLines(annotation.anchor.quote));
|
|
67
69
|
lines.push("With:");
|
|
68
|
-
lines.push(quoteLines(
|
|
70
|
+
lines.push(quoteLines(annotation.body));
|
|
69
71
|
} else {
|
|
70
|
-
lines.push(`### ${
|
|
72
|
+
lines.push(`### ${annotationIndex + 1}. ${capitalize(annotation.kind)}${location}${orphanFlag}`);
|
|
71
73
|
lines.push("");
|
|
72
|
-
lines.push(quoteLines(
|
|
74
|
+
lines.push(quoteLines(annotation.anchor.quote));
|
|
73
75
|
lines.push("");
|
|
74
|
-
lines.push(
|
|
76
|
+
lines.push(annotation.body);
|
|
75
77
|
}
|
|
76
78
|
lines.push("");
|
|
77
79
|
});
|
|
78
80
|
}
|
|
79
81
|
|
|
80
|
-
if (!diff && !
|
|
82
|
+
if (!diff && !annotations.length) {
|
|
81
83
|
lines.push("_No edits or annotations._");
|
|
82
84
|
lines.push("");
|
|
83
85
|
}
|
|
@@ -95,6 +97,6 @@ export function feedbackForSession(session: ReviewSession, verdictKind: VerdictK
|
|
|
95
97
|
});
|
|
96
98
|
}
|
|
97
99
|
|
|
98
|
-
function capitalize(
|
|
99
|
-
return
|
|
100
|
+
function capitalize(word: string): string {
|
|
101
|
+
return word.charAt(0).toUpperCase() + word.slice(1);
|
|
100
102
|
}
|
package/src/markdown.test.ts
CHANGED
|
@@ -30,7 +30,7 @@ const y = 2;
|
|
|
30
30
|
describe("parseBlocks", () => {
|
|
31
31
|
test("parses every block kind with source line ranges", () => {
|
|
32
32
|
const blocks = parseBlocks(SAMPLE);
|
|
33
|
-
expect(blocks.map((
|
|
33
|
+
expect(blocks.map((block) => block.kind)).toEqual([
|
|
34
34
|
"h1",
|
|
35
35
|
"h2",
|
|
36
36
|
"p",
|
|
@@ -43,7 +43,7 @@ describe("parseBlocks", () => {
|
|
|
43
43
|
"quote",
|
|
44
44
|
"hr",
|
|
45
45
|
]);
|
|
46
|
-
const code = blocks.find((
|
|
46
|
+
const code = blocks.find((block) => block.kind === "code")!;
|
|
47
47
|
expect(code.lang).toBe("ts");
|
|
48
48
|
expect(code.text).toBe("const x = 1;\nconst y = 2;");
|
|
49
49
|
// fence lines included in the source range
|
|
@@ -52,49 +52,49 @@ describe("parseBlocks", () => {
|
|
|
52
52
|
|
|
53
53
|
test("multi-line paragraph keeps its text and range", () => {
|
|
54
54
|
const blocks = parseBlocks(SAMPLE);
|
|
55
|
-
const
|
|
56
|
-
expect(
|
|
57
|
-
expect(
|
|
55
|
+
const paragraph = blocks.find((block) => block.kind === "p")!;
|
|
56
|
+
expect(paragraph.text).toBe("A paragraph that\nspans two lines.");
|
|
57
|
+
expect(paragraph.lineEnd - paragraph.lineStart).toBe(1);
|
|
58
58
|
});
|
|
59
59
|
|
|
60
60
|
test("line ranges index into the source", () => {
|
|
61
61
|
const lines = SAMPLE.split("\n");
|
|
62
|
-
for (const
|
|
63
|
-
if (
|
|
64
|
-
expect(lines[
|
|
65
|
-
} else if (
|
|
66
|
-
expect(lines[
|
|
62
|
+
for (const block of parseBlocks(SAMPLE)) {
|
|
63
|
+
if (block.kind === "code") {
|
|
64
|
+
expect(lines[block.lineStart]!.startsWith("```")).toBe(true);
|
|
65
|
+
} else if (block.kind === "h2") {
|
|
66
|
+
expect(lines[block.lineStart]!.startsWith("## ")).toBe(true);
|
|
67
67
|
}
|
|
68
68
|
}
|
|
69
69
|
});
|
|
70
70
|
|
|
71
71
|
test("round-trips through blockToMd", () => {
|
|
72
72
|
const blocks = parseBlocks(SAMPLE);
|
|
73
|
-
let
|
|
73
|
+
let orderedItemCount = 0;
|
|
74
74
|
const rebuilt = blocks
|
|
75
|
-
.map((
|
|
76
|
-
|
|
77
|
-
return blockToMd(
|
|
75
|
+
.map((block) => {
|
|
76
|
+
orderedItemCount = block.kind === "oli" ? orderedItemCount + 1 : 0;
|
|
77
|
+
return blockToMd(block, orderedItemCount || 1);
|
|
78
78
|
})
|
|
79
79
|
.join("\n\n");
|
|
80
80
|
// re-parsing the rebuild yields the same kinds and texts
|
|
81
81
|
const again = parseBlocks(rebuilt);
|
|
82
|
-
expect(again.map((
|
|
82
|
+
expect(again.map((block) => [block.kind, block.text])).toEqual(blocks.map((block) => [block.kind, block.text]));
|
|
83
83
|
});
|
|
84
84
|
|
|
85
85
|
test("unknown constructs degrade to paragraphs, no content lost", () => {
|
|
86
86
|
const md = "| a | b |\n|---|---|\n| 1 | 2 |";
|
|
87
87
|
const blocks = parseBlocks(md);
|
|
88
|
-
expect(blocks.map((
|
|
88
|
+
expect(blocks.map((block) => block.text).join("\n")).toContain("| a | b |");
|
|
89
89
|
});
|
|
90
90
|
});
|
|
91
91
|
|
|
92
92
|
describe("sectionOf", () => {
|
|
93
93
|
test("returns the nearest preceding heading", () => {
|
|
94
94
|
const blocks = parseBlocks(SAMPLE);
|
|
95
|
-
const
|
|
96
|
-
expect(sectionOf(blocks,
|
|
97
|
-
const
|
|
98
|
-
expect(sectionOf(blocks,
|
|
95
|
+
const orderedItemIndex = blocks.findIndex((block) => block.kind === "oli");
|
|
96
|
+
expect(sectionOf(blocks, orderedItemIndex)).toBe("Steps");
|
|
97
|
+
const paragraphIndex = blocks.findIndex((block) => block.kind === "p");
|
|
98
|
+
expect(sectionOf(blocks, paragraphIndex)).toBe("Context");
|
|
99
99
|
});
|
|
100
100
|
});
|
package/src/markdown.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Markdown block model
|
|
3
|
-
*
|
|
2
|
+
* Markdown block model: parse source into addressable blocks that record
|
|
3
|
+
* their source line ranges. Rendered-to-source mapping is block+line
|
|
4
4
|
* granular; the quote carries sub-block precision. Round-trip safe for the
|
|
5
5
|
* subset cueloop plans use; unknown constructs fall back to paragraph blocks
|
|
6
6
|
* so no content is ever lost.
|
|
@@ -19,113 +19,113 @@ export interface Block {
|
|
|
19
19
|
lineEnd: number;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
function isMarkerLine(
|
|
22
|
+
function isMarkerLine(line: string): boolean {
|
|
23
23
|
return (
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
/^- /.test(
|
|
30
|
-
/^\d+\. /.test(
|
|
31
|
-
/^(---|\*\*\*|___)\s*$/.test(
|
|
24
|
+
line.startsWith("```") ||
|
|
25
|
+
line.startsWith("# ") ||
|
|
26
|
+
line.startsWith("## ") ||
|
|
27
|
+
line.startsWith("### ") ||
|
|
28
|
+
line.startsWith("> ") ||
|
|
29
|
+
/^- /.test(line) ||
|
|
30
|
+
/^\d+\. /.test(line) ||
|
|
31
|
+
/^(---|\*\*\*|___)\s*$/.test(line)
|
|
32
32
|
);
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
export function parseBlocks(
|
|
36
|
-
const lines =
|
|
35
|
+
export function parseBlocks(markdown: string): Block[] {
|
|
36
|
+
const lines = markdown.split("\n");
|
|
37
37
|
const blocks: Block[] = [];
|
|
38
|
-
let
|
|
39
|
-
while (
|
|
40
|
-
const
|
|
41
|
-
if (
|
|
42
|
-
|
|
38
|
+
let lineIndex = 0;
|
|
39
|
+
while (lineIndex < lines.length) {
|
|
40
|
+
const line = lines[lineIndex]!;
|
|
41
|
+
if (line.trim() === "") {
|
|
42
|
+
lineIndex++;
|
|
43
43
|
continue;
|
|
44
44
|
}
|
|
45
|
-
if (
|
|
46
|
-
const start =
|
|
47
|
-
const lang =
|
|
48
|
-
|
|
45
|
+
if (line.startsWith("```")) {
|
|
46
|
+
const start = lineIndex;
|
|
47
|
+
const lang = line.slice(3).trim() || undefined;
|
|
48
|
+
lineIndex++;
|
|
49
49
|
const body: string[] = [];
|
|
50
|
-
while (
|
|
51
|
-
body.push(lines[
|
|
52
|
-
|
|
50
|
+
while (lineIndex < lines.length && !lines[lineIndex]!.startsWith("```")) {
|
|
51
|
+
body.push(lines[lineIndex]!);
|
|
52
|
+
lineIndex++;
|
|
53
53
|
}
|
|
54
|
-
const end = Math.min(
|
|
55
|
-
|
|
54
|
+
const end = Math.min(lineIndex, lines.length - 1);
|
|
55
|
+
lineIndex++;
|
|
56
56
|
blocks.push({ kind: "code", text: body.join("\n"), lang, lineStart: start, lineEnd: end });
|
|
57
|
-
} else if (
|
|
58
|
-
blocks.push({ kind: "h3", text:
|
|
59
|
-
|
|
60
|
-
} else if (
|
|
61
|
-
blocks.push({ kind: "h2", text:
|
|
62
|
-
|
|
63
|
-
} else if (
|
|
64
|
-
blocks.push({ kind: "h1", text:
|
|
65
|
-
|
|
66
|
-
} else if (/^(---|\*\*\*|___)\s*$/.test(
|
|
67
|
-
blocks.push({ kind: "hr", text: "", lineStart:
|
|
68
|
-
|
|
69
|
-
} else if (
|
|
70
|
-
const start =
|
|
57
|
+
} else if (line.startsWith("### ")) {
|
|
58
|
+
blocks.push({ kind: "h3", text: line.slice(4), lineStart: lineIndex, lineEnd: lineIndex });
|
|
59
|
+
lineIndex++;
|
|
60
|
+
} else if (line.startsWith("## ")) {
|
|
61
|
+
blocks.push({ kind: "h2", text: line.slice(3), lineStart: lineIndex, lineEnd: lineIndex });
|
|
62
|
+
lineIndex++;
|
|
63
|
+
} else if (line.startsWith("# ")) {
|
|
64
|
+
blocks.push({ kind: "h1", text: line.slice(2), lineStart: lineIndex, lineEnd: lineIndex });
|
|
65
|
+
lineIndex++;
|
|
66
|
+
} else if (/^(---|\*\*\*|___)\s*$/.test(line)) {
|
|
67
|
+
blocks.push({ kind: "hr", text: "", lineStart: lineIndex, lineEnd: lineIndex });
|
|
68
|
+
lineIndex++;
|
|
69
|
+
} else if (line.startsWith("> ")) {
|
|
70
|
+
const start = lineIndex;
|
|
71
71
|
const body: string[] = [];
|
|
72
|
-
while (
|
|
73
|
-
body.push(lines[
|
|
74
|
-
|
|
72
|
+
while (lineIndex < lines.length && lines[lineIndex]!.startsWith("> ")) {
|
|
73
|
+
body.push(lines[lineIndex]!.slice(2));
|
|
74
|
+
lineIndex++;
|
|
75
75
|
}
|
|
76
|
-
blocks.push({ kind: "quote", text: body.join("\n"), lineStart: start, lineEnd:
|
|
77
|
-
} else if (/^- /.test(
|
|
78
|
-
blocks.push({ kind: "li", text:
|
|
79
|
-
|
|
80
|
-
} else if (/^\d+\. /.test(
|
|
81
|
-
blocks.push({ kind: "oli", text:
|
|
82
|
-
|
|
76
|
+
blocks.push({ kind: "quote", text: body.join("\n"), lineStart: start, lineEnd: lineIndex - 1 });
|
|
77
|
+
} else if (/^- /.test(line)) {
|
|
78
|
+
blocks.push({ kind: "li", text: line.slice(2), lineStart: lineIndex, lineEnd: lineIndex });
|
|
79
|
+
lineIndex++;
|
|
80
|
+
} else if (/^\d+\. /.test(line)) {
|
|
81
|
+
blocks.push({ kind: "oli", text: line.replace(/^\d+\. /, ""), lineStart: lineIndex, lineEnd: lineIndex });
|
|
82
|
+
lineIndex++;
|
|
83
83
|
} else {
|
|
84
|
-
const start =
|
|
84
|
+
const start = lineIndex;
|
|
85
85
|
const body: string[] = [];
|
|
86
|
-
while (
|
|
87
|
-
body.push(lines[
|
|
88
|
-
|
|
86
|
+
while (lineIndex < lines.length && lines[lineIndex]!.trim() !== "" && !isMarkerLine(lines[lineIndex]!)) {
|
|
87
|
+
body.push(lines[lineIndex]!);
|
|
88
|
+
lineIndex++;
|
|
89
89
|
}
|
|
90
|
-
blocks.push({ kind: "p", text: body.join("\n"), lineStart: start, lineEnd:
|
|
90
|
+
blocks.push({ kind: "p", text: body.join("\n"), lineStart: start, lineEnd: lineIndex - 1 });
|
|
91
91
|
}
|
|
92
92
|
}
|
|
93
93
|
return blocks;
|
|
94
94
|
}
|
|
95
95
|
|
|
96
96
|
/** Serialize one block back to its markdown chunk. */
|
|
97
|
-
export function blockToMd(
|
|
98
|
-
switch (
|
|
97
|
+
export function blockToMd(block: Block, ordinal = 1): string {
|
|
98
|
+
switch (block.kind) {
|
|
99
99
|
case "h1":
|
|
100
|
-
return "# " +
|
|
100
|
+
return "# " + block.text;
|
|
101
101
|
case "h2":
|
|
102
|
-
return "## " +
|
|
102
|
+
return "## " + block.text;
|
|
103
103
|
case "h3":
|
|
104
|
-
return "### " +
|
|
104
|
+
return "### " + block.text;
|
|
105
105
|
case "li":
|
|
106
|
-
return "- " +
|
|
106
|
+
return "- " + block.text;
|
|
107
107
|
case "oli":
|
|
108
|
-
return `${ordinal}. ` +
|
|
108
|
+
return `${ordinal}. ` + block.text;
|
|
109
109
|
case "quote":
|
|
110
|
-
return
|
|
110
|
+
return block.text
|
|
111
111
|
.split("\n")
|
|
112
|
-
.map((
|
|
112
|
+
.map((line) => "> " + line)
|
|
113
113
|
.join("\n");
|
|
114
114
|
case "code":
|
|
115
|
-
return "```" + (
|
|
115
|
+
return "```" + (block.lang ?? "") + "\n" + block.text + "\n```";
|
|
116
116
|
case "hr":
|
|
117
117
|
return "---";
|
|
118
118
|
default:
|
|
119
|
-
return
|
|
119
|
+
return block.text;
|
|
120
120
|
}
|
|
121
121
|
}
|
|
122
122
|
|
|
123
123
|
/** The section (nearest preceding heading) a block belongs to. */
|
|
124
124
|
export function sectionOf(blocks: Block[], index: number): string {
|
|
125
|
-
let
|
|
126
|
-
for (let
|
|
127
|
-
const
|
|
128
|
-
if (
|
|
125
|
+
let sectionTitle = "";
|
|
126
|
+
for (let blockIndex = 0; blockIndex <= index && blockIndex < blocks.length; blockIndex++) {
|
|
127
|
+
const block = blocks[blockIndex]!;
|
|
128
|
+
if (block.kind === "h1" || block.kind === "h2" || block.kind === "h3") sectionTitle = block.text;
|
|
129
129
|
}
|
|
130
|
-
return
|
|
130
|
+
return sectionTitle;
|
|
131
131
|
}
|
package/src/types.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The ReviewSession primitive
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* The ReviewSession primitive. Everything in cueloop renders, annotates, or
|
|
3
|
+
* resolves this one noun. This module is pure data shapes - no IO, no
|
|
4
|
+
* dependencies.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
export const SCHEMA_VERSION = "1";
|
|
@@ -51,9 +51,19 @@ export interface Anchor {
|
|
|
51
51
|
end?: number;
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
/** The annotation kind set is open
|
|
54
|
+
/** The annotation kind set is open; these are the built-ins. */
|
|
55
55
|
export type AnnotationKind = "comment" | "suggestion" | (string & {});
|
|
56
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Agent-authored context, not reviewer feedback: the guided walk's per-file
|
|
59
|
+
* notes (kind "note", anchored by the file path). Excluded from the feedback
|
|
60
|
+
* document and the reviewer's pending counts - an agent must never receive
|
|
61
|
+
* its own notes back as items to address.
|
|
62
|
+
*/
|
|
63
|
+
export function isAgentNote(annotation: Pick<Annotation, "kind">): boolean {
|
|
64
|
+
return annotation.kind === "note";
|
|
65
|
+
}
|
|
66
|
+
|
|
57
67
|
export interface Annotation {
|
|
58
68
|
id: string;
|
|
59
69
|
kind: AnnotationKind;
|
|
@@ -97,6 +107,12 @@ export interface ReviewSession {
|
|
|
97
107
|
* Undefined = no direct edits.
|
|
98
108
|
*/
|
|
99
109
|
workingCopy?: string;
|
|
110
|
+
/**
|
|
111
|
+
* File paths the reviewer marked viewed during the guided walk (diff
|
|
112
|
+
* sessions). Persisting with the session means a resumed review keeps its
|
|
113
|
+
* progress. Undefined = the walk never started.
|
|
114
|
+
*/
|
|
115
|
+
viewedPaths?: string[];
|
|
100
116
|
verdict: Verdict | null;
|
|
101
117
|
status: SessionStatus;
|
|
102
118
|
createdAt: string;
|
package/src/working-copy.test.ts
CHANGED
|
@@ -18,8 +18,8 @@ const y = 2;
|
|
|
18
18
|
\`\`\`
|
|
19
19
|
`;
|
|
20
20
|
|
|
21
|
-
function block(
|
|
22
|
-
return parseBlocks(
|
|
21
|
+
function block(markdown: string, text: string): Block {
|
|
22
|
+
return parseBlocks(markdown).find((block) => block.text.includes(text))!;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
describe("sourceChunk", () => {
|
|
@@ -55,19 +55,19 @@ describe("cutBlock", () => {
|
|
|
55
55
|
|
|
56
56
|
describe("restoreBlock", () => {
|
|
57
57
|
test("restoring the only cut returns undefined - back to pristine", () => {
|
|
58
|
-
const
|
|
59
|
-
const cut = cutBlock(BASE,
|
|
58
|
+
const listItem = block(BASE, "second item");
|
|
59
|
+
const cut = cutBlock(BASE, listItem);
|
|
60
60
|
// the cut block re-enters before the code fence, the next surviving block
|
|
61
|
-
const next = parseBlocks(cut).find((
|
|
62
|
-
const restored = restoreBlock(BASE, cut,
|
|
61
|
+
const next = parseBlocks(cut).find((candidate) => candidate.kind === "code");
|
|
62
|
+
const restored = restoreBlock(BASE, cut, listItem, restoreLine(next, cut.split("\n").length));
|
|
63
63
|
expect(restored).toBeUndefined();
|
|
64
64
|
});
|
|
65
65
|
|
|
66
66
|
test("restore after another edit returns the merged working copy", () => {
|
|
67
|
-
const
|
|
68
|
-
const edited = cutBlock(BASE,
|
|
69
|
-
const next = parseBlocks(edited).find((
|
|
70
|
-
const restored = restoreBlock(BASE, edited,
|
|
67
|
+
const listItem = block(BASE, "second item");
|
|
68
|
+
const edited = cutBlock(BASE, listItem).replace("first item", "first item reworded");
|
|
69
|
+
const next = parseBlocks(edited).find((candidate) => candidate.kind === "code");
|
|
70
|
+
const restored = restoreBlock(BASE, edited, listItem, restoreLine(next, edited.split("\n").length));
|
|
71
71
|
expect(restored).toContain("- second item");
|
|
72
72
|
expect(restored).toContain("first item reworded");
|
|
73
73
|
});
|
|
@@ -80,30 +80,30 @@ describe("restoreBlock", () => {
|
|
|
80
80
|
});
|
|
81
81
|
|
|
82
82
|
test("restore a multi-line block round-trips to pristine", () => {
|
|
83
|
-
const
|
|
84
|
-
const cut = cutBlock(BASE,
|
|
85
|
-
const next = parseBlocks(cut).find((
|
|
86
|
-
const restored = restoreBlock(BASE, cut,
|
|
83
|
+
const paragraph = block(BASE, "spans two lines");
|
|
84
|
+
const cut = cutBlock(BASE, paragraph);
|
|
85
|
+
const next = parseBlocks(cut).find((block) => block.text === "first item");
|
|
86
|
+
const restored = restoreBlock(BASE, cut, paragraph, restoreLine(next, cut.split("\n").length));
|
|
87
87
|
expect(restored).toBeUndefined();
|
|
88
88
|
});
|
|
89
89
|
|
|
90
90
|
test("signature rule ignores blank-line layout but not text changes", () => {
|
|
91
|
-
const
|
|
92
|
-
const cut = cutBlock(BASE,
|
|
93
|
-
const next = parseBlocks(cut).find((
|
|
91
|
+
const listItem = block(BASE, "second item");
|
|
92
|
+
const cut = cutBlock(BASE, listItem);
|
|
93
|
+
const next = parseBlocks(cut).find((candidate) => candidate.kind === "code");
|
|
94
94
|
const line = restoreLine(next, cut.split("\n").length);
|
|
95
95
|
// extra blank lines elsewhere do not block pristine detection
|
|
96
|
-
expect(restoreBlock(BASE, cut.replace("## Context", "## Context\n"),
|
|
96
|
+
expect(restoreBlock(BASE, cut.replace("## Context", "## Context\n"), listItem, line)).toBeUndefined();
|
|
97
97
|
// a real text change keeps the working copy alive
|
|
98
|
-
const changed = restoreBlock(BASE, cut.replace("first", "1st"),
|
|
98
|
+
const changed = restoreBlock(BASE, cut.replace("first", "1st"), listItem, line);
|
|
99
99
|
expect(changed).toContain("1st item");
|
|
100
100
|
});
|
|
101
101
|
});
|
|
102
102
|
|
|
103
103
|
describe("restoreLine", () => {
|
|
104
104
|
test("next surviving block's start, or the line count at the end", () => {
|
|
105
|
-
const
|
|
106
|
-
expect(restoreLine(
|
|
105
|
+
const listItem = block(BASE, "first item");
|
|
106
|
+
expect(restoreLine(listItem, 99)).toBe(listItem.lineStart);
|
|
107
107
|
expect(restoreLine(undefined, 99)).toBe(99);
|
|
108
108
|
});
|
|
109
109
|
});
|
package/src/working-copy.ts
CHANGED
|
@@ -34,7 +34,7 @@ export function restoreLine(next: Block | undefined, workingLineCount: number):
|
|
|
34
34
|
*/
|
|
35
35
|
function signature(text: string): string {
|
|
36
36
|
return parseBlocks(text)
|
|
37
|
-
.map((
|
|
37
|
+
.map((block) => block.kind + " " + block.text)
|
|
38
38
|
.join("\0");
|
|
39
39
|
}
|
|
40
40
|
|
|
@@ -53,9 +53,9 @@ export function restoreBlock(
|
|
|
53
53
|
const lines = working.split("\n");
|
|
54
54
|
const before = lines.slice(0, beforeLine);
|
|
55
55
|
const after = lines.slice(beforeLine);
|
|
56
|
-
const
|
|
57
|
-
if (before.length && before[before.length - 1]!.trim() !== "")
|
|
58
|
-
if (after.length && after[0]!.trim() !== "")
|
|
59
|
-
const restored = [...before, ...
|
|
56
|
+
const insertedLines = sourceChunk(base, block).split("\n");
|
|
57
|
+
if (before.length && before[before.length - 1]!.trim() !== "") insertedLines.unshift("");
|
|
58
|
+
if (after.length && after[0]!.trim() !== "") insertedLines.push("");
|
|
59
|
+
const restored = [...before, ...insertedLines, ...after].join("\n");
|
|
60
60
|
return signature(restored) === signature(base) ? undefined : restored;
|
|
61
61
|
}
|