@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.
Files changed (2) hide show
  1. package/dist/cli.js +428 -48
  2. 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.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
- const POLL_INTERVAL_MS = 2_000;
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 { status, body } = await fetchReviewJson(`${API_URL}/api/reviews/cli`, {
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({ type, value: code, language, files, repo }),
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
- return pollForResults(`${API_URL}/api/reviews/cli/${body.data.accessToken}`, body.meta, { "X-API-Key": API_KEY });
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
- return pollForResults(`${API_URL}/api/reviews/anonymous/${body.data.accessToken}`, body.meta);
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, POLL_INTERVAL_MS));
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 printFindings(findings) {
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
- for (const f of findings) {
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
- console.log(` ${style(f.explanation, c.gray)}`);
362
- if (f.codeContext)
363
- console.log(` ${style(f.codeContext, c.dim)}`);
364
- if (f.suggestedCode)
365
- console.log(` ${style("fix:", c.green)} ${style(f.suggestedCode, c.green)}`);
366
- console.log();
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
- // ── Spinner ───────────────────────────────────────────────────────────
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
- stop: (final) => {
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)} ${msg}`);
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 || msg}${"".padEnd(20)}\n`);
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 spinner = createSpinner(`Reviewing ${label} (${files.length} file${files.length !== 1 ? "s" : ""}, ${lang})`);
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
- ${style("rmcode", c.bold, c.green)} ${style(`v${VERSION}`, c.dim)} — AI code review from your terminal
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
- if (!jsonMode) {
621
- console.log(`\n ${style("rmcode", c.green, c.bold)} ${style(`v${VERSION}`, c.dim)}`);
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 spinner = createSpinner(`Reviewing stdin (${lang})`);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@review-my-code/rmcode",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "AI code review from your terminal. Catches logic errors, null risks, security holes, and broken error handling.",
5
5
  "keywords": [
6
6
  "code-review",