@review-my-code/rmcode 0.1.1 → 0.1.2
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/dist/cli.js +428 -48
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -2,17 +2,33 @@
|
|
|
2
2
|
"use strict";
|
|
3
3
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
4
|
const child_process_1 = require("child_process");
|
|
5
|
+
const crypto_1 = require("crypto");
|
|
5
6
|
const fs_1 = require("fs");
|
|
7
|
+
const os_1 = require("os");
|
|
6
8
|
const path_1 = require("path");
|
|
7
9
|
const readline_1 = require("readline");
|
|
8
10
|
const API_URL = process.env.RMC_API_URL || "https://review-my-code.com";
|
|
9
11
|
const APP_URL = process.env.RMC_APP_URL || "https://review-my-code.com";
|
|
10
|
-
const VERSION = "0.1.
|
|
12
|
+
const VERSION = "0.1.2";
|
|
11
13
|
const FREE_PLAN_CREDITS_PER_MONTH = 30;
|
|
12
14
|
const REQUEST_TIMEOUT_MS = 290_000;
|
|
13
15
|
const POLL_TIMEOUT_MS = 10 * 60_000;
|
|
14
|
-
|
|
16
|
+
// Backed-off polling keeps long reviews well under the backend's per-IP
|
|
17
|
+
// rate limit (100 requests / 15 min).
|
|
18
|
+
const POLL_INITIAL_INTERVAL_MS = 3_000;
|
|
19
|
+
const POLL_MAX_INTERVAL_MS = 15_000;
|
|
20
|
+
const POLL_BACKOFF_FACTOR = 1.5;
|
|
15
21
|
const MAX_UNTRACKED_FILE_BYTES = 512 * 1024;
|
|
22
|
+
const EXTRACTOR_TIMEOUT_MS = 120_000;
|
|
23
|
+
const EXTRACTOR_DOWNLOAD_TIMEOUT_MS = 60_000;
|
|
24
|
+
const EXTRACTOR_INFO_TIMEOUT_MS = 15_000;
|
|
25
|
+
// Mirrors the GitHub App workflow template's "Install extractor runtime
|
|
26
|
+
// dependencies" step and REPOMAP_MAX_CHARS env.
|
|
27
|
+
const EXTRACTOR_RUNTIME_DEPS = [
|
|
28
|
+
"web-tree-sitter@0.20.8",
|
|
29
|
+
"tree-sitter-wasms@0.1.13",
|
|
30
|
+
];
|
|
31
|
+
const EXTRACTOR_REPOMAP_MAX_CHARS = "80000";
|
|
16
32
|
// ── Colors (no dependencies) ──────────────────────────────────────────
|
|
17
33
|
const c = {
|
|
18
34
|
reset: "\x1b[0m",
|
|
@@ -35,6 +51,19 @@ function style(text, ...codes) {
|
|
|
35
51
|
return text;
|
|
36
52
|
return codes.join("") + text + c.reset;
|
|
37
53
|
}
|
|
54
|
+
// ── Owl header ────────────────────────────────────────────────────────
|
|
55
|
+
//
|
|
56
|
+
// Brand header shown once at the start of review runs (stderr, so piped
|
|
57
|
+
// stdout stays parseable) and in --help. Hidden in --json mode, non-TTY,
|
|
58
|
+
// NO_COLOR, and CI via the noColor/isCI guards.
|
|
59
|
+
function owlHeader() {
|
|
60
|
+
const owl = (part) => style(part, c.dim, c.gray);
|
|
61
|
+
return [
|
|
62
|
+
` ${owl("{o,o}")}`,
|
|
63
|
+
` ${owl("/)__)")} ${style("rmcode", c.bold, c.white)} ${style(`v${VERSION} — AI code review`, c.dim)}`,
|
|
64
|
+
` ${owl('" "')} ${style("─────────────────────────────────", c.dim)}`,
|
|
65
|
+
].join("\n");
|
|
66
|
+
}
|
|
38
67
|
// ── Severity styling ──────────────────────────────────────────────────
|
|
39
68
|
function severityBadge(severity) {
|
|
40
69
|
const s = severity.toLowerCase();
|
|
@@ -53,6 +82,13 @@ function git(args) {
|
|
|
53
82
|
maxBuffer: 10 * 1024 * 1024,
|
|
54
83
|
}).trim();
|
|
55
84
|
}
|
|
85
|
+
function gitQuiet(args) {
|
|
86
|
+
return (0, child_process_1.execFileSync)("git", args, {
|
|
87
|
+
encoding: "utf-8",
|
|
88
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
89
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
90
|
+
}).trim();
|
|
91
|
+
}
|
|
56
92
|
function isGitRepo() {
|
|
57
93
|
try {
|
|
58
94
|
git(["rev-parse", "--is-inside-work-tree"]);
|
|
@@ -90,25 +126,33 @@ function mergeBase() {
|
|
|
90
126
|
}
|
|
91
127
|
throw new Error("Could not determine a base branch. Set an upstream branch, or run rmcode --staged, rmcode --all, or pipe an explicit git diff.");
|
|
92
128
|
}
|
|
129
|
+
function headCommit() {
|
|
130
|
+
try {
|
|
131
|
+
return git(["rev-parse", "--verify", "HEAD"]);
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
return undefined;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
93
137
|
function diffFromMergeBase() {
|
|
94
138
|
const base = mergeBase();
|
|
95
139
|
const diff = git(["diff", `${base}...HEAD`]);
|
|
96
140
|
const files = git(["diff", `${base}...HEAD`, "--name-only"])
|
|
97
141
|
.split("\n")
|
|
98
142
|
.filter(Boolean);
|
|
99
|
-
return { diff, files };
|
|
143
|
+
return { diff, files, baseRef: base };
|
|
100
144
|
}
|
|
101
145
|
function diffStaged() {
|
|
102
146
|
const diff = git(["diff", "--cached"]);
|
|
103
147
|
const files = git(["diff", "--cached", "--name-only"])
|
|
104
148
|
.split("\n")
|
|
105
149
|
.filter(Boolean);
|
|
106
|
-
return { diff, files };
|
|
150
|
+
return { diff, files, baseRef: headCommit() };
|
|
107
151
|
}
|
|
108
152
|
function diffUnstaged() {
|
|
109
153
|
const diff = git(["diff"]);
|
|
110
154
|
const files = git(["diff", "--name-only"]).split("\n").filter(Boolean);
|
|
111
|
-
return { diff, files };
|
|
155
|
+
return { diff, files, baseRef: headCommit() };
|
|
112
156
|
}
|
|
113
157
|
function diffAll() {
|
|
114
158
|
const root = gitWorktreeRoot();
|
|
@@ -124,7 +168,7 @@ function diffAll() {
|
|
|
124
168
|
.filter(Boolean);
|
|
125
169
|
const diff = [trackedDiff, ...untrackedDiffs].filter(Boolean).join("\n\n");
|
|
126
170
|
const files = [...trackedFiles, ...untrackedFiles];
|
|
127
|
-
return { diff, files };
|
|
171
|
+
return { diff, files, baseRef: headCommit() };
|
|
128
172
|
}
|
|
129
173
|
function pseudoDiffForNewFile(file, worktreeRoot) {
|
|
130
174
|
try {
|
|
@@ -236,22 +280,50 @@ async function fetchReviewJson(url, init) {
|
|
|
236
280
|
clearTimeout(timeout);
|
|
237
281
|
}
|
|
238
282
|
}
|
|
239
|
-
async function callAuthenticatedAPI(code, language, type, files) {
|
|
283
|
+
async function callAuthenticatedAPI(code, language, type, files, bundle, progress) {
|
|
240
284
|
const repo = gitRepoName();
|
|
241
|
-
const
|
|
285
|
+
const payload = {
|
|
286
|
+
type,
|
|
287
|
+
value: code,
|
|
288
|
+
language,
|
|
289
|
+
files,
|
|
290
|
+
repo,
|
|
291
|
+
};
|
|
292
|
+
if (bundle)
|
|
293
|
+
payload.bundle = bundle;
|
|
294
|
+
let { status, body } = await fetchReviewJson(`${API_URL}/api/reviews/cli`, {
|
|
242
295
|
method: "POST",
|
|
243
296
|
headers: {
|
|
244
297
|
"Content-Type": "application/json",
|
|
245
298
|
"X-API-Key": API_KEY,
|
|
246
299
|
},
|
|
247
|
-
body: JSON.stringify(
|
|
300
|
+
body: JSON.stringify(payload),
|
|
248
301
|
});
|
|
302
|
+
// If the API rejects the attached context bundle (too large or invalid),
|
|
303
|
+
// degrade to the legacy diff-only request instead of failing the review.
|
|
304
|
+
if (bundle && (status === 413 || status === 400)) {
|
|
305
|
+
const notice = style(" Repository context was rejected by the API — reviewing without it.", c.dim);
|
|
306
|
+
if (progress)
|
|
307
|
+
progress.note(notice);
|
|
308
|
+
else
|
|
309
|
+
console.error(notice);
|
|
310
|
+
delete payload.bundle;
|
|
311
|
+
({ status, body } = await fetchReviewJson(`${API_URL}/api/reviews/cli`, {
|
|
312
|
+
method: "POST",
|
|
313
|
+
headers: {
|
|
314
|
+
"Content-Type": "application/json",
|
|
315
|
+
"X-API-Key": API_KEY,
|
|
316
|
+
},
|
|
317
|
+
body: JSON.stringify(payload),
|
|
318
|
+
}));
|
|
319
|
+
}
|
|
249
320
|
if (status === 202 && body.data?.accessToken) {
|
|
250
|
-
|
|
321
|
+
progress?.update("Reviewing (queued)");
|
|
322
|
+
return pollForResults(`${API_URL}/api/reviews/cli/${body.data.accessToken}`, body.meta, { "X-API-Key": API_KEY }, progress);
|
|
251
323
|
}
|
|
252
324
|
return body;
|
|
253
325
|
}
|
|
254
|
-
async function callAnonymousAPI(code, language, type, files) {
|
|
326
|
+
async function callAnonymousAPI(code, language, type, files, progress) {
|
|
255
327
|
const repo = gitRepoName();
|
|
256
328
|
const { status, body } = await fetchReviewJson(`${API_URL}/api/reviews/anonymous`, {
|
|
257
329
|
method: "POST",
|
|
@@ -259,15 +331,23 @@ async function callAnonymousAPI(code, language, type, files) {
|
|
|
259
331
|
body: JSON.stringify({ type, value: code, language, files, repo }),
|
|
260
332
|
});
|
|
261
333
|
if (status === 202 && body.data?.accessToken) {
|
|
262
|
-
|
|
334
|
+
progress?.update("Reviewing (queued)");
|
|
335
|
+
return pollForResults(`${API_URL}/api/reviews/anonymous/${body.data.accessToken}`, body.meta, undefined, progress);
|
|
263
336
|
}
|
|
264
337
|
return body;
|
|
265
338
|
}
|
|
266
|
-
async function pollForResults(url, initialMeta, headers) {
|
|
339
|
+
async function pollForResults(url, initialMeta, headers, progress) {
|
|
267
340
|
const deadline = Date.now() + POLL_TIMEOUT_MS;
|
|
341
|
+
let interval = POLL_INITIAL_INTERVAL_MS;
|
|
342
|
+
let reviewing = false;
|
|
268
343
|
while (Date.now() < deadline) {
|
|
269
|
-
await new Promise((r) => setTimeout(r,
|
|
344
|
+
await new Promise((r) => setTimeout(r, interval));
|
|
345
|
+
interval = Math.min(interval * POLL_BACKOFF_FACTOR, POLL_MAX_INTERVAL_MS);
|
|
270
346
|
const { status, body } = await fetchReviewJson(url, { headers });
|
|
347
|
+
if (status === 429 || body.error?.includes("Too many requests")) {
|
|
348
|
+
// Rate limiting is transient — retry at the backed-off interval.
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
271
351
|
if (status >= 400 || body.success === false) {
|
|
272
352
|
if (initialMeta && !body.meta)
|
|
273
353
|
body.meta = initialMeta;
|
|
@@ -278,12 +358,19 @@ async function pollForResults(url, initialMeta, headers) {
|
|
|
278
358
|
body.meta = initialMeta;
|
|
279
359
|
return body;
|
|
280
360
|
}
|
|
361
|
+
// The poll body carries no real queue state ("pending" covers both
|
|
362
|
+
// queued and running), so flip to the active phase once the first
|
|
363
|
+
// poll answers — unless the API explicitly reports "queued".
|
|
364
|
+
if (progress && !reviewing && body.data?.status !== "queued") {
|
|
365
|
+
reviewing = true;
|
|
366
|
+
progress.update("Reviewing");
|
|
367
|
+
}
|
|
281
368
|
}
|
|
282
369
|
return { success: false, error: "Review timed out after 10 minutes." };
|
|
283
370
|
}
|
|
284
|
-
function callAPI(code, language, type = "snippet", files = []) {
|
|
371
|
+
function callAPI(code, language, type = "snippet", files = [], bundle, progress) {
|
|
285
372
|
if (API_KEY)
|
|
286
|
-
return callAuthenticatedAPI(code, language, type, files);
|
|
373
|
+
return callAuthenticatedAPI(code, language, type, files, bundle, progress);
|
|
287
374
|
return Promise.resolve({
|
|
288
375
|
success: false,
|
|
289
376
|
error: "API key required. Run `rmcode login`, then set RMC_API_KEY before sending code for review.",
|
|
@@ -295,6 +382,186 @@ function callAPI(code, language, type = "snippet", files = []) {
|
|
|
295
382
|
},
|
|
296
383
|
});
|
|
297
384
|
}
|
|
385
|
+
class ExtractorIntegrityError extends Error {
|
|
386
|
+
}
|
|
387
|
+
function sha256Hex(data) {
|
|
388
|
+
return (0, crypto_1.createHash)("sha256").update(data).digest("hex");
|
|
389
|
+
}
|
|
390
|
+
async function fetchExtractorInfo() {
|
|
391
|
+
try {
|
|
392
|
+
const resp = await fetch(`${API_URL}/api/reviews/cli/extractor`, {
|
|
393
|
+
headers: { "X-API-Key": API_KEY },
|
|
394
|
+
signal: AbortSignal.timeout(EXTRACTOR_INFO_TIMEOUT_MS),
|
|
395
|
+
});
|
|
396
|
+
if (!resp.ok)
|
|
397
|
+
return null;
|
|
398
|
+
const body = (await resp.json());
|
|
399
|
+
const data = body.data;
|
|
400
|
+
if (!data?.version || !data.sha256 || !data.url)
|
|
401
|
+
return null;
|
|
402
|
+
return {
|
|
403
|
+
version: data.version,
|
|
404
|
+
sha256: data.sha256.toLowerCase(),
|
|
405
|
+
url: data.url,
|
|
406
|
+
tier: data.tier,
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
catch {
|
|
410
|
+
return null;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
/**
|
|
414
|
+
* Download (or reuse a cached copy of) the pinned extractor and install its
|
|
415
|
+
* runtime dependencies next to it, mirroring the GitHub App workflow.
|
|
416
|
+
* Throws ExtractorIntegrityError on checksum mismatch — never run an
|
|
417
|
+
* artifact that fails verification.
|
|
418
|
+
*/
|
|
419
|
+
async function ensureExtractor(info) {
|
|
420
|
+
const cacheDir = (0, path_1.join)((0, os_1.tmpdir)(), "rmcode-extractor", info.version);
|
|
421
|
+
(0, fs_1.mkdirSync)(cacheDir, { recursive: true });
|
|
422
|
+
const extractorPath = (0, path_1.join)(cacheDir, "extractor.mjs");
|
|
423
|
+
let verified = false;
|
|
424
|
+
if ((0, fs_1.existsSync)(extractorPath)) {
|
|
425
|
+
verified = sha256Hex((0, fs_1.readFileSync)(extractorPath)) === info.sha256;
|
|
426
|
+
}
|
|
427
|
+
if (!verified) {
|
|
428
|
+
const resp = await fetch(info.url, {
|
|
429
|
+
signal: AbortSignal.timeout(EXTRACTOR_DOWNLOAD_TIMEOUT_MS),
|
|
430
|
+
});
|
|
431
|
+
if (!resp.ok) {
|
|
432
|
+
throw new Error(`Extractor download failed (${resp.status}).`);
|
|
433
|
+
}
|
|
434
|
+
const data = Buffer.from(await resp.arrayBuffer());
|
|
435
|
+
if (sha256Hex(data) !== info.sha256) {
|
|
436
|
+
throw new ExtractorIntegrityError(`Extractor checksum mismatch for ${info.url} — refusing to run it. Expected sha256 ${info.sha256}.`);
|
|
437
|
+
}
|
|
438
|
+
(0, fs_1.writeFileSync)(extractorPath, data);
|
|
439
|
+
}
|
|
440
|
+
if (!(0, fs_1.existsSync)((0, path_1.join)(cacheDir, "node_modules", "web-tree-sitter"))) {
|
|
441
|
+
const npm = process.platform === "win32" ? "npm.cmd" : "npm";
|
|
442
|
+
const init = (0, child_process_1.spawnSync)(npm, ["init", "-y"], {
|
|
443
|
+
cwd: cacheDir,
|
|
444
|
+
stdio: "ignore",
|
|
445
|
+
timeout: 30_000,
|
|
446
|
+
});
|
|
447
|
+
if (init.status !== 0) {
|
|
448
|
+
throw new Error("Could not initialize extractor dependency directory.");
|
|
449
|
+
}
|
|
450
|
+
const install = (0, child_process_1.spawnSync)(npm, [
|
|
451
|
+
"install",
|
|
452
|
+
"--no-save",
|
|
453
|
+
"--legacy-peer-deps",
|
|
454
|
+
"--no-audit",
|
|
455
|
+
"--no-fund",
|
|
456
|
+
...EXTRACTOR_RUNTIME_DEPS,
|
|
457
|
+
], { cwd: cacheDir, stdio: "ignore", timeout: EXTRACTOR_TIMEOUT_MS });
|
|
458
|
+
if (install.status !== 0) {
|
|
459
|
+
throw new Error("Could not install extractor runtime dependencies.");
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
return extractorPath;
|
|
463
|
+
}
|
|
464
|
+
/**
|
|
465
|
+
* Run the pinned extractor against a temporary detached worktree at the
|
|
466
|
+
* diff's base state and return the ContextBundle, or null when extraction
|
|
467
|
+
* is unavailable. Throws ExtractorIntegrityError on checksum mismatch.
|
|
468
|
+
*/
|
|
469
|
+
async function extractContextBundle(diff, baseRef) {
|
|
470
|
+
const info = await fetchExtractorInfo();
|
|
471
|
+
if (!info)
|
|
472
|
+
return null;
|
|
473
|
+
const extractorPath = await ensureExtractor(info);
|
|
474
|
+
const runDir = (0, fs_1.mkdtempSync)((0, path_1.join)((0, os_1.tmpdir)(), "rmcode-run-"));
|
|
475
|
+
const diffPath = (0, path_1.join)(runDir, "pr.diff");
|
|
476
|
+
const bundlePath = (0, path_1.join)(runDir, "bundle.json");
|
|
477
|
+
const baseDir = (0, path_1.join)(runDir, "base");
|
|
478
|
+
let worktreeAdded = false;
|
|
479
|
+
try {
|
|
480
|
+
try {
|
|
481
|
+
gitQuiet(["worktree", "add", "--detach", baseDir, baseRef]);
|
|
482
|
+
worktreeAdded = true;
|
|
483
|
+
}
|
|
484
|
+
catch {
|
|
485
|
+
// Without a base-state worktree the extractor would see the wrong
|
|
486
|
+
// tree (e.g. uncommitted or post-PR files), so degrade to diff-only
|
|
487
|
+
// instead of extracting from the live checkout.
|
|
488
|
+
return null;
|
|
489
|
+
}
|
|
490
|
+
(0, fs_1.writeFileSync)(diffPath, diff);
|
|
491
|
+
const result = (0, child_process_1.spawnSync)(process.execPath, [
|
|
492
|
+
extractorPath,
|
|
493
|
+
"--pr=0",
|
|
494
|
+
`--diff-file=${diffPath}`,
|
|
495
|
+
`--output-bundle=${bundlePath}`,
|
|
496
|
+
`--source-repo=${process.env.RMC_SOURCE_REPO || gitRepoName() || "local/repo"}`,
|
|
497
|
+
`--tier=${info.tier || "free"}`,
|
|
498
|
+
], {
|
|
499
|
+
cwd: baseDir,
|
|
500
|
+
stdio: "ignore",
|
|
501
|
+
timeout: EXTRACTOR_TIMEOUT_MS,
|
|
502
|
+
env: {
|
|
503
|
+
...process.env,
|
|
504
|
+
REPOMAP_MAX_CHARS: EXTRACTOR_REPOMAP_MAX_CHARS,
|
|
505
|
+
RMCODE_EXTRACTOR_VERSION: info.version,
|
|
506
|
+
RMCODE_EXTRACTOR_SHA256: info.sha256,
|
|
507
|
+
},
|
|
508
|
+
});
|
|
509
|
+
if (result.status !== 0 || !(0, fs_1.existsSync)(bundlePath))
|
|
510
|
+
return null;
|
|
511
|
+
const bundle = JSON.parse((0, fs_1.readFileSync)(bundlePath, "utf-8"));
|
|
512
|
+
if (typeof bundle.diff !== "string" ||
|
|
513
|
+
typeof bundle.repomapContext !== "string" ||
|
|
514
|
+
typeof bundle.enrichedContext !== "string" ||
|
|
515
|
+
!Array.isArray(bundle.changedFiles)) {
|
|
516
|
+
return null;
|
|
517
|
+
}
|
|
518
|
+
return bundle;
|
|
519
|
+
}
|
|
520
|
+
catch {
|
|
521
|
+
return null;
|
|
522
|
+
}
|
|
523
|
+
finally {
|
|
524
|
+
if (worktreeAdded) {
|
|
525
|
+
try {
|
|
526
|
+
gitQuiet(["worktree", "remove", "--force", baseDir]);
|
|
527
|
+
}
|
|
528
|
+
catch {
|
|
529
|
+
(0, fs_1.rmSync)(baseDir, { recursive: true, force: true });
|
|
530
|
+
try {
|
|
531
|
+
gitQuiet(["worktree", "prune"]);
|
|
532
|
+
}
|
|
533
|
+
catch {
|
|
534
|
+
// Stale worktree metadata only; git prunes it on its own later.
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
(0, fs_1.rmSync)(runDir, { recursive: true, force: true });
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
/**
|
|
542
|
+
* Best-effort context extraction for git-based diff reviews. Returns null
|
|
543
|
+
* (after printing a one-line notice) when the review must run diff-only.
|
|
544
|
+
* Reports progress on the shared review spinner so the run renders as one
|
|
545
|
+
* phase-aware line. Exits the process on extractor checksum mismatch.
|
|
546
|
+
*/
|
|
547
|
+
async function maybeExtractContext(diff, spinner, baseRef) {
|
|
548
|
+
if (!API_KEY || !baseRef || !isGitRepo())
|
|
549
|
+
return null;
|
|
550
|
+
try {
|
|
551
|
+
const bundle = await extractContextBundle(diff, baseRef);
|
|
552
|
+
if (bundle)
|
|
553
|
+
return bundle;
|
|
554
|
+
}
|
|
555
|
+
catch (err) {
|
|
556
|
+
if (err instanceof ExtractorIntegrityError) {
|
|
557
|
+
spinner.stop("Context extraction aborted");
|
|
558
|
+
console.error(style(` ${err.message}`, c.red));
|
|
559
|
+
process.exit(1);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
spinner.note(style(" Running review without repository context.", c.dim));
|
|
563
|
+
return null;
|
|
564
|
+
}
|
|
298
565
|
// ── Language detection ────────────────────────────────────────────────
|
|
299
566
|
const EXT_TO_LANG = {
|
|
300
567
|
".ts": "typescript",
|
|
@@ -339,9 +606,54 @@ function detectLanguage(files) {
|
|
|
339
606
|
return "javascript";
|
|
340
607
|
}
|
|
341
608
|
// ── Output formatting ─────────────────────────────────────────────────
|
|
342
|
-
function
|
|
609
|
+
function severityRank(severity) {
|
|
610
|
+
const s = severity.toLowerCase();
|
|
611
|
+
if (s === "critical" || s === "security")
|
|
612
|
+
return 0;
|
|
613
|
+
if (s === "bug" || s === "high")
|
|
614
|
+
return 1;
|
|
615
|
+
if (s === "perf" || s === "medium")
|
|
616
|
+
return 2;
|
|
617
|
+
return 3;
|
|
618
|
+
}
|
|
619
|
+
function wrapText(text, width) {
|
|
620
|
+
const lines = [];
|
|
621
|
+
for (const paragraph of text.split("\n")) {
|
|
622
|
+
if (!paragraph.trim()) {
|
|
623
|
+
lines.push("");
|
|
624
|
+
continue;
|
|
625
|
+
}
|
|
626
|
+
let line = "";
|
|
627
|
+
for (const word of paragraph.split(/\s+/)) {
|
|
628
|
+
if (!line)
|
|
629
|
+
line = word;
|
|
630
|
+
else if (line.length + 1 + word.length <= width)
|
|
631
|
+
line += ` ${word}`;
|
|
632
|
+
else {
|
|
633
|
+
lines.push(line);
|
|
634
|
+
line = word;
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
if (line)
|
|
638
|
+
lines.push(line);
|
|
639
|
+
}
|
|
640
|
+
return lines;
|
|
641
|
+
}
|
|
642
|
+
function verdictFooter(findingCount, startedAt) {
|
|
643
|
+
const rule = noColor ? "--" : style("──", c.dim);
|
|
644
|
+
const mark = findingCount > 0 ? style("✗", c.red, c.bold) : style("✓", c.green, c.bold);
|
|
645
|
+
const verdict = findingCount > 0
|
|
646
|
+
? `changes requested · ${findingCount} finding${findingCount !== 1 ? "s" : ""}`
|
|
647
|
+
: "approved · no blocking findings";
|
|
648
|
+
const elapsed = startedAt != null
|
|
649
|
+
? ` · ${formatElapsed(Date.now() - startedAt, true)}`
|
|
650
|
+
: "";
|
|
651
|
+
return ` ${rule} ${mark} ${verdict}${elapsed} ${rule}`;
|
|
652
|
+
}
|
|
653
|
+
function printFindings(findings, startedAt) {
|
|
343
654
|
if (findings.length === 0) {
|
|
344
655
|
console.log(`\n ${style("No issues found.", c.green, c.bold)} Your code looks good.\n`);
|
|
656
|
+
console.log(`${verdictFooter(0, startedAt)}\n`);
|
|
345
657
|
return;
|
|
346
658
|
}
|
|
347
659
|
const counts = {};
|
|
@@ -351,20 +663,43 @@ function printFindings(findings) {
|
|
|
351
663
|
.map(([k, v]) => `${v} ${k}`)
|
|
352
664
|
.join(" · ");
|
|
353
665
|
console.log(`\n ${style(`${findings.length} finding${findings.length !== 1 ? "s" : ""}`, c.bold, c.white)} · ${summary}\n`);
|
|
354
|
-
|
|
666
|
+
const sorted = [...findings].sort((a, b) => severityRank(a.severity) - severityRank(b.severity));
|
|
667
|
+
const numWidth = String(sorted.length).length;
|
|
668
|
+
// Each body line is indented by " " + number column + " " + gutter bar
|
|
669
|
+
// + " " = numWidth + 5 chars. Clamp to a sane floor so narrow terminals
|
|
670
|
+
// never produce a negative width for wrap/repeat.
|
|
671
|
+
const wrapWidth = Math.max(20, Math.min(process.stdout.columns || 80, 100) - (numWidth + 5));
|
|
672
|
+
const pad = " ".repeat(numWidth);
|
|
673
|
+
const bar = noColor ? "|" : style("▌", c.dim);
|
|
674
|
+
const fixPad = " ".repeat("fix: ".length);
|
|
675
|
+
sorted.forEach((f, idx) => {
|
|
676
|
+
const num = String(idx + 1).padStart(numWidth);
|
|
355
677
|
const location = f.filePath
|
|
356
678
|
? `${style(f.filePath, c.cyan)}${f.line ? `:${f.line}` : ""}`
|
|
357
679
|
: "";
|
|
358
|
-
console.log(` ${severityBadge(f.severity)} ${style(f.title, c.bold, c.white)}`);
|
|
680
|
+
console.log(` ${style(num, c.bold, c.white)} ${bar} ${severityBadge(f.severity)} ${style(f.title, c.bold, c.white)}`);
|
|
359
681
|
if (location)
|
|
360
|
-
console.log(` ${style("at", c.dim)} ${location}`);
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
if (f.
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
682
|
+
console.log(` ${pad} ${bar} ${style("at", c.dim)} ${location}`);
|
|
683
|
+
for (const line of wrapText(f.explanation, wrapWidth)) {
|
|
684
|
+
console.log(` ${pad} ${bar} ${style(line, c.gray)}`);
|
|
685
|
+
}
|
|
686
|
+
if (f.codeContext) {
|
|
687
|
+
for (const line of f.codeContext.split("\n")) {
|
|
688
|
+
console.log(` ${pad} ${bar} ${style(line, c.dim)}`);
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
if (f.suggestedCode) {
|
|
692
|
+
const [first, ...rest] = f.suggestedCode.split("\n");
|
|
693
|
+
console.log(` ${pad} ${bar} ${style("fix:", c.green)} ${style(first, c.green)}`);
|
|
694
|
+
for (const line of rest) {
|
|
695
|
+
console.log(` ${pad} ${bar} ${fixPad}${style(line, c.green)}`);
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
if (idx < sorted.length - 1) {
|
|
699
|
+
console.log(` ${noColor ? "-".repeat(wrapWidth) : style("─".repeat(wrapWidth), c.dim)}`);
|
|
700
|
+
}
|
|
701
|
+
});
|
|
702
|
+
console.log(`\n${verdictFooter(sorted.length, startedAt)}\n`);
|
|
368
703
|
}
|
|
369
704
|
function printMeta(meta) {
|
|
370
705
|
if (!meta)
|
|
@@ -390,35 +725,73 @@ function hasBlockingFindings(result) {
|
|
|
390
725
|
const findings = result.data?.findings?.findings || [];
|
|
391
726
|
return (findings.length > 0 || result.data?.findings?.verdict === "request_changes");
|
|
392
727
|
}
|
|
393
|
-
|
|
728
|
+
function formatElapsed(ms, compact = false) {
|
|
729
|
+
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
|
|
730
|
+
const minutes = Math.floor(totalSeconds / 60);
|
|
731
|
+
const seconds = String(totalSeconds % 60).padStart(2, "0");
|
|
732
|
+
if (compact)
|
|
733
|
+
return minutes > 0 ? `${minutes}m${seconds}s` : `${totalSeconds % 60}s`;
|
|
734
|
+
return `${minutes}m ${seconds}s`;
|
|
735
|
+
}
|
|
394
736
|
function createSpinner(msg) {
|
|
395
737
|
if (noColor || isCI) {
|
|
738
|
+
// Plain fallback: print each phase transition once, no timer spam.
|
|
739
|
+
let lastMsg = msg;
|
|
396
740
|
process.stderr.write(` ${msg}...\n`);
|
|
397
741
|
return {
|
|
398
|
-
|
|
742
|
+
update(nextMsg) {
|
|
743
|
+
if (nextMsg === lastMsg)
|
|
744
|
+
return;
|
|
745
|
+
lastMsg = nextMsg;
|
|
746
|
+
process.stderr.write(` ${nextMsg}...\n`);
|
|
747
|
+
},
|
|
748
|
+
note(line) {
|
|
749
|
+
process.stderr.write(`${line}\n`);
|
|
750
|
+
},
|
|
751
|
+
stop(final) {
|
|
399
752
|
if (final)
|
|
400
753
|
process.stderr.write(` ${final}\n`);
|
|
401
754
|
},
|
|
402
755
|
};
|
|
403
756
|
}
|
|
404
757
|
const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
758
|
+
const startedAt = Date.now();
|
|
759
|
+
let currentMsg = msg;
|
|
405
760
|
let i = 0;
|
|
406
761
|
const interval = setInterval(() => {
|
|
407
|
-
process.stderr.write(`\r ${style(frames[i++ % frames.length], c.green)} ${
|
|
762
|
+
process.stderr.write(`\r ${style(frames[i++ % frames.length], c.green)} ${currentMsg}… ${style(formatElapsed(Date.now() - startedAt), c.dim)}\x1b[K`);
|
|
408
763
|
}, 80);
|
|
409
764
|
return {
|
|
765
|
+
update(nextMsg) {
|
|
766
|
+
currentMsg = nextMsg;
|
|
767
|
+
},
|
|
768
|
+
note(line) {
|
|
769
|
+
process.stderr.write(`\r\x1b[K${line}\n`);
|
|
770
|
+
},
|
|
410
771
|
stop(finalMsg) {
|
|
411
772
|
clearInterval(interval);
|
|
412
|
-
process.stderr.write(`\r ${style("✓", c.green)} ${finalMsg ||
|
|
773
|
+
process.stderr.write(`\r ${style("✓", c.green)} ${finalMsg || currentMsg}\x1b[K\n`);
|
|
413
774
|
},
|
|
414
775
|
};
|
|
415
776
|
}
|
|
416
777
|
// ── Review runner ─────────────────────────────────────────────────────
|
|
417
|
-
async function runReview(content, files, label, jsonMode, failOnFindings, type = "diff", language) {
|
|
778
|
+
async function runReview(content, files, label, jsonMode, failOnFindings, type = "diff", language, baseRef) {
|
|
779
|
+
const startedAt = Date.now();
|
|
418
780
|
const lang = language || detectLanguage(files);
|
|
419
|
-
const
|
|
781
|
+
const uploadMsg = `Uploading review request (${files.length} file${files.length !== 1 ? "s" : ""}, ${lang})`;
|
|
782
|
+
// Reach parity with the GitHub App path: run the pinned extractor against
|
|
783
|
+
// the diff's base state so the review sees the same repository context.
|
|
784
|
+
// One spinner carries the whole run through its phases. The guard here
|
|
785
|
+
// mirrors maybeExtractContext's own early return.
|
|
786
|
+
const willExtract = type === "diff" && Boolean(API_KEY) && Boolean(baseRef) && isGitRepo();
|
|
787
|
+
const spinner = createSpinner(willExtract ? "Extracting repository context" : uploadMsg);
|
|
788
|
+
const bundle = willExtract
|
|
789
|
+
? await maybeExtractContext(content, spinner, baseRef)
|
|
790
|
+
: null;
|
|
791
|
+
if (willExtract)
|
|
792
|
+
spinner.update(uploadMsg);
|
|
420
793
|
try {
|
|
421
|
-
const result = await callAPI(content, lang, type, files);
|
|
794
|
+
const result = await callAPI(content, lang, type, files, bundle, spinner);
|
|
422
795
|
spinner.stop(`Reviewed ${label}`);
|
|
423
796
|
if (jsonMode) {
|
|
424
797
|
printJsonResponse(result);
|
|
@@ -434,9 +807,9 @@ async function runReview(content, files, label, jsonMode, failOnFindings, type =
|
|
|
434
807
|
console.log(` ${style(result.meta.message, c.yellow)}`);
|
|
435
808
|
process.exit(1);
|
|
436
809
|
}
|
|
437
|
-
printMeta(result.meta);
|
|
438
810
|
if (result.data?.findings?.findings)
|
|
439
|
-
printFindings(result.data.findings.findings);
|
|
811
|
+
printFindings(result.data.findings.findings, startedAt);
|
|
812
|
+
printMeta(result.meta);
|
|
440
813
|
}
|
|
441
814
|
catch (err) {
|
|
442
815
|
spinner.stop("Review failed");
|
|
@@ -446,8 +819,11 @@ async function runReview(content, files, label, jsonMode, failOnFindings, type =
|
|
|
446
819
|
}
|
|
447
820
|
// ── Help ──────────────────────────────────────────────────────────────
|
|
448
821
|
function printHelp() {
|
|
822
|
+
const header = !noColor && !isCI
|
|
823
|
+
? owlHeader()
|
|
824
|
+
: ` ${style("rmcode", c.bold, c.green)} ${style(`v${VERSION}`, c.dim)} — AI code review from your terminal`;
|
|
449
825
|
console.log(`
|
|
450
|
-
|
|
826
|
+
${header}
|
|
451
827
|
|
|
452
828
|
${style("COMMANDS", c.bold)}
|
|
453
829
|
|
|
@@ -617,8 +993,10 @@ async function main() {
|
|
|
617
993
|
console.error(style(` Run: rmcode help\n`, c.dim));
|
|
618
994
|
process.exit(1);
|
|
619
995
|
}
|
|
620
|
-
|
|
621
|
-
|
|
996
|
+
// Brand header for review runs — stderr so any stdout consumers stay
|
|
997
|
+
// safe; hidden in --json, non-TTY, NO_COLOR, and CI.
|
|
998
|
+
if (!jsonMode && !noColor && !isCI) {
|
|
999
|
+
process.stderr.write(`\n${owlHeader()}\n\n`);
|
|
622
1000
|
}
|
|
623
1001
|
// Piped stdin
|
|
624
1002
|
if (hasPipedStdin()) {
|
|
@@ -632,11 +1010,12 @@ async function main() {
|
|
|
632
1010
|
console.error(style(" No input received on stdin.", c.red));
|
|
633
1011
|
process.exit(1);
|
|
634
1012
|
}
|
|
635
|
-
const
|
|
1013
|
+
const startedAt = Date.now();
|
|
1014
|
+
const spinner = createSpinner(`Uploading review request (stdin, ${lang})`);
|
|
636
1015
|
try {
|
|
637
1016
|
const stdinType = looksLikeUnifiedDiff(code) ? "diff" : "snippet";
|
|
638
1017
|
const stdinFiles = stdinType === "diff" ? filesFromDiff(code) : [];
|
|
639
|
-
const result = await callAPI(code, lang, stdinType, stdinFiles);
|
|
1018
|
+
const result = await callAPI(code, lang, stdinType, stdinFiles, null, spinner);
|
|
640
1019
|
spinner.stop("Reviewed stdin");
|
|
641
1020
|
if (jsonMode) {
|
|
642
1021
|
printJsonResponse(result);
|
|
@@ -650,9 +1029,9 @@ async function main() {
|
|
|
650
1029
|
console.error(style(` ${result.error || "Review failed."}`, c.red));
|
|
651
1030
|
process.exit(1);
|
|
652
1031
|
}
|
|
653
|
-
printMeta(result.meta);
|
|
654
1032
|
if (result.data?.findings?.findings)
|
|
655
|
-
printFindings(result.data.findings.findings);
|
|
1033
|
+
printFindings(result.data.findings.findings, startedAt);
|
|
1034
|
+
printMeta(result.meta);
|
|
656
1035
|
}
|
|
657
1036
|
catch (err) {
|
|
658
1037
|
spinner.stop("Review failed");
|
|
@@ -688,9 +1067,10 @@ async function main() {
|
|
|
688
1067
|
const branch = currentBranch();
|
|
689
1068
|
let diff;
|
|
690
1069
|
let files;
|
|
1070
|
+
let baseRef;
|
|
691
1071
|
let label;
|
|
692
1072
|
if (flags.has("--staged")) {
|
|
693
|
-
({ diff, files } = diffStaged());
|
|
1073
|
+
({ diff, files, baseRef } = diffStaged());
|
|
694
1074
|
label = "staged changes";
|
|
695
1075
|
if (!diff) {
|
|
696
1076
|
console.error(style(" No staged changes. Stage files with: git add <files>", c.yellow));
|
|
@@ -698,7 +1078,7 @@ async function main() {
|
|
|
698
1078
|
}
|
|
699
1079
|
}
|
|
700
1080
|
else if (flags.has("--unstaged")) {
|
|
701
|
-
({ diff, files } = diffUnstaged());
|
|
1081
|
+
({ diff, files, baseRef } = diffUnstaged());
|
|
702
1082
|
label = "unstaged changes";
|
|
703
1083
|
if (!diff) {
|
|
704
1084
|
console.error(style(" No unstaged changes.", c.yellow));
|
|
@@ -706,7 +1086,7 @@ async function main() {
|
|
|
706
1086
|
}
|
|
707
1087
|
}
|
|
708
1088
|
else if (flags.has("--all")) {
|
|
709
|
-
({ diff, files } = diffAll());
|
|
1089
|
+
({ diff, files, baseRef } = diffAll());
|
|
710
1090
|
label = "all uncommitted changes";
|
|
711
1091
|
if (!diff) {
|
|
712
1092
|
console.error(style(" No uncommitted changes.", c.yellow));
|
|
@@ -714,14 +1094,14 @@ async function main() {
|
|
|
714
1094
|
}
|
|
715
1095
|
}
|
|
716
1096
|
else {
|
|
717
|
-
({ diff, files } = diffFromMergeBase());
|
|
1097
|
+
({ diff, files, baseRef } = diffFromMergeBase());
|
|
718
1098
|
label = `branch ${style(branch, c.cyan)} vs base`;
|
|
719
1099
|
if (!diff) {
|
|
720
1100
|
console.error(style(" No changes from merge base. Your branch matches main.", c.yellow));
|
|
721
1101
|
process.exit(0);
|
|
722
1102
|
}
|
|
723
1103
|
}
|
|
724
|
-
await runReview(diff, files, label, jsonMode, failOnFindings);
|
|
1104
|
+
await runReview(diff, files, label, jsonMode, failOnFindings, "diff", undefined, baseRef);
|
|
725
1105
|
if (!jsonMode && !API_KEY) {
|
|
726
1106
|
console.log(style(` Get ${FREE_PLAN_CREDITS_PER_MONTH} credits/month free: rmcode login`, c.dim));
|
|
727
1107
|
console.log(style(" Auto-review every PR: rmcode install", c.dim));
|
package/package.json
CHANGED