diffprism 0.28.0 → 0.31.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/dist/bin.js CHANGED
@@ -6,7 +6,7 @@ import {
6
6
  startGlobalServer,
7
7
  startReview,
8
8
  startWatch
9
- } from "./chunk-VPIL4KYB.js";
9
+ } from "./chunk-OJ723D6Z.js";
10
10
 
11
11
  // cli/src/index.ts
12
12
  import { Command } from "commander";
@@ -140,10 +140,46 @@ Use this pattern:
140
140
 
141
141
  You can also check for feedback without blocking by calling \`get_review_result\` without \`wait\` at natural breakpoints (between tasks, before committing, etc.).
142
142
 
143
+ ## Self-Review: Headless Analysis Tools
144
+
145
+ DiffPrism provides two headless tools that return analysis data as JSON without opening a browser. Use these to check your own work before requesting human review.
146
+
147
+ ### Available Headless Tools
148
+
149
+ - **\`mcp__diffprism__get_diff\`** \u2014 Returns a structured \`DiffSet\` (files, hunks, additions/deletions) for a given diff ref. Use this to inspect exactly what changed.
150
+ - **\`mcp__diffprism__analyze_diff\`** \u2014 Returns a \`ReviewBriefing\` with summary, file triage, impact detection, complexity scores, test coverage gaps, and pattern flags (security issues, TODOs, console.logs left in, etc.).
151
+
152
+ Both accept a \`diff_ref\` parameter: \`"staged"\`, \`"unstaged"\`, \`"working-copy"\`, or a git range like \`"HEAD~3..HEAD"\`.
153
+
154
+ ### Self-Review Loop
155
+
156
+ When you've finished writing code and before requesting human review, use this pattern:
157
+
158
+ 1. **Analyze your changes:** Call \`mcp__diffprism__analyze_diff\` with \`diff_ref: "working-copy"\`
159
+ 2. **Check the briefing for issues:**
160
+ - \`patterns\` \u2014 Look for console.logs, TODOs, security flags, disabled tests
161
+ - \`testCoverage\` \u2014 Check if changed source files have corresponding test changes
162
+ - \`complexity\` \u2014 Review high-complexity scores
163
+ - \`impact.newDependencies\` \u2014 Verify any new deps are intentional
164
+ - \`impact.breakingChanges\` \u2014 Confirm breaking changes are expected
165
+ 3. **Fix any issues found** \u2014 Remove debug statements, add missing tests, address security flags
166
+ 4. **Re-analyze** \u2014 Run \`analyze_diff\` again to confirm the issues are resolved
167
+ 5. **Open for human review** \u2014 Once clean, use \`/review\` or \`open_review\` for final human sign-off
168
+
169
+ This loop catches common issues (leftover console.logs, missing tests, security anti-patterns) before the human reviewer sees them, making reviews faster and more focused.
170
+
171
+ ### When to Use Headless Tools
172
+
173
+ - **After completing a coding task** \u2014 Self-check before requesting review
174
+ - **During implementation** \u2014 Periodically check for patterns and issues as you work
175
+ - **Before committing** \u2014 Quick sanity check on what's about to be committed
176
+ - **Do NOT use these as a replacement for human review** \u2014 They complement, not replace, \`/review\`
177
+
143
178
  ## Behavior Rules
144
179
 
145
180
  - **IMPORTANT: Do NOT open reviews automatically.** Only open a review when the user explicitly invokes \`/review\` or directly asks for a review.
146
181
  - Do NOT open reviews before commits, after code changes, or at any other time unless the user requests it.
182
+ - Headless tools (\`get_diff\`, \`analyze_diff\`) can be used proactively during development without explicit user request \u2014 they don't open a browser or interrupt the user.
147
183
  - Power users can create \`diffprism.config.json\` manually to customize defaults (diff scope, reasoning).
148
184
  `;
149
185
 
