@cueloop/schema 0.1.0-alpha.5 → 0.1.0-alpha.7
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/index.ts +1 -0
- package/src/types.test.ts +18 -0
- package/src/types.ts +12 -0
- package/src/working-copy.test.ts +109 -0
- package/src/working-copy.ts +61 -0
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { newAnnotationId, verdictAllows } from "./types";
|
|
3
|
+
|
|
4
|
+
describe("verdictAllows", () => {
|
|
5
|
+
test("only approve maps to allow", () => {
|
|
6
|
+
expect(verdictAllows("approve")).toBe(true);
|
|
7
|
+
expect(verdictAllows("comment")).toBe(false);
|
|
8
|
+
expect(verdictAllows("request_changes")).toBe(false);
|
|
9
|
+
});
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
describe("newAnnotationId", () => {
|
|
13
|
+
test("unique by construction within a process, even on one millisecond", () => {
|
|
14
|
+
const ids = new Set(Array.from({ length: 10_000 }, () => newAnnotationId()));
|
|
15
|
+
expect(ids.size).toBe(10_000);
|
|
16
|
+
for (const id of ids) expect(id).toMatch(/^a_[0-9a-z]+_[0-9a-z]+$/);
|
|
17
|
+
});
|
|
18
|
+
});
|
package/src/types.ts
CHANGED
|
@@ -23,6 +23,8 @@ export interface ArtifactMeta {
|
|
|
23
23
|
planPath?: string;
|
|
24
24
|
/** Pull request reference the diff came from, so the verdict can be posted back. */
|
|
25
25
|
pr?: string;
|
|
26
|
+
/** herdr pane the submitting agent runs in - the review returns focus there. */
|
|
27
|
+
herdrPane?: string;
|
|
26
28
|
title?: string;
|
|
27
29
|
}
|
|
28
30
|
|
|
@@ -104,3 +106,13 @@ export interface ReviewSession {
|
|
|
104
106
|
export function verdictAllows(kind: VerdictKind): boolean {
|
|
105
107
|
return kind === "approve";
|
|
106
108
|
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Annotation ids are minted client-side. The counter makes ids unique within
|
|
112
|
+
* a process by construction; the random suffix separates concurrent
|
|
113
|
+
* annotators in different processes on the same millisecond.
|
|
114
|
+
*/
|
|
115
|
+
let annotationSeq = 0;
|
|
116
|
+
export function newAnnotationId(): string {
|
|
117
|
+
return `a_${Date.now().toString(36)}${(annotationSeq++).toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
|
|
118
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { cutBlock, restoreBlock, restoreLine, sourceChunk } from "./working-copy";
|
|
3
|
+
import { parseBlocks, type Block } from "./markdown";
|
|
4
|
+
|
|
5
|
+
const BASE = `# Plan
|
|
6
|
+
|
|
7
|
+
## Context
|
|
8
|
+
|
|
9
|
+
A paragraph that
|
|
10
|
+
spans two lines.
|
|
11
|
+
|
|
12
|
+
- first item
|
|
13
|
+
- second item
|
|
14
|
+
|
|
15
|
+
\`\`\`ts
|
|
16
|
+
const x = 1;
|
|
17
|
+
const y = 2;
|
|
18
|
+
\`\`\`
|
|
19
|
+
`;
|
|
20
|
+
|
|
21
|
+
function block(md: string, text: string): Block {
|
|
22
|
+
return parseBlocks(md).find((b) => b.text.includes(text))!;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe("sourceChunk", () => {
|
|
26
|
+
test("returns the exact source lines a block occupies", () => {
|
|
27
|
+
expect(sourceChunk(BASE, block(BASE, "first item"))).toBe("- first item");
|
|
28
|
+
expect(sourceChunk(BASE, block(BASE, "spans two lines"))).toBe("A paragraph that\nspans two lines.");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("code block chunk includes both fence lines", () => {
|
|
32
|
+
expect(sourceChunk(BASE, block(BASE, "const x"))).toBe("```ts\nconst x = 1;\nconst y = 2;\n```");
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe("cutBlock", () => {
|
|
37
|
+
test("removes the block lines and the blank line before it", () => {
|
|
38
|
+
const cut = cutBlock(BASE, block(BASE, "spans two lines"));
|
|
39
|
+
expect(cut).not.toContain("spans two lines");
|
|
40
|
+
expect(cut).toContain("## Context\n\n- first item");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("cut at the start of the document leaves the leading blank line", () => {
|
|
44
|
+
const cut = cutBlock(BASE, block(BASE, "Plan"));
|
|
45
|
+
expect(cut).not.toContain("# Plan");
|
|
46
|
+
expect(cut.trimStart().startsWith("## Context")).toBe(true);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("cut at the end of the document keeps the rest intact", () => {
|
|
50
|
+
const cut = cutBlock(BASE, block(BASE, "const x"));
|
|
51
|
+
expect(cut).not.toContain("const x");
|
|
52
|
+
expect(cut).toContain("- second item");
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("restoreBlock", () => {
|
|
57
|
+
test("restoring the only cut returns undefined - back to pristine", () => {
|
|
58
|
+
const li = block(BASE, "second item");
|
|
59
|
+
const cut = cutBlock(BASE, li);
|
|
60
|
+
// the cut block re-enters before the code fence, the next surviving block
|
|
61
|
+
const next = parseBlocks(cut).find((b) => b.kind === "code");
|
|
62
|
+
const restored = restoreBlock(BASE, cut, li, restoreLine(next, cut.split("\n").length));
|
|
63
|
+
expect(restored).toBeUndefined();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("restore after another edit returns the merged working copy", () => {
|
|
67
|
+
const li = block(BASE, "second item");
|
|
68
|
+
const edited = cutBlock(BASE, li).replace("first item", "first item reworded");
|
|
69
|
+
const next = parseBlocks(edited).find((b) => b.kind === "code");
|
|
70
|
+
const restored = restoreBlock(BASE, edited, li, restoreLine(next, edited.split("\n").length));
|
|
71
|
+
expect(restored).toContain("- second item");
|
|
72
|
+
expect(restored).toContain("first item reworded");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("restore at the end of the document (no next block)", () => {
|
|
76
|
+
const code = block(BASE, "const x");
|
|
77
|
+
const cut = cutBlock(BASE, code);
|
|
78
|
+
const restored = restoreBlock(BASE, cut, code, restoreLine(undefined, cut.split("\n").length));
|
|
79
|
+
expect(restored).toBeUndefined();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("restore a multi-line block round-trips to pristine", () => {
|
|
83
|
+
const p = block(BASE, "spans two lines");
|
|
84
|
+
const cut = cutBlock(BASE, p);
|
|
85
|
+
const next = parseBlocks(cut).find((b) => b.text === "first item");
|
|
86
|
+
const restored = restoreBlock(BASE, cut, p, restoreLine(next, cut.split("\n").length));
|
|
87
|
+
expect(restored).toBeUndefined();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("signature rule ignores blank-line layout but not text changes", () => {
|
|
91
|
+
const li = block(BASE, "second item");
|
|
92
|
+
const cut = cutBlock(BASE, li);
|
|
93
|
+
const next = parseBlocks(cut).find((b) => b.kind === "code");
|
|
94
|
+
const line = restoreLine(next, cut.split("\n").length);
|
|
95
|
+
// extra blank lines elsewhere do not block pristine detection
|
|
96
|
+
expect(restoreBlock(BASE, cut.replace("## Context", "## Context\n"), li, line)).toBeUndefined();
|
|
97
|
+
// a real text change keeps the working copy alive
|
|
98
|
+
const changed = restoreBlock(BASE, cut.replace("first", "1st"), li, line);
|
|
99
|
+
expect(changed).toContain("1st item");
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe("restoreLine", () => {
|
|
104
|
+
test("next surviving block's start, or the line count at the end", () => {
|
|
105
|
+
const li = block(BASE, "first item");
|
|
106
|
+
expect(restoreLine(li, 99)).toBe(li.lineStart);
|
|
107
|
+
expect(restoreLine(undefined, 99)).toBe(99);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Working-copy block surgery: the only module that slices raw source text by
|
|
3
|
+
* Block line ranges. Cut removes a block's lines; restore re-inserts a base
|
|
4
|
+
* block's chunk and reports when the working copy round-trips back to the
|
|
5
|
+
* submitted revision (pristine). Callers never touch lineStart/lineEnd.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { parseBlocks, type Block } from "./markdown";
|
|
9
|
+
|
|
10
|
+
/** Chunk of the base source a block occupies (for restore and display). */
|
|
11
|
+
export function sourceChunk(base: string, block: Block): string {
|
|
12
|
+
const lines = base.split("\n");
|
|
13
|
+
return lines.slice(block.lineStart, block.lineEnd + 1).join("\n");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Remove a work block's lines from the working source (Cut). */
|
|
17
|
+
export function cutBlock(working: string, block: Block): string {
|
|
18
|
+
const lines = working.split("\n");
|
|
19
|
+
const before = lines.slice(0, block.lineStart);
|
|
20
|
+
const after = lines.slice(block.lineEnd + 1);
|
|
21
|
+
while (before.length && before[before.length - 1]!.trim() === "") before.pop();
|
|
22
|
+
return [...before, ...after].join("\n");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The line a restored block re-enters at: the next surviving block's start. */
|
|
26
|
+
export function restoreLine(next: Block | undefined, workingLineCount: number): number {
|
|
27
|
+
return next ? next.lineStart : workingLineCount;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Block-structure signature: kind + text of every block, insensitive to
|
|
32
|
+
* blank-line layout between blocks. NUL separates blocks so no block text
|
|
33
|
+
* can fake a boundary.
|
|
34
|
+
*/
|
|
35
|
+
function signature(text: string): string {
|
|
36
|
+
return parseBlocks(text)
|
|
37
|
+
.map((b) => b.kind + " " + b.text)
|
|
38
|
+
.join("\0");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Re-insert a cut base block into the working source before `beforeLine`.
|
|
43
|
+
* Returns undefined when the block structure round-trips to the base content
|
|
44
|
+
* (restore may differ in blank-line placement only) - the working copy is
|
|
45
|
+
* back to pristine and should be dropped.
|
|
46
|
+
*/
|
|
47
|
+
export function restoreBlock(
|
|
48
|
+
base: string,
|
|
49
|
+
working: string,
|
|
50
|
+
block: Block,
|
|
51
|
+
beforeLine: number,
|
|
52
|
+
): string | undefined {
|
|
53
|
+
const lines = working.split("\n");
|
|
54
|
+
const before = lines.slice(0, beforeLine);
|
|
55
|
+
const after = lines.slice(beforeLine);
|
|
56
|
+
const ins = sourceChunk(base, block).split("\n");
|
|
57
|
+
if (before.length && before[before.length - 1]!.trim() !== "") ins.unshift("");
|
|
58
|
+
if (after.length && after[0]!.trim() !== "") ins.push("");
|
|
59
|
+
const restored = [...before, ...ins, ...after].join("\n");
|
|
60
|
+
return signature(restored) === signature(base) ? undefined : restored;
|
|
61
|
+
}
|