@jpkme/diff-review 0.1.2 → 0.1.4

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 (101) hide show
  1. package/README.md +22 -6
  2. package/bin/diff-review.mjs +187 -19
  3. package/lib/clipboard.mjs +29 -0
  4. package/lib/config.mjs +233 -0
  5. package/lib/config.test.mjs +73 -0
  6. package/lib/extract.mjs +111 -0
  7. package/lib/git.mjs +202 -0
  8. package/lib/git.test.mjs +29 -0
  9. package/lib/json-payload.mjs +31 -0
  10. package/lib/json-payload.test.mjs +22 -0
  11. package/lib/output-name.mjs +56 -0
  12. package/lib/output-name.test.mjs +54 -0
  13. package/lib/pick-folder.mjs +4 -0
  14. package/lib/prompt.mjs +154 -0
  15. package/lib/remote-repo.mjs +182 -0
  16. package/lib/review.mjs +228 -0
  17. package/lib/review.test.mjs +160 -0
  18. package/lib/validate.mjs +221 -0
  19. package/lib/validate.test.mjs +85 -0
  20. package/lib/wait-output.mjs +104 -0
  21. package/package.json +2 -1
  22. package/web/build/client/_app/immutable/assets/2.Xnfq26gJ.css +1 -0
  23. package/web/build/client/_app/immutable/assets/2.Xnfq26gJ.css.br +0 -0
  24. package/web/build/client/_app/immutable/assets/2.Xnfq26gJ.css.gz +0 -0
  25. package/web/build/client/_app/immutable/assets/{3.BDe920_w.css → 3.qhPDz1xQ.css} +1 -1
  26. package/web/build/client/_app/immutable/assets/3.qhPDz1xQ.css.br +0 -0
  27. package/web/build/client/_app/immutable/assets/3.qhPDz1xQ.css.gz +0 -0
  28. package/web/build/client/_app/immutable/chunks/{DA6AD4pE.js → BaYDuTXu.js} +36 -29
  29. package/web/build/client/_app/immutable/chunks/BaYDuTXu.js.br +0 -0
  30. package/web/build/client/_app/immutable/chunks/BaYDuTXu.js.gz +0 -0
  31. package/web/build/client/_app/immutable/chunks/CAnYe5db.js +1 -0
  32. package/web/build/client/_app/immutable/chunks/CAnYe5db.js.br +1 -0
  33. package/web/build/client/_app/immutable/chunks/CAnYe5db.js.gz +0 -0
  34. package/web/build/client/_app/immutable/chunks/Gya4IU1I.js +2 -0
  35. package/web/build/client/_app/immutable/chunks/Gya4IU1I.js.br +0 -0
  36. package/web/build/client/_app/immutable/chunks/Gya4IU1I.js.gz +0 -0
  37. package/web/build/client/_app/immutable/entry/{app.DfeyUgCE.js → app.e96uPVtl.js} +2 -2
  38. package/web/build/client/_app/immutable/entry/app.e96uPVtl.js.br +0 -0
  39. package/web/build/client/_app/immutable/entry/app.e96uPVtl.js.gz +0 -0
  40. package/web/build/client/_app/immutable/entry/start.D-D-7QPy.js +1 -0
  41. package/web/build/client/_app/immutable/entry/start.D-D-7QPy.js.br +0 -0
  42. package/web/build/client/_app/immutable/entry/start.D-D-7QPy.js.gz +0 -0
  43. package/web/build/client/_app/immutable/nodes/1.V-g5-sfL.js +1 -0
  44. package/web/build/client/_app/immutable/nodes/1.V-g5-sfL.js.br +0 -0
  45. package/web/build/client/_app/immutable/nodes/1.V-g5-sfL.js.gz +0 -0
  46. package/web/build/client/_app/immutable/nodes/2.lcRePHZ5.js +66 -0
  47. package/web/build/client/_app/immutable/nodes/2.lcRePHZ5.js.br +0 -0
  48. package/web/build/client/_app/immutable/nodes/2.lcRePHZ5.js.gz +0 -0
  49. package/web/build/client/_app/immutable/nodes/3.GZPWeD9I.js +170 -0
  50. package/web/build/client/_app/immutable/nodes/3.GZPWeD9I.js.br +0 -0
  51. package/web/build/client/_app/immutable/nodes/3.GZPWeD9I.js.gz +0 -0
  52. package/web/build/client/_app/version.json +1 -1
  53. package/web/build/client/_app/version.json.br +0 -0
  54. package/web/build/client/_app/version.json.gz +0 -0
  55. package/web/build/handler.js +3 -3
  56. package/web/build/index.js +3 -3
  57. package/web/build/server/chunks/1-DCKScL3A.js +7 -0
  58. package/web/build/server/chunks/1-DCKScL3A.js.map +1 -0
  59. package/web/build/server/chunks/2-BpBQQ02B.js +7 -0
  60. package/web/build/server/chunks/2-BpBQQ02B.js.map +1 -0
  61. package/web/build/server/chunks/3-rGkcNCVz.js +7 -0
  62. package/web/build/server/chunks/{3-D95XsQMA.js.map → 3-rGkcNCVz.js.map} +1 -1
  63. package/web/build/server/chunks/{handler-BtMUV-EL.js → handler-BaT-QavL.js} +3 -3
  64. package/web/build/server/chunks/{handler-BtMUV-EL.js.map → handler-BaT-QavL.js.map} +1 -1
  65. package/web/build/server/chunks/{index.js-D3hB-kQu.js → index.js-C-K0YDBJ.js} +150 -150
  66. package/web/build/server/chunks/index.js-C-K0YDBJ.js.map +1 -0
  67. package/web/build/server/chunks/{manifest.js-BjQCm6Fj.js → manifest.js-CDz-Fem4.js} +6 -6
  68. package/web/build/server/chunks/{manifest.js-BjQCm6Fj.js.map → manifest.js-CDz-Fem4.js.map} +1 -1
  69. package/web/build/server/chunks/{remote-1g4ayag-vByXJn4W.js → remote-1g4ayag-DXdFe1BG.js} +645 -146
  70. package/web/build/server/chunks/remote-1g4ayag-DXdFe1BG.js.map +1 -0
  71. package/web/build/client/_app/immutable/assets/2.Du8E-7-D.css +0 -1
  72. package/web/build/client/_app/immutable/assets/2.Du8E-7-D.css.br +0 -0
  73. package/web/build/client/_app/immutable/assets/2.Du8E-7-D.css.gz +0 -0
  74. package/web/build/client/_app/immutable/assets/3.BDe920_w.css.br +0 -0
  75. package/web/build/client/_app/immutable/assets/3.BDe920_w.css.gz +0 -0
  76. package/web/build/client/_app/immutable/chunks/DA6AD4pE.js.br +0 -0
  77. package/web/build/client/_app/immutable/chunks/DA6AD4pE.js.gz +0 -0
  78. package/web/build/client/_app/immutable/chunks/DWVUjZfW.js +0 -2
  79. package/web/build/client/_app/immutable/chunks/DWVUjZfW.js.br +0 -0
  80. package/web/build/client/_app/immutable/chunks/DWVUjZfW.js.gz +0 -0
  81. package/web/build/client/_app/immutable/entry/app.DfeyUgCE.js.br +0 -0
  82. package/web/build/client/_app/immutable/entry/app.DfeyUgCE.js.gz +0 -0
  83. package/web/build/client/_app/immutable/entry/start.CBt8Pu89.js +0 -1
  84. package/web/build/client/_app/immutable/entry/start.CBt8Pu89.js.br +0 -0
  85. package/web/build/client/_app/immutable/entry/start.CBt8Pu89.js.gz +0 -0
  86. package/web/build/client/_app/immutable/nodes/1.BnePkiVL.js +0 -1
  87. package/web/build/client/_app/immutable/nodes/1.BnePkiVL.js.br +0 -0
  88. package/web/build/client/_app/immutable/nodes/1.BnePkiVL.js.gz +0 -0
  89. package/web/build/client/_app/immutable/nodes/2.Dr69zJpD.js +0 -66
  90. package/web/build/client/_app/immutable/nodes/2.Dr69zJpD.js.br +0 -0
  91. package/web/build/client/_app/immutable/nodes/2.Dr69zJpD.js.gz +0 -0
  92. package/web/build/client/_app/immutable/nodes/3.BjjzqFKA.js +0 -170
  93. package/web/build/client/_app/immutable/nodes/3.BjjzqFKA.js.br +0 -0
  94. package/web/build/client/_app/immutable/nodes/3.BjjzqFKA.js.gz +0 -0
  95. package/web/build/server/chunks/1-eKskvjnM.js +0 -7
  96. package/web/build/server/chunks/1-eKskvjnM.js.map +0 -1
  97. package/web/build/server/chunks/2-WcOxbtb4.js +0 -7
  98. package/web/build/server/chunks/2-WcOxbtb4.js.map +0 -1
  99. package/web/build/server/chunks/3-D95XsQMA.js +0 -7
  100. package/web/build/server/chunks/index.js-D3hB-kQu.js.map +0 -1
  101. package/web/build/server/chunks/remote-1g4ayag-vByXJn4W.js.map +0 -1
