@cueloop/schema 0.1.0-alpha.21 → 0.1.0-alpha.22
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 -0
- package/src/diff.test.ts +24 -0
- package/src/feedback.test.ts +25 -0
- package/src/feedback.ts +21 -3
- package/src/herdr.test.ts +3 -0
- package/src/markdown.test.ts +22 -0
- package/src/types.test.ts +3 -0
- package/src/types.ts +24 -0
- package/src/working-copy.test.ts +35 -0
package/package.json
CHANGED
package/src/anchor.test.ts
CHANGED
|
@@ -21,74 +21,110 @@ function blocksOf(markdown: string) {
|
|
|
21
21
|
|
|
22
22
|
describe("anchor cascade", () => {
|
|
23
23
|
test("makeAnchor captures quote + context selectors", () => {
|
|
24
|
+
// Arrange
|
|
24
25
|
const blocks = blocksOf(DOC);
|
|
25
26
|
const contextBlockIndex = blocks.findIndex((block) => block.text.startsWith("Review sessions"));
|
|
27
|
+
|
|
28
|
+
// Act
|
|
26
29
|
const anchor = makeAnchor(blocks, contextBlockIndex, 0, 15); // "Review sessions"
|
|
30
|
+
|
|
31
|
+
// Assert
|
|
27
32
|
expect(anchor.quote).toBe("Review sessions");
|
|
28
33
|
expect(anchor.prefix).toBe("");
|
|
29
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
40
|
const contextBlockIndex = blocks.findIndex((block) => block.text.startsWith("Review sessions"));
|
|
35
41
|
const anchor = makeAnchor(blocks, contextBlockIndex, 7, 15); // "sessions"
|
|
42
|
+
|
|
43
|
+
// Act
|
|
36
44
|
const resolved = resolveAnchor(anchor, blocks)!;
|
|
45
|
+
|
|
46
|
+
// Assert
|
|
37
47
|
expect(resolved.blockIndex).toBe(contextBlockIndex);
|
|
38
48
|
expect(resolved.start).toBe(7);
|
|
39
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
55
|
const contextBlockIndex = blocks.findIndex((block) => block.text.startsWith("Review sessions"));
|
|
45
56
|
const anchor = makeAnchor(blocks, contextBlockIndex, 0, 15);
|
|
46
57
|
const edited = blocksOf(DOC.replace("Review sessions currently", "PREFIX. Review sessions now"));
|
|
58
|
+
|
|
59
|
+
// Act
|
|
47
60
|
const resolved = resolveAnchor(anchor, edited)!;
|
|
61
|
+
|
|
62
|
+
// Assert
|
|
48
63
|
const editedBlockIndex = edited.findIndex((block) => block.text.includes("Review sessions"));
|
|
49
64
|
expect(resolved.blockIndex).toBe(editedBlockIndex);
|
|
50
65
|
expect(edited[resolved.blockIndex]!.text.slice(resolved.start, resolved.end)).toBe("Review sessions");
|
|
51
66
|
});
|
|
52
67
|
|
|
53
68
|
test("prefix/suffix selectors disambiguate repeated quotes", () => {
|
|
69
|
+
// Arrange
|
|
54
70
|
const blocks = blocksOf(DOC);
|
|
55
71
|
const storageBlockIndex = blocks.findIndex((block) => block.text.includes("atomically"));
|
|
56
72
|
// anchor on the SECOND "Sessions are written" occurrence
|
|
57
73
|
const text = blocks[storageBlockIndex]!.text;
|
|
58
74
|
const second = text.indexOf("Sessions are written", text.indexOf("Sessions are written") + 1);
|
|
59
75
|
const anchor = makeAnchor(blocks, storageBlockIndex, second, second + "Sessions are written".length);
|
|
76
|
+
|
|
77
|
+
// Act
|
|
60
78
|
const resolved = resolveAnchor(anchor, blocks)!;
|
|
79
|
+
|
|
80
|
+
// Assert
|
|
61
81
|
expect(resolved.start).toBe(second);
|
|
62
82
|
});
|
|
63
83
|
|
|
64
84
|
test("position hint breaks ties when context also repeats", () => {
|
|
85
|
+
// Arrange
|
|
65
86
|
const repeatedWords = "word word word";
|
|
66
87
|
const blocks = blocksOf(repeatedWords);
|
|
67
88
|
const anchor = makeAnchor(blocks, 0, 5, 9); // middle "word"
|
|
89
|
+
|
|
90
|
+
// Act
|
|
68
91
|
const resolved = resolveAnchor(anchor, blocks)!;
|
|
92
|
+
|
|
93
|
+
// Assert
|
|
69
94
|
expect(resolved.start).toBe(5);
|
|
70
95
|
});
|
|
71
96
|
|
|
72
97
|
test("trimmed-quote fallback marks the resolution approximate", () => {
|
|
98
|
+
// Arrange
|
|
73
99
|
const blocks = blocksOf("some text here");
|
|
74
100
|
const anchor = { quote: " text ", prefix: "some", suffix: "here", blockIndex: 0, start: 4, end: 10 };
|
|
75
101
|
const edited = blocksOf("some\ntext\nhere");
|
|
76
102
|
// " text " (with spaces) is gone, "text" survives
|
|
103
|
+
|
|
104
|
+
// Act
|
|
77
105
|
const resolved = resolveAnchor(anchor, edited);
|
|
106
|
+
|
|
107
|
+
// Assert
|
|
78
108
|
expect(resolved).not.toBeNull();
|
|
79
109
|
expect(resolved!.approximate).toBe(true);
|
|
80
110
|
});
|
|
81
111
|
|
|
82
112
|
test("orphans when the quote is gone entirely", () => {
|
|
113
|
+
// Arrange
|
|
83
114
|
const blocks = blocksOf(DOC);
|
|
84
115
|
const contextBlockIndex = blocks.findIndex((block) => block.text.startsWith("Review sessions"));
|
|
85
116
|
const anchor = makeAnchor(blocks, contextBlockIndex, 0, 15);
|
|
86
117
|
const edited = blocksOf(DOC.replace(/Review sessions[^\n]*\n[^\n]*/, "Completely different text."));
|
|
118
|
+
|
|
119
|
+
// Assert
|
|
87
120
|
expect(resolveAnchor(anchor, edited)).toBeNull();
|
|
88
121
|
});
|
|
89
122
|
|
|
90
123
|
test("empty quote never binds", () => {
|
|
124
|
+
// Arrange
|
|
91
125
|
const blocks = blocksOf(DOC);
|
|
126
|
+
|
|
127
|
+
// Assert
|
|
92
128
|
expect(resolveAnchor({ quote: "", prefix: "", suffix: "" }, blocks)).toBeNull();
|
|
93
129
|
});
|
|
94
130
|
});
|
package/src/diff.test.ts
CHANGED
|
@@ -7,9 +7,14 @@ 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");
|
|
13
|
+
|
|
14
|
+
// Act
|
|
12
15
|
const diffLines = unifiedDiff(oldText, newText, 1)!;
|
|
16
|
+
|
|
17
|
+
// Assert
|
|
13
18
|
expect(diffLines[0]!.kind).toBe("hunk");
|
|
14
19
|
expect(diffLines[0]!.text).toBe("@@ -3,3 +3,3 @@");
|
|
15
20
|
expect(diffLines.map((line) => line.text)).toEqual([
|
|
@@ -22,18 +27,28 @@ 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";
|
|
35
|
+
|
|
36
|
+
// Act
|
|
29
37
|
const diffLines = unifiedDiff(oldLines.join("\n"), newLines.join("\n"), 2)!;
|
|
38
|
+
|
|
39
|
+
// Assert
|
|
30
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";
|
|
47
|
+
|
|
48
|
+
// Act
|
|
36
49
|
const diffLines = unifiedDiff(oldText, newText, 50)!;
|
|
50
|
+
|
|
51
|
+
// Assert
|
|
37
52
|
const rebuilt: string[] = [];
|
|
38
53
|
for (const line of diffLines) {
|
|
39
54
|
if (line.kind === "hunk") continue;
|
|
@@ -43,7 +58,10 @@ describe("unifiedDiff", () => {
|
|
|
43
58
|
});
|
|
44
59
|
|
|
45
60
|
test("unifiedDiffText carries file headers and path", () => {
|
|
61
|
+
// Act
|
|
46
62
|
const diffText = unifiedDiffText("a", "b", "docs/plan.md")!;
|
|
63
|
+
|
|
64
|
+
// Assert
|
|
47
65
|
expect(diffText.startsWith("--- a/docs/plan.md\n+++ b/docs/plan.md\n@@")).toBe(true);
|
|
48
66
|
});
|
|
49
67
|
});
|
|
@@ -56,7 +74,10 @@ describe("editStats", () => {
|
|
|
56
74
|
|
|
57
75
|
describe("wordDiff", () => {
|
|
58
76
|
test("preserves whitespace tokens and reconstructs both sides", () => {
|
|
77
|
+
// Act
|
|
59
78
|
const ops = wordDiff("the quick brown fox", "the slow brown foxes");
|
|
79
|
+
|
|
80
|
+
// Assert
|
|
60
81
|
const oldSide = ops.filter((op) => op.kind !== "add").map((op) => op.oldValue).join("");
|
|
61
82
|
const newSide = ops.filter((op) => op.kind !== "del").map((op) => op.newValue).join("");
|
|
62
83
|
expect(oldSide).toBe("the quick brown fox");
|
|
@@ -66,7 +87,10 @@ describe("wordDiff", () => {
|
|
|
66
87
|
|
|
67
88
|
describe("lcsDiff", () => {
|
|
68
89
|
test("custom equality", () => {
|
|
90
|
+
// Act
|
|
69
91
|
const ops = lcsDiff([{ v: 1 }, { v: 2 }], [{ v: 2 }, { v: 3 }], (oldItem, newItem) => oldItem.v === newItem.v);
|
|
92
|
+
|
|
93
|
+
// Assert
|
|
70
94
|
expect(ops.map((op) => op.kind)).toEqual(["del", "ctx", "add"]);
|
|
71
95
|
});
|
|
72
96
|
});
|
package/src/feedback.test.ts
CHANGED
|
@@ -25,6 +25,7 @@ function makeAnnotation(partial: Partial<Annotation> & { anchor: Annotation["anc
|
|
|
25
25
|
|
|
26
26
|
describe("renderFeedback", () => {
|
|
27
27
|
test("plan edits section carries the one unified diff, directive framing first", () => {
|
|
28
|
+
// Act
|
|
28
29
|
const feedback = renderFeedback({
|
|
29
30
|
verdictKind: "request_changes",
|
|
30
31
|
summary: "Tighten the storage section.",
|
|
@@ -33,6 +34,8 @@ describe("renderFeedback", () => {
|
|
|
33
34
|
annotations: [],
|
|
34
35
|
planPath: "docs/plan.md",
|
|
35
36
|
});
|
|
37
|
+
|
|
38
|
+
// Assert
|
|
36
39
|
expect(feedback).toContain("# Review: request changes");
|
|
37
40
|
expect(feedback).toContain("Tighten the storage section.");
|
|
38
41
|
expect(feedback).toContain("## Plan edits");
|
|
@@ -43,6 +46,7 @@ describe("renderFeedback", () => {
|
|
|
43
46
|
});
|
|
44
47
|
|
|
45
48
|
test("annotations are located by quote and section", () => {
|
|
49
|
+
// Act
|
|
46
50
|
const feedback = renderFeedback({
|
|
47
51
|
verdictKind: "comment",
|
|
48
52
|
summary: "",
|
|
@@ -53,6 +57,8 @@ describe("renderFeedback", () => {
|
|
|
53
57
|
}),
|
|
54
58
|
],
|
|
55
59
|
});
|
|
60
|
+
|
|
61
|
+
// Assert
|
|
56
62
|
expect(feedback).toContain("## Annotations (1)");
|
|
57
63
|
expect(feedback).toContain("### 1. Comment (§ Context)");
|
|
58
64
|
expect(feedback).toContain("> daemon memory");
|
|
@@ -60,6 +66,7 @@ describe("renderFeedback", () => {
|
|
|
60
66
|
});
|
|
61
67
|
|
|
62
68
|
test("suggestions render as replace/with pairs", () => {
|
|
69
|
+
// Act
|
|
63
70
|
const feedback = renderFeedback({
|
|
64
71
|
verdictKind: "request_changes",
|
|
65
72
|
summary: "",
|
|
@@ -72,39 +79,52 @@ describe("renderFeedback", () => {
|
|
|
72
79
|
}),
|
|
73
80
|
],
|
|
74
81
|
});
|
|
82
|
+
|
|
83
|
+
// Assert
|
|
75
84
|
expect(feedback).toContain("### 1. Suggested change (§ Storage)");
|
|
76
85
|
expect(feedback).toContain("Replace:\n> one JSON document");
|
|
77
86
|
expect(feedback).toContain("With:\n> one durable JSON record");
|
|
78
87
|
});
|
|
79
88
|
|
|
80
89
|
test("orphaned anchors are flagged, never dropped", () => {
|
|
90
|
+
// Act
|
|
81
91
|
const feedback = renderFeedback({
|
|
82
92
|
verdictKind: "comment",
|
|
83
93
|
summary: "",
|
|
84
94
|
artifactContent: PLAN,
|
|
85
95
|
annotations: [makeAnnotation({ anchor: { quote: "text that is gone", prefix: "", suffix: "" } })],
|
|
86
96
|
});
|
|
97
|
+
|
|
98
|
+
// Assert
|
|
87
99
|
expect(feedback).toContain("[orphaned anchor: the quoted text is no longer present]");
|
|
88
100
|
expect(feedback).toContain("> text that is gone");
|
|
89
101
|
});
|
|
90
102
|
|
|
91
103
|
test("empty review says so explicitly", () => {
|
|
104
|
+
// Act
|
|
92
105
|
const feedback = renderFeedback({
|
|
93
106
|
verdictKind: "approve",
|
|
94
107
|
summary: "",
|
|
95
108
|
artifactContent: PLAN,
|
|
96
109
|
annotations: [],
|
|
97
110
|
});
|
|
111
|
+
|
|
112
|
+
// Assert
|
|
98
113
|
expect(feedback).toContain("_No edits or annotations._");
|
|
99
114
|
});
|
|
100
115
|
|
|
101
116
|
test("agent notes never come back as feedback items", () => {
|
|
117
|
+
// Arrange
|
|
102
118
|
const note = makeAnnotation({
|
|
103
119
|
kind: "note",
|
|
104
120
|
anchor: { quote: "src/a.ts", prefix: "", suffix: "" },
|
|
105
121
|
body: "The agent's own explanation of the change.",
|
|
106
122
|
});
|
|
123
|
+
|
|
124
|
+
// Act
|
|
107
125
|
const noteOnly = renderFeedback({ verdictKind: "approve", summary: "", artifactContent: PLAN, annotations: [note] });
|
|
126
|
+
|
|
127
|
+
// Assert
|
|
108
128
|
expect(noteOnly).toContain("_No edits or annotations._");
|
|
109
129
|
expect(noteOnly).not.toContain("The agent's own explanation of the change.");
|
|
110
130
|
const mixed = renderFeedback({
|
|
@@ -118,8 +138,11 @@ describe("renderFeedback", () => {
|
|
|
118
138
|
});
|
|
119
139
|
|
|
120
140
|
test("anchors resolve against the working copy when present", () => {
|
|
141
|
+
// Arrange
|
|
121
142
|
// annotation made on text that only exists in the edited copy
|
|
122
143
|
const working = PLAN.replace("## Storage", "## Persistence");
|
|
144
|
+
|
|
145
|
+
// Act
|
|
123
146
|
const feedback = renderFeedback({
|
|
124
147
|
verdictKind: "request_changes",
|
|
125
148
|
summary: "",
|
|
@@ -129,6 +152,8 @@ describe("renderFeedback", () => {
|
|
|
129
152
|
makeAnnotation({ anchor: { quote: "one JSON document", prefix: "written as ", suffix: " per session." } }),
|
|
130
153
|
],
|
|
131
154
|
});
|
|
155
|
+
|
|
156
|
+
// Assert
|
|
132
157
|
expect(feedback).toContain("(§ Persistence)");
|
|
133
158
|
});
|
|
134
159
|
});
|
package/src/feedback.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* by quoted text.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { isAgentNote, type Annotation, type ReviewSession, type VerdictKind } from "./types";
|
|
8
|
+
import { isAddressed, 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";
|
|
@@ -20,14 +20,18 @@ export interface FeedbackInput {
|
|
|
20
20
|
annotations: Annotation[];
|
|
21
21
|
/** Path the agent knows the plan by, for direct reference. */
|
|
22
22
|
planPath?: string;
|
|
23
|
+
/** Session id, so the document can teach the addressed-ids resubmit call. */
|
|
24
|
+
sessionId?: string;
|
|
23
25
|
}
|
|
24
26
|
|
|
25
27
|
const quoteLines = (text: string) => "> " + text.replace(/\n/g, "\n> ");
|
|
26
28
|
|
|
27
29
|
export function renderFeedback(input: FeedbackInput): string {
|
|
28
30
|
const path = input.planPath ?? "plan.md";
|
|
29
|
-
// agent notes are the submitter's own context - never echoed back as
|
|
30
|
-
|
|
31
|
+
// agent notes are the submitter's own context - never echoed back as
|
|
32
|
+
// feedback - and annotations a previous revision already addressed stay out
|
|
33
|
+
// of the next document, so the agent only ever sees the open items
|
|
34
|
+
const annotations = input.annotations.filter((annotation) => !isAgentNote(annotation) && !isAddressed(annotation));
|
|
31
35
|
const blocks = parseBlocks(input.workingCopy ?? input.artifactContent);
|
|
32
36
|
const lines: string[] = [];
|
|
33
37
|
lines.push("# Review: " + input.verdictKind.replace("_", " "));
|
|
@@ -76,7 +80,20 @@ export function renderFeedback(input: FeedbackInput): string {
|
|
|
76
80
|
lines.push(annotation.body);
|
|
77
81
|
}
|
|
78
82
|
lines.push("");
|
|
83
|
+
lines.push(`annotation id: \`${annotation.id}\``);
|
|
84
|
+
lines.push("");
|
|
79
85
|
});
|
|
86
|
+
if (input.sessionId) {
|
|
87
|
+
lines.push("## Reporting what you addressed");
|
|
88
|
+
lines.push("");
|
|
89
|
+
lines.push("When you resubmit the revised plan, list the annotation ids you acted on -");
|
|
90
|
+
lines.push("they are marked addressed for the reviewer and leave the open list:");
|
|
91
|
+
lines.push("");
|
|
92
|
+
lines.push("```sh");
|
|
93
|
+
lines.push(`cueloop session submit-revision ${input.sessionId} --content-file ${path} --addressed <id,id,...>`);
|
|
94
|
+
lines.push("```");
|
|
95
|
+
lines.push("");
|
|
96
|
+
}
|
|
80
97
|
}
|
|
81
98
|
|
|
82
99
|
if (!diff && !annotations.length) {
|
|
@@ -94,6 +111,7 @@ export function feedbackForSession(session: ReviewSession, verdictKind: VerdictK
|
|
|
94
111
|
workingCopy: session.workingCopy,
|
|
95
112
|
annotations: session.annotations,
|
|
96
113
|
planPath: session.artifact.meta.planPath,
|
|
114
|
+
sessionId: session.id,
|
|
97
115
|
});
|
|
98
116
|
}
|
|
99
117
|
|
package/src/herdr.test.ts
CHANGED
|
@@ -26,7 +26,10 @@ describe("returnPaneFor", () => {
|
|
|
26
26
|
});
|
|
27
27
|
|
|
28
28
|
test("env override wins, session meta is the fallback", () => {
|
|
29
|
+
// Arrange
|
|
29
30
|
const inside = { HERDR_ENV: "1", HERDR_PANE_ID: "w1:p9" };
|
|
31
|
+
|
|
32
|
+
// Assert
|
|
30
33
|
expect(returnPaneFor("w1:p1", inside)).toBe("w1:p1");
|
|
31
34
|
expect(returnPaneFor("w1:p1", { ...inside, CUELOOP_RETURN_PANE: "w1:p2" })).toBe("w1:p2");
|
|
32
35
|
});
|
package/src/markdown.test.ts
CHANGED
|
@@ -29,7 +29,10 @@ const y = 2;
|
|
|
29
29
|
|
|
30
30
|
describe("parseBlocks", () => {
|
|
31
31
|
test("parses every block kind with source line ranges", () => {
|
|
32
|
+
// Act
|
|
32
33
|
const blocks = parseBlocks(SAMPLE);
|
|
34
|
+
|
|
35
|
+
// Assert
|
|
33
36
|
expect(blocks.map((block) => block.kind)).toEqual([
|
|
34
37
|
"h1",
|
|
35
38
|
"h2",
|
|
@@ -51,14 +54,20 @@ describe("parseBlocks", () => {
|
|
|
51
54
|
});
|
|
52
55
|
|
|
53
56
|
test("multi-line paragraph keeps its text and range", () => {
|
|
57
|
+
// Arrange
|
|
54
58
|
const blocks = parseBlocks(SAMPLE);
|
|
55
59
|
const paragraph = blocks.find((block) => block.kind === "p")!;
|
|
60
|
+
|
|
61
|
+
// Assert
|
|
56
62
|
expect(paragraph.text).toBe("A paragraph that\nspans two lines.");
|
|
57
63
|
expect(paragraph.lineEnd - paragraph.lineStart).toBe(1);
|
|
58
64
|
});
|
|
59
65
|
|
|
60
66
|
test("line ranges index into the source", () => {
|
|
67
|
+
// Arrange
|
|
61
68
|
const lines = SAMPLE.split("\n");
|
|
69
|
+
|
|
70
|
+
// Assert
|
|
62
71
|
for (const block of parseBlocks(SAMPLE)) {
|
|
63
72
|
if (block.kind === "code") {
|
|
64
73
|
expect(lines[block.lineStart]!.startsWith("```")).toBe(true);
|
|
@@ -69,6 +78,7 @@ describe("parseBlocks", () => {
|
|
|
69
78
|
});
|
|
70
79
|
|
|
71
80
|
test("round-trips through blockToMd", () => {
|
|
81
|
+
// Arrange
|
|
72
82
|
const blocks = parseBlocks(SAMPLE);
|
|
73
83
|
let orderedItemCount = 0;
|
|
74
84
|
const rebuilt = blocks
|
|
@@ -77,21 +87,33 @@ describe("parseBlocks", () => {
|
|
|
77
87
|
return blockToMd(block, orderedItemCount || 1);
|
|
78
88
|
})
|
|
79
89
|
.join("\n\n");
|
|
90
|
+
|
|
91
|
+
// Act
|
|
80
92
|
// re-parsing the rebuild yields the same kinds and texts
|
|
81
93
|
const again = parseBlocks(rebuilt);
|
|
94
|
+
|
|
95
|
+
// Assert
|
|
82
96
|
expect(again.map((block) => [block.kind, block.text])).toEqual(blocks.map((block) => [block.kind, block.text]));
|
|
83
97
|
});
|
|
84
98
|
|
|
85
99
|
test("unknown constructs degrade to paragraphs, no content lost", () => {
|
|
100
|
+
// Arrange
|
|
86
101
|
const md = "| a | b |\n|---|---|\n| 1 | 2 |";
|
|
102
|
+
|
|
103
|
+
// Act
|
|
87
104
|
const blocks = parseBlocks(md);
|
|
105
|
+
|
|
106
|
+
// Assert
|
|
88
107
|
expect(blocks.map((block) => block.text).join("\n")).toContain("| a | b |");
|
|
89
108
|
});
|
|
90
109
|
});
|
|
91
110
|
|
|
92
111
|
describe("sectionOf", () => {
|
|
93
112
|
test("returns the nearest preceding heading", () => {
|
|
113
|
+
// Arrange
|
|
94
114
|
const blocks = parseBlocks(SAMPLE);
|
|
115
|
+
|
|
116
|
+
// Assert
|
|
95
117
|
const orderedItemIndex = blocks.findIndex((block) => block.kind === "oli");
|
|
96
118
|
expect(sectionOf(blocks, orderedItemIndex)).toBe("Steps");
|
|
97
119
|
const paragraphIndex = blocks.findIndex((block) => block.kind === "p");
|
package/src/types.test.ts
CHANGED
|
@@ -11,7 +11,10 @@ describe("verdictAllows", () => {
|
|
|
11
11
|
|
|
12
12
|
describe("newAnnotationId", () => {
|
|
13
13
|
test("unique by construction within a process, even on one millisecond", () => {
|
|
14
|
+
// Act
|
|
14
15
|
const ids = new Set(Array.from({ length: 10_000 }, () => newAnnotationId()));
|
|
16
|
+
|
|
17
|
+
// Assert
|
|
15
18
|
expect(ids.size).toBe(10_000);
|
|
16
19
|
for (const id of ids) expect(id).toMatch(/^a_[0-9a-z]+_[0-9a-z]+$/);
|
|
17
20
|
});
|
package/src/types.ts
CHANGED
|
@@ -72,9 +72,33 @@ export interface Annotation {
|
|
|
72
72
|
body: string;
|
|
73
73
|
/** Set by resolution when the quote can no longer be found. */
|
|
74
74
|
orphan?: boolean;
|
|
75
|
+
/**
|
|
76
|
+
* SSH key fingerprint of a share collaborator who authored this note. Absent
|
|
77
|
+
* on the planner's own annotations; the sharing gateway stamps it so the
|
|
78
|
+
* planner can tell whose note is whose and never overwrite a collaborator's.
|
|
79
|
+
*/
|
|
80
|
+
author?: string;
|
|
81
|
+
/**
|
|
82
|
+
* Set when a revision addressed this annotation: the agent reported the id
|
|
83
|
+
* on resubmit ("agent"), or the quoted text disappeared from the revised
|
|
84
|
+
* plan ("drift"). Addressed annotations leave the default rail view and the
|
|
85
|
+
* next feedback document, but are never deleted.
|
|
86
|
+
*/
|
|
87
|
+
resolution?: AnnotationResolution;
|
|
75
88
|
createdAt: string;
|
|
76
89
|
}
|
|
77
90
|
|
|
91
|
+
export interface AnnotationResolution {
|
|
92
|
+
/** The revision number whose submission addressed this annotation. */
|
|
93
|
+
revision: number;
|
|
94
|
+
source: "agent" | "drift";
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** An annotation a revision has addressed; it leaves the default views. */
|
|
98
|
+
export function isAddressed(annotation: Annotation): boolean {
|
|
99
|
+
return annotation.resolution !== undefined;
|
|
100
|
+
}
|
|
101
|
+
|
|
78
102
|
export type VerdictKind = "comment" | "approve" | "request_changes";
|
|
79
103
|
|
|
80
104
|
export interface Verdict {
|
package/src/working-copy.test.ts
CHANGED
|
@@ -35,19 +35,28 @@ describe("sourceChunk", () => {
|
|
|
35
35
|
|
|
36
36
|
describe("cutBlock", () => {
|
|
37
37
|
test("removes the block lines and the blank line before it", () => {
|
|
38
|
+
// Act
|
|
38
39
|
const cut = cutBlock(BASE, block(BASE, "spans two lines"));
|
|
40
|
+
|
|
41
|
+
// Assert
|
|
39
42
|
expect(cut).not.toContain("spans two lines");
|
|
40
43
|
expect(cut).toContain("## Context\n\n- first item");
|
|
41
44
|
});
|
|
42
45
|
|
|
43
46
|
test("cut at the start of the document leaves the leading blank line", () => {
|
|
47
|
+
// Act
|
|
44
48
|
const cut = cutBlock(BASE, block(BASE, "Plan"));
|
|
49
|
+
|
|
50
|
+
// Assert
|
|
45
51
|
expect(cut).not.toContain("# Plan");
|
|
46
52
|
expect(cut.trimStart().startsWith("## Context")).toBe(true);
|
|
47
53
|
});
|
|
48
54
|
|
|
49
55
|
test("cut at the end of the document keeps the rest intact", () => {
|
|
56
|
+
// Act
|
|
50
57
|
const cut = cutBlock(BASE, block(BASE, "const x"));
|
|
58
|
+
|
|
59
|
+
// Assert
|
|
51
60
|
expect(cut).not.toContain("const x");
|
|
52
61
|
expect(cut).toContain("- second item");
|
|
53
62
|
});
|
|
@@ -55,43 +64,66 @@ describe("cutBlock", () => {
|
|
|
55
64
|
|
|
56
65
|
describe("restoreBlock", () => {
|
|
57
66
|
test("restoring the only cut returns undefined - back to pristine", () => {
|
|
67
|
+
// Arrange
|
|
58
68
|
const listItem = block(BASE, "second item");
|
|
59
69
|
const cut = cutBlock(BASE, listItem);
|
|
60
70
|
// the cut block re-enters before the code fence, the next surviving block
|
|
61
71
|
const next = parseBlocks(cut).find((candidate) => candidate.kind === "code");
|
|
72
|
+
|
|
73
|
+
// Act
|
|
62
74
|
const restored = restoreBlock(BASE, cut, listItem, restoreLine(next, cut.split("\n").length));
|
|
75
|
+
|
|
76
|
+
// Assert
|
|
63
77
|
expect(restored).toBeUndefined();
|
|
64
78
|
});
|
|
65
79
|
|
|
66
80
|
test("restore after another edit returns the merged working copy", () => {
|
|
81
|
+
// Arrange
|
|
67
82
|
const listItem = block(BASE, "second item");
|
|
68
83
|
const edited = cutBlock(BASE, listItem).replace("first item", "first item reworded");
|
|
69
84
|
const next = parseBlocks(edited).find((candidate) => candidate.kind === "code");
|
|
85
|
+
|
|
86
|
+
// Act
|
|
70
87
|
const restored = restoreBlock(BASE, edited, listItem, restoreLine(next, edited.split("\n").length));
|
|
88
|
+
|
|
89
|
+
// Assert
|
|
71
90
|
expect(restored).toContain("- second item");
|
|
72
91
|
expect(restored).toContain("first item reworded");
|
|
73
92
|
});
|
|
74
93
|
|
|
75
94
|
test("restore at the end of the document (no next block)", () => {
|
|
95
|
+
// Arrange
|
|
76
96
|
const code = block(BASE, "const x");
|
|
77
97
|
const cut = cutBlock(BASE, code);
|
|
98
|
+
|
|
99
|
+
// Act
|
|
78
100
|
const restored = restoreBlock(BASE, cut, code, restoreLine(undefined, cut.split("\n").length));
|
|
101
|
+
|
|
102
|
+
// Assert
|
|
79
103
|
expect(restored).toBeUndefined();
|
|
80
104
|
});
|
|
81
105
|
|
|
82
106
|
test("restore a multi-line block round-trips to pristine", () => {
|
|
107
|
+
// Arrange
|
|
83
108
|
const paragraph = block(BASE, "spans two lines");
|
|
84
109
|
const cut = cutBlock(BASE, paragraph);
|
|
85
110
|
const next = parseBlocks(cut).find((block) => block.text === "first item");
|
|
111
|
+
|
|
112
|
+
// Act
|
|
86
113
|
const restored = restoreBlock(BASE, cut, paragraph, restoreLine(next, cut.split("\n").length));
|
|
114
|
+
|
|
115
|
+
// Assert
|
|
87
116
|
expect(restored).toBeUndefined();
|
|
88
117
|
});
|
|
89
118
|
|
|
90
119
|
test("signature rule ignores blank-line layout but not text changes", () => {
|
|
120
|
+
// Arrange
|
|
91
121
|
const listItem = block(BASE, "second item");
|
|
92
122
|
const cut = cutBlock(BASE, listItem);
|
|
93
123
|
const next = parseBlocks(cut).find((candidate) => candidate.kind === "code");
|
|
94
124
|
const line = restoreLine(next, cut.split("\n").length);
|
|
125
|
+
|
|
126
|
+
// Assert
|
|
95
127
|
// extra blank lines elsewhere do not block pristine detection
|
|
96
128
|
expect(restoreBlock(BASE, cut.replace("## Context", "## Context\n"), listItem, line)).toBeUndefined();
|
|
97
129
|
// a real text change keeps the working copy alive
|
|
@@ -102,7 +134,10 @@ describe("restoreBlock", () => {
|
|
|
102
134
|
|
|
103
135
|
describe("restoreLine", () => {
|
|
104
136
|
test("next surviving block's start, or the line count at the end", () => {
|
|
137
|
+
// Arrange
|
|
105
138
|
const listItem = block(BASE, "first item");
|
|
139
|
+
|
|
140
|
+
// Assert
|
|
106
141
|
expect(restoreLine(listItem, 99)).toBe(listItem.lineStart);
|
|
107
142
|
expect(restoreLine(undefined, 99)).toBe(99);
|
|
108
143
|
});
|