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,55 @@
|
|
|
1
|
+
export type Status = "open" | "pending" | "resolved" | "outdated";
|
|
2
|
+
|
|
3
|
+
export interface Message {
|
|
4
|
+
author: "human" | "ai";
|
|
5
|
+
text: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface Thread {
|
|
9
|
+
id: string;
|
|
10
|
+
line: number;
|
|
11
|
+
status: Status;
|
|
12
|
+
messages: Message[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ReviewFile {
|
|
16
|
+
file: string;
|
|
17
|
+
threads: Thread[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface DraftFile {
|
|
21
|
+
approved?: boolean;
|
|
22
|
+
threads?: Thread[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const VALID_STATUSES: readonly string[] = [
|
|
26
|
+
"open",
|
|
27
|
+
"pending",
|
|
28
|
+
"resolved",
|
|
29
|
+
"outdated",
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
export function isValidStatus(value: unknown): value is Status {
|
|
33
|
+
return typeof value === "string" && VALID_STATUSES.includes(value);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function isValidThread(value: unknown): value is Thread {
|
|
37
|
+
if (typeof value !== "object" || value === null) return false;
|
|
38
|
+
const v = value as Record<string, unknown>;
|
|
39
|
+
return (
|
|
40
|
+
typeof v.id === "string" &&
|
|
41
|
+
typeof v.line === "number" &&
|
|
42
|
+
isValidStatus(v.status) &&
|
|
43
|
+
Array.isArray(v.messages)
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function isValidReviewFile(value: unknown): value is ReviewFile {
|
|
48
|
+
if (typeof value !== "object" || value === null) return false;
|
|
49
|
+
const v = value as Record<string, unknown>;
|
|
50
|
+
return (
|
|
51
|
+
typeof v.file === "string" &&
|
|
52
|
+
Array.isArray(v.threads) &&
|
|
53
|
+
(v.threads as unknown[]).every(isValidThread)
|
|
54
|
+
);
|
|
55
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { writeFileSync } from "fs";
|
|
2
|
+
import type { ReviewFile, DraftFile } from "./types";
|
|
3
|
+
|
|
4
|
+
export function writeReviewFile(path: string, review: ReviewFile): void {
|
|
5
|
+
writeFileSync(path, JSON.stringify(review, null, 2), "utf8");
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function writeDraftFile(path: string, draft: DraftFile): void {
|
|
9
|
+
writeFileSync(path, JSON.stringify(draft, null, 2), "utf8");
|
|
10
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import type { Thread } from "../protocol/types";
|
|
2
|
+
|
|
3
|
+
export class ReviewState {
|
|
4
|
+
specLines: string[];
|
|
5
|
+
threads: Thread[];
|
|
6
|
+
cursorLine: number = 1;
|
|
7
|
+
|
|
8
|
+
constructor(specLines: string[], threads: Thread[]) {
|
|
9
|
+
this.specLines = specLines;
|
|
10
|
+
this.threads = threads;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
get lineCount(): number {
|
|
14
|
+
return this.specLines.length;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
nextThreadId(): string {
|
|
18
|
+
if (this.threads.length === 0) return "t1";
|
|
19
|
+
const highest = this.threads.reduce((max, t) => {
|
|
20
|
+
const n = parseInt(t.id.replace(/^t/, ""), 10);
|
|
21
|
+
return isNaN(n) ? max : Math.max(max, n);
|
|
22
|
+
}, 0);
|
|
23
|
+
return `t${highest + 1}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
addComment(line: number, text: string): void {
|
|
27
|
+
const thread: Thread = {
|
|
28
|
+
id: this.nextThreadId(),
|
|
29
|
+
line,
|
|
30
|
+
status: "open",
|
|
31
|
+
messages: [{ author: "human", text }],
|
|
32
|
+
};
|
|
33
|
+
this.threads.push(thread);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
replyToThread(threadId: string, text: string): void {
|
|
37
|
+
const thread = this.threads.find((t) => t.id === threadId);
|
|
38
|
+
if (!thread) return;
|
|
39
|
+
thread.messages.push({ author: "human", text });
|
|
40
|
+
thread.status = "open";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
resolveThread(threadId: string): void {
|
|
44
|
+
const thread = this.threads.find((t) => t.id === threadId);
|
|
45
|
+
if (!thread) return;
|
|
46
|
+
// Toggle: resolved → open, anything else → resolved
|
|
47
|
+
if (thread.status === "resolved") {
|
|
48
|
+
thread.status = "open";
|
|
49
|
+
} else {
|
|
50
|
+
thread.status = "resolved";
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
resolveAllPending(): void {
|
|
55
|
+
for (const thread of this.threads) {
|
|
56
|
+
if (thread.status === "pending") {
|
|
57
|
+
thread.status = "resolved";
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
threadAtLine(line: number): Thread | null {
|
|
63
|
+
return this.threads.find((t) => t.line === line) ?? null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
nextActiveThread(): number | null {
|
|
67
|
+
const active = this.threads.filter(
|
|
68
|
+
(t) => t.status === "open" || t.status === "pending"
|
|
69
|
+
);
|
|
70
|
+
if (active.length === 0) return null;
|
|
71
|
+
|
|
72
|
+
// Look for first active thread after cursor (strictly after)
|
|
73
|
+
const after = active.filter((t) => t.line > this.cursorLine);
|
|
74
|
+
if (after.length > 0) {
|
|
75
|
+
return after.reduce((min, t) => (t.line < min.line ? t : min)).line;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Wrap: return the lowest-line active thread
|
|
79
|
+
return active.reduce((min, t) => (t.line < min.line ? t : min)).line;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
prevActiveThread(): number | null {
|
|
83
|
+
const active = this.threads.filter(
|
|
84
|
+
(t) => t.status === "open" || t.status === "pending"
|
|
85
|
+
);
|
|
86
|
+
if (active.length === 0) return null;
|
|
87
|
+
|
|
88
|
+
// Look for last active thread before cursor (strictly before)
|
|
89
|
+
const before = active.filter((t) => t.line < this.cursorLine);
|
|
90
|
+
if (before.length > 0) {
|
|
91
|
+
return before.reduce((max, t) => (t.line > max.line ? t : max)).line;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Wrap: return the highest-line active thread
|
|
95
|
+
return active.reduce((max, t) => (t.line > max.line ? t : max)).line;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
canApprove(): boolean {
|
|
99
|
+
if (this.threads.length === 0) return false;
|
|
100
|
+
return this.threads.every(
|
|
101
|
+
(t) => t.status === "resolved" || t.status === "outdated"
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
activeThreadCount(): { open: number; pending: number } {
|
|
106
|
+
let open = 0;
|
|
107
|
+
let pending = 0;
|
|
108
|
+
for (const t of this.threads) {
|
|
109
|
+
if (t.status === "open") open++;
|
|
110
|
+
else if (t.status === "pending") pending++;
|
|
111
|
+
}
|
|
112
|
+
return { open, pending };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
deleteLastDraftMessage(threadId: string): void {
|
|
116
|
+
const thread = this.threads.find((t) => t.id === threadId);
|
|
117
|
+
if (!thread) return;
|
|
118
|
+
|
|
119
|
+
// Find and remove the last human message
|
|
120
|
+
for (let i = thread.messages.length - 1; i >= 0; i--) {
|
|
121
|
+
if (thread.messages[i].author === "human") {
|
|
122
|
+
thread.messages.splice(i, 1);
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Remove thread entirely if now empty
|
|
128
|
+
if (thread.messages.length === 0) {
|
|
129
|
+
this.threads = this.threads.filter((t) => t.id !== threadId);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
toDraft(): { threads: Thread[] } {
|
|
134
|
+
return { threads: this.threads };
|
|
135
|
+
}
|
|
136
|
+
}
|