package/README.md CHANGED
@@ -22,7 +22,23 @@ O sin instalar:
22
22
  npx @jpkme/diff-review
23
23
  ```
24
24
 
25
- Abre `http://127.0.0.1:5190`. Opciones: `--port`, `--host`, `--no-open`.
25
+ ### Modos
26
+
27
+ Desde un **repo git**, el default es terminal-first:
28
+
29
+ 1. Loguea repo / branch y copia el prompt al portapapeles.
30
+ 2. Espera el JSON (`diff-report_{branchTip}-{baseTip}_YYYYMMDD-HHmmss.json`) en la raíz del repo.
31
+ 3. Valida y abre el viewer con el reporte ya importado.
32
+
33
+ Si el branch actual y la base son iguales (diff vacío), abre la UI directamente para que elijas otra base o branch.
34
+
35
+ Para abrir la UI primero (sin esperar el JSON):
36
+
37
+ ```bash
38
+ diff-review --ui
39
+ ```
40
+
41
+ Opciones: `--port`, `--host`, `--base`, `--branch`, `--no-open`.
26
42
 
27
43
  Si lo lanzás desde un repositorio git, la UI precarga esa ruta y el branch actual.
28
44
 
@@ -35,12 +51,12 @@ npm run build # build de producción → web/build
35
51
  npm start # mismo que el bin global
36
52
  ```
37
53
 
38
- ## Flujo
54
+ ## Flujo (UI)
39
55
 
40
- 1. Pegá la ruta (o URL) del repo y elegí branch / base — o lanzá `diff-review` desde el repo y se precarga solo.
56
+ 1. Pegá la ruta (o URL) del repo y elegí branch / base — o lanzá `diff-review` / `diff-review --ui` desde el repo.
41
57
  2. Copiá el prompt (o el comando de skill `/diff-review`).
42
- 3. Corrélo en tu agente; que escriba `diff-review-output.json`.
43
- 4. Arrastrá ese JSON a la dropzone. La UI hidrata los diffs con git.
58
+ 3. Corrélo en tu agente; que escriba `diff-report_{tips}_{stamp}.json` (también acepta el legacy `diff-review-output….json`).
59
+ 4. Arrastrá ese JSON a la dropzone (o dejá que el CLI lo importe solo). La UI hidrata los diffs con git.
44
60
 
45
61
  "Ver ejemplo" carga un reporte de muestra sin tocar un repo.
46
62
 
@@ -84,7 +100,7 @@ y minificados.
84
100
  .
85
101
  ├── bin/ # CLI (diff-review)
86
102
  ├── schema.json # contrato del JSON que escribe el agente
87
- ├── lib/ # git / extract (dev + tests; va embebido en el build)
103
+ ├── lib/ # git / extract / prompt (dev + tests; va en el paquete)
88
104
  └── web/ # UI SvelteKit → web/build en el paquete npm
89
105
  ```
90
106
 
@@ -1,8 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * CLI: levanta la UI local (build de SvelteKit) y abre el browser.
3
+ * CLI híbrido C:
4
+ * diff-review → terminal: prompt + clipboard + espera JSON → abre viewer
5
+ * diff-review --ui → web-first (UI inmediata)
4
6
  *
5
- * diff-review
6
7
  * diff-review --port 5191
7
8
  * diff-review --no-open
8
9
  */
@@ -12,6 +13,11 @@ import { existsSync } from 'node:fs';
12
13
  import { createServer } from 'node:net';
13
14
  import { dirname, join } from 'node:path';
14
15
  import { fileURLToPath } from 'node:url';
16
+ import { copyTextToClipboard } from '../lib/clipboard.mjs';
17
+ import { branchTips, getRemoteUrl, listBranches, tryResolveGitRoot } from '../lib/git.mjs';
18
+ import { formatOutputStamp, makeOutputFilename } from '../lib/output-name.mjs';
19
+ import { buildPrompt } from '../lib/prompt.mjs';
20
+ import { waitForReviewOutput } from '../lib/wait-output.mjs';
15
21
 
16
22
  const here = dirname(fileURLToPath(import.meta.url));
17
23
  const packageRoot = join(here, '..');
@@ -37,14 +43,21 @@ function resolveNodeBin() {
37
43
  }
38
44
 
39
45
  function printHelp() {
40
- console.log(`diff-review — UI local para revisar un branch con tu agente.
46
+ console.log(`diff-review — review de branch con tu agente + UI local.
41
47
 
42
48
  Usage:
43
49
  diff-review [options]
44
50
 
51
+ Por defecto (desde un repo git): copia el prompt, espera el JSON y abre el viewer.
52
+ Si branch y base coinciden, abre la UI para que elijas otra combinación.
53
+ Con --ui: abre la UI de inmediato (web-first).
54
+
45
55
  Options:
46
56
  -p, --port <n> Puerto (default ${DEFAULT_PORT})
47
57
  --host <h> Host (default ${DEFAULT_HOST})
58
+ --ui Abrir la UI primero (sin esperar JSON)
59
+ --base <b> Base del merge-base (default: develop / detección)
60
+ --branch <b> Branch a revisar (default: branch actual)
48
61
  --no-open No abrir el navegador
49
62
  -h, --help Esta ayuda
50
63
 
@@ -53,12 +66,23 @@ Requiere git en PATH. Los reportes viven en localStorage del navegador.
53
66
  }
54
67
 
55
68
  function parseArgs(argv) {
56
- const opts = { port: DEFAULT_PORT, host: DEFAULT_HOST, open: true, help: false };
69
+ const opts = {
70
+ port: DEFAULT_PORT,
71
+ host: DEFAULT_HOST,
72
+ open: true,
73
+ help: false,
74
+ ui: false,
75
+ base: '',
76
+ branch: ''
77
+ };
57
78
  for (let i = 0; i < argv.length; i++) {
58
79
  const a = argv[i];
59
80
  if (a === '-h' || a === '--help') opts.help = true;
60
81
  else if (a === '--no-open') opts.open = false;
82
+ else if (a === '--ui') opts.ui = true;
61
83
  else if (a === '--host') opts.host = argv[++i] || opts.host;
84
+ else if (a === '--base') opts.base = argv[++i] || opts.base;
85
+ else if (a === '--branch') opts.branch = argv[++i] || opts.branch;
62
86
  else if (a === '-p' || a === '--port') {
63
87
  const n = Number(argv[++i]);
64
88
  if (!Number.isFinite(n) || n <= 0) {
@@ -105,13 +129,42 @@ function canListen(host, port) {
105
129
  });
106
130
  }
107
131
 
108
- async function main() {
109
- const opts = parseArgs(process.argv.slice(2));
110
- if (opts.help) {
111
- printHelp();
112
- process.exit(0);
132
+ /**
133
+ * @returns {{ repo: string, branch: string, base: string, remoteUrl?: string } | null}
134
+ */
135
+ function resolveLaunchRepo(opts) {
136
+ const cwd = process.cwd();
137
+ const root = tryResolveGitRoot(cwd);
138
+ if (!root) return null;
139
+ const listed = listBranches(root);
140
+ const branch =
141
+ String(opts.branch || '').trim() ||
142
+ (listed.current && listed.current !== 'HEAD' ? listed.current : listed.branches[0] || '');
143
+ const base =
144
+ String(opts.base || '').trim() ||
145
+ (listed.branches.includes('develop') ? 'develop' : listed.defaultBase);
146
+ const remoteUrl = getRemoteUrl(root) || undefined;
147
+ return { repo: root, branch, base, remoteUrl };
148
+ }
149
+
150
+ function logRepoContext(ctx) {
151
+ console.log(`Repo: ${ctx.repo}`);
152
+ console.log(`Branch: ${ctx.branch} (base: ${ctx.base})`);
153
+ }
154
+
155
+ function copyPromptOrWarn(prompt) {
156
+ if (copyTextToClipboard(prompt)) {
157
+ console.log('Prompt copiado al portapapeles.');
158
+ return true;
113
159
  }
160
+ console.log('No pude copiar al portapapeles; pegá el prompt a mano desde la UI o reintentá.');
161
+ return false;
162
+ }
114
163
 
164
+ /**
165
+ * @param {{ host: string, port: number, open: boolean, launchCwd: string, importFile?: string, openPath?: string }} cfg
166
+ */
167
+ async function startServer(cfg) {
115
168
  if (!existsSync(entry)) {
116
169
  console.error(`No encontré el build en ${entry}
117
170
  Corré desde el repo: npm run build
@@ -119,23 +172,23 @@ O reinstalá el paquete (el tarball de npm incluye el build).`);
119
172
  process.exit(1);
120
173
  }
121
174
 
122
- if (!(await canListen(opts.host, opts.port))) {
123
- console.error(`No se pudo usar ${opts.host}:${opts.port} (¿ocupado?). Probá --port otro.`);
175
+ if (!(await canListen(cfg.host, cfg.port))) {
176
+ console.error(`No se pudo usar ${cfg.host}:${cfg.port} (¿ocupado?). Probá --port otro.`);
124
177
  process.exit(1);
125
178
  }
126
179
 
127
- const origin = `http://${opts.host}:${opts.port}`;
180
+ const origin = `http://${cfg.host}:${cfg.port}`;
181
+ const openUrl = cfg.openPath ? `${origin}${cfg.openPath}` : origin;
128
182
  const nodeBin = resolveNodeBin();
129
- /** CWD del usuario al invocar el CLI (el server corre desde web/build). */
130
- const launchCwd = process.cwd();
131
183
  const child = spawn(nodeBin, [entry], {
132
184
  cwd: buildDir,
133
185
  env: {
134
186
  ...process.env,
135
- HOST: opts.host,
136
- PORT: String(opts.port),
187
+ HOST: cfg.host,
188
+ PORT: String(cfg.port),
137
189
  ORIGIN: origin,
138
- DIFF_REVIEW_LAUNCH_CWD: launchCwd
190
+ DIFF_REVIEW_LAUNCH_CWD: cfg.launchCwd,
191
+ ...(cfg.importFile ? { DIFF_REVIEW_IMPORT_FILE: cfg.importFile } : {})
139
192
  },
140
193
  stdio: ['ignore', 'pipe', 'inherit'],
141
194
  windowsHide: false
@@ -152,7 +205,7 @@ O reinstalá el paquete (el tarball de npm incluye el build).`);
152
205
  if (!ready) {
153
206
  ready = true;
154
207
  console.log(`diff-review listo en ${terminalLink(origin)} — Ctrl+C para salir.`);
155
- if (opts.open) openBrowser(origin);
208
+ if (cfg.open) openBrowser(openUrl);
156
209
  }
157
210
  continue;
158
211
  }
@@ -175,4 +228,119 @@ O reinstalá el paquete (el tarball de npm incluye el build).`);
175
228
  process.on('SIGTERM', stop);
176
229
  }
177
230
 
178
- main();
231
+ async function main() {
232
+ const opts = parseArgs(process.argv.slice(2));
233
+ if (opts.help) {
234
+ printHelp();
235
+ process.exit(0);
236
+ }
237
+
238
+ const launchCwd = process.cwd();
239
+ const ctx = resolveLaunchRepo(opts);
240
+ const outputStamp = formatOutputStamp();
241
+ /** @type {string} */
242
+ let outputFilename = makeOutputFilename({ stamp: outputStamp });
243
+ if (ctx) {
244
+ try {
245
+ const tips = branchTips(ctx.repo, ctx.branch, ctx.base);
246
+ outputFilename = makeOutputFilename({
247
+ branchSha: tips.branchSha,
248
+ baseSha: tips.baseSha,
249
+ stamp: outputStamp
250
+ });
251
+ } catch (err) {
252
+ console.warn(
253
+ 'No pude resolver tips para el nombre del JSON:',
254
+ err instanceof Error ? err.message : err
255
+ );
256
+ }
257
+ }
258
+
259
+ /** Si branch===base el three-dot está vacío: forzar UI para elegir. */
260
+ let openUi = opts.ui;
261
+ let skipPromptCopy = false;
262
+ if (!openUi && ctx && ctx.branch && ctx.branch === ctx.base) {
263
+ console.log(
264
+ `Branch y base son iguales (${ctx.branch}): el diff estaría vacío.\nAbro la UI para que elijas otra base o branch.`
265
+ );
266
+ openUi = true;
267
+ skipPromptCopy = true;
268
+ }
269
+
270
+ if (openUi) {
271
+ if (ctx) {
272
+ logRepoContext(ctx);
273
+ if (!skipPromptCopy) {
274
+ const prompt = buildPrompt(
275
+ {
276
+ source: 'local',
277
+ repo: ctx.repo,
278
+ branch: ctx.branch,
279
+ base: ctx.base,
280
+ remoteUrl: ctx.remoteUrl
281
+ },
282
+ { outputFilename }
283
+ );
284
+ copyPromptOrWarn(prompt);
285
+ }
286
+ } else {
287
+ console.log('Sin repo git en el CWD — la UI arranca vacía.');
288
+ }
289
+ await startServer({
290
+ host: opts.host,
291
+ port: opts.port,
292
+ open: opts.open,
293
+ launchCwd,
294
+ openPath:
295
+ ctx && !skipPromptCopy
296
+ ? `/?notify=prompt&out=${encodeURIComponent(outputFilename)}`
297
+ : '/'
298
+ });
299
+ return;
300
+ }
301
+
302
+ // Flujo terminal por defecto
303
+ if (!ctx) {
304
+ console.error(
305
+ 'No hay un repositorio git en el directorio actual.\nUsá --ui para abrir la interfaz, o corré diff-review desde un repo.'
306
+ );
307
+ process.exit(1);
308
+ }
309
+
310
+ logRepoContext(ctx);
311
+ console.log(`Salida: ${outputFilename}`);
312
+ const prompt = buildPrompt(
313
+ {
314
+ source: 'local',
315
+ repo: ctx.repo,
316
+ branch: ctx.branch,
317
+ base: ctx.base,
318
+ remoteUrl: ctx.remoteUrl
319
+ },
320
+ { outputFilename }
321
+ );
322
+ copyPromptOrWarn(prompt);
323
+ console.log(`Esperando ${outputFilename} en la raíz del repo… (Ctrl+C para cancelar)`);
324
+
325
+ const result = await waitForReviewOutput(ctx.repo, outputFilename, {
326
+ onInvalid: (errors) => {
327
+ console.log(`JSON encontrado pero inválido (${errors.length} error(es)). Esperando corrección…`);
328
+ for (const e of errors.slice(0, 5)) console.log(` · ${e}`);
329
+ }
330
+ });
331
+
332
+ console.log('JSON OK. Abriendo viewer…');
333
+ await startServer({
334
+ host: opts.host,
335
+ port: opts.port,
336
+ open: opts.open,
337
+ launchCwd: ctx.repo,
338
+ importFile: result.path,
339
+ openPath: '/?import=1'
340
+ });
341
+ }
342
+
343
+ main().catch((err) => {
344
+ console.error(err?.message || err);
345
+ process.exit(1);
346
+ });
@@ -0,0 +1,29 @@
1
+ import { spawnSync } from "node:child_process";
2
+
3
+ /** Copia texto al portapapeles del SO. Devuelve true si parece haber funcionado. */
4
+ export function copyTextToClipboard(text) {
5
+ const value = String(text ?? "");
6
+ try {
7
+ if (process.platform === "win32") {
8
+ const r = spawnSync("clip", [], {
9
+ input: value,
10
+ encoding: "utf8",
11
+ windowsHide: true,
12
+ });
13
+ return r.status === 0;
14
+ }
15
+ if (process.platform === "darwin") {
16
+ const r = spawnSync("pbcopy", [], { input: value, encoding: "utf8" });
17
+ return r.status === 0;
18
+ }
19
+ let r = spawnSync("wl-copy", [], { input: value, encoding: "utf8" });
20
+ if (r.status === 0) return true;
21
+ r = spawnSync("xclip", ["-selection", "clipboard"], {
22
+ input: value,
23
+ encoding: "utf8",
24
+ });
25
+ return r.status === 0;
26
+ } catch {
27
+ return false;
28
+ }
29
+ }
package/lib/config.mjs ADDED
@@ -0,0 +1,233 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ export const DEFAULT_IGNORE = [
5
+ "**/package-lock.json",
6
+ "**/pnpm-lock.yaml",
7
+ "**/yarn.lock",
8
+ "**/bun.lock",
9
+ "**/bun.lockb",
10
+ "**/*.g.cs",
11
+ "**/*Snapshot.cs",
12
+ "**/*.Designer.cs",
13
+ "**/node_modules/**",
14
+ "**/.svelte-kit/**",
15
+ "**/dist/**",
16
+ "**/build/**",
17
+ "**/*.min.js",
18
+ "**/*.min.css",
19
+ "**/CODE_REVIEW_CORRECTNESS_FINDINGS.md",
20
+ ];
21
+
22
+ export const STORE_DIFF_MAX = 400_000;
23
+ /** Pack prompts so a single CLI turn stays inside a typical Pro context window. Files are not truncated to fit. */
24
+ export const PROMPT_BATCH_CHARS = 200_000;
25
+ export const PROMPT_BATCH_FILES = 16;
26
+ /** Independent judge: drop findings scored below this (Claude code-review uses 80). */
27
+ export const JUDGE_MIN_SCORE = 80;
28
+ export const GUIDELINE_FILES = ["REVIEW.md", "CLAUDE.md", "AGENTS.md"];
29
+
30
+ function unquote(s) {
31
+ const t = String(s || "").trim();
32
+ if ((t.startsWith('"') && t.endsWith('"')) || (t.startsWith("'") && t.endsWith("'"))) {
33
+ return t.slice(1, -1);
34
+ }
35
+ return t;
36
+ }
37
+
38
+ /** Minimal YAML: `key: value` and `key:\n - item`. */
39
+ export function parseSimpleYaml(text) {
40
+ const result = {};
41
+ let key = null;
42
+ for (const raw of String(text || "").split(/\r?\n/)) {
43
+ if (!raw.trim() || raw.trim().startsWith("#")) continue;
44
+ const list = raw.match(/^\s+-\s+(.*)$/);
45
+ if (list && key) {
46
+ if (!Array.isArray(result[key])) result[key] = [];
47
+ result[key].push(unquote(list[1]));
48
+ continue;
49
+ }
50
+ const kv = raw.match(/^([A-Za-z][\w]*)\s*:\s*(.*)$/);
51
+ if (!kv) continue;
52
+ key = kv[1];
53
+ const v = kv[2].trim();
54
+ result[key] = v === "" ? [] : unquote(v);
55
+ }
56
+ return result;
57
+ }
58
+
59
+ export function globToRegExp(glob) {
60
+ let g = String(glob || "").replace(/\\/g, "/");
61
+ if (!g.includes("/")) g = `**/${g}`;
62
+ let s = "";
63
+ for (let i = 0; i < g.length; i++) {
64
+ const c = g[i];
65
+ if (c === "*" && g[i + 1] === "*") {
66
+ if (g[i + 2] === "/") {
67
+ s += "(?:.*/)?";
68
+ i += 2;
69
+ } else {
70
+ s += ".*";
71
+ i += 1;
72
+ }
73
+ } else if (c === "*") s += "[^/]*";
74
+ else if (c === "?") s += "[^/]";
75
+ else if ("+()[]{}|.^$".includes(c)) s += `\\${c}`;
76
+ else s += c;
77
+ }
78
+ return new RegExp(`^${s}$`, "i");
79
+ }
80
+
81
+ export function globMatch(filePath, pattern) {
82
+ const norm = String(filePath || "").replace(/\\/g, "/");
83
+ return globToRegExp(pattern).test(norm);
84
+ }
85
+
86
+ export function isIgnored(filePath, patterns) {
87
+ return (patterns || []).some((p) => globMatch(filePath, p));
88
+ }
89
+
90
+ export function isTestPath(filePath) {
91
+ const p = String(filePath || "").replace(/\\/g, "/");
92
+ return /(^|\/)(__tests__|tests?|spec)s?(\/|$)/i.test(p) || /\.(test|spec)\.[^.]+$/i.test(p);
93
+ }
94
+
95
+ function readIfExists(path) {
96
+ try {
97
+ return existsSync(path) ? readFileSync(path, "utf8") : null;
98
+ } catch {
99
+ return null;
100
+ }
101
+ }
102
+
103
+ export function loadReviewConfig(repo) {
104
+ const jsonText = readIfExists(join(repo, ".diff-review.json"));
105
+ const yamlText =
106
+ readIfExists(join(repo, ".diff-review.yml")) || readIfExists(join(repo, ".diff-review.yaml"));
107
+ let extra = {};
108
+ if (jsonText) {
109
+ try {
110
+ extra = JSON.parse(jsonText);
111
+ } catch {
112
+ extra = {};
113
+ }
114
+ } else if (yamlText) {
115
+ extra = parseSimpleYaml(yamlText);
116
+ }
117
+
118
+ const extraIgnore = Array.isArray(extra.ignore) ? extra.ignore.map(String) : [];
119
+ const rulesFile = extra.rulesFile ? String(extra.rulesFile) : "REVIEW.md";
120
+ const rules = readIfExists(join(repo, rulesFile)) || "";
121
+ const model = extra.model && extra.model !== "default" ? String(extra.model) : null;
122
+
123
+ return {
124
+ ignore: [...DEFAULT_IGNORE, ...extraIgnore],
125
+ model,
126
+ rulesFile,
127
+ rules: rules.trim(),
128
+ };
129
+ }
130
+
131
+ /** CLAUDE.md / REVIEW.md / AGENTS.md from repo root and immediate parent dirs of touched files. */
132
+ export function collectGuidelines(
133
+ repo,
134
+ filePaths,
135
+ {
136
+ rulesFile = "REVIEW.md",
137
+ maxChars = 8_000,
138
+ maxTotalChars = 12_000,
139
+ maxDepth = 1,
140
+ skipNames = [],
141
+ } = {},
142
+ ) {
143
+ const skip = new Set(skipNames);
144
+ const names = [...new Set([rulesFile, ...GUIDELINE_FILES])].filter((name) => !skip.has(name));
145
+ const seen = new Set();
146
+ const out = [];
147
+ const addRel = (rel) => {
148
+ const norm = String(rel || "").replace(/\\/g, "/").replace(/^\/+/, "");
149
+ if (!norm || seen.has(norm)) return;
150
+ const abs = join(repo, ...norm.split("/"));
151
+ const text = readIfExists(abs);
152
+ if (!text || !text.trim()) return;
153
+ seen.add(norm);
154
+ out.push({ path: norm, text: text.trim() });
155
+ };
156
+ for (const name of names) addRel(name);
157
+ const depth = Math.max(1, Number(maxDepth) || 1);
158
+ for (const filePath of filePaths || []) {
159
+ const parts = String(filePath || "").replace(/\\/g, "/").split("/").filter(Boolean);
160
+ const minLen = Math.max(1, parts.length - depth);
161
+ for (let len = parts.length - 1; len >= minLen; len--) {
162
+ const dir = parts.slice(0, len).join("/");
163
+ for (const name of names) addRel(`${dir}/${name}`);
164
+ }
165
+ }
166
+ let total = 0;
167
+ const capped = [];
168
+ for (const item of out) {
169
+ if (total >= maxTotalChars) break;
170
+ const room = maxTotalChars - total;
171
+ const slice = item.text.slice(0, Math.min(maxChars, room));
172
+ if (!slice) continue;
173
+ capped.push({ path: item.path, text: slice });
174
+ total += slice.length;
175
+ }
176
+ return capped;
177
+ }
178
+
179
+ export function formatGuidelines(guidelines) {
180
+ if (!Array.isArray(guidelines) || !guidelines.length) return "";
181
+ return `\nGuidelines del repo (aplicá las que correspondan a los archivos tocados; no todas valen en una review):\n${guidelines
182
+ .map((g) => `--- ${g.path} ---\n${g.text}`)
183
+ .join("\n\n")}\n`;
184
+ }
185
+
186
+ export function capDiff(diff, max = STORE_DIFF_MAX) {
187
+ const text = String(diff || "");
188
+ if (text.length <= max) {
189
+ return { diff: text, diffChars: text.length, truncated: false };
190
+ }
191
+ return {
192
+ diff: `${text.slice(0, max)}\n\n… [diff recortado: se guardan ${max} de ${text.length} caracteres]`,
193
+ diffChars: text.length,
194
+ truncated: true,
195
+ };
196
+ }
197
+
198
+ /** Drop unified-diff context lines so the model mostly sees @@ / + / -. */
199
+ export function compactDiff(diff) {
200
+ return String(diff || "")
201
+ .replace(/\r\n/g, "\n")
202
+ .split("\n")
203
+ .filter((line) => line.length > 0 && !line.startsWith(" "))
204
+ .join("\n");
205
+ }
206
+
207
+ export function hunkHeaders(diff, limit = 10) {
208
+ const out = [];
209
+ for (const line of String(diff || "").split("\n")) {
210
+ if (!line.startsWith("@@")) continue;
211
+ out.push(line.trim().slice(0, 140));
212
+ if (out.length >= limit) break;
213
+ }
214
+ return out;
215
+ }
216
+
217
+ /**
218
+ * Model-facing diff: send the stored unified diff as-is, including context lines.
219
+ * Only clips if a single file exceeds `max` (safety for generated dumps that slipped ignore).
220
+ */
221
+ export function modelSlice(diff, max = STORE_DIFF_MAX) {
222
+ const raw = String(diff || "");
223
+ if (raw.length <= max) {
224
+ return { diff: raw, modelChars: raw.length, modelTruncated: false };
225
+ }
226
+ const note = `\n\n… [el modelo no vio el resto: ${raw.length} chars en el archivo]`;
227
+ const clipped = raw.slice(0, Math.max(0, max - note.length));
228
+ return {
229
+ diff: clipped + note,
230
+ modelChars: clipped.length,
231
+ modelTruncated: true,
232
+ };
233
+ }
@@ -0,0 +1,73 @@
1
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { test } from "node:test";
5
+ import assert from "node:assert/strict";
6
+ import { collectGuidelines, compactDiff, formatGuidelines, hunkHeaders, modelSlice } from "./config.mjs";
7
+
8
+ const sample = [
9
+ "diff --git a/src/big.ts b/src/big.ts",
10
+ "--- a/src/big.ts",
11
+ "+++ b/src/big.ts",
12
+ "@@ -1,3 +1,3 @@",
13
+ " import a from 'a';",
14
+ "-const oldHead = 1;",
15
+ "+const newHead = 1;",
16
+ " keep();",
17
+ "@@ -80,4 +80,8 @@ export function later() {",
18
+ " const ctx = true;",
19
+ "- return 0;",
20
+ "+ if (!ready) throw new Error('boom');",
21
+ "+ const token = auth();",
22
+ "+ if (!token) return null;",
23
+ "+ return token;",
24
+ " }",
25
+ ].join("\n");
26
+
27
+ test("compactDiff drops context lines and keeps hunks", () => {
28
+ const compact = compactDiff(sample);
29
+ assert.equal(compact.includes(" keep();"), false);
30
+ assert.equal(compact.includes("@@ -80,4 +80,8 @@"), true);
31
+ assert.equal(compact.includes("+ if (!ready)"), true);
32
+ });
33
+
34
+ test("hunkHeaders returns @@ lines", () => {
35
+ assert.deepEqual(hunkHeaders(sample, 4), [
36
+ "@@ -1,3 +1,3 @@",
37
+ "@@ -80,4 +80,8 @@ export function later() {",
38
+ ]);
39
+ });
40
+
41
+ test("modelSlice keeps the full unified diff including context", () => {
42
+ const { diff, modelTruncated, modelChars } = modelSlice(sample);
43
+ assert.equal(modelTruncated, false);
44
+ assert.equal(modelChars, sample.length);
45
+ assert.equal(diff.includes(" keep();"), true);
46
+ assert.equal(diff.includes("+const newHead"), true);
47
+ assert.equal(diff.includes("+ if (!ready)"), true);
48
+ });
49
+
50
+ test("modelSlice only clips when a file exceeds max", () => {
51
+ const { diff, modelTruncated } = modelSlice(sample, 80);
52
+ assert.equal(modelTruncated, true);
53
+ assert.equal(diff.includes("el modelo no vio el resto"), true);
54
+ });
55
+
56
+ test("collectGuidelines loads root and nested CLAUDE.md", () => {
57
+ const root = mkdtempSync(join(tmpdir(), "diff-review-g-"));
58
+ try {
59
+ writeFileSync(join(root, "CLAUDE.md"), "root rules");
60
+ writeFileSync(join(root, "REVIEW.md"), "team review");
61
+ mkdirSync(join(root, "apps", "foo"), { recursive: true });
62
+ writeFileSync(join(root, "apps", "foo", "CLAUDE.md"), "nested rules");
63
+ const docs = collectGuidelines(root, ["apps/foo/bar.ts"]);
64
+ const paths = docs.map((d) => d.path);
65
+ assert.ok(paths.includes("CLAUDE.md"));
66
+ assert.ok(paths.includes("REVIEW.md"));
67
+ assert.ok(paths.includes("apps/foo/CLAUDE.md"));
68
+ assert.match(docs.find((d) => d.path === "apps/foo/CLAUDE.md").text, /nested/);
69
+ assert.match(formatGuidelines(docs), /apps\/foo\/CLAUDE\.md/);
70
+ } finally {
71
+ rmSync(root, { recursive: true, force: true });
72
+ }
73
+ });