@@ -203,7 +239,12 @@ function setupClaudeSettings(baseDir, force) {
203
239
  const toolNames = [
204
240
  "mcp__diffprism__open_review",
205
241
  "mcp__diffprism__update_review_context",
206
- "mcp__diffprism__get_review_result"
242
+ "mcp__diffprism__get_review_result",
243
+ "mcp__diffprism__get_diff",
244
+ "mcp__diffprism__analyze_diff",
245
+ "mcp__diffprism__add_annotation",
246
+ "mcp__diffprism__get_review_state",
247
+ "mcp__diffprism__flag_for_attention"
207
248
  ];
208
249
  const allPresent = toolNames.every((t) => allow.includes(t));
209
250
  if (allPresent && !force) {
@@ -433,7 +474,12 @@ function isGlobalSetupDone() {
433
474
  const toolNames = [
434
475
  "mcp__diffprism__open_review",
435
476
  "mcp__diffprism__update_review_context",
436
- "mcp__diffprism__get_review_result"
477
+ "mcp__diffprism__get_review_result",
478
+ "mcp__diffprism__get_diff",
479
+ "mcp__diffprism__analyze_diff",
480
+ "mcp__diffprism__add_annotation",
481
+ "mcp__diffprism__get_review_state",
482
+ "mcp__diffprism__flag_for_attention"
437
483
  ];
438
484
  return toolNames.every((t) => allow.includes(t));
439
485
  }
@@ -476,7 +522,12 @@ function teardownClaudePermissions(baseDir) {
476
522
  const toolNames = [
477
523
  "mcp__diffprism__open_review",
478
524
  "mcp__diffprism__update_review_context",
479
- "mcp__diffprism__get_review_result"
525
+ "mcp__diffprism__get_review_result",
526
+ "mcp__diffprism__get_diff",
527
+ "mcp__diffprism__analyze_diff",
528
+ "mcp__diffprism__add_annotation",
529
+ "mcp__diffprism__get_review_state",
530
+ "mcp__diffprism__flag_for_attention"
480
531
  ];
481
532
  const filtered = allow.filter((t) => !toolNames.includes(t));
482
533
  if (filtered.length === allow.length) {
@@ -836,7 +887,7 @@ async function serverStop() {
836
887
 
837
888
  // cli/src/index.ts
838
889
  var program = new Command();
839
- program.name("diffprism").description("Local-first code review tool for agent-generated changes").version(true ? "0.28.0" : "0.0.0-dev");
890
+ program.name("diffprism").description("Local-first code review tool for agent-generated changes").version(true ? "0.31.0" : "0.0.0-dev");
840
891
  program.command("review [ref]").description("Open a browser-based diff review").option("--staged", "Review staged changes").option("--unstaged", "Review unstaged changes").option("-t, --title <title>", "Review title").option("--dev", "Use Vite dev server with HMR instead of static files").action(review);
841
892
  program.command("start [ref]").description("Set up DiffPrism and start watching for changes").option("--staged", "Watch staged changes").option("--unstaged", "Watch unstaged changes").option("-t, --title <title>", "Review title").option("--interval <ms>", "Poll interval in milliseconds (default: 1000)").option("--dev", "Use Vite dev server with HMR instead of static files").option("--global", "Install skill globally (~/.claude/skills/)").option("--force", "Overwrite existing configuration files").action(start);
842
893
  program.command("watch [ref]").description("Start a persistent diff watcher with live-updating browser UI").option("--staged", "Watch staged changes").option("--unstaged", "Watch unstaged changes").option("-t, --title <title>", "Review title").option("--interval <ms>", "Poll interval in milliseconds (default: 1000)").option("--dev", "Use Vite dev server with HMR instead of static files").action(watch);
@@ -116,6 +116,46 @@ function listCommits(options) {
116
116
  return [];
117
117
  }
118
118
  }
119
+ function detectWorktree(options) {
120
+ const cwd = options?.cwd ?? process.cwd();
121
+ try {
122
+ const gitDir = execSync("git rev-parse --git-dir", {
123
+ cwd,
124
+ encoding: "utf-8",
125
+ stdio: ["pipe", "pipe", "pipe"]
126
+ }).trim();
127
+ const gitCommonDir = execSync("git rev-parse --git-common-dir", {
128
+ cwd,
129
+ encoding: "utf-8",
130
+ stdio: ["pipe", "pipe", "pipe"]
131
+ }).trim();
132
+ const resolvedGitDir = path.resolve(cwd, gitDir);
133
+ const resolvedCommonDir = path.resolve(cwd, gitCommonDir);
134
+ const isWorktree = resolvedGitDir !== resolvedCommonDir;
135
+ if (!isWorktree) {
136
+ return { isWorktree: false };
137
+ }
138
+ const worktreePath = execSync("git rev-parse --show-toplevel", {
139
+ cwd,
140
+ encoding: "utf-8",
141
+ stdio: ["pipe", "pipe", "pipe"]
142
+ }).trim();
143
+ const mainWorktreePath = path.dirname(resolvedCommonDir);
144
+ const branch = execSync("git rev-parse --abbrev-ref HEAD", {
145
+ cwd,
146
+ encoding: "utf-8",
147
+ stdio: ["pipe", "pipe", "pipe"]
148
+ }).trim();
149
+ return {
150
+ isWorktree: true,
151
+ worktreePath,
152
+ mainWorktreePath,
153
+ branch: branch === "HEAD" ? void 0 : branch
154
+ };
155
+ } catch {
156
+ return { isWorktree: false };
157
+ }
158
+ }
119
159
  function getUntrackedDiffs(cwd) {
120
160
  let untrackedList;
121
161
  try {
@@ -398,17 +438,138 @@ function getWorkingCopyDiff(options) {
398
438
  }
399
439
 
400
440
  // packages/analysis/src/deterministic.ts
441
+ var MECHANICAL_CONFIG_PATTERNS = [
442
+ /\.config\./,
443
+ /\.eslintrc/,
444
+ /\.prettierrc/,
445
+ /tsconfig.*\.json$/,
446
+ /\.gitignore$/,
447
+ /\.lock$/
448
+ ];
449
+ var API_SURFACE_PATTERNS = [
450
+ /\/api\//,
451
+ /\/routes\//
452
+ ];
453
+ function isFormattingOnly(file) {
454
+ if (file.hunks.length === 0) return false;
455
+ for (const hunk of file.hunks) {
456
+ const adds = hunk.changes.filter((c) => c.type === "add").map((c) => c.content.replace(/\s/g, ""));
457
+ const deletes = hunk.changes.filter((c) => c.type === "delete").map((c) => c.content.replace(/\s/g, ""));
458
+ if (adds.length === 0 || deletes.length === 0) return false;
459
+ const deleteBag = [...deletes];
460
+ for (const add of adds) {
461
+ const idx = deleteBag.indexOf(add);
462
+ if (idx === -1) return false;
463
+ deleteBag.splice(idx, 1);
464
+ }
465
+ if (deleteBag.length > 0) return false;
466
+ }
467
+ return true;
468
+ }
469
+ function isImportOnly(file) {
470
+ if (file.hunks.length === 0) return false;
471
+ const importPattern = /^\s*(import\s|export\s.*from\s|const\s+\w+\s*=\s*require\(|require\()/;
472
+ for (const hunk of file.hunks) {
473
+ for (const change of hunk.changes) {
474
+ if (change.type === "context") continue;
475
+ const trimmed = change.content.trim();
476
+ if (trimmed === "") continue;
477
+ if (!importPattern.test(trimmed)) return false;
478
+ }
479
+ }
480
+ return true;
481
+ }
482
+ function isMechanicalConfigFile(path7) {
483
+ return MECHANICAL_CONFIG_PATTERNS.some((re) => re.test(path7));
484
+ }
485
+ function isApiSurface(file) {
486
+ if (API_SURFACE_PATTERNS.some((re) => re.test(file.path))) return true;
487
+ const basename = file.path.slice(file.path.lastIndexOf("/") + 1);
488
+ if ((basename === "index.ts" || basename === "index.js") && file.additions >= 10) {
489
+ return true;
490
+ }
491
+ return false;
492
+ }
401
493
  function categorizeFiles(files) {
402
- const notable = files.map((f) => ({
403
- file: f.path,
404
- description: `${f.status} (${f.language || "unknown"}) +${f.additions} -${f.deletions}`,
405
- reason: "Uncategorized in M0 \u2014 placed in notable by default"
406
- }));
407
- return {
408
- critical: [],
409
- notable,
410
- mechanical: []
411
- };
494
+ const critical = [];
495
+ const notable = [];
496
+ const mechanical = [];
497
+ const securityFlags = detectSecurityPatterns(files);
498
+ const complexityScores = computeComplexityScores(files);
499
+ const securityByFile = /* @__PURE__ */ new Map();
500
+ for (const flag of securityFlags) {
501
+ const existing = securityByFile.get(flag.file) || [];
502
+ existing.push(flag);
503
+ securityByFile.set(flag.file, existing);
504
+ }
505
+ const complexityByFile = /* @__PURE__ */ new Map();
506
+ for (const score of complexityScores) {
507
+ complexityByFile.set(score.path, score);
508
+ }
509
+ for (const file of files) {
510
+ const description = `${file.status} (${file.language || "unknown"}) +${file.additions} -${file.deletions}`;
511
+ const fileSecurityFlags = securityByFile.get(file.path);
512
+ const fileComplexity = complexityByFile.get(file.path);
513
+ const criticalReasons = [];
514
+ if (fileSecurityFlags && fileSecurityFlags.length > 0) {
515
+ const patterns = fileSecurityFlags.map((f) => f.pattern);
516
+ const unique = [...new Set(patterns)];
517
+ criticalReasons.push(`security patterns detected: ${unique.join(", ")}`);
518
+ }
519
+ if (fileComplexity && fileComplexity.score >= 8) {
520
+ criticalReasons.push(`high complexity score (${fileComplexity.score}/10)`);
521
+ }
522
+ if (isApiSurface(file)) {
523
+ criticalReasons.push("modifies public API surface");
524
+ }
525
+ if (criticalReasons.length > 0) {
526
+ critical.push({
527
+ file: file.path,
528
+ description,
529
+ reason: `Critical: ${criticalReasons.join("; ")}`
530
+ });
531
+ continue;
532
+ }
533
+ const isPureRename = file.status === "renamed" && file.additions === 0 && file.deletions === 0;
534
+ if (isPureRename) {
535
+ mechanical.push({
536
+ file: file.path,
537
+ description,
538
+ reason: "Mechanical: pure rename with no content changes"
539
+ });
540
+ continue;
541
+ }
542
+ if (isFormattingOnly(file)) {
543
+ mechanical.push({
544
+ file: file.path,
545
+ description,
546
+ reason: "Mechanical: formatting/whitespace-only changes"
547
+ });
548
+ continue;
549
+ }
550
+ if (isMechanicalConfigFile(file.path)) {
551
+ mechanical.push({
552
+ file: file.path,
553
+ description,
554
+ reason: "Mechanical: config file change"
555
+ });
556
+ continue;
557
+ }
558
+ if (file.hunks.length > 0 && isImportOnly(file)) {
559
+ mechanical.push({
560
+ file: file.path,
561
+ description,
562
+ reason: "Mechanical: import/require-only changes"
563
+ });
564
+ continue;
565
+ }
566
+ notable.push({
567
+ file: file.path,
568
+ description,
569
+ reason: "Notable: requires review"
570
+ });
571
+ }
572
+ return { critical, notable, mechanical };
412
573
  }
413
574
  function computeFileStats(files) {
414
575
  return files.map((f) => ({
@@ -584,15 +745,15 @@ var CONFIG_PATTERNS = [
584
745
  /vite\.config/,
585
746
  /vitest\.config/
586
747
  ];
587
- function isTestFile(path6) {
588
- return TEST_PATTERNS.some((re) => re.test(path6));
748
+ function isTestFile(path7) {
749
+ return TEST_PATTERNS.some((re) => re.test(path7));
589
750
  }
590
- function isNonCodeFile(path6) {
591
- const ext = path6.slice(path6.lastIndexOf("."));
751
+ function isNonCodeFile(path7) {
752
+ const ext = path7.slice(path7.lastIndexOf("."));
592
753
  return NON_CODE_EXTENSIONS.has(ext);
593
754
  }
594
- function isConfigFile(path6) {
595
- return CONFIG_PATTERNS.some((re) => re.test(path6));
755
+ function isConfigFile(path7) {
756
+ return CONFIG_PATTERNS.some((re) => re.test(path7));
596
757
  }
597
758
  function detectTestCoverageGaps(files) {
598
759
  const filePaths = new Set(files.map((f) => f.path));
@@ -1289,11 +1450,17 @@ async function startReview(options) {
1289
1450
  const briefing = analyze(diffSet);
1290
1451
  const session = createSession(options);
1291
1452
  updateSession(session.id, { status: "in_progress" });
1453
+ const worktreeInfo = detectWorktree({ cwd });
1292
1454
  const metadata = {
1293
1455
  title,
1294
1456
  description,
1295
1457
  reasoning,
1296
- currentBranch
1458
+ currentBranch,
1459
+ worktree: worktreeInfo.isWorktree ? {
1460
+ isWorktree: true,
1461
+ worktreePath: worktreeInfo.worktreePath,
1462
+ mainWorktreePath: worktreeInfo.mainWorktreePath
1463
+ } : void 0
1297
1464
  };
1298
1465
  let poller = null;
1299
1466
  const [bridgePort, httpPort] = await Promise.all([
@@ -1901,6 +2068,7 @@ async function handleApiRequest(req, res) {
1901
2068
  existingSession.lastDiffHash = diffRef ? hashDiff(payload.rawDiff) : void 0;
1902
2069
  existingSession.lastDiffSet = diffRef ? payload.diffSet : void 0;
1903
2070
  existingSession.hasNewChanges = false;
2071
+ existingSession.annotations = [];
1904
2072
  if (diffRef && hasConnectedClients()) {
1905
2073
  startSessionWatcher(sessionId);
1906
2074
  }
@@ -1929,7 +2097,8 @@ async function handleApiRequest(req, res) {
1929
2097
  diffRef,
1930
2098
  lastDiffHash: diffRef ? hashDiff(payload.rawDiff) : void 0,
1931
2099
  lastDiffSet: diffRef ? payload.diffSet : void 0,
1932
- hasNewChanges: false
2100
+ hasNewChanges: false,
2101
+ annotations: []
1933
2102
  };
1934
2103
  sessions2.set(sessionId, session);
1935
2104
  if (diffRef && hasConnectedClients()) {
@@ -2028,6 +2197,65 @@ async function handleApiRequest(req, res) {
2028
2197
  }
2029
2198
  return true;
2030
2199
  }
2200
+ const postAnnotationParams = matchRoute(method, url, "POST", "/api/reviews/:id/annotations");
2201
+ if (postAnnotationParams) {
2202
+ const session = sessions2.get(postAnnotationParams.id);
2203
+ if (!session) {
2204
+ jsonResponse(res, 404, { error: "Session not found" });
2205
+ return true;
2206
+ }
2207
+ try {
2208
+ const body = await readBody(req);
2209
+ const { file, line, body: annotationBody, type, confidence, category, source } = JSON.parse(body);
2210
+ const annotation = {
2211
+ id: randomUUID(),
2212
+ sessionId: session.id,
2213
+ file,
2214
+ line,
2215
+ body: annotationBody,
2216
+ type,
2217
+ confidence: confidence ?? 1,
2218
+ category: category ?? "other",
2219
+ source,
2220
+ createdAt: Date.now()
2221
+ };
2222
+ session.annotations.push(annotation);
2223
+ sendToSessionClients(session.id, {
2224
+ type: "annotation:added",
2225
+ payload: annotation
2226
+ });
2227
+ jsonResponse(res, 200, { annotationId: annotation.id });
2228
+ } catch {
2229
+ jsonResponse(res, 400, { error: "Invalid request body" });
2230
+ }
2231
+ return true;
2232
+ }
2233
+ const getAnnotationsParams = matchRoute(method, url, "GET", "/api/reviews/:id/annotations");
2234
+ if (getAnnotationsParams) {
2235
+ const session = sessions2.get(getAnnotationsParams.id);
2236
+ if (!session) {
2237
+ jsonResponse(res, 404, { error: "Session not found" });
2238
+ return true;
2239
+ }
2240
+ jsonResponse(res, 200, { annotations: session.annotations });
2241
+ return true;
2242
+ }
2243
+ const dismissAnnotationParams = matchRoute(method, url, "POST", "/api/reviews/:id/annotations/:annotationId/dismiss");
2244
+ if (dismissAnnotationParams) {
2245
+ const session = sessions2.get(dismissAnnotationParams.id);
2246
+ if (!session) {
2247
+ jsonResponse(res, 404, { error: "Session not found" });
2248
+ return true;
2249
+ }
2250
+ const annotation = session.annotations.find((a) => a.id === dismissAnnotationParams.annotationId);
2251
+ if (!annotation) {
2252
+ jsonResponse(res, 404, { error: "Annotation not found" });
2253
+ return true;
2254
+ }
2255
+ annotation.dismissed = true;
2256
+ jsonResponse(res, 200, { ok: true });
2257
+ return true;
2258
+ }
2031
2259
  const deleteParams = matchRoute(method, url, "DELETE", "/api/reviews/:id");
2032
2260
  if (deleteParams) {
2033
2261
  stopSessionWatcher(deleteParams.id);
@@ -2340,8 +2568,14 @@ Waiting for reviews...
2340
2568
  return { httpPort, wsPort, stop };
2341
2569
  }
2342
2570
 
2571
+ // packages/core/src/review-history.ts
2572
+ import fs4 from "fs";
2573
+ import path6 from "path";
2574
+ import { randomUUID as randomUUID2 } from "crypto";
2575
+
2343
2576
  export {
2344
2577
  getCurrentBranch,
2578
+ detectWorktree,
2345
2579
  getDiff,
2346
2580
  analyze,
2347
2581
  readWatchFile,