crloop 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.
@@ -0,0 +1,190 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { randomUUID } from "node:crypto";
4
+ export const REVIEW_STORAGE_DIRECTORY = ".local-code-review";
5
+ export function getReviewSessionFileName(reviewBaseShortId) {
6
+ return `${reviewBaseShortId}.json`;
7
+ }
8
+ export class CommentStore {
9
+ sessionFileName;
10
+ storageDir;
11
+ constructor(repoPath, sessionFileName = getReviewSessionFileName("HEAD"), storageDir = path.join(repoPath, REVIEW_STORAGE_DIRECTORY)) {
12
+ this.sessionFileName = sessionFileName;
13
+ this.storageDir = storageDir;
14
+ }
15
+ async list() {
16
+ const storedComments = await this.read();
17
+ return flattenStoredComments(storedComments).map(toReviewComment);
18
+ }
19
+ async create(input) {
20
+ const storedComments = await this.read();
21
+ const commentsForPath = storedComments[input.path] ?? [];
22
+ const storedComment = {
23
+ id: createCommentId(),
24
+ side: input.side,
25
+ line: input.lineNumber,
26
+ body: input.body,
27
+ diffFingerprint: input.diffFingerprint
28
+ };
29
+ commentsForPath.push(storedComment);
30
+ storedComments[input.path] = commentsForPath;
31
+ await this.write(storedComments);
32
+ return toReviewComment({
33
+ path: input.path,
34
+ comment: storedComment
35
+ });
36
+ }
37
+ async update(commentId, body) {
38
+ const storedComments = await this.read();
39
+ const record = findStoredComment(storedComments, commentId);
40
+ if (!record) {
41
+ return null;
42
+ }
43
+ const updated = {
44
+ ...record.comment,
45
+ body
46
+ };
47
+ storedComments[record.path][record.index] = updated;
48
+ await this.write(storedComments);
49
+ return toReviewComment({
50
+ path: record.path,
51
+ comment: updated
52
+ });
53
+ }
54
+ async delete(commentId) {
55
+ const storedComments = await this.read();
56
+ const record = findStoredComment(storedComments, commentId);
57
+ if (!record) {
58
+ return false;
59
+ }
60
+ const nextComments = storedComments[record.path].filter((_comment, index) => index !== record.index);
61
+ if (nextComments.length === 0) {
62
+ delete storedComments[record.path];
63
+ }
64
+ else {
65
+ storedComments[record.path] = nextComments;
66
+ }
67
+ await this.write(storedComments);
68
+ return true;
69
+ }
70
+ async getSessionFilePath() {
71
+ await fs.mkdir(this.storageDir, { recursive: true });
72
+ return path.join(this.storageDir, this.sessionFileName);
73
+ }
74
+ async read() {
75
+ const sessionPath = await this.getSessionFilePath();
76
+ try {
77
+ const content = await fs.readFile(sessionPath, "utf8");
78
+ const normalized = normalizeStoredComments(JSON.parse(content));
79
+ if (normalized.didUpgrade) {
80
+ await this.write(normalized.comments);
81
+ }
82
+ return normalized.comments;
83
+ }
84
+ catch (error) {
85
+ if (error.code === "ENOENT") {
86
+ return {};
87
+ }
88
+ throw error;
89
+ }
90
+ }
91
+ async write(storedComments) {
92
+ const sessionPath = await this.getSessionFilePath();
93
+ const sortedEntries = Object.entries(storedComments).sort(([left], [right]) => left.localeCompare(right));
94
+ const normalizedComments = Object.fromEntries(sortedEntries);
95
+ await fs.writeFile(sessionPath, `${JSON.stringify(normalizedComments, null, 2)}\n`, "utf8");
96
+ }
97
+ }
98
+ function flattenStoredComments(storedComments) {
99
+ return Object.entries(storedComments).flatMap(([path, comments]) => comments.map((comment) => ({
100
+ path,
101
+ comment
102
+ })));
103
+ }
104
+ function toReviewComment(entry) {
105
+ return {
106
+ commentId: entry.comment.id,
107
+ path: entry.path,
108
+ side: entry.comment.side,
109
+ lineNumber: entry.comment.line,
110
+ body: entry.comment.body,
111
+ diffFingerprint: entry.comment.diffFingerprint
112
+ };
113
+ }
114
+ function createCommentId() {
115
+ return randomUUID().replace(/-/g, "").slice(0, 12);
116
+ }
117
+ function findStoredComment(storedComments, commentId) {
118
+ for (const [filePath, comments] of Object.entries(storedComments)) {
119
+ for (const [index, comment] of comments.entries()) {
120
+ if (comment.id === commentId) {
121
+ return {
122
+ path: filePath,
123
+ index,
124
+ comment
125
+ };
126
+ }
127
+ }
128
+ }
129
+ return null;
130
+ }
131
+ function normalizeStoredComments(value) {
132
+ if (isLegacySessionFile(value)) {
133
+ return {
134
+ comments: value.comments.reduce((accumulator, comment) => {
135
+ const lineNumber = comment.side === "old" ? comment.oldLineNumber : comment.newLineNumber;
136
+ if (typeof lineNumber !== "number") {
137
+ return accumulator;
138
+ }
139
+ const commentsForPath = accumulator[comment.fileId] ?? [];
140
+ commentsForPath.push({
141
+ id: createCommentId(),
142
+ side: comment.side,
143
+ line: lineNumber,
144
+ body: comment.body,
145
+ diffFingerprint: comment.diffFingerprint
146
+ });
147
+ accumulator[comment.fileId] = commentsForPath;
148
+ return accumulator;
149
+ }, {}),
150
+ didUpgrade: true
151
+ };
152
+ }
153
+ if (!isStoredCommentsFile(value)) {
154
+ throw new Error("Invalid comments file");
155
+ }
156
+ let didUpgrade = false;
157
+ const comments = {};
158
+ for (const [filePath, storedComments] of Object.entries(value)) {
159
+ comments[filePath] = storedComments.map((comment) => {
160
+ const id = typeof comment.id === "string" && comment.id.length > 0 ? comment.id : createCommentId();
161
+ if (id !== comment.id) {
162
+ didUpgrade = true;
163
+ }
164
+ return {
165
+ ...comment,
166
+ id
167
+ };
168
+ });
169
+ }
170
+ return {
171
+ comments,
172
+ didUpgrade
173
+ };
174
+ }
175
+ function isLegacySessionFile(value) {
176
+ return typeof value === "object" && value !== null && Array.isArray(value.comments);
177
+ }
178
+ function isStoredCommentsFile(value) {
179
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
180
+ return false;
181
+ }
182
+ return Object.values(value).every((comments) => Array.isArray(comments) &&
183
+ comments.every((comment) => typeof comment === "object" &&
184
+ comment !== null &&
185
+ (typeof comment.id === "string" || comment.id === undefined) &&
186
+ (comment.side === "old" || comment.side === "new") &&
187
+ typeof comment.line === "number" &&
188
+ typeof comment.body === "string" &&
189
+ typeof comment.diffFingerprint === "string"));
190
+ }
@@ -0,0 +1,34 @@
1
+ import fs from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { afterEach, describe, expect, it } from "vitest";
5
+ import { CommentStore } from "./commentStore.js";
6
+ const createdDirectories = [];
7
+ afterEach(async () => {
8
+ await Promise.all(createdDirectories.splice(0).map((directory) => fs.rm(directory, { recursive: true, force: true })));
9
+ });
10
+ describe("CommentStore", () => {
11
+ it("persists comments in a deterministic session file", async () => {
12
+ const storageDir = await fs.mkdtemp(path.join(os.tmpdir(), "comment-store-"));
13
+ createdDirectories.push(storageDir);
14
+ const store = new CommentStore("/tmp/repo", storageDir);
15
+ const created = await store.create({
16
+ fileId: "change-1",
17
+ side: "new",
18
+ oldLineNumber: null,
19
+ newLineNumber: 4,
20
+ hunkHeader: "@@ -1,1 +1,2 @@",
21
+ body: "Looks good",
22
+ diffFingerprint: "fingerprint-1"
23
+ });
24
+ const comments = await store.list();
25
+ const sessionFiles = await fs.readdir(storageDir);
26
+ expect(sessionFiles).toHaveLength(1);
27
+ expect(comments).toHaveLength(1);
28
+ expect(comments[0]).toMatchObject({
29
+ commentId: created.commentId,
30
+ body: "Looks good",
31
+ createdAt: created.createdAt
32
+ });
33
+ });
34
+ });
@@ -0,0 +1,2 @@
1
+ import { logFatalError, runServer } from "./runServer.js";
2
+ runServer({ dev: true }).catch(logFatalError);
@@ -0,0 +1,234 @@
1
+ import { getChangePath } from "../shared/changePaths.js";
2
+ import { sha256 } from "./hash.js";
3
+ const HUNK_RE = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$/;
4
+ export function parseTrackedDiff(patch) {
5
+ const state = {
6
+ current: null,
7
+ currentHunk: null,
8
+ files: []
9
+ };
10
+ for (const line of patch.split("\n")) {
11
+ if (line.startsWith("diff --git ")) {
12
+ finalizeHunk(state);
13
+ finalizeFile(state);
14
+ state.current = createFileFromHeader(line);
15
+ continue;
16
+ }
17
+ if (!state.current) {
18
+ continue;
19
+ }
20
+ if (line.startsWith("old mode ") || line.startsWith("new mode ") || line.startsWith("similarity index ")) {
21
+ appendFingerprintLine(state.current, line);
22
+ continue;
23
+ }
24
+ if (line.startsWith("rename from ")) {
25
+ state.current.changeType = "renamed";
26
+ state.current.oldPath = line.slice("rename from ".length);
27
+ appendFingerprintLine(state.current, line);
28
+ continue;
29
+ }
30
+ if (line.startsWith("rename to ")) {
31
+ state.current.changeType = "renamed";
32
+ state.current.newPath = line.slice("rename to ".length);
33
+ appendFingerprintLine(state.current, line);
34
+ continue;
35
+ }
36
+ if (line.startsWith("new file mode ")) {
37
+ state.current.changeType = "added";
38
+ appendFingerprintLine(state.current, line);
39
+ continue;
40
+ }
41
+ if (line.startsWith("deleted file mode ")) {
42
+ state.current.changeType = "deleted";
43
+ appendFingerprintLine(state.current, line);
44
+ continue;
45
+ }
46
+ if (line.startsWith("Binary files ") || line === "GIT binary patch") {
47
+ state.current.isBinary = true;
48
+ appendFingerprintLine(state.current, line);
49
+ continue;
50
+ }
51
+ if (line.startsWith("index ") || line.startsWith("--- ") || line.startsWith("+++ ")) {
52
+ updatePathsFromPatchLine(state.current, line);
53
+ appendFingerprintLine(state.current, line);
54
+ continue;
55
+ }
56
+ if (line.startsWith("@@ ")) {
57
+ finalizeHunk(state);
58
+ state.currentHunk = createHunk(line);
59
+ continue;
60
+ }
61
+ if (state.currentHunk && isDiffBodyLine(line)) {
62
+ const diffLine = parseDiffLine(line, state.currentHunk);
63
+ state.currentHunk.lines.push(diffLine);
64
+ if (diffLine.kind !== "context") {
65
+ appendFingerprintLine(state.current, normalizeDiffLine(diffLine));
66
+ }
67
+ continue;
68
+ }
69
+ if (line === "\") {
70
+ appendFingerprintLine(state.current, line);
71
+ }
72
+ }
73
+ finalizeHunk(state);
74
+ finalizeFile(state);
75
+ return state.files
76
+ .map(({ rawFingerprintLines, ...file }) => ({
77
+ ...file,
78
+ diffFingerprint: sha256(rawFingerprintLines.join("\n"))
79
+ }))
80
+ .sort((left, right) => getChangePath(left).localeCompare(getChangePath(right)));
81
+ }
82
+ export function createUntrackedChange(filePath, content) {
83
+ const lines = content.split(/\r?\n/);
84
+ const visibleLines = lines.at(-1) === "" ? lines.slice(0, -1) : lines;
85
+ const hunkHeader = `@@ -0,0 +1,${Math.max(visibleLines.length, 0)} @@`;
86
+ const diffLines = visibleLines.map((text, index) => ({
87
+ kind: "added",
88
+ oldLineNumber: null,
89
+ newLineNumber: index + 1,
90
+ text,
91
+ commentableSide: "new"
92
+ }));
93
+ const fingerprintSource = [`untracked:${filePath}`, hunkHeader, ...diffLines.map(normalizeDiffLine)].join("\n");
94
+ const changeId = sha256(`untracked:${filePath}`);
95
+ return {
96
+ changeId,
97
+ changeType: "untracked",
98
+ oldPath: null,
99
+ newPath: filePath,
100
+ isBinary: false,
101
+ diffFingerprint: sha256(fingerprintSource),
102
+ hunks: [
103
+ {
104
+ header: hunkHeader,
105
+ lines: diffLines
106
+ }
107
+ ]
108
+ };
109
+ }
110
+ export function createBinaryUntrackedChange(filePath, content) {
111
+ return {
112
+ changeId: sha256(`untracked:${filePath}`),
113
+ changeType: "untracked",
114
+ oldPath: null,
115
+ newPath: filePath,
116
+ isBinary: true,
117
+ diffFingerprint: sha256(`untracked-binary:${filePath}:${content.toString("base64")}`),
118
+ hunks: []
119
+ };
120
+ }
121
+ function createFileFromHeader(line) {
122
+ const match = /^diff --git a\/(.*) b\/(.*)$/.exec(line);
123
+ const oldPath = match?.[1] ?? null;
124
+ const newPath = match?.[2] ?? null;
125
+ return {
126
+ changeId: sha256(`tracked:${oldPath ?? ""}:${newPath ?? ""}`),
127
+ changeType: inferInitialChangeType(oldPath, newPath),
128
+ oldPath,
129
+ newPath,
130
+ isBinary: false,
131
+ hunks: [],
132
+ rawFingerprintLines: [line]
133
+ };
134
+ }
135
+ function inferInitialChangeType(oldPath, newPath) {
136
+ if (!oldPath && newPath) {
137
+ return "added";
138
+ }
139
+ if (oldPath && !newPath) {
140
+ return "deleted";
141
+ }
142
+ return "modified";
143
+ }
144
+ function finalizeHunk(state) {
145
+ if (!state.current || !state.currentHunk || state.current.isBinary) {
146
+ state.currentHunk = null;
147
+ return;
148
+ }
149
+ state.current.hunks.push({
150
+ header: state.currentHunk.header,
151
+ lines: state.currentHunk.lines
152
+ });
153
+ state.currentHunk = null;
154
+ }
155
+ function finalizeFile(state) {
156
+ if (!state.current) {
157
+ return;
158
+ }
159
+ state.current.changeId = sha256(`${state.current.changeType}:${state.current.oldPath ?? ""}:${state.current.newPath ?? ""}`);
160
+ state.files.push(state.current);
161
+ state.current = null;
162
+ }
163
+ function appendFingerprintLine(file, line) {
164
+ file.rawFingerprintLines.push(line);
165
+ }
166
+ function updatePathsFromPatchLine(file, line) {
167
+ if (!line.startsWith("--- ") && !line.startsWith("+++ ")) {
168
+ return;
169
+ }
170
+ const rawPath = line.slice(4).trim();
171
+ const parsed = rawPath === "/dev/null" ? null : stripPatchPrefix(rawPath);
172
+ if (line.startsWith("--- ")) {
173
+ file.oldPath = parsed;
174
+ }
175
+ else {
176
+ file.newPath = parsed;
177
+ }
178
+ }
179
+ function stripPatchPrefix(value) {
180
+ return value.replace(/^[ab]\//, "");
181
+ }
182
+ function createHunk(headerLine) {
183
+ const match = HUNK_RE.exec(headerLine);
184
+ if (!match) {
185
+ throw new Error(`Invalid hunk header: ${headerLine}`);
186
+ }
187
+ return {
188
+ header: `@@ -${match[1]}${match[2] ? `,${match[2]}` : ""} +${match[3]}${match[4] ? `,${match[4]}` : ""} @@${match[5]}`,
189
+ lines: [],
190
+ oldLineNumber: Number(match[1]),
191
+ newLineNumber: Number(match[3])
192
+ };
193
+ }
194
+ function isDiffBodyLine(line) {
195
+ return line.startsWith(" ") || line.startsWith("+") || line.startsWith("-");
196
+ }
197
+ function parseDiffLine(line, hunk) {
198
+ const text = line.slice(1);
199
+ if (line.startsWith(" ")) {
200
+ const diffLine = {
201
+ kind: "context",
202
+ oldLineNumber: hunk.oldLineNumber,
203
+ newLineNumber: hunk.newLineNumber,
204
+ text,
205
+ commentableSide: null
206
+ };
207
+ hunk.oldLineNumber += 1;
208
+ hunk.newLineNumber += 1;
209
+ return diffLine;
210
+ }
211
+ if (line.startsWith("+")) {
212
+ const diffLine = {
213
+ kind: "added",
214
+ oldLineNumber: null,
215
+ newLineNumber: hunk.newLineNumber,
216
+ text,
217
+ commentableSide: "new"
218
+ };
219
+ hunk.newLineNumber += 1;
220
+ return diffLine;
221
+ }
222
+ const diffLine = {
223
+ kind: "removed",
224
+ oldLineNumber: hunk.oldLineNumber,
225
+ newLineNumber: null,
226
+ text,
227
+ commentableSide: "old"
228
+ };
229
+ hunk.oldLineNumber += 1;
230
+ return diffLine;
231
+ }
232
+ function normalizeDiffLine(line) {
233
+ return [line.kind, line.oldLineNumber ?? "-", line.newLineNumber ?? "-", line.text].join("|");
234
+ }
@@ -0,0 +1,71 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { createBinaryUntrackedChange, createUntrackedChange, parseTrackedDiff } from "./diffParser.js";
3
+ describe("parseTrackedDiff", () => {
4
+ it("parses tracked text diffs with stable line numbers and commentable sides", () => {
5
+ const patch = [
6
+ "diff --git a/tracked.txt b/tracked.txt",
7
+ "index 83db48f..bf8a6f4 100644",
8
+ "--- a/tracked.txt",
9
+ "+++ b/tracked.txt",
10
+ "@@ -1,2 +1,2 @@",
11
+ "-before",
12
+ "+after",
13
+ " stay"
14
+ ].join("\n");
15
+ const [change] = parseTrackedDiff(patch);
16
+ expect(change.changeType).toBe("modified");
17
+ expect(change.hunks).toHaveLength(1);
18
+ expect(change.hunks[0].lines).toEqual([
19
+ {
20
+ kind: "removed",
21
+ oldLineNumber: 1,
22
+ newLineNumber: null,
23
+ text: "before",
24
+ commentableSide: "old"
25
+ },
26
+ {
27
+ kind: "added",
28
+ oldLineNumber: null,
29
+ newLineNumber: 1,
30
+ text: "after",
31
+ commentableSide: "new"
32
+ },
33
+ {
34
+ kind: "context",
35
+ oldLineNumber: 2,
36
+ newLineNumber: 2,
37
+ text: "stay",
38
+ commentableSide: null
39
+ }
40
+ ]);
41
+ });
42
+ it("marks binary tracked diffs as non-commentable", () => {
43
+ const patch = [
44
+ "diff --git a/image.png b/image.png",
45
+ "index 1111111..2222222 100644",
46
+ "Binary files a/image.png and b/image.png differ"
47
+ ].join("\n");
48
+ const [change] = parseTrackedDiff(patch);
49
+ expect(change.isBinary).toBe(true);
50
+ expect(change.hunks).toEqual([]);
51
+ });
52
+ });
53
+ describe("untracked changes", () => {
54
+ it("creates text hunks for untracked files", () => {
55
+ const change = createUntrackedChange("notes.txt", "first\nsecond\n");
56
+ expect(change.changeType).toBe("untracked");
57
+ expect(change.hunks[0].header).toBe("@@ -0,0 +1,2 @@");
58
+ expect(change.hunks[0].lines[1]).toEqual({
59
+ kind: "added",
60
+ oldLineNumber: null,
61
+ newLineNumber: 2,
62
+ text: "second",
63
+ commentableSide: "new"
64
+ });
65
+ });
66
+ it("creates non-commentable binary untracked changes", () => {
67
+ const change = createBinaryUntrackedChange("blob.bin", Buffer.from([0, 1, 2]));
68
+ expect(change.isBinary).toBe(true);
69
+ expect(change.hunks).toEqual([]);
70
+ });
71
+ });
@@ -0,0 +1,6 @@
1
+ export class ClientError extends Error {
2
+ constructor(message) {
3
+ super(message);
4
+ this.name = "ClientError";
5
+ }
6
+ }
@@ -0,0 +1,4 @@
1
+ import { renderReviewCommentsText } from "../shared/export.js";
2
+ export function renderCommentsMarkdown(files) {
3
+ return renderReviewCommentsText(files);
4
+ }
@@ -0,0 +1,47 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { renderCommentsMarkdown } from "./exporter.js";
3
+ describe("renderCommentsMarkdown", () => {
4
+ it("renders comments in stable file and line order", () => {
5
+ const markdown = renderCommentsMarkdown("/repo", [
6
+ {
7
+ change: null,
8
+ path: "b.ts",
9
+ current: [
10
+ {
11
+ commentId: "2",
12
+ fileId: "b",
13
+ side: "new",
14
+ oldLineNumber: null,
15
+ newLineNumber: 10,
16
+ hunkHeader: "@@ -1,1 +1,1 @@",
17
+ body: "Second",
18
+ createdAt: "2026-03-10T10:00:00.000Z",
19
+ diffFingerprint: "fp"
20
+ }
21
+ ],
22
+ outdated: []
23
+ },
24
+ {
25
+ change: null,
26
+ path: "a.ts",
27
+ current: [
28
+ {
29
+ commentId: "1",
30
+ fileId: "a",
31
+ side: "old",
32
+ oldLineNumber: 3,
33
+ newLineNumber: null,
34
+ hunkHeader: "@@ -3,1 +3,0 @@",
35
+ body: "First",
36
+ createdAt: "2026-03-10T09:00:00.000Z",
37
+ diffFingerprint: "fp"
38
+ }
39
+ ],
40
+ outdated: []
41
+ }
42
+ ]);
43
+ expect(markdown.indexOf("## File: a.ts")).toBeLessThan(markdown.indexOf("## File: b.ts"));
44
+ expect(markdown).toContain("Status: current");
45
+ expect(markdown).toContain("Body:\nFirst");
46
+ });
47
+ });
@@ -0,0 +1,11 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ const execFileAsync = promisify(execFile);
4
+ export async function runGit(repoPath, args) {
5
+ const { stdout } = await execFileAsync("git", args, {
6
+ cwd: repoPath,
7
+ encoding: "utf8",
8
+ maxBuffer: 20 * 1024 * 1024
9
+ });
10
+ return stdout;
11
+ }
@@ -0,0 +1,4 @@
1
+ import crypto from "node:crypto";
2
+ export function sha256(value) {
3
+ return crypto.createHash("sha256").update(value).digest("hex");
4
+ }
@@ -0,0 +1,2 @@
1
+ import { logFatalError, runServer } from "./runServer.js";
2
+ runServer().catch(logFatalError);
@@ -0,0 +1,12 @@
1
+ import os from "node:os";
2
+ import path from "node:path";
3
+ export function getAppDataDirectory() {
4
+ const home = os.homedir();
5
+ if (process.platform === "darwin") {
6
+ return path.join(home, "Library", "Application Support", "local-review-tool");
7
+ }
8
+ if (process.platform === "win32") {
9
+ return path.join(process.env.APPDATA ?? path.join(home, "AppData", "Roaming"), "local-review-tool");
10
+ }
11
+ return path.join(process.env.XDG_DATA_HOME ?? path.join(home, ".local", "share"), "local-review-tool");
12
+ }