pi-diff-review 0.1.19 → 0.1.21

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,493 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { dirname, isAbsolute, relative, resolve } from "node:path";
4
+ import { spawnSync } from "node:child_process";
5
+ import type { ReviewComment, ReviewLine } from "./types.ts";
6
+
7
+ const STORE_VERSION = 1;
8
+ const MAX_CONTEXT_LINES = 2;
9
+
10
+ export type PersistentCommentStatus = "active" | "stale" | "orphaned";
11
+
12
+ export type WorkspaceCommentRecord = {
13
+ id: string;
14
+ filePath: string;
15
+ text: string;
16
+ startLine: number;
17
+ endLine: number;
18
+ excerpt: string;
19
+ beforeContext: string[];
20
+ afterContext: string[];
21
+ fileContentHash: string;
22
+ createdAt: number;
23
+ updatedAt: number;
24
+ };
25
+
26
+ export type ResolvedWorkspaceComment = WorkspaceCommentRecord & {
27
+ status: PersistentCommentStatus;
28
+ resolvedStartLine?: number;
29
+ resolvedEndLine?: number;
30
+ };
31
+
32
+ type WorkspaceCommentStoreFile = {
33
+ version: number;
34
+ comments: WorkspaceCommentRecord[];
35
+ };
36
+
37
+ export type WorkspaceCommentSummary = {
38
+ visible: number;
39
+ hiddenInCurrentFiles: number;
40
+ elsewhere: number;
41
+ stale: number;
42
+ orphaned: number;
43
+ };
44
+
45
+ export class WorkspaceCommentStore {
46
+ private readonly storePath: string;
47
+ private readonly root: string;
48
+
49
+ constructor(cwd: string) {
50
+ this.root = getWorkspaceRoot(cwd);
51
+ this.storePath = getStorePath(cwd, this.root);
52
+ }
53
+
54
+ get rootPath(): string {
55
+ return this.root;
56
+ }
57
+
58
+ list(): WorkspaceCommentRecord[] {
59
+ return readStoreFile(this.storePath).comments;
60
+ }
61
+
62
+ resolveAll(): ResolvedWorkspaceComment[] {
63
+ return this.list().map((comment) => this.resolveComment(comment));
64
+ }
65
+
66
+ getVisibleComments(lines: ReviewLine[]): Map<string, ReviewComment> {
67
+ const visible = new Map<string, ReviewComment>();
68
+ const lineByFileAndNumber = buildLineLookup(lines);
69
+
70
+ for (const comment of this.resolveAll()) {
71
+ if (comment.status === "orphaned") continue;
72
+ const startLine = comment.resolvedStartLine ?? comment.startLine;
73
+ const endLine = comment.resolvedEndLine ?? comment.endLine;
74
+ const start = lineByFileAndNumber.get(
75
+ getFileLineKey(comment.filePath, startLine),
76
+ );
77
+ const end = lineByFileAndNumber.get(
78
+ getFileLineKey(comment.filePath, endLine),
79
+ );
80
+ if (!start || !end) continue;
81
+ const reviewCommentId = `${start.id}:${end.id}`;
82
+ visible.set(reviewCommentId, {
83
+ id: reviewCommentId,
84
+ filePath: comment.filePath,
85
+ text: comment.text,
86
+ startLineId: start.id,
87
+ endLineId: end.id,
88
+ startNewLineNumber: startLine,
89
+ endNewLineNumber: endLine,
90
+ lineText: comment.excerpt,
91
+ });
92
+ }
93
+
94
+ return visible;
95
+ }
96
+
97
+ summarize(lines: ReviewLine[]): WorkspaceCommentSummary {
98
+ const lineByFileAndNumber = buildLineLookup(lines);
99
+ const currentFiles = new Set(
100
+ lines.map((line) => line.filePath).filter(isPresent),
101
+ );
102
+ const visible = new Set<string>();
103
+ let hiddenInCurrentFiles = 0;
104
+ let elsewhere = 0;
105
+ let stale = 0;
106
+ let orphaned = 0;
107
+
108
+ for (const comment of this.resolveAll()) {
109
+ if (comment.status === "stale") stale++;
110
+ if (comment.status === "orphaned") {
111
+ orphaned++;
112
+ continue;
113
+ }
114
+
115
+ const startLine = comment.resolvedStartLine ?? comment.startLine;
116
+ const endLine = comment.resolvedEndLine ?? comment.endLine;
117
+ const startVisible = lineByFileAndNumber.has(
118
+ getFileLineKey(comment.filePath, startLine),
119
+ );
120
+ const endVisible = lineByFileAndNumber.has(
121
+ getFileLineKey(comment.filePath, endLine),
122
+ );
123
+
124
+ if (startVisible && endVisible) {
125
+ visible.add(comment.id);
126
+ continue;
127
+ }
128
+
129
+ if (currentFiles.has(comment.filePath)) {
130
+ hiddenInCurrentFiles++;
131
+ } else {
132
+ elsewhere++;
133
+ }
134
+ }
135
+
136
+ return {
137
+ visible: visible.size,
138
+ hiddenInCurrentFiles,
139
+ elsewhere,
140
+ stale,
141
+ orphaned,
142
+ };
143
+ }
144
+
145
+ syncFromComments(
146
+ lines: ReviewLine[],
147
+ comments: Iterable<ReviewComment>,
148
+ ): void {
149
+ const store = readStoreFile(this.storePath);
150
+ const next = new Map(
151
+ store.comments.map((comment) => [comment.id, comment]),
152
+ );
153
+ const commentIdsForCurrentFiles = new Set<string>();
154
+ const currentFiles = new Set(
155
+ lines.map((line) => line.filePath).filter(isPresent),
156
+ );
157
+
158
+ for (const comment of store.comments) {
159
+ if (currentFiles.has(comment.filePath)) {
160
+ commentIdsForCurrentFiles.add(comment.id);
161
+ }
162
+ }
163
+
164
+ for (const id of commentIdsForCurrentFiles) {
165
+ next.delete(id);
166
+ }
167
+
168
+ for (const comment of comments) {
169
+ const previous = this.getPreviousRecord(lines, comment, next);
170
+ const record = this.buildRecord(lines, comment, previous);
171
+ if (record) next.set(record.id, record);
172
+ }
173
+
174
+ writeStoreFile(this.storePath, {
175
+ version: STORE_VERSION,
176
+ comments: [...next.values()],
177
+ });
178
+ }
179
+
180
+ private getPreviousRecord(
181
+ lines: ReviewLine[],
182
+ comment: ReviewComment,
183
+ commentsById: Map<string, WorkspaceCommentRecord>,
184
+ ): WorkspaceCommentRecord | undefined {
185
+ if (comment.global) return undefined;
186
+ const start = lines.find((line) => line.id === comment.startLineId);
187
+ const end = lines.find((line) => line.id === comment.endLineId);
188
+ const filePath = normalizePath(comment.filePath);
189
+ const startLine = start?.newLineNumber;
190
+ const endLine = end?.newLineNumber;
191
+ if (!filePath || startLine == null || endLine == null) return undefined;
192
+ return commentsById.get(
193
+ getPersistentCommentId(
194
+ filePath,
195
+ Math.max(1, Math.min(startLine, endLine)),
196
+ Math.max(startLine, endLine),
197
+ ),
198
+ );
199
+ }
200
+
201
+ private buildRecord(
202
+ lines: ReviewLine[],
203
+ comment: ReviewComment,
204
+ previous?: WorkspaceCommentRecord,
205
+ ): WorkspaceCommentRecord | undefined {
206
+ if (comment.global) return undefined;
207
+ const start = lines.find((line) => line.id === comment.startLineId);
208
+ const end = lines.find((line) => line.id === comment.endLineId);
209
+ const filePath = normalizePath(comment.filePath);
210
+ if (!start || !end || !filePath) return undefined;
211
+ const startLine = start.newLineNumber;
212
+ const endLine = end.newLineNumber;
213
+ if (startLine == null || endLine == null) return undefined;
214
+
215
+ const absolutePath = resolve(this.root, filePath);
216
+ const currentLines = readTextLines(absolutePath);
217
+ if (!currentLines) return undefined;
218
+
219
+ const from = Math.max(1, Math.min(startLine, endLine));
220
+ const to = Math.max(startLine, endLine);
221
+ const excerpt = currentLines.slice(from - 1, to).join("\n");
222
+ const beforeContext = currentLines.slice(
223
+ Math.max(0, from - 1 - MAX_CONTEXT_LINES),
224
+ from - 1,
225
+ );
226
+ const afterContext = currentLines.slice(
227
+ to,
228
+ Math.min(currentLines.length, to + MAX_CONTEXT_LINES),
229
+ );
230
+ const now = Date.now();
231
+
232
+ return {
233
+ id: getPersistentCommentId(filePath, from, to),
234
+ filePath,
235
+ text: comment.text,
236
+ startLine,
237
+ endLine,
238
+ excerpt,
239
+ beforeContext,
240
+ afterContext,
241
+ fileContentHash: hashText(currentLines.join("\n")),
242
+ createdAt: previous?.createdAt ?? now,
243
+ updatedAt: now,
244
+ };
245
+ }
246
+
247
+ private resolveComment(
248
+ comment: WorkspaceCommentRecord,
249
+ ): ResolvedWorkspaceComment {
250
+ const absolutePath = resolve(this.root, comment.filePath);
251
+ const currentLines = readTextLines(absolutePath);
252
+ if (!currentLines) {
253
+ return { ...comment, status: "orphaned" };
254
+ }
255
+
256
+ const currentHash = hashText(currentLines.join("\n"));
257
+ const expectedExcerpt = currentLines
258
+ .slice(comment.startLine - 1, comment.endLine)
259
+ .join("\n");
260
+
261
+ if (
262
+ currentHash === comment.fileContentHash &&
263
+ expectedExcerpt === comment.excerpt
264
+ ) {
265
+ return {
266
+ ...comment,
267
+ status: "active",
268
+ resolvedStartLine: comment.startLine,
269
+ resolvedEndLine: comment.endLine,
270
+ };
271
+ }
272
+
273
+ const exactMatch = findExcerptMatch(currentLines, comment.excerpt);
274
+ if (exactMatch) {
275
+ return {
276
+ ...comment,
277
+ status: "stale",
278
+ resolvedStartLine: exactMatch.startLine,
279
+ resolvedEndLine: exactMatch.endLine,
280
+ };
281
+ }
282
+
283
+ const contextualMatch = findContextualMatch(currentLines, comment);
284
+ if (contextualMatch) {
285
+ return {
286
+ ...comment,
287
+ status: "stale",
288
+ resolvedStartLine: contextualMatch.startLine,
289
+ resolvedEndLine: contextualMatch.endLine,
290
+ };
291
+ }
292
+
293
+ return { ...comment, status: "orphaned" };
294
+ }
295
+ }
296
+
297
+ function buildLineLookup(lines: ReviewLine[]): Map<string, ReviewLine> {
298
+ const lookup = new Map<string, ReviewLine>();
299
+ for (const line of lines) {
300
+ const filePath = normalizePath(line.filePath);
301
+ const lineNumber = line.newLineNumber ?? line.oldLineNumber;
302
+ if (!filePath || lineNumber == null) continue;
303
+ lookup.set(getFileLineKey(filePath, lineNumber), line);
304
+ }
305
+ return lookup;
306
+ }
307
+
308
+ function findExcerptMatch(
309
+ lines: string[],
310
+ excerpt: string,
311
+ ): { startLine: number; endLine: number } | undefined {
312
+ const excerptLines = excerpt.split("\n");
313
+ if (excerptLines.length === 0 || !excerpt.trim()) return undefined;
314
+
315
+ let match: { startLine: number; endLine: number } | undefined;
316
+ for (let index = 0; index <= lines.length - excerptLines.length; index++) {
317
+ const candidate = lines
318
+ .slice(index, index + excerptLines.length)
319
+ .join("\n");
320
+ if (candidate !== excerpt) continue;
321
+ if (match) return undefined;
322
+ match = { startLine: index + 1, endLine: index + excerptLines.length };
323
+ }
324
+ return match;
325
+ }
326
+
327
+ function findContextualMatch(
328
+ lines: string[],
329
+ comment: WorkspaceCommentRecord,
330
+ ): { startLine: number; endLine: number } | undefined {
331
+ const excerptLines = comment.excerpt.split("\n");
332
+ const before = comment.beforeContext;
333
+ const after = comment.afterContext;
334
+ if (excerptLines.length === 0) return undefined;
335
+
336
+ let best: { startLine: number; endLine: number; score: number } | undefined;
337
+
338
+ for (let index = 0; index <= lines.length - excerptLines.length; index++) {
339
+ const candidateLines = lines.slice(index, index + excerptLines.length);
340
+ let score = 0;
341
+ if (candidateLines.join("\n") === comment.excerpt) score += 10;
342
+
343
+ const beforeCandidate = lines.slice(
344
+ Math.max(0, index - before.length),
345
+ index,
346
+ );
347
+ const afterCandidate = lines.slice(
348
+ index + excerptLines.length,
349
+ index + excerptLines.length + after.length,
350
+ );
351
+ score += countSuffixMatches(beforeCandidate, before);
352
+ score += countPrefixMatches(afterCandidate, after);
353
+ score -= Math.abs(index + 1 - comment.startLine) * 0.01;
354
+
355
+ if (!best || score > best.score) {
356
+ best = {
357
+ startLine: index + 1,
358
+ endLine: index + excerptLines.length,
359
+ score,
360
+ };
361
+ } else if (best && score === best.score) {
362
+ best = undefined;
363
+ }
364
+ }
365
+
366
+ return best && best.score > 0 ? best : undefined;
367
+ }
368
+
369
+ function countSuffixMatches(left: string[], right: string[]): number {
370
+ let count = 0;
371
+ for (let i = 1; i <= Math.min(left.length, right.length); i++) {
372
+ if (left[left.length - i] !== right[right.length - i]) break;
373
+ count++;
374
+ }
375
+ return count;
376
+ }
377
+
378
+ function countPrefixMatches(left: string[], right: string[]): number {
379
+ let count = 0;
380
+ for (let i = 0; i < Math.min(left.length, right.length); i++) {
381
+ if (left[i] !== right[i]) break;
382
+ count++;
383
+ }
384
+ return count;
385
+ }
386
+
387
+ function readTextLines(path: string): string[] | undefined {
388
+ try {
389
+ const text = readFileSync(path, "utf8");
390
+ return text.replace(/\r\n/g, "\n").split("\n");
391
+ } catch {
392
+ return undefined;
393
+ }
394
+ }
395
+
396
+ function hashText(text: string): string {
397
+ return createHash("sha256").update(text).digest("hex");
398
+ }
399
+
400
+ function readStoreFile(path: string): WorkspaceCommentStoreFile {
401
+ try {
402
+ if (!existsSync(path)) return { version: STORE_VERSION, comments: [] };
403
+ const parsed = JSON.parse(
404
+ readFileSync(path, "utf8"),
405
+ ) as Partial<WorkspaceCommentStoreFile>;
406
+ const comments = Array.isArray(parsed.comments)
407
+ ? parsed.comments.filter(isWorkspaceCommentRecord)
408
+ : [];
409
+ return { version: STORE_VERSION, comments };
410
+ } catch {
411
+ return { version: STORE_VERSION, comments: [] };
412
+ }
413
+ }
414
+
415
+ function writeStoreFile(path: string, store: WorkspaceCommentStoreFile): void {
416
+ mkdirSync(dirname(path), { recursive: true });
417
+ writeFileSync(path, `${JSON.stringify(store, null, 2)}\n`, "utf8");
418
+ }
419
+
420
+ function isWorkspaceCommentRecord(
421
+ value: unknown,
422
+ ): value is WorkspaceCommentRecord {
423
+ if (!value || typeof value !== "object") return false;
424
+ const record = value as Partial<WorkspaceCommentRecord>;
425
+ return (
426
+ typeof record.id === "string" &&
427
+ typeof record.filePath === "string" &&
428
+ typeof record.text === "string" &&
429
+ typeof record.startLine === "number" &&
430
+ typeof record.endLine === "number" &&
431
+ typeof record.excerpt === "string" &&
432
+ Array.isArray(record.beforeContext) &&
433
+ Array.isArray(record.afterContext) &&
434
+ typeof record.fileContentHash === "string" &&
435
+ typeof record.createdAt === "number" &&
436
+ typeof record.updatedAt === "number"
437
+ );
438
+ }
439
+
440
+ function getWorkspaceRoot(cwd: string): string {
441
+ const result = spawnSync("git", ["rev-parse", "--show-toplevel"], {
442
+ cwd,
443
+ encoding: "utf8",
444
+ stdio: ["ignore", "pipe", "ignore"],
445
+ });
446
+ if (result.status === 0) {
447
+ const root = result.stdout.trim();
448
+ if (root) return root;
449
+ }
450
+ return cwd;
451
+ }
452
+
453
+ function getStorePath(cwd: string, root: string): string {
454
+ const gitPathResult = spawnSync(
455
+ "git",
456
+ ["rev-parse", "--git-path", "pi-diff-review-comments.json"],
457
+ {
458
+ cwd,
459
+ encoding: "utf8",
460
+ stdio: ["ignore", "pipe", "ignore"],
461
+ },
462
+ );
463
+ if (gitPathResult.status === 0) {
464
+ const gitPath = gitPathResult.stdout.trim();
465
+ if (gitPath) return isAbsolute(gitPath) ? gitPath : resolve(root, gitPath);
466
+ }
467
+ return resolve(root, ".pi-diff-review-comments.json");
468
+ }
469
+
470
+ function normalizePath(path?: string): string | undefined {
471
+ if (!path) return undefined;
472
+ return path.replace(/\\/g, "/");
473
+ }
474
+
475
+ function getFileLineKey(filePath: string, lineNumber: number): string {
476
+ return `${filePath}:${lineNumber}`;
477
+ }
478
+
479
+ function getPersistentCommentId(
480
+ filePath: string,
481
+ startLine: number,
482
+ endLine: number,
483
+ ): string {
484
+ return `${filePath}:${startLine}-${endLine}`;
485
+ }
486
+
487
+ function isPresent<T>(value: T | undefined): value is T {
488
+ return value != null;
489
+ }
490
+
491
+ export function getRelativeWorkspacePath(root: string, path: string): string {
492
+ return normalizePath(relative(root, path)) ?? path;
493
+ }
@@ -0,0 +1,44 @@
1
+ export function tokenizeShellArgs(input: string): string[] {
2
+ const args: string[] = [];
3
+ let current = "";
4
+ let quote: '"' | "'" | undefined;
5
+ let escaping = false;
6
+
7
+ for (const char of input) {
8
+ if (escaping) {
9
+ current += char;
10
+ escaping = false;
11
+ continue;
12
+ }
13
+
14
+ if (char === "\\" && quote !== "'") {
15
+ escaping = true;
16
+ continue;
17
+ }
18
+
19
+ if ((char === '"' || char === "'") && !quote) {
20
+ quote = char;
21
+ continue;
22
+ }
23
+
24
+ if (char === quote) {
25
+ quote = undefined;
26
+ continue;
27
+ }
28
+
29
+ if (/\s/.test(char) && !quote) {
30
+ if (current) {
31
+ args.push(current);
32
+ current = "";
33
+ }
34
+ continue;
35
+ }
36
+
37
+ current += char;
38
+ }
39
+
40
+ if (escaping) current += "\\";
41
+ if (quote) throw new Error(`Unterminated ${quote} quote in arguments`);
42
+ if (current) args.push(current);
43
+ return args;
44
+ }
@@ -0,0 +1,54 @@
1
+ import { readFileSync } from "node:fs";
2
+ import type { ReviewLine } from "../review/types.ts";
3
+ import { getRelativeWorkspacePath } from "../review/workspace-comments.ts";
4
+
5
+ export function parseViewFiles(
6
+ workspaceRoot: string,
7
+ absolutePaths: string[],
8
+ ): ReviewLine[] {
9
+ const lines: ReviewLine[] = [];
10
+ let lineIndex = 0;
11
+
12
+ for (const absolutePath of absolutePaths) {
13
+ const filePath = getRelativeWorkspacePath(workspaceRoot, absolutePath);
14
+ lines.push({
15
+ id: `line-${lineIndex++}`,
16
+ kind: "meta",
17
+ text: `# ${filePath}`,
18
+ filePath,
19
+ commentable: false,
20
+ });
21
+
22
+ const content = readFileSync(absolutePath, "utf8").replace(/\r\n/g, "\n");
23
+ const fileLines = content.split("\n");
24
+ const hasTrailingNewline = content.endsWith("\n");
25
+ const visibleLines = hasTrailingNewline
26
+ ? fileLines.slice(0, -1)
27
+ : fileLines;
28
+
29
+ if (visibleLines.length === 0) {
30
+ lines.push({
31
+ id: `line-${lineIndex++}`,
32
+ kind: "context",
33
+ text: " ",
34
+ filePath,
35
+ newLineNumber: 1,
36
+ commentable: true,
37
+ });
38
+ continue;
39
+ }
40
+
41
+ visibleLines.forEach((text, index) => {
42
+ lines.push({
43
+ id: `line-${lineIndex++}`,
44
+ kind: "context",
45
+ text: ` ${text}`,
46
+ filePath,
47
+ newLineNumber: index + 1,
48
+ commentable: true,
49
+ });
50
+ });
51
+ }
52
+
53
+ return lines;
54
+ }