git-reviewer 2.0.0 → 2.2.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/README.md CHANGED
@@ -3,27 +3,34 @@
3
3
  [![CI](https://github.com/dheerajjha/reviewer/actions/workflows/ci.yml/badge.svg)](https://github.com/dheerajjha/reviewer/actions/workflows/ci.yml)
4
4
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
5
5
  [![Node](https://img.shields.io/badge/node-%3E%3D18-brightgreen.svg)](package.json)
6
- [![Tests](https://img.shields.io/badge/tests-163-brightgreen.svg)](test/)
6
+ [![Tests](https://img.shields.io/badge/tests-206-brightgreen.svg)](test/)
7
7
  [![Dependencies](https://img.shields.io/badge/dependencies-3-brightgreen.svg)](package.json)
8
8
 
9
9
  ![Plain grey code lines on the left resolving into colored diff stripes, with threaded comment markers attached in the right margin](docs/banner.jpg)
10
10
 
11
- Review code before it becomes a pull request. `reviewer` opens any local git
12
- repository in your browser, shows what changed, and lets you leave inline
13
- comments with threaded follow-ups — against your working directory, without a
14
- branch, a remote, a push, or an account. Comments persist between sessions and
15
- survive the code moving underneath them, so you can review, edit, and come back
16
- tomorrow to a review that still points at the right lines. When you are done it
17
- writes a plain Markdown file you can paste anywhere.
11
+ When an agent writes the code, reading it becomes the bottleneck — and the tool
12
+ built for reading a diff, the pull request, wants a branch, a remote and a push
13
+ you are not ready to make yet.
14
+
15
+ `reviewer` gives you the pull-request view of your working directory instead:
16
+ inline comments with threaded follow-ups, each attached to the exact line you
17
+ mean, with no branch, no remote, no push and no account. Then it hands the
18
+ marked-up review back to the agent to act on.
18
19
 
19
20
  ```bash
20
- cd ~/work/my-app
21
- npx git-reviewer .
21
+ npx git-reviewer . # read the diff, leave comments
22
+ npx git-reviewer export . --format prompt | claude -p "Apply this review."
22
23
  ```
23
24
 
24
- That starts a local server and opens your browser on the repository you named.
25
- There is nothing to install into the project, nothing to sign in to, and
26
- nothing leaves your machine.
25
+ The first command starts a local server and opens your browser on the
26
+ repository you named. Nothing is installed into the project, there is nothing
27
+ to sign in to, and nothing leaves your machine.
28
+
29
+ Comments persist between sessions and survive the code moving underneath them —
30
+ including the agent's own edits, which shift every line below the first change —
31
+ so a review you wrote yesterday still points at the right lines today. When you
32
+ would rather read the review than pipe it, it also writes a plain Markdown file
33
+ you can paste anywhere.
27
34
 
28
35
  ![The app reviewing a modified file: changed files on the left, a colour-coded diff in the middle with an inline comment and a threaded follow-up attached to line 10, and a comments sidebar on the right](docs/screenshot-comments.png)
29
36
 
@@ -109,8 +116,20 @@ shown in full, read back out of `HEAD`:
109
116
 
110
117
  ## What it writes
111
118
 
112
- Two files, both under `reviews/`, both plain text you can read without this
113
- app:
119
+ Two files, both plain text you can read without this app, both in a per-user
120
+ data directory outside the install:
121
+
122
+ | platform | location |
123
+ | --- | --- |
124
+ | macOS | `~/Library/Application Support/git-reviewer` |
125
+ | Linux, BSD | `$XDG_DATA_HOME/git-reviewer`, else `~/.local/share/git-reviewer` |
126
+ | Windows | `%LOCALAPPDATA%\git-reviewer` |
127
+
128
+ Set `REVIEWER_DATA_DIR` to put them anywhere else. They are deliberately *not*
129
+ kept inside the installed package: that directory belongs to npm, which deletes
130
+ it on upgrade and on uninstall, and `npx` puts it in a cache that is pruned
131
+ without warning. A review you wrote by hand is the most expensive thing this
132
+ tool holds.
114
133
 
115
134
  - **`.code-review-comments-<repo>.json`** — the live state of the review. It is
116
135
  written on every edit and is the source of truth; reopening the same
@@ -220,7 +239,7 @@ rather than a public issue.
220
239
 
221
240
  ```bash
222
241
  npm install # 3 dependencies, no build step, ~6MB
223
- npm test # 163 tests
242
+ npm test # 206 tests
224
243
  npm run test:watch
225
244
  npm run test:coverage
226
245
  ```
@@ -268,7 +287,6 @@ including some tagged
268
287
  [good first issue](https://github.com/dheerajjha/reviewer/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22).
269
288
  The ones most likely to change how the tool feels:
270
289
 
271
- - [#1](https://github.com/dheerajjha/reviewer/issues/1) — reviews are stored inside the install rather than with the repository
272
290
  - [#2](https://github.com/dheerajjha/reviewer/issues/2) — give comments a state, so a review can be worked through and marked off
273
291
  - [#3](https://github.com/dheerajjha/reviewer/issues/3) — report whether each anchor still matches the file
274
292
  - [#4](https://github.com/dheerajjha/reviewer/issues/4) — expose the review over MCP, so an agent works through it interactively
package/bin/reviewer.js CHANGED
@@ -4,13 +4,17 @@
4
4
  const fs = require('fs').promises;
5
5
  const path = require('path');
6
6
 
7
- const simpleGit = require('simple-git');
8
-
9
7
  const { parseArgs, buildUrl, UsageError, USAGE } = require('../lib/cli');
10
8
  const { openInBrowser } = require('../lib/browser');
11
- const { buildReviewDocument, filterReviewDocument, formatPrompt } = require('../lib/agent');
12
- const { readSavedComments, ReviewOwnershipError } = require('../lib/store');
13
- const { startServer, DEFAULT_PORT, DEFAULT_HOST, REVIEWS_DIR } = require('../server');
9
+ const { formatPrompt } = require('../lib/agent');
10
+ const { ReviewOwnershipError } = require('../lib/store');
11
+ const { reviewsDir, adoptLegacyReviews } = require('../lib/paths');
12
+ const {
13
+ loadReviewDocument,
14
+ NoReviewError,
15
+ NoMatchingCommentsError
16
+ } = require('../lib/export');
17
+ const { startServer, DEFAULT_PORT, DEFAULT_HOST } = require('../server');
14
18
  const { version } = require('../package.json');
15
19
 
16
20
  /**
@@ -48,39 +52,23 @@ async function listen(port) {
48
52
  async function exportReview(options) {
49
53
  const repoPath = options.repoPath ?? process.cwd();
50
54
 
51
- let comments;
55
+ let document;
52
56
  try {
53
- ({ comments } = await readSavedComments(REVIEWS_DIR, repoPath));
57
+ document = await loadReviewDocument(reviewsDir(), repoPath, { file: options.file });
54
58
  } catch (error) {
55
- if (error instanceof ReviewOwnershipError) throw new UsageError(error.message);
59
+ // `lib/export` raises domain errors so that an MCP tool or an agent
60
+ // handover can answer them its own way. The terminal wants all three as
61
+ // the same usage error, with the same wording it has always printed.
62
+ if (
63
+ error instanceof NoReviewError ||
64
+ error instanceof NoMatchingCommentsError ||
65
+ error instanceof ReviewOwnershipError
66
+ ) {
67
+ throw new UsageError(error.message);
68
+ }
56
69
  throw error;
57
70
  }
58
71
 
59
- if (comments.length === 0) {
60
- throw new UsageError(`No saved review for ${repoPath}. Review it first, then export.`);
61
- }
62
-
63
- // Recording the commit lets a consumer tell whether the tree has moved on
64
- // since the review was written. Neither is fatal if git will not say.
65
- const git = simpleGit(repoPath);
66
- const head = await git.revparse(['HEAD']).then(sha => sha.trim()).catch(() => null);
67
- const branch = await git.revparse(['--abbrev-ref', 'HEAD']).then(name => name.trim()).catch(() => null);
68
-
69
- const document = filterReviewDocument(
70
- buildReviewDocument({
71
- repoPath,
72
- comments,
73
- generatedAt: new Date(),
74
- head,
75
- branch
76
- }),
77
- { file: options.file }
78
- );
79
-
80
- if (document.comments.length === 0) {
81
- throw new UsageError(`The saved review for ${repoPath} has no comments for ${options.file}.`);
82
- }
83
-
84
72
  process.stdout.write(
85
73
  options.format === 'prompt'
86
74
  ? formatPrompt(document)
@@ -91,6 +79,19 @@ async function exportReview(options) {
91
79
  async function main() {
92
80
  const options = parseArgs(process.argv.slice(2));
93
81
 
82
+ // Reviews written by a version that kept them inside the package directory
83
+ // are moved out before anything reads them. Once, synchronously, so no read
84
+ // can start against a half-populated directory. See #33.
85
+ const adopted = adoptLegacyReviews();
86
+ if (adopted > 0) {
87
+ console.log(
88
+ ` Copied ${adopted} saved review${adopted === 1 ? '' : 's'} out of the ` +
89
+ `install directory to\n ${reviewsDir()}\n so that upgrading or ` +
90
+ 'reinstalling this package can no longer delete them.\n' +
91
+ ' The originals are left where they were.\n'
92
+ );
93
+ }
94
+
94
95
  if (options.help) {
95
96
  process.stdout.write(USAGE);
96
97
  return;
package/lib/agent.js CHANGED
@@ -1,7 +1,8 @@
1
1
  'use strict';
2
2
 
3
+ const path = require('path');
3
4
  const { groupByFile } = require('./comments');
4
- const { repoSlug } = require('./review');
5
+ const { fence, repoSlug } = require('./review');
5
6
 
6
7
  /**
7
8
  * The machine-readable form of a review.
@@ -66,10 +67,43 @@ function buildReviewDocument({ repoPath, comments, generatedAt, head = null, bra
66
67
  comments: comments.length,
67
68
  files: grouped.length
68
69
  },
69
- comments: grouped.flatMap(([, fileComments]) => fileComments.map(toAgentComment))
70
+ comments: assignIds(grouped.flatMap(([, fileComments]) => fileComments))
70
71
  };
71
72
  }
72
73
 
74
+ /**
75
+ * Give every comment an id that is unique within the document.
76
+ *
77
+ * `<file>:<line>` was assumed to be unique because "the UI anchors one
78
+ * comment per line". It does not: in a diff, the removed line and the line
79
+ * that replaced it can carry the *same* number, one on each side, and
80
+ * commenting on both is two ordinary clicks. The export then contained two
81
+ * different comments under one id, and a consumer keyed by id — which is
82
+ * what an id is for — silently kept one of them. See #26.
83
+ *
84
+ * The first comment on a line keeps the bare `<file>:<line>`, so ids do not
85
+ * churn for the overwhelmingly common case and stay stable for anything
86
+ * already consuming them. Only a genuine collision gets a suffix.
87
+ *
88
+ * Stable across exports because the order is: `groupByFile` sorts by line
89
+ * with a stable sort, so two comments sharing a line keep the order they
90
+ * were saved in, and saving appends.
91
+ *
92
+ * @param {import('./comments').Comment[]} comments in document order
93
+ * @returns {AgentComment[]}
94
+ */
95
+ function assignIds(comments) {
96
+ const seen = new Map();
97
+
98
+ return comments.map(comment => {
99
+ const base = `${comment.file}:${comment.line}`;
100
+ const n = (seen.get(base) ?? 0) + 1;
101
+ seen.set(base, n);
102
+ return toAgentComment(comment, n === 1 ? base : `${base}#${n}`);
103
+ });
104
+ }
105
+
106
+
73
107
  /**
74
108
  * Select comments from an already-built document and keep its summary honest.
75
109
  *
@@ -81,7 +115,8 @@ function buildReviewDocument({ repoPath, comments, generatedAt, head = null, bra
81
115
  function filterReviewDocument(document, { file = null } = {}) {
82
116
  if (file === null) return document;
83
117
 
84
- const comments = document.comments.filter(comment => comment.file === file);
118
+ const wanted = normalizeReviewPath(file, document.repository.path);
119
+ const comments = document.comments.filter(comment => comment.file === wanted);
85
120
  return {
86
121
  ...document,
87
122
  summary: {
@@ -92,15 +127,36 @@ function filterReviewDocument(document, { file = null } = {}) {
92
127
  };
93
128
  }
94
129
 
130
+ /**
131
+ * Turn a `--file` argument into the repo-relative, forward-slash form comments
132
+ * are stored in.
133
+ *
134
+ * Shells hand paths over as `./src/auth.js` on tab-completion, scripts often
135
+ * pass absolute paths, and Windows users type backslashes. Matching literally
136
+ * made every one of those miss and fail with the same error as a file that
137
+ * genuinely has no comments.
138
+ *
139
+ * @param {string} file
140
+ * @param {string} repoPath
141
+ * @returns {string}
142
+ */
143
+ function normalizeReviewPath(file, repoPath) {
144
+ let candidate = file;
145
+ if (path.isAbsolute(candidate)) candidate = path.relative(repoPath, candidate);
146
+
147
+ const normalized = path.posix.normalize(candidate.replace(/\\/g, '/'));
148
+ return normalized.replace(/^(\.\/)+/, '').replace(/\/$/, '');
149
+ }
150
+
95
151
  /**
96
152
  * @param {import('./comments').Comment} comment
97
153
  * @returns {AgentComment}
98
154
  */
99
- function toAgentComment(comment) {
155
+ function toAgentComment(comment, id) {
100
156
  const entry = {
101
- // File and line identify a comment uniquely — the UI anchors one comment
102
- // per line — and staying derived keeps ids stable across exports.
103
- id: `${comment.file}:${comment.line}`,
157
+ // Derived from file and line so it stays stable across exports, with a
158
+ // suffix where that is not enough on its own. See `assignIds`.
159
+ id,
104
160
  file: comment.file,
105
161
  line: comment.line,
106
162
  anchor: comment.lineContent ?? null,
@@ -158,11 +214,16 @@ function formatPrompt(document) {
158
214
  }
159
215
 
160
216
  lines.push(`### ${comment.id}`, '');
217
+ // Fenced with `fence`, not a literal ```, because the anchor is arbitrary
218
+ // source and in a Markdown file it is routinely a fence itself. A
219
+ // same-length marker closes the block on the anchor's own line, and the
220
+ // comment body below it — the instruction the agent is meant to act on —
221
+ // becomes code, taking the next comment's heading with it. See #22.
161
222
  if (comment.anchor !== null) {
162
- lines.push('Anchor line:', '', '```', comment.anchor, '```', '');
223
+ lines.push('Anchor line:', '', fence(comment.anchor), '');
163
224
  }
164
225
  if (comment.selection) {
165
- lines.push('Selected code:', '', '```', comment.selection, '```', '');
226
+ lines.push('Selected code:', '', fence(comment.selection), '');
166
227
  }
167
228
 
168
229
  lines.push(comment.body, '');
package/lib/export.js ADDED
@@ -0,0 +1,205 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs').promises;
4
+ const path = require('path');
5
+
6
+ const simpleGit = require('simple-git');
7
+
8
+ const { buildReviewDocument, filterReviewDocument } = require('./agent');
9
+ const { readSavedComments, belongsTo } = require('./store');
10
+ const { commentsFilename, canonicalRepoPath, repoSlug } = require('./review');
11
+
12
+ /**
13
+ * Producing a `code-review/v1` document from a repository on disk.
14
+ *
15
+ * This chain — read the saved comments, build the document, filter it — used
16
+ * to be assembled exactly once, inside a local un-exported function in
17
+ * `bin/reviewer.js`. That was fine while the CLI was the only consumer. It
18
+ * stops being fine the moment there is a second one, because the second
19
+ * consumer cannot call it and has to rebuild it, and then the two copies
20
+ * drift in the way that matters most: quietly, and only in the edge cases.
21
+ *
22
+ * The HTTP server reaches the same document by a third route entirely
23
+ * (`POST /api/submit-review`), which is session-bound and writes two files to
24
+ * disk as a side effect of being asked for a document. That one is left alone
25
+ * here; this module is what anything new should call.
26
+ *
27
+ * Nothing in here knows about the CLI. The three conditions the CLI reports
28
+ * as usage errors are raised as domain errors and translated at the boundary,
29
+ * so an MCP tool or an agent handover can answer them its own way rather than
30
+ * inheriting a message written for a terminal.
31
+ */
32
+
33
+ /** No review has been saved for this repository. */
34
+ class NoReviewError extends Error {
35
+ /** @param {string} repoPath */
36
+ constructor(repoPath) {
37
+ super(`No saved review for ${repoPath}. Review it first, then export.`);
38
+ this.name = 'NoReviewError';
39
+ this.repoPath = repoPath;
40
+ }
41
+ }
42
+
43
+ /** A review exists, but the requested filter selected none of it. */
44
+ class NoMatchingCommentsError extends Error {
45
+ /**
46
+ * @param {string} repoPath
47
+ * @param {string} file the filter that matched nothing
48
+ */
49
+ constructor(repoPath, file) {
50
+ super(`The saved review for ${repoPath} has no comments for ${file}.`);
51
+ this.name = 'NoMatchingCommentsError';
52
+ this.repoPath = repoPath;
53
+ this.file = file;
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Where the working tree is, as far as git will say.
59
+ *
60
+ * Every failure here is answered with nulls rather than raised. A review is
61
+ * still a review when git cannot be reached, and this has to hold for three
62
+ * separate failures that do not look alike:
63
+ *
64
+ * - the directory is not a repository -> revparse rejects
65
+ * - git is not installed -> revparse rejects
66
+ * - the directory does not exist at all -> `simpleGit()` throws
67
+ * *synchronously, at construction*, which is why the constructor is
68
+ * inside the try and not above it. The CLI never hit this because
69
+ * `main()` does an `fs.access` check first; a library has no such
70
+ * guarantee, and an agent asking about a repository that has since been
71
+ * moved or deleted would otherwise get a crash instead of an answer.
72
+ *
73
+ * @param {string} repoPath
74
+ * @returns {Promise<{head: string|null, branch: string|null}>}
75
+ */
76
+ async function readGitPosition(repoPath) {
77
+ try {
78
+ const git = simpleGit(repoPath);
79
+ const [head, branch] = await Promise.all([
80
+ git.revparse(['HEAD']).then(sha => sha.trim()).catch(() => null),
81
+ git.revparse(['--abbrev-ref', 'HEAD']).then(name => name.trim()).catch(() => null)
82
+ ]);
83
+ return { head, branch };
84
+ } catch {
85
+ return { head: null, branch: null };
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Load a repository's saved review as a `code-review/v1` document.
91
+ *
92
+ * @param {string} reviewsDir directory holding the saved comment files
93
+ * @param {string} repoPath repository to export
94
+ * @param {object} [options]
95
+ * @param {string|null} [options.file] restrict to one repo-relative path
96
+ * @returns {Promise<import('./agent').ReviewDocument>}
97
+ * @throws {NoReviewError} when nothing has been saved for this repository
98
+ * @throws {NoMatchingCommentsError} when `file` selects no comments
99
+ * @throws {import('./store').ReviewOwnershipError} when the file on disk
100
+ * records a different repository than the one asked for
101
+ */
102
+ async function loadReviewDocument(reviewsDir, repoPath, { file = null } = {}) {
103
+ const { comments } = await readSavedComments(reviewsDir, repoPath);
104
+
105
+ if (comments.length === 0) throw new NoReviewError(repoPath);
106
+
107
+ // Recording the commit lets a consumer tell whether the tree has moved on
108
+ // since the review was written. Neither is fatal if git will not say.
109
+ //
110
+ // Caveat worth knowing before you build on this: `head` is read live, here,
111
+ // at export time -- it is not what HEAD was when the review was written. So
112
+ // docs/agent-format.md telling consumers to compare it against current HEAD
113
+ // to detect staleness cannot work, because it is always current HEAD. That
114
+ // is a real contract bug (#32); it is reproduced rather than fixed here so
115
+ // this stays the pure lift it claims to be, and so the fix lands where it
116
+ // can change the stored format deliberately.
117
+ const { head, branch } = await readGitPosition(repoPath);
118
+
119
+ // Build then filter, in that order, and do not tidy this into one step:
120
+ // `filterReviewDocument` recomputes `summary` honestly and returns the very
121
+ // same object reference when no filter is given. `test/agent.test.js`
122
+ // asserts that identity with `assert.equal`.
123
+ const document = filterReviewDocument(
124
+ buildReviewDocument({ repoPath, comments, generatedAt: new Date(), head, branch }),
125
+ { file }
126
+ );
127
+
128
+ if (document.comments.length === 0) throw new NoMatchingCommentsError(repoPath, file);
129
+
130
+ return document;
131
+ }
132
+
133
+ /**
134
+ * Every repository with a saved review in `reviewsDir`.
135
+ *
136
+ * Nothing in this codebase enumerated `reviews/` before: there was no way to
137
+ * ask "which repositories have reviews?", only to ask about one you already
138
+ * knew the path of. Any agent-facing surface needs discovery first.
139
+ *
140
+ * Entries are reported from what each file records about itself, with the
141
+ * same ownership rule `readSavedComments` enforces: a file whose recorded
142
+ * repository disagrees with its own filename is corrupt and is skipped rather
143
+ * than listed under a name it does not own. Unreadable and unparseable files
144
+ * are skipped for the same reason — a listing that throws because one file in
145
+ * the directory is damaged is useless.
146
+ *
147
+ * A missing `reviewsDir` is an empty list, not an error: nobody has reviewed
148
+ * anything yet.
149
+ *
150
+ * @param {string} reviewsDir
151
+ * @returns {Promise<Array<{repoPath: string, name: string, comments: number, updatedAt: string}>>}
152
+ * newest first
153
+ */
154
+ async function listReviews(reviewsDir) {
155
+ let names;
156
+ try {
157
+ names = await fs.readdir(reviewsDir);
158
+ } catch (error) {
159
+ if (error.code === 'ENOENT') return [];
160
+ throw error;
161
+ }
162
+
163
+ const reviews = [];
164
+
165
+ for (const name of names) {
166
+ if (!name.startsWith('.code-review-comments-') || !name.endsWith('.json')) continue;
167
+
168
+ const file = path.join(reviewsDir, name);
169
+
170
+ let saved;
171
+ let stat;
172
+ try {
173
+ [saved, stat] = await Promise.all([
174
+ fs.readFile(file, 'utf-8').then(JSON.parse),
175
+ fs.stat(file)
176
+ ]);
177
+ } catch {
178
+ continue; // unreadable or not JSON; a damaged file is not a listing failure
179
+ }
180
+
181
+ const repoPath = saved?.repoPath;
182
+ if (typeof repoPath !== 'string' || repoPath === '') continue;
183
+
184
+ // The filename carries a fingerprint of the canonical path. If the file
185
+ // records a repository that would not produce this filename, the two
186
+ // disagree about who it belongs to, and store.js would refuse to serve
187
+ // it. Listing it anyway would advertise a review that cannot be opened.
188
+ if (commentsFilename(repoPath) !== name) continue;
189
+ if (!belongsTo(repoPath, repoPath)) continue;
190
+
191
+ const comments = Array.isArray(saved.comments) ? saved.comments.length : 0;
192
+ if (comments === 0) continue; // an emptied review is not a review
193
+
194
+ reviews.push({
195
+ repoPath: canonicalRepoPath(repoPath),
196
+ name: repoSlug(repoPath),
197
+ comments,
198
+ updatedAt: stat.mtime.toISOString()
199
+ });
200
+ }
201
+
202
+ return reviews.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
203
+ }
204
+
205
+ module.exports = { loadReviewDocument, listReviews, NoReviewError, NoMatchingCommentsError };
package/lib/paths.js CHANGED
@@ -1,11 +1,117 @@
1
1
  'use strict';
2
2
 
3
+ const fs = require('fs');
4
+ const os = require('os');
3
5
  const path = require('path');
4
6
 
5
7
  /**
6
- * Confining request-supplied paths to the repository being reviewed.
8
+ * Where review state lives, and confining request-supplied paths to the
9
+ * repository being reviewed.
7
10
  */
8
11
 
12
+ /** The directory reviews were written to before #33. Inside the package. */
13
+ const LEGACY_REVIEWS_DIR = path.join(__dirname, '..', 'reviews');
14
+
15
+ /**
16
+ * The per-user directory this tool may keep state in.
17
+ *
18
+ * Reviews used to be written to `<package>/reviews`, which put user data
19
+ * inside a directory npm owns and reuses. Both of the things npm does to that
20
+ * directory destroy the data:
21
+ *
22
+ * npm uninstall git-reviewer -> the reviews go with it
23
+ * npm i git-reviewer@<newer> -> npm replaces the package directory
24
+ *
25
+ * and `npx git-reviewer .` -- the command the README leads with -- writes into
26
+ * the npx cache, which is pruned on npm's schedule rather than the user's.
27
+ *
28
+ * A review someone wrote by hand is the most expensive thing this tool holds.
29
+ * It does not belong anywhere a package manager is entitled to delete.
30
+ *
31
+ * @returns {string}
32
+ */
33
+ function userDataDir() {
34
+ const home = os.homedir();
35
+
36
+ if (process.platform === 'darwin') {
37
+ return path.join(home, 'Library', 'Application Support');
38
+ }
39
+
40
+ if (process.platform === 'win32') {
41
+ return (
42
+ process.env.LOCALAPPDATA ||
43
+ process.env.APPDATA ||
44
+ path.join(home, 'AppData', 'Local')
45
+ );
46
+ }
47
+
48
+ return process.env.XDG_DATA_HOME || path.join(home, '.local', 'share');
49
+ }
50
+
51
+ /**
52
+ * Where comment state and submitted reviews are written.
53
+ *
54
+ * Resolved on every call rather than frozen at import, so that setting
55
+ * `REVIEWER_DATA_DIR` always takes effect. That is not only a convenience:
56
+ * the test suite spawns the real CLI, and a value captured at import time
57
+ * would have those tests writing into the developer's actual review
58
+ * directory.
59
+ *
60
+ * @returns {string}
61
+ */
62
+ function reviewsDir() {
63
+ const override = process.env.REVIEWER_DATA_DIR;
64
+ if (override) return path.resolve(override);
65
+ return path.join(userDataDir(), 'git-reviewer');
66
+ }
67
+
68
+ /**
69
+ * Move reviews written by an older version out of the package directory.
70
+ *
71
+ * Same shape, and the same reasoning, as the legacy-filename adoption in
72
+ * `lib/store.js`: a review someone wrote is not ours to abandon because we
73
+ * changed our minds about where it belongs.
74
+ *
75
+ * Copy rather than rename: the source may be read-only, on another device, or
76
+ * an npx cache we do not own, and none of those is a reason to fail. Existing
77
+ * files at the destination always win -- a second run must not overwrite newer
78
+ * work with the stale copy it already migrated.
79
+ *
80
+ * Synchronous on purpose. It runs once, before anything can read the
81
+ * directory, and an async version would let a read start against a directory
82
+ * that is still half-populated.
83
+ *
84
+ * @param {string} [target] defaults to the resolved reviews directory
85
+ * @returns {number} how many files were adopted
86
+ */
87
+ function adoptLegacyReviews(target = reviewsDir()) {
88
+ if (path.resolve(target) === path.resolve(LEGACY_REVIEWS_DIR)) return 0;
89
+
90
+ let names;
91
+ try {
92
+ names = fs.readdirSync(LEGACY_REVIEWS_DIR);
93
+ } catch {
94
+ return 0; // nothing written by an older version
95
+ }
96
+
97
+ let adopted = 0;
98
+ for (const name of names) {
99
+ const from = path.join(LEGACY_REVIEWS_DIR, name);
100
+ const to = path.join(target, name);
101
+ try {
102
+ if (!fs.statSync(from).isFile()) continue;
103
+ if (fs.existsSync(to)) continue;
104
+ fs.mkdirSync(target, { recursive: true });
105
+ fs.copyFileSync(from, to);
106
+ adopted += 1;
107
+ } catch {
108
+ /* one unreadable file is not a reason to abandon the rest */
109
+ }
110
+ }
111
+
112
+ return adopted;
113
+ }
114
+
9
115
  class PathEscapeError extends Error {
10
116
  /** @param {string} requested the offending path, as received */
11
117
  constructor(requested) {
@@ -49,4 +155,19 @@ function resolveRepoFile(repoPath, filePath) {
49
155
  return resolved;
50
156
  }
51
157
 
52
- module.exports = { resolveRepoFile, PathEscapeError };
158
+ module.exports = {
159
+ reviewsDir,
160
+ userDataDir,
161
+ adoptLegacyReviews,
162
+ LEGACY_REVIEWS_DIR,
163
+ resolveRepoFile,
164
+ PathEscapeError
165
+ };
166
+
167
+ // Kept so `require('./lib/paths').REVIEWS_DIR` and the server's re-export
168
+ // keep working. A getter, not a value, so it honours REVIEWER_DATA_DIR at
169
+ // the moment it is read.
170
+ Object.defineProperty(module.exports, 'REVIEWS_DIR', {
171
+ get: reviewsDir,
172
+ enumerable: true
173
+ });
package/lib/review.js CHANGED
@@ -179,6 +179,8 @@ function formatComment(comment) {
179
179
  * @param {string} text
180
180
  * @returns {string}
181
181
  */
182
+ // Exported for lib/agent.js: the prompt format has the same problem this
183
+ // solves, and one rule for choosing a marker is better than two.
182
184
  function fence(text) {
183
185
  const longest = String(text).match(/^`{3,}/gm)?.reduce((a, b) => (b.length > a.length ? b : a), '') ?? '';
184
186
  const marker = '`'.repeat(Math.max(3, longest.length + 1));
@@ -197,6 +199,7 @@ function formatTimestamp(timestamp) {
197
199
 
198
200
  module.exports = {
199
201
  formatReview,
202
+ fence,
200
203
  reviewFilename,
201
204
  commentsFilename,
202
205
  legacyCommentsFilename,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "git-reviewer",
3
- "version": "2.0.0",
4
- "description": "Review local git changes in your browser with inline comments and threaded follow-ups \u2014 no branch, remote, or account required",
3
+ "version": "2.2.0",
4
+ "description": "Review your coding agent's changes like a pull request \u2014 locally, with inline comments and threaded follow-ups, then hand the marked-up review back to the agent",
5
5
  "author": "Dheeraj Jha",
6
6
  "license": "MIT",
7
7
  "homepage": "https://github.com/dheerajjha/reviewer#readme",
@@ -14,11 +14,17 @@
14
14
  },
15
15
  "keywords": [
16
16
  "code-review",
17
+ "code-review-tool",
17
18
  "git",
18
19
  "diff",
19
20
  "review",
20
21
  "cli",
21
- "developer-tools"
22
+ "developer-tools",
23
+ "ai",
24
+ "agent",
25
+ "coding-agent",
26
+ "llm",
27
+ "local-first"
22
28
  ],
23
29
  "engines": {
24
30
  "node": ">=18.0.0"
package/public/app.js CHANGED
@@ -296,7 +296,7 @@ async function loadFile(filePath, index) {
296
296
  updateCommentsSidebar();
297
297
  }
298
298
 
299
- displayCode(data.filePath, data.diffLines);
299
+ displayCode(data.filePath, data.diffLines, data.binary);
300
300
  updateFullContextButton();
301
301
  return Promise.resolve();
302
302
 
@@ -307,7 +307,7 @@ async function loadFile(filePath, index) {
307
307
  }
308
308
 
309
309
  // Display code with diff highlighting
310
- function displayCode(filePath, diffLines) {
310
+ function displayCode(filePath, diffLines, binary) {
311
311
  const codeSection = document.getElementById('codeSection');
312
312
  const currentFileEl = document.getElementById('currentFile');
313
313
  const codeViewer = document.getElementById('codeViewer');
@@ -315,6 +315,19 @@ function displayCode(filePath, diffLines) {
315
315
  currentFileEl.textContent = filePath;
316
316
  currentDiffLines = diffLines; // Store for reference when adding comments
317
317
 
318
+ // A binary file has no lines to show and none to comment on. Saying so is
319
+ // the whole feature: it used to be decoded as UTF-8 and rendered as
320
+ // mojibake, and a comment left on one of those lines exported an anchor
321
+ // that could never match the file again.
322
+ if (binary) {
323
+ codeSection.style.display = 'block';
324
+ codeViewer.innerHTML =
325
+ '<div class="binary-notice">Binary file &mdash; not shown.' +
326
+ '<span>git reports this file as binary, so there are no lines to ' +
327
+ 'review. Comments need a line to anchor to.</span></div>';
328
+ return;
329
+ }
330
+
318
331
  const fileComments = comments.filter(c => c.file === filePath);
319
332
 
320
333
  let html = '';
package/public/style.css CHANGED
@@ -988,3 +988,19 @@ button:disabled {
988
988
  color: #24292e;
989
989
  border: 1px solid #e1e4e8;
990
990
  }
991
+
992
+ /* A binary file has no lines to render; say so rather than showing a
993
+ lossy UTF-8 decode of its bytes. See #23. */
994
+ .binary-notice {
995
+ padding: 28px 24px;
996
+ color: #57606a;
997
+ font-size: 14px;
998
+ line-height: 1.6;
999
+ }
1000
+
1001
+ .binary-notice span {
1002
+ display: block;
1003
+ margin-top: 6px;
1004
+ font-size: 13px;
1005
+ opacity: 0.8;
1006
+ }
package/server.js CHANGED
@@ -7,7 +7,11 @@ const fs = require('fs').promises;
7
7
  const path = require('path');
8
8
 
9
9
  const { parseDiff } = require('./lib/diff');
10
- const { resolveRepoFile, PathEscapeError } = require('./lib/paths');
10
+ const {
11
+ reviewsDir: resolveReviewsDir,
12
+ resolveRepoFile,
13
+ PathEscapeError
14
+ } = require('./lib/paths');
11
15
  const { collectWorkingChanges, collectCommitChanges } = require('./lib/changes');
12
16
  const { SessionStore } = require('./lib/sessions');
13
17
  const { normalizeComments } = require('./lib/comments');
@@ -18,8 +22,6 @@ const { readSavedComments, ReviewOwnershipError } = require('./lib/store');
18
22
  const DEFAULT_PORT = 4500;
19
23
  const DEFAULT_HOST = '127.0.0.1';
20
24
 
21
- /** Where comment state and submitted reviews are written. */
22
- const REVIEWS_DIR = path.join(__dirname, 'reviews');
23
25
 
24
26
  /** A path inside the repository that names nothing, on disk or at HEAD. */
25
27
  class FileNotFoundError extends Error {
@@ -46,7 +48,7 @@ class FileNotFoundError extends Error {
46
48
  */
47
49
  function createApp(options = {}) {
48
50
  const {
49
- reviewsDir = REVIEWS_DIR,
51
+ reviewsDir = resolveReviewsDir(),
50
52
  sessions = new SessionStore(),
51
53
  git: gitFactory = simpleGit
52
54
  } = options;
@@ -147,6 +149,29 @@ function createApp(options = {}) {
147
149
  * @param {string} filePath
148
150
  * @returns {Promise<string>}
149
151
  */
152
+ /**
153
+ * Whether git considers this file binary.
154
+ *
155
+ * Asking git rather than sniffing the bytes ourselves, because git's
156
+ * answer is the one that produced the diff we are about to render, and a
157
+ * second opinion that disagreed would be worse than no opinion. It says
158
+ * so in the diff itself: `Binary files a/x and b/x differ` for the
159
+ * default format, `GIT binary patch` when binary patches are enabled.
160
+ *
161
+ * This matters beyond the mojibake. `lib/changes.js` already classifies
162
+ * these as `B` and the sidebar renders that letter, but the file endpoint
163
+ * never consulted it -- so a 2 KB PNG came back as ten `add` lines that
164
+ * were ~45% U+FFFD, and a comment left on one of them exported an
165
+ * `anchor` that can never match the file. docs/agent-format.md tells a
166
+ * consumer to locate comments by that anchor.
167
+ *
168
+ * @param {string} diffText
169
+ * @returns {boolean}
170
+ */
171
+ function isBinaryDiff(diffText) {
172
+ return /^Binary files .* differ$/m.test(diffText) || /^GIT binary patch$/m.test(diffText);
173
+ }
174
+
150
175
  async function diffForFile(git, session, filePath) {
151
176
  if (session.mode === 'lastCommit') {
152
177
  return git.diff(['HEAD~1', 'HEAD', '--', filePath]);
@@ -219,11 +244,26 @@ function createApp(options = {}) {
219
244
  const session = requireSession(res, repoId);
220
245
  if (!session) return;
221
246
 
247
+ // Confine the path first, explicitly. It used to be confined as a
248
+ // side effect of `readFileForMode` calling `resolveRepoFile`, so
249
+ // moving the read below the binary check silently turned a 400 into a
250
+ // 500 and let the traversal reach git. A containment check that only
251
+ // works because of statement order is not a containment check.
252
+ resolveRepoFile(session.repoPath, filePath);
253
+
222
254
  const git = gitFactory(session.repoPath);
223
- const content = await readFileForMode(session, filePath);
224
255
  const diffText = await diffForFile(git, session, filePath);
225
256
 
226
- res.json({ filePath, diffLines: parseDiff(diffText, content) });
257
+ // Answered before the file is read: decoding a PNG as UTF-8 to then
258
+ // throw the result away is how the mojibake got served in the first
259
+ // place.
260
+ if (isBinaryDiff(diffText)) {
261
+ return res.json({ filePath, binary: true, diffLines: [] });
262
+ }
263
+
264
+ const content = await readFileForMode(session, filePath);
265
+
266
+ res.json({ filePath, binary: false, diffLines: parseDiff(diffText, content) });
227
267
  } catch (error) {
228
268
  if (error instanceof PathEscapeError) {
229
269
  return res.status(400).json({ error: error.message });
@@ -418,4 +458,11 @@ if (require.main === module) {
418
458
  });
419
459
  }
420
460
 
421
- module.exports = { createApp, startServer, DEFAULT_PORT, DEFAULT_HOST, REVIEWS_DIR };
461
+ module.exports = { createApp, startServer, DEFAULT_PORT, DEFAULT_HOST };
462
+
463
+ // Re-exported as a getter so existing callers keep working and still see
464
+ // REVIEWER_DATA_DIR. See lib/paths.js.
465
+ Object.defineProperty(module.exports, 'REVIEWS_DIR', {
466
+ get: resolveReviewsDir,
467
+ enumerable: true
468
+ });