revspec 0.1.0
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/.github/workflows/ci.yml +18 -0
- package/README.md +90 -0
- package/bin/revspec.ts +109 -0
- package/bun.lock +213 -0
- package/docs/superpowers/plans/2026-03-14-spectral-v1-implementation.md +2139 -0
- package/docs/superpowers/specs/2026-03-14-spec-review-tool-design.md +331 -0
- package/docs/superpowers/specs/2026-03-14-spec-review-tool-design.review.json +141 -0
- package/docs/superpowers/specs/claude-code-integration-notes.md +26 -0
- package/package.json +21 -0
- package/scripts/release.sh +76 -0
- package/src/protocol/merge.ts +52 -0
- package/src/protocol/read.ts +25 -0
- package/src/protocol/types.ts +55 -0
- package/src/protocol/write.ts +10 -0
- package/src/state/review-state.ts +136 -0
- package/src/tui/app.ts +691 -0
- package/src/tui/comment-input.ts +189 -0
- package/src/tui/confirm.ts +93 -0
- package/src/tui/help.ts +134 -0
- package/src/tui/pager.ts +158 -0
- package/src/tui/search.ts +119 -0
- package/src/tui/status-bar.ts +76 -0
- package/src/tui/theme.ts +34 -0
- package/src/tui/thread-list.ts +145 -0
- package/test/cli.test.ts +151 -0
- package/test/opentui-smoke.test.ts +12 -0
- package/test/protocol/merge.test.ts +100 -0
- package/test/protocol/read.test.ts +92 -0
- package/test/protocol/types.test.ts +95 -0
- package/test/protocol/write.test.ts +72 -0
- package/test/state/review-state.test.ts +326 -0
- package/test/tui/pager.test.ts +184 -0
- package/tsconfig.json +14 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { TextRenderable, type CliRenderer } from "@opentui/core";
|
|
2
|
+
import type { ReviewState } from "../state/review-state";
|
|
3
|
+
import { basename } from "path";
|
|
4
|
+
import { theme } from "./theme";
|
|
5
|
+
|
|
6
|
+
export interface TopBarComponents {
|
|
7
|
+
bar: TextRenderable;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface BottomBarComponents {
|
|
11
|
+
bar: TextRenderable;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Build the top bar text: filename + thread summary.
|
|
16
|
+
*/
|
|
17
|
+
export function buildTopBarText(
|
|
18
|
+
specFile: string,
|
|
19
|
+
state: ReviewState
|
|
20
|
+
): string {
|
|
21
|
+
const name = basename(specFile);
|
|
22
|
+
const { open, pending } = state.activeThreadCount();
|
|
23
|
+
const parts: string[] = [];
|
|
24
|
+
if (open > 0) parts.push(`${open} open`);
|
|
25
|
+
if (pending > 0) parts.push(`${pending} pending`);
|
|
26
|
+
const threadSummary =
|
|
27
|
+
parts.length > 0 ? `Threads: ${parts.join(", ")}` : "No active threads";
|
|
28
|
+
return ` ${name} | ${threadSummary} | L${state.cursorLine}/${state.lineCount}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Build the bottom bar text: keybinding hints.
|
|
33
|
+
* Contextually shows command buffer when in command mode.
|
|
34
|
+
* Prepends mode indicator when provided.
|
|
35
|
+
*/
|
|
36
|
+
export function buildBottomBarText(commandBuffer: string | null, mode?: "markdown" | "line"): string {
|
|
37
|
+
const modeLabel = mode === "markdown" ? "[md]" : mode === "line" ? "[line]" : "";
|
|
38
|
+
if (commandBuffer !== null) {
|
|
39
|
+
return ` ${modeLabel} :${commandBuffer}`;
|
|
40
|
+
}
|
|
41
|
+
return ` ${modeLabel} j/k:move c:comment r:resolve /:search ?:help`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Create the top status bar.
|
|
46
|
+
*/
|
|
47
|
+
export function createTopBar(renderer: CliRenderer): TopBarComponents {
|
|
48
|
+
const bar = new TextRenderable(renderer, {
|
|
49
|
+
content: "",
|
|
50
|
+
width: "100%",
|
|
51
|
+
height: 1,
|
|
52
|
+
bg: theme.surface0,
|
|
53
|
+
fg: theme.text,
|
|
54
|
+
wrapMode: "none",
|
|
55
|
+
truncate: true,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
return { bar };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Create the bottom status bar.
|
|
63
|
+
*/
|
|
64
|
+
export function createBottomBar(renderer: CliRenderer): BottomBarComponents {
|
|
65
|
+
const bar = new TextRenderable(renderer, {
|
|
66
|
+
content: "",
|
|
67
|
+
width: "100%",
|
|
68
|
+
height: 1,
|
|
69
|
+
bg: theme.surface0,
|
|
70
|
+
fg: theme.text,
|
|
71
|
+
wrapMode: "none",
|
|
72
|
+
truncate: true,
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
return { bar };
|
|
76
|
+
}
|
package/src/tui/theme.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export const theme = {
|
|
2
|
+
// Base surfaces
|
|
3
|
+
base: "#1e1e2e",
|
|
4
|
+
surface0: "#313244",
|
|
5
|
+
surface1: "#45475a",
|
|
6
|
+
|
|
7
|
+
// Text hierarchy
|
|
8
|
+
text: "#cdd6f4",
|
|
9
|
+
subtext: "#a6adc8",
|
|
10
|
+
overlay: "#6c7086",
|
|
11
|
+
|
|
12
|
+
// Semantic accents
|
|
13
|
+
blue: "#89b4fa",
|
|
14
|
+
green: "#a6e3a1",
|
|
15
|
+
red: "#f38ba8",
|
|
16
|
+
yellow: "#f9e2af",
|
|
17
|
+
mauve: "#cba6f7",
|
|
18
|
+
|
|
19
|
+
// Derived roles
|
|
20
|
+
borderComment: "#89b4fa",
|
|
21
|
+
borderThread: "#f9e2af", // yellow for informational view
|
|
22
|
+
borderList: "#cba6f7",
|
|
23
|
+
borderConfirm: "#f38ba8",
|
|
24
|
+
borderSearch: "#89b4fa",
|
|
25
|
+
hintFg: "#6c7086",
|
|
26
|
+
hintBg: "#313244",
|
|
27
|
+
} as const;
|
|
28
|
+
|
|
29
|
+
export const STATUS_ICONS: Record<string, string> = {
|
|
30
|
+
open: "*",
|
|
31
|
+
pending: "~",
|
|
32
|
+
resolved: "\u2714",
|
|
33
|
+
outdated: "\u26A0",
|
|
34
|
+
};
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BoxRenderable,
|
|
3
|
+
TextRenderable,
|
|
4
|
+
SelectRenderable,
|
|
5
|
+
SelectRenderableEvents,
|
|
6
|
+
type CliRenderer,
|
|
7
|
+
type KeyEvent,
|
|
8
|
+
} from "@opentui/core";
|
|
9
|
+
import type { Thread } from "../protocol/types";
|
|
10
|
+
import { theme, STATUS_ICONS } from "./theme";
|
|
11
|
+
|
|
12
|
+
export interface ThreadListOptions {
|
|
13
|
+
renderer: CliRenderer;
|
|
14
|
+
threads: Thread[];
|
|
15
|
+
onSelect: (lineNumber: number) => void;
|
|
16
|
+
onCancel: () => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ThreadListOverlay {
|
|
20
|
+
container: BoxRenderable;
|
|
21
|
+
cleanup: () => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const MAX_PREVIEW_LENGTH = 50;
|
|
25
|
+
|
|
26
|
+
function previewText(thread: Thread): string {
|
|
27
|
+
if (thread.messages.length === 0) return "(empty)";
|
|
28
|
+
const last = thread.messages[thread.messages.length - 1];
|
|
29
|
+
const text = last.text.replace(/\n/g, " ");
|
|
30
|
+
if (text.length <= MAX_PREVIEW_LENGTH) return text;
|
|
31
|
+
return text.slice(0, MAX_PREVIEW_LENGTH - 1) + "\u2026";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Create a thread list overlay showing open/pending threads.
|
|
36
|
+
* Select + Enter: jump to that thread's line.
|
|
37
|
+
* Escape: cancel.
|
|
38
|
+
*/
|
|
39
|
+
export function createThreadList(opts: ThreadListOptions): ThreadListOverlay {
|
|
40
|
+
const { renderer, threads, onSelect, onCancel } = opts;
|
|
41
|
+
|
|
42
|
+
// Filter to active threads (open/pending)
|
|
43
|
+
const activeThreads = threads.filter(
|
|
44
|
+
(t) => t.status === "open" || t.status === "pending"
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
// Overlay container
|
|
48
|
+
const container = new BoxRenderable(renderer, {
|
|
49
|
+
position: "absolute",
|
|
50
|
+
top: "15%",
|
|
51
|
+
left: "15%",
|
|
52
|
+
width: "70%",
|
|
53
|
+
height: "60%",
|
|
54
|
+
zIndex: 100,
|
|
55
|
+
backgroundColor: theme.base,
|
|
56
|
+
border: true,
|
|
57
|
+
borderStyle: "single",
|
|
58
|
+
borderColor: theme.borderList,
|
|
59
|
+
title: ` Threads (${activeThreads.length} active) `,
|
|
60
|
+
flexDirection: "column",
|
|
61
|
+
padding: 1,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
if (activeThreads.length === 0) {
|
|
65
|
+
const emptyMsg = new TextRenderable(renderer, {
|
|
66
|
+
content: "No active threads. Press [Esc] to close.",
|
|
67
|
+
width: "100%",
|
|
68
|
+
height: 1,
|
|
69
|
+
fg: theme.overlay,
|
|
70
|
+
wrapMode: "none",
|
|
71
|
+
});
|
|
72
|
+
container.add(emptyMsg);
|
|
73
|
+
} else {
|
|
74
|
+
// Build select options from threads
|
|
75
|
+
const selectOptions = activeThreads.map((t) => {
|
|
76
|
+
const icon = STATUS_ICONS[t.status];
|
|
77
|
+
return {
|
|
78
|
+
name: `${icon} #${t.id} line ${t.line}: ${previewText(t)}`,
|
|
79
|
+
description: `${t.status} - ${t.messages.length} message(s)`,
|
|
80
|
+
value: t.line,
|
|
81
|
+
};
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const select = new SelectRenderable(renderer, {
|
|
85
|
+
width: "100%",
|
|
86
|
+
flexGrow: 1,
|
|
87
|
+
options: selectOptions,
|
|
88
|
+
selectedIndex: 0,
|
|
89
|
+
backgroundColor: theme.base,
|
|
90
|
+
textColor: theme.text,
|
|
91
|
+
focusedBackgroundColor: theme.base,
|
|
92
|
+
focusedTextColor: theme.text,
|
|
93
|
+
selectedBackgroundColor: theme.surface1,
|
|
94
|
+
selectedTextColor: "#f5c2e7",
|
|
95
|
+
descriptionColor: theme.overlay,
|
|
96
|
+
selectedDescriptionColor: theme.subtext,
|
|
97
|
+
showDescription: true,
|
|
98
|
+
wrapSelection: true,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
container.add(select);
|
|
102
|
+
|
|
103
|
+
// Focus the select so it handles j/k navigation
|
|
104
|
+
renderer.focusRenderable(select);
|
|
105
|
+
|
|
106
|
+
// Listen for item selection (Enter key)
|
|
107
|
+
select.on(SelectRenderableEvents.ITEM_SELECTED, () => {
|
|
108
|
+
const selected = select.getSelectedOption();
|
|
109
|
+
if (selected && selected.value != null) {
|
|
110
|
+
onSelect(selected.value as number);
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Hint bar
|
|
116
|
+
const hint = new TextRenderable(renderer, {
|
|
117
|
+
content: " [j/k] navigate [Enter] jump [Esc] close",
|
|
118
|
+
width: "100%",
|
|
119
|
+
height: 1,
|
|
120
|
+
fg: theme.hintFg,
|
|
121
|
+
bg: theme.hintBg,
|
|
122
|
+
wrapMode: "none",
|
|
123
|
+
truncate: true,
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
container.add(hint);
|
|
127
|
+
|
|
128
|
+
// Key handler for Esc
|
|
129
|
+
const keyHandler = (key: KeyEvent) => {
|
|
130
|
+
if (key.name === "escape") {
|
|
131
|
+
key.preventDefault();
|
|
132
|
+
key.stopPropagation();
|
|
133
|
+
onCancel();
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
renderer.keyInput.on("keypress", keyHandler);
|
|
139
|
+
|
|
140
|
+
function cleanup(): void {
|
|
141
|
+
renderer.keyInput.off("keypress", keyHandler);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return { container, cleanup };
|
|
145
|
+
}
|
package/test/cli.test.ts
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach } from "bun:test";
|
|
2
|
+
import { mkdtempSync, rmSync, writeFileSync } from "fs";
|
|
3
|
+
import { join, resolve } from "path";
|
|
4
|
+
import { tmpdir } from "os";
|
|
5
|
+
|
|
6
|
+
const CLI = resolve(import.meta.dir, "../bin/revspec.ts");
|
|
7
|
+
|
|
8
|
+
interface SpawnResult {
|
|
9
|
+
exitCode: number;
|
|
10
|
+
stdout: string;
|
|
11
|
+
stderr: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async function runCli(
|
|
15
|
+
args: string[],
|
|
16
|
+
env: Record<string, string> = {}
|
|
17
|
+
): Promise<SpawnResult> {
|
|
18
|
+
const proc = Bun.spawn(["bun", "run", CLI, ...args], {
|
|
19
|
+
env: { ...process.env, REVSPEC_SKIP_TUI: "1", ...env },
|
|
20
|
+
stdout: "pipe",
|
|
21
|
+
stderr: "pipe",
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const [stdout, stderr] = await Promise.all([
|
|
25
|
+
new Response(proc.stdout).text(),
|
|
26
|
+
new Response(proc.stderr).text(),
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
const exitCode = await proc.exited;
|
|
30
|
+
return { exitCode, stdout, stderr };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe("CLI entry point", () => {
|
|
34
|
+
let tmpDir: string;
|
|
35
|
+
|
|
36
|
+
// Create a fresh temp dir before each test
|
|
37
|
+
function setup(): string {
|
|
38
|
+
tmpDir = mkdtempSync(join(tmpdir(), "revspec-test-"));
|
|
39
|
+
return tmpDir;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
afterEach(() => {
|
|
43
|
+
if (tmpDir) {
|
|
44
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("exits 1 for missing spec file", async () => {
|
|
49
|
+
setup();
|
|
50
|
+
const result = await runCli([join(tmpDir, "nonexistent.md")]);
|
|
51
|
+
expect(result.exitCode).toBe(1);
|
|
52
|
+
expect(result.stderr).toContain("not found");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("exits 0 with no output when no review file exists", async () => {
|
|
56
|
+
const dir = setup();
|
|
57
|
+
const specFile = join(dir, "spec.md");
|
|
58
|
+
writeFileSync(specFile, "# My Spec\n");
|
|
59
|
+
|
|
60
|
+
const result = await runCli([specFile]);
|
|
61
|
+
expect(result.exitCode).toBe(0);
|
|
62
|
+
expect(result.stdout.trim()).toBe("");
|
|
63
|
+
expect(result.stderr.trim()).toBe("");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("outputs APPROVED when draft has approved flag", async () => {
|
|
67
|
+
const dir = setup();
|
|
68
|
+
const specFile = join(dir, "spec.md");
|
|
69
|
+
writeFileSync(specFile, "# My Spec\n");
|
|
70
|
+
|
|
71
|
+
const draftPath = join(dir, "spec.review.draft.json");
|
|
72
|
+
writeFileSync(draftPath, JSON.stringify({ approved: true }));
|
|
73
|
+
|
|
74
|
+
const result = await runCli([specFile]);
|
|
75
|
+
expect(result.exitCode).toBe(0);
|
|
76
|
+
expect(result.stdout).toContain("APPROVED:");
|
|
77
|
+
expect(result.stdout).toContain("spec.review.json");
|
|
78
|
+
|
|
79
|
+
// Draft should be deleted
|
|
80
|
+
const approvedDraftExists = await Bun.file(draftPath)
|
|
81
|
+
.exists()
|
|
82
|
+
.catch(() => false);
|
|
83
|
+
expect(approvedDraftExists).toBe(false);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("merges draft into review file", async () => {
|
|
87
|
+
const dir = setup();
|
|
88
|
+
const specFile = join(dir, "spec.md");
|
|
89
|
+
writeFileSync(specFile, "# My Spec\n");
|
|
90
|
+
|
|
91
|
+
const draft = {
|
|
92
|
+
threads: [
|
|
93
|
+
{
|
|
94
|
+
id: "t1",
|
|
95
|
+
line: 5,
|
|
96
|
+
status: "open",
|
|
97
|
+
messages: [{ author: "human", text: "This needs clarification" }],
|
|
98
|
+
},
|
|
99
|
+
],
|
|
100
|
+
};
|
|
101
|
+
const draftPath = join(dir, "spec.review.draft.json");
|
|
102
|
+
writeFileSync(draftPath, JSON.stringify(draft));
|
|
103
|
+
|
|
104
|
+
const result = await runCli([specFile]);
|
|
105
|
+
expect(result.exitCode).toBe(0);
|
|
106
|
+
|
|
107
|
+
const reviewPath = join(dir, "spec.review.json");
|
|
108
|
+
const reviewFile = await Bun.file(reviewPath).json();
|
|
109
|
+
expect(reviewFile.threads).toHaveLength(1);
|
|
110
|
+
expect(reviewFile.threads[0].id).toBe("t1");
|
|
111
|
+
|
|
112
|
+
// Draft should be deleted
|
|
113
|
+
const draftExists = await Bun.file(draftPath)
|
|
114
|
+
.exists()
|
|
115
|
+
.catch(() => false);
|
|
116
|
+
expect(draftExists).toBe(false);
|
|
117
|
+
|
|
118
|
+
// Should output review path (has open thread)
|
|
119
|
+
expect(result.stdout).toContain("spec.review.json");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("warns and deletes corrupted draft file", async () => {
|
|
123
|
+
const dir = setup();
|
|
124
|
+
const specFile = join(dir, "spec.md");
|
|
125
|
+
writeFileSync(specFile, "# My Spec\n");
|
|
126
|
+
|
|
127
|
+
const draftPath = join(dir, "spec.review.draft.json");
|
|
128
|
+
writeFileSync(draftPath, "this is not valid JSON {{{{");
|
|
129
|
+
|
|
130
|
+
const result = await runCli([specFile]);
|
|
131
|
+
expect(result.exitCode).toBe(0);
|
|
132
|
+
expect(result.stderr).toContain("corrupted");
|
|
133
|
+
|
|
134
|
+
// Draft should be deleted
|
|
135
|
+
const draftExists = await Bun.file(draftPath)
|
|
136
|
+
.exists()
|
|
137
|
+
.catch(() => false);
|
|
138
|
+
expect(draftExists).toBe(false);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("prints nothing when human adds no comments (no prior review)", async () => {
|
|
142
|
+
const dir = setup();
|
|
143
|
+
const specFile = join(dir, "spec.md");
|
|
144
|
+
writeFileSync(specFile, "# My Spec\n");
|
|
145
|
+
|
|
146
|
+
// No draft, no review file — TUI is skipped, nothing happened
|
|
147
|
+
const result = await runCli([specFile]);
|
|
148
|
+
expect(result.exitCode).toBe(0);
|
|
149
|
+
expect(result.stdout.trim()).toBe("");
|
|
150
|
+
});
|
|
151
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
describe("OpenTUI API availability", () => {
|
|
3
|
+
it("core imports exist", async () => {
|
|
4
|
+
const core = await import("@opentui/core");
|
|
5
|
+
expect(core.createCliRenderer).toBeDefined();
|
|
6
|
+
expect(core.TextRenderable).toBeDefined();
|
|
7
|
+
expect(core.BoxRenderable).toBeDefined();
|
|
8
|
+
expect(core.ScrollBoxRenderable).toBeDefined();
|
|
9
|
+
expect(core.InputRenderable).toBeDefined();
|
|
10
|
+
expect(core.SelectRenderable).toBeDefined();
|
|
11
|
+
});
|
|
12
|
+
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { mergeDraftIntoReview } from "../../src/protocol/merge";
|
|
3
|
+
import type { ReviewFile, DraftFile, Thread } from "../../src/protocol/types";
|
|
4
|
+
|
|
5
|
+
const baseReview: ReviewFile = {
|
|
6
|
+
file: "spec.md",
|
|
7
|
+
threads: [],
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
function makeThread(id: string, messages: string[] = ["hello"]): Thread {
|
|
11
|
+
return {
|
|
12
|
+
id,
|
|
13
|
+
line: 1,
|
|
14
|
+
status: "open",
|
|
15
|
+
messages: messages.map((text) => ({ author: "human", text })),
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe("mergeDraftIntoReview", () => {
|
|
20
|
+
it("adds new threads from draft to an empty review", () => {
|
|
21
|
+
const draft: DraftFile = {
|
|
22
|
+
threads: [makeThread("t1"), makeThread("t2")],
|
|
23
|
+
};
|
|
24
|
+
const result = mergeDraftIntoReview({ ...baseReview }, draft);
|
|
25
|
+
expect(result.threads).toHaveLength(2);
|
|
26
|
+
expect(result.threads.map((t) => t.id)).toEqual(["t1", "t2"]);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("appends only new messages to an existing thread (draft has full history)", () => {
|
|
30
|
+
const existingThread: Thread = makeThread("t1", ["first message"]);
|
|
31
|
+
const review: ReviewFile = { file: "spec.md", threads: [existingThread] };
|
|
32
|
+
|
|
33
|
+
// Draft contains full history (existing + new)
|
|
34
|
+
const draftThread: Thread = {
|
|
35
|
+
...existingThread,
|
|
36
|
+
messages: [
|
|
37
|
+
{ author: "human", text: "first message" },
|
|
38
|
+
{ author: "ai", text: "second message" },
|
|
39
|
+
],
|
|
40
|
+
};
|
|
41
|
+
const draft: DraftFile = { threads: [draftThread] };
|
|
42
|
+
|
|
43
|
+
const result = mergeDraftIntoReview(review, draft);
|
|
44
|
+
expect(result.threads).toHaveLength(1);
|
|
45
|
+
expect(result.threads[0].messages).toHaveLength(2);
|
|
46
|
+
expect(result.threads[0].messages[1].text).toBe("second message");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("handles a mix of new and existing threads", () => {
|
|
50
|
+
const existing: Thread = makeThread("t1", ["msg1"]);
|
|
51
|
+
const review: ReviewFile = { file: "spec.md", threads: [existing] };
|
|
52
|
+
|
|
53
|
+
const draftExisting: Thread = {
|
|
54
|
+
...existing,
|
|
55
|
+
messages: [
|
|
56
|
+
{ author: "human", text: "msg1" },
|
|
57
|
+
{ author: "ai", text: "reply" },
|
|
58
|
+
],
|
|
59
|
+
};
|
|
60
|
+
const draftNew: Thread = makeThread("t2", ["new thread"]);
|
|
61
|
+
const draft: DraftFile = { threads: [draftExisting, draftNew] };
|
|
62
|
+
|
|
63
|
+
const result = mergeDraftIntoReview(review, draft);
|
|
64
|
+
expect(result.threads).toHaveLength(2);
|
|
65
|
+
expect(result.threads[0].messages).toHaveLength(2);
|
|
66
|
+
expect(result.threads[1].id).toBe("t2");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("updates thread status from draft", () => {
|
|
70
|
+
const existing: Thread = { ...makeThread("t1"), status: "open" };
|
|
71
|
+
const review: ReviewFile = { file: "spec.md", threads: [existing] };
|
|
72
|
+
|
|
73
|
+
const draftThread: Thread = {
|
|
74
|
+
...existing,
|
|
75
|
+
status: "resolved",
|
|
76
|
+
messages: existing.messages,
|
|
77
|
+
};
|
|
78
|
+
const draft: DraftFile = { threads: [draftThread] };
|
|
79
|
+
|
|
80
|
+
const result = mergeDraftIntoReview(review, draft);
|
|
81
|
+
expect(result.threads[0].status).toBe("resolved");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("returns review unchanged when draft has no threads", () => {
|
|
85
|
+
const thread: Thread = makeThread("t1");
|
|
86
|
+
const review: ReviewFile = { file: "spec.md", threads: [thread] };
|
|
87
|
+
const draft: DraftFile = { approved: true };
|
|
88
|
+
|
|
89
|
+
const result = mergeDraftIntoReview(review, draft);
|
|
90
|
+
expect(result.threads).toHaveLength(1);
|
|
91
|
+
expect(result.threads[0].id).toBe("t1");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("creates a new ReviewFile with specFile when review is null", () => {
|
|
95
|
+
const draft: DraftFile = { threads: [makeThread("t1")] };
|
|
96
|
+
const result = mergeDraftIntoReview(null, draft, "new-spec.md");
|
|
97
|
+
expect(result.file).toBe("new-spec.md");
|
|
98
|
+
expect(result.threads).toHaveLength(1);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { describe, expect, it, afterEach } from "bun:test";
|
|
2
|
+
import { mkdtempSync, rmSync, writeFileSync } from "fs";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import { readReviewFile, readDraftFile } from "../../src/protocol/read";
|
|
5
|
+
import type { ReviewFile, DraftFile } from "../../src/protocol/types";
|
|
6
|
+
|
|
7
|
+
function tmpDir() {
|
|
8
|
+
return mkdtempSync("/tmp/revspec-test-");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
describe("readReviewFile", () => {
|
|
12
|
+
it("returns null for a missing file", () => {
|
|
13
|
+
expect(readReviewFile("/nonexistent/path/review.json")).toBeNull();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("returns parsed ReviewFile for a valid file", () => {
|
|
17
|
+
const dir = tmpDir();
|
|
18
|
+
const filePath = join(dir, "review.json");
|
|
19
|
+
const data: ReviewFile = {
|
|
20
|
+
file: "spec.md",
|
|
21
|
+
threads: [
|
|
22
|
+
{
|
|
23
|
+
id: "t1",
|
|
24
|
+
line: 10,
|
|
25
|
+
status: "open",
|
|
26
|
+
messages: [{ author: "human", text: "Looks good?" }],
|
|
27
|
+
},
|
|
28
|
+
],
|
|
29
|
+
};
|
|
30
|
+
writeFileSync(filePath, JSON.stringify(data));
|
|
31
|
+
const result = readReviewFile(filePath);
|
|
32
|
+
expect(result).toEqual(data);
|
|
33
|
+
rmSync(dir, { recursive: true });
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("returns null for invalid JSON", () => {
|
|
37
|
+
const dir = tmpDir();
|
|
38
|
+
const filePath = join(dir, "review.json");
|
|
39
|
+
writeFileSync(filePath, "not valid json {{");
|
|
40
|
+
expect(readReviewFile(filePath)).toBeNull();
|
|
41
|
+
rmSync(dir, { recursive: true });
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("returns null when JSON doesn't match ReviewFile schema", () => {
|
|
45
|
+
const dir = tmpDir();
|
|
46
|
+
const filePath = join(dir, "review.json");
|
|
47
|
+
writeFileSync(filePath, JSON.stringify({ threads: [] })); // missing 'file'
|
|
48
|
+
expect(readReviewFile(filePath)).toBeNull();
|
|
49
|
+
rmSync(dir, { recursive: true });
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe("readDraftFile", () => {
|
|
54
|
+
it("returns null for a missing file", () => {
|
|
55
|
+
expect(readDraftFile("/nonexistent/path/draft.json")).toBeNull();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("returns parsed DraftFile for an approval draft", () => {
|
|
59
|
+
const dir = tmpDir();
|
|
60
|
+
const filePath = join(dir, "draft.json");
|
|
61
|
+
const data: DraftFile = { approved: true };
|
|
62
|
+
writeFileSync(filePath, JSON.stringify(data));
|
|
63
|
+
expect(readDraftFile(filePath)).toEqual(data);
|
|
64
|
+
rmSync(dir, { recursive: true });
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("returns parsed DraftFile for a threads draft", () => {
|
|
68
|
+
const dir = tmpDir();
|
|
69
|
+
const filePath = join(dir, "draft.json");
|
|
70
|
+
const data: DraftFile = {
|
|
71
|
+
threads: [
|
|
72
|
+
{
|
|
73
|
+
id: "t2",
|
|
74
|
+
line: 5,
|
|
75
|
+
status: "pending",
|
|
76
|
+
messages: [{ author: "ai", text: "Response here" }],
|
|
77
|
+
},
|
|
78
|
+
],
|
|
79
|
+
};
|
|
80
|
+
writeFileSync(filePath, JSON.stringify(data));
|
|
81
|
+
expect(readDraftFile(filePath)).toEqual(data);
|
|
82
|
+
rmSync(dir, { recursive: true });
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("returns null for corrupted JSON", () => {
|
|
86
|
+
const dir = tmpDir();
|
|
87
|
+
const filePath = join(dir, "draft.json");
|
|
88
|
+
writeFileSync(filePath, "{bad json");
|
|
89
|
+
expect(readDraftFile(filePath)).toBeNull();
|
|
90
|
+
rmSync(dir, { recursive: true });
|
|
91
|
+
});
|
|
92
|
+
});
|