@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.
- package/README.md +22 -6
- package/bin/diff-review.mjs +187 -19
- package/lib/clipboard.mjs +29 -0
- package/lib/config.mjs +233 -0
- package/lib/config.test.mjs +73 -0
- package/lib/extract.mjs +111 -0
- package/lib/git.mjs +202 -0
- package/lib/git.test.mjs +29 -0
- package/lib/json-payload.mjs +31 -0
- package/lib/json-payload.test.mjs +22 -0
- package/lib/output-name.mjs +56 -0
- package/lib/output-name.test.mjs +54 -0
- package/lib/pick-folder.mjs +4 -0
- package/lib/prompt.mjs +154 -0
- package/lib/remote-repo.mjs +182 -0
- package/lib/review.mjs +228 -0
- package/lib/review.test.mjs +160 -0
- package/lib/validate.mjs +221 -0
- package/lib/validate.test.mjs +85 -0
- package/lib/wait-output.mjs +104 -0
- package/package.json +2 -1
- package/web/build/client/_app/immutable/assets/2.Xnfq26gJ.css +1 -0
- package/web/build/client/_app/immutable/assets/2.Xnfq26gJ.css.br +0 -0
- package/web/build/client/_app/immutable/assets/2.Xnfq26gJ.css.gz +0 -0
- package/web/build/client/_app/immutable/assets/{3.BDe920_w.css → 3.qhPDz1xQ.css} +1 -1
- package/web/build/client/_app/immutable/assets/3.qhPDz1xQ.css.br +0 -0
- package/web/build/client/_app/immutable/assets/3.qhPDz1xQ.css.gz +0 -0
- package/web/build/client/_app/immutable/chunks/{DA6AD4pE.js → BaYDuTXu.js} +36 -29
- package/web/build/client/_app/immutable/chunks/BaYDuTXu.js.br +0 -0
- package/web/build/client/_app/immutable/chunks/BaYDuTXu.js.gz +0 -0
- package/web/build/client/_app/immutable/chunks/CAnYe5db.js +1 -0
- package/web/build/client/_app/immutable/chunks/CAnYe5db.js.br +1 -0
- package/web/build/client/_app/immutable/chunks/CAnYe5db.js.gz +0 -0
- package/web/build/client/_app/immutable/chunks/Gya4IU1I.js +2 -0
- package/web/build/client/_app/immutable/chunks/Gya4IU1I.js.br +0 -0
- package/web/build/client/_app/immutable/chunks/Gya4IU1I.js.gz +0 -0
- package/web/build/client/_app/immutable/entry/{app.DfeyUgCE.js → app.e96uPVtl.js} +2 -2
- package/web/build/client/_app/immutable/entry/app.e96uPVtl.js.br +0 -0
- package/web/build/client/_app/immutable/entry/app.e96uPVtl.js.gz +0 -0
- package/web/build/client/_app/immutable/entry/start.D-D-7QPy.js +1 -0
- package/web/build/client/_app/immutable/entry/start.D-D-7QPy.js.br +0 -0
- package/web/build/client/_app/immutable/entry/start.D-D-7QPy.js.gz +0 -0
- package/web/build/client/_app/immutable/nodes/1.V-g5-sfL.js +1 -0
- package/web/build/client/_app/immutable/nodes/1.V-g5-sfL.js.br +0 -0
- package/web/build/client/_app/immutable/nodes/1.V-g5-sfL.js.gz +0 -0
- package/web/build/client/_app/immutable/nodes/2.lcRePHZ5.js +66 -0
- package/web/build/client/_app/immutable/nodes/2.lcRePHZ5.js.br +0 -0
- package/web/build/client/_app/immutable/nodes/2.lcRePHZ5.js.gz +0 -0
- package/web/build/client/_app/immutable/nodes/3.GZPWeD9I.js +170 -0
- package/web/build/client/_app/immutable/nodes/3.GZPWeD9I.js.br +0 -0
- package/web/build/client/_app/immutable/nodes/3.GZPWeD9I.js.gz +0 -0
- package/web/build/client/_app/version.json +1 -1
- package/web/build/client/_app/version.json.br +0 -0
- package/web/build/client/_app/version.json.gz +0 -0
- package/web/build/handler.js +3 -3
- package/web/build/index.js +3 -3
- package/web/build/server/chunks/1-DCKScL3A.js +7 -0
- package/web/build/server/chunks/1-DCKScL3A.js.map +1 -0
- package/web/build/server/chunks/2-BpBQQ02B.js +7 -0
- package/web/build/server/chunks/2-BpBQQ02B.js.map +1 -0
- package/web/build/server/chunks/3-rGkcNCVz.js +7 -0
- package/web/build/server/chunks/{3-D95XsQMA.js.map → 3-rGkcNCVz.js.map} +1 -1
- package/web/build/server/chunks/{handler-BtMUV-EL.js → handler-BaT-QavL.js} +3 -3
- package/web/build/server/chunks/{handler-BtMUV-EL.js.map → handler-BaT-QavL.js.map} +1 -1
- package/web/build/server/chunks/{index.js-D3hB-kQu.js → index.js-C-K0YDBJ.js} +150 -150
- package/web/build/server/chunks/index.js-C-K0YDBJ.js.map +1 -0
- package/web/build/server/chunks/{manifest.js-BjQCm6Fj.js → manifest.js-CDz-Fem4.js} +6 -6
- package/web/build/server/chunks/{manifest.js-BjQCm6Fj.js.map → manifest.js-CDz-Fem4.js.map} +1 -1
- package/web/build/server/chunks/{remote-1g4ayag-vByXJn4W.js → remote-1g4ayag-DXdFe1BG.js} +645 -146
- package/web/build/server/chunks/remote-1g4ayag-DXdFe1BG.js.map +1 -0
- package/web/build/client/_app/immutable/assets/2.Du8E-7-D.css +0 -1
- package/web/build/client/_app/immutable/assets/2.Du8E-7-D.css.br +0 -0
- package/web/build/client/_app/immutable/assets/2.Du8E-7-D.css.gz +0 -0
- package/web/build/client/_app/immutable/assets/3.BDe920_w.css.br +0 -0
- package/web/build/client/_app/immutable/assets/3.BDe920_w.css.gz +0 -0
- package/web/build/client/_app/immutable/chunks/DA6AD4pE.js.br +0 -0
- package/web/build/client/_app/immutable/chunks/DA6AD4pE.js.gz +0 -0
- package/web/build/client/_app/immutable/chunks/DWVUjZfW.js +0 -2
- package/web/build/client/_app/immutable/chunks/DWVUjZfW.js.br +0 -0
- package/web/build/client/_app/immutable/chunks/DWVUjZfW.js.gz +0 -0
- package/web/build/client/_app/immutable/entry/app.DfeyUgCE.js.br +0 -0
- package/web/build/client/_app/immutable/entry/app.DfeyUgCE.js.gz +0 -0
- package/web/build/client/_app/immutable/entry/start.CBt8Pu89.js +0 -1
- package/web/build/client/_app/immutable/entry/start.CBt8Pu89.js.br +0 -0
- package/web/build/client/_app/immutable/entry/start.CBt8Pu89.js.gz +0 -0
- package/web/build/client/_app/immutable/nodes/1.BnePkiVL.js +0 -1
- package/web/build/client/_app/immutable/nodes/1.BnePkiVL.js.br +0 -0
- package/web/build/client/_app/immutable/nodes/1.BnePkiVL.js.gz +0 -0
- package/web/build/client/_app/immutable/nodes/2.Dr69zJpD.js +0 -66
- package/web/build/client/_app/immutable/nodes/2.Dr69zJpD.js.br +0 -0
- package/web/build/client/_app/immutable/nodes/2.Dr69zJpD.js.gz +0 -0
- package/web/build/client/_app/immutable/nodes/3.BjjzqFKA.js +0 -170
- package/web/build/client/_app/immutable/nodes/3.BjjzqFKA.js.br +0 -0
- package/web/build/client/_app/immutable/nodes/3.BjjzqFKA.js.gz +0 -0
- package/web/build/server/chunks/1-eKskvjnM.js +0 -7
- package/web/build/server/chunks/1-eKskvjnM.js.map +0 -1
- package/web/build/server/chunks/2-WcOxbtb4.js +0 -7
- package/web/build/server/chunks/2-WcOxbtb4.js.map +0 -1
- package/web/build/server/chunks/3-D95XsQMA.js +0 -7
- package/web/build/server/chunks/index.js-D3hB-kQu.js.map +0 -1
- 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
|
-
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
package/bin/diff-review.mjs
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* CLI
|
|
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 —
|
|
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 = {
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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(
|
|
123
|
-
console.error(`No se pudo usar ${
|
|
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://${
|
|
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:
|
|
136
|
-
PORT: String(
|
|
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 (
|
|
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
|
+
});
|