@jpkme/diff-review 0.1.3 → 0.1.5
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 +328 -18
- package/lib/autocomplete-es.mjs +143 -0
- 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 +44 -0
- package/lib/output-name.test.mjs +37 -0
- package/lib/pick-folder.mjs +4 -0
- package/lib/prompt.mjs +160 -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 +4 -2
- package/schema.json +12 -0
- 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/chunks/CO0mUwMJ.js +1 -0
- package/web/build/client/_app/immutable/chunks/CO0mUwMJ.js.br +2 -0
- package/web/build/client/_app/immutable/chunks/CO0mUwMJ.js.gz +0 -0
- package/web/build/client/_app/immutable/chunks/{C5moC2DW.js → CdHZDAAo.js} +42 -32
- package/web/build/client/_app/immutable/chunks/CdHZDAAo.js.br +0 -0
- package/web/build/client/_app/immutable/chunks/CdHZDAAo.js.gz +0 -0
- package/web/build/client/_app/immutable/chunks/DzqTpz7_.js +2 -0
- package/web/build/client/_app/immutable/chunks/DzqTpz7_.js.br +0 -0
- package/web/build/client/_app/immutable/chunks/DzqTpz7_.js.gz +0 -0
- package/web/build/client/_app/immutable/entry/{app.Dei8qxo7.js → app.BRzz8J51.js} +2 -2
- package/web/build/client/_app/immutable/entry/app.BRzz8J51.js.br +0 -0
- package/web/build/client/_app/immutable/entry/app.BRzz8J51.js.gz +0 -0
- package/web/build/client/_app/immutable/entry/start.BSioRkqK.js +1 -0
- package/web/build/client/_app/immutable/entry/start.BSioRkqK.js.br +0 -0
- package/web/build/client/_app/immutable/entry/start.BSioRkqK.js.gz +0 -0
- package/web/build/client/_app/immutable/nodes/1.ws8Khan5.js +1 -0
- package/web/build/client/_app/immutable/nodes/1.ws8Khan5.js.br +1 -0
- package/web/build/client/_app/immutable/nodes/1.ws8Khan5.js.gz +0 -0
- package/web/build/client/_app/immutable/nodes/2.CsD5xqRB.js +66 -0
- package/web/build/client/_app/immutable/nodes/2.CsD5xqRB.js.br +0 -0
- package/web/build/client/_app/immutable/nodes/2.CsD5xqRB.js.gz +0 -0
- package/web/build/client/_app/immutable/nodes/3.ByTmpJQZ.js +170 -0
- package/web/build/client/_app/immutable/nodes/3.ByTmpJQZ.js.br +0 -0
- package/web/build/client/_app/immutable/nodes/3.ByTmpJQZ.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-qrn24yN5.js +7 -0
- package/web/build/server/chunks/1-qrn24yN5.js.map +1 -0
- package/web/build/server/chunks/2-C7_Loqyn.js +7 -0
- package/web/build/server/chunks/2-C7_Loqyn.js.map +1 -0
- package/web/build/server/chunks/{3-Zc5_qwgV.js → 3-DZ5PBhOc.js} +2 -2
- package/web/build/server/chunks/{3-Zc5_qwgV.js.map → 3-DZ5PBhOc.js.map} +1 -1
- package/web/build/server/chunks/{handler-BmuqxCCf.js → handler-DD4vP3bU.js} +3 -3
- package/web/build/server/chunks/{handler-BmuqxCCf.js.map → handler-DD4vP3bU.js.map} +1 -1
- package/web/build/server/chunks/{index.js-41EcOndB.js → index.js-IhFPHwmp.js} +150 -150
- package/web/build/server/chunks/index.js-IhFPHwmp.js.map +1 -0
- package/web/build/server/chunks/{manifest.js-KnmnjP9i.js → manifest.js-Dk3RuAA5.js} +6 -6
- package/web/build/server/chunks/{manifest.js-KnmnjP9i.js.map → manifest.js-Dk3RuAA5.js.map} +1 -1
- package/web/build/server/chunks/{remote-1g4ayag-BgUNMUS8.js → remote-1g4ayag-BRL-uaKE.js} +632 -146
- package/web/build/server/chunks/remote-1g4ayag-BRL-uaKE.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/chunks/C5moC2DW.js.br +0 -0
- package/web/build/client/_app/immutable/chunks/C5moC2DW.js.gz +0 -0
- package/web/build/client/_app/immutable/chunks/WxPrACSs.js +0 -2
- package/web/build/client/_app/immutable/chunks/WxPrACSs.js.br +0 -0
- package/web/build/client/_app/immutable/chunks/WxPrACSs.js.gz +0 -0
- package/web/build/client/_app/immutable/entry/app.Dei8qxo7.js.br +0 -0
- package/web/build/client/_app/immutable/entry/app.Dei8qxo7.js.gz +0 -0
- package/web/build/client/_app/immutable/entry/start.Fzxrn2l4.js +0 -1
- package/web/build/client/_app/immutable/entry/start.Fzxrn2l4.js.br +0 -1
- package/web/build/client/_app/immutable/entry/start.Fzxrn2l4.js.gz +0 -0
- package/web/build/client/_app/immutable/nodes/1.Cn_iktkl.js +0 -1
- package/web/build/client/_app/immutable/nodes/1.Cn_iktkl.js.br +0 -0
- package/web/build/client/_app/immutable/nodes/1.Cn_iktkl.js.gz +0 -0
- package/web/build/client/_app/immutable/nodes/2.vvYFHTy-.js +0 -66
- package/web/build/client/_app/immutable/nodes/2.vvYFHTy-.js.br +0 -0
- package/web/build/client/_app/immutable/nodes/2.vvYFHTy-.js.gz +0 -0
- package/web/build/client/_app/immutable/nodes/3.CeJ7dEsh.js +0 -170
- package/web/build/client/_app/immutable/nodes/3.CeJ7dEsh.js.br +0 -0
- package/web/build/client/_app/immutable/nodes/3.CeJ7dEsh.js.gz +0 -0
- package/web/build/server/chunks/1-Bjgeu1Iz.js +0 -7
- package/web/build/server/chunks/1-Bjgeu1Iz.js.map +0 -1
- package/web/build/server/chunks/2-BvMZU9fP.js +0 -7
- package/web/build/server/chunks/2-BvMZU9fP.js.map +0 -1
- package/web/build/server/chunks/index.js-41EcOndB.js.map +0 -1
- package/web/build/server/chunks/remote-1g4ayag-BgUNMUS8.js.map +0 -1
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
|
+
});
|
package/lib/extract.mjs
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, resolve } from "node:path";
|
|
3
|
+
import { capDiff, collectGuidelines, isIgnored, isTestPath, loadReviewConfig } from "./config.mjs";
|
|
4
|
+
import { commitLog, git } from "./git.mjs";
|
|
5
|
+
|
|
6
|
+
export function flattenFiles(report) {
|
|
7
|
+
return Array.isArray(report?.files) ? report.files : [];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function extractBranchDiff({ repo, branch, base = "develop" }) {
|
|
11
|
+
if (!branch) throw new Error("Falta el branch a revisar.");
|
|
12
|
+
|
|
13
|
+
const config = loadReviewConfig(repo);
|
|
14
|
+
const mergeBase = git(repo, ["merge-base", base, branch]).trim();
|
|
15
|
+
const branchSha = git(repo, ["rev-parse", branch]).trim();
|
|
16
|
+
const baseSha = git(repo, ["rev-parse", base]).trim();
|
|
17
|
+
|
|
18
|
+
const nameStatus = git(repo, ["diff", mergeBase, branch, "--name-status"])
|
|
19
|
+
.trim()
|
|
20
|
+
.split("\n")
|
|
21
|
+
.filter(Boolean)
|
|
22
|
+
.map((line) => {
|
|
23
|
+
const [status, ...rest] = line.split("\t");
|
|
24
|
+
const path = rest[rest.length - 1];
|
|
25
|
+
const oldPath = rest.length > 1 ? rest[0] : null;
|
|
26
|
+
let changeType = "modified";
|
|
27
|
+
if (status.startsWith("A")) changeType = "added";
|
|
28
|
+
else if (status.startsWith("D")) changeType = "deleted";
|
|
29
|
+
else if (status.startsWith("R")) changeType = "renamed";
|
|
30
|
+
return { path, oldPath, changeType };
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const ignored = [];
|
|
34
|
+
const included = [];
|
|
35
|
+
for (const f of nameStatus) {
|
|
36
|
+
if (isIgnored(f.path, config.ignore)) ignored.push(f.path);
|
|
37
|
+
else included.push(f);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const files = included.map((f) => {
|
|
41
|
+
let raw = "";
|
|
42
|
+
try {
|
|
43
|
+
raw = git(repo, ["diff", mergeBase, branch, "--", f.path]);
|
|
44
|
+
const idx = raw.indexOf("\n@@");
|
|
45
|
+
if (idx !== -1) raw = raw.slice(idx + 1);
|
|
46
|
+
} catch {
|
|
47
|
+
raw = "";
|
|
48
|
+
}
|
|
49
|
+
const capped = capDiff(raw);
|
|
50
|
+
return {
|
|
51
|
+
path: f.path,
|
|
52
|
+
oldPath: f.oldPath || undefined,
|
|
53
|
+
changeType: f.changeType,
|
|
54
|
+
diff: capped.diff,
|
|
55
|
+
diffChars: capped.diffChars,
|
|
56
|
+
truncated: capped.truncated,
|
|
57
|
+
modelChars: capped.diff.length,
|
|
58
|
+
group: "",
|
|
59
|
+
};
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const shortstat = git(repo, ["diff", mergeBase, branch, "--shortstat"]).trim();
|
|
63
|
+
const insMatch = shortstat.match(/(\d+) insertion/);
|
|
64
|
+
const delMatch = shortstat.match(/(\d+) deletion/);
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
version: 2,
|
|
68
|
+
repo,
|
|
69
|
+
branch,
|
|
70
|
+
baseBranch: base,
|
|
71
|
+
mergeBase,
|
|
72
|
+
branchSha,
|
|
73
|
+
baseSha,
|
|
74
|
+
generatedAt: new Date().toISOString(),
|
|
75
|
+
intent: "",
|
|
76
|
+
stats: {
|
|
77
|
+
filesChanged: files.length,
|
|
78
|
+
filesIgnored: ignored.length,
|
|
79
|
+
filesTruncated: files.filter((f) => f.truncated).length,
|
|
80
|
+
insertions: insMatch ? Number(insMatch[1]) : 0,
|
|
81
|
+
deletions: delMatch ? Number(delMatch[1]) : 0,
|
|
82
|
+
},
|
|
83
|
+
context: {
|
|
84
|
+
commits: commitLog(repo, mergeBase, branch),
|
|
85
|
+
rules: config.rules,
|
|
86
|
+
rulesFile: config.rulesFile,
|
|
87
|
+
ignored: ignored.slice(0, 40),
|
|
88
|
+
testsTouched: files.filter((f) => isTestPath(f.path)).map((f) => f.path),
|
|
89
|
+
testsMissing: files.filter((f) => !isTestPath(f.path)).length,
|
|
90
|
+
guidelines: collectGuidelines(repo, files.map((f) => f.path), {
|
|
91
|
+
rulesFile: config.rulesFile,
|
|
92
|
+
skipNames: config.rules.trim() ? [config.rulesFile] : [],
|
|
93
|
+
maxDepth: 1,
|
|
94
|
+
maxChars: 8_000,
|
|
95
|
+
maxTotalChars: 12_000,
|
|
96
|
+
}),
|
|
97
|
+
},
|
|
98
|
+
files,
|
|
99
|
+
groups: [],
|
|
100
|
+
blocks: [],
|
|
101
|
+
findings: [],
|
|
102
|
+
skipped: [],
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function writeSkeleton(outPath, skeleton) {
|
|
107
|
+
const resolved = resolve(outPath);
|
|
108
|
+
mkdirSync(dirname(resolved), { recursive: true });
|
|
109
|
+
writeFileSync(resolved, JSON.stringify(skeleton, null, 2), "utf8");
|
|
110
|
+
return resolved;
|
|
111
|
+
}
|
package/lib/git.mjs
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
|
|
5
|
+
export function assertGitRepo(repo) {
|
|
6
|
+
const raw = String(repo || "").trim();
|
|
7
|
+
if (!raw) {
|
|
8
|
+
throw new Error("Indicá la ruta absoluta de un repositorio git.");
|
|
9
|
+
}
|
|
10
|
+
if (!/[\\/]/.test(raw) && !/^[A-Za-z]:/.test(raw)) {
|
|
11
|
+
throw new Error(
|
|
12
|
+
`"${raw}" no es una ruta. Pegá la ruta absoluta del repo (ej. C:\\tienda-argenta\\tienda-argenta-2).`,
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
const resolved = resolve(raw);
|
|
16
|
+
if (!resolved || resolved === ".") {
|
|
17
|
+
throw new Error("Indicá la ruta absoluta de un repositorio git.");
|
|
18
|
+
}
|
|
19
|
+
if (!existsSync(resolved)) {
|
|
20
|
+
throw new Error(`No existe la carpeta: ${resolved}`);
|
|
21
|
+
}
|
|
22
|
+
if (!existsSync(join(resolved, ".git"))) {
|
|
23
|
+
throw new Error(`No es un repositorio git (falta .git): ${resolved}`);
|
|
24
|
+
}
|
|
25
|
+
return resolved;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Si `cwd` está dentro de un repo git, devuelve la raíz absoluta; si no, null.
|
|
30
|
+
* Acepta subcarpetas (usa `git rev-parse --show-toplevel`).
|
|
31
|
+
*/
|
|
32
|
+
export function tryResolveGitRoot(cwd) {
|
|
33
|
+
const raw = String(cwd || "").trim();
|
|
34
|
+
if (!raw) return null;
|
|
35
|
+
const dir = resolve(raw);
|
|
36
|
+
if (!existsSync(dir)) return null;
|
|
37
|
+
try {
|
|
38
|
+
const root = execFileSync("git", ["rev-parse", "--show-toplevel"], {
|
|
39
|
+
cwd: dir,
|
|
40
|
+
encoding: "utf8",
|
|
41
|
+
windowsHide: true,
|
|
42
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
43
|
+
}).trim();
|
|
44
|
+
return root ? resolve(root) : null;
|
|
45
|
+
} catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function git(repo, args, { maxBuffer = 64 * 1024 * 1024 } = {}) {
|
|
51
|
+
return execFileSync("git", args, {
|
|
52
|
+
cwd: repo,
|
|
53
|
+
encoding: "utf8",
|
|
54
|
+
maxBuffer,
|
|
55
|
+
windowsHide: true,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function slug(s) {
|
|
60
|
+
return String(s)
|
|
61
|
+
.toLowerCase()
|
|
62
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
63
|
+
.replace(/(^-|-$)/g, "");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function resolveRev(repo, ref) {
|
|
67
|
+
return git(repo, ["rev-parse", ref]).trim();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function branchTips(repo, branch, base) {
|
|
71
|
+
return {
|
|
72
|
+
branchSha: resolveRev(repo, branch),
|
|
73
|
+
baseSha: resolveRev(repo, base),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function fingerprint(branchSha, baseSha) {
|
|
78
|
+
const a = shortSha(branchSha);
|
|
79
|
+
const b = shortSha(baseSha);
|
|
80
|
+
if (!a || !b) return null;
|
|
81
|
+
return `${a}-${b}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** File/URL id: short tip of the reviewed branch + short tip of the base. */
|
|
85
|
+
export function reportId(branchSha, baseSha) {
|
|
86
|
+
const id = fingerprint(branchSha, baseSha);
|
|
87
|
+
if (!id) throw new Error("Faltan SHAs de los tips para el id del reporte.");
|
|
88
|
+
return id;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function shortSha(sha, n = 10) {
|
|
92
|
+
const hex = String(sha || "").toLowerCase().replace(/[^a-f0-9]/g, "");
|
|
93
|
+
return hex.length >= 7 ? hex.slice(0, n) : "";
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function commitLog(repo, mergeBase, branch, limit = 25) {
|
|
97
|
+
try {
|
|
98
|
+
return git(repo, [
|
|
99
|
+
"log",
|
|
100
|
+
"--oneline",
|
|
101
|
+
"--no-merges",
|
|
102
|
+
"-n",
|
|
103
|
+
String(limit),
|
|
104
|
+
`${mergeBase}..${branch}`,
|
|
105
|
+
])
|
|
106
|
+
.trim()
|
|
107
|
+
.split("\n")
|
|
108
|
+
.filter(Boolean);
|
|
109
|
+
} catch {
|
|
110
|
+
return [];
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const FILE_LINE_CACHE = new Map();
|
|
115
|
+
const FILE_LINE_CACHE_MAX = 48;
|
|
116
|
+
|
|
117
|
+
function rememberFileLines(key, lines) {
|
|
118
|
+
if (FILE_LINE_CACHE.has(key)) FILE_LINE_CACHE.delete(key);
|
|
119
|
+
FILE_LINE_CACHE.set(key, lines);
|
|
120
|
+
while (FILE_LINE_CACHE.size > FILE_LINE_CACHE_MAX) {
|
|
121
|
+
const oldest = FILE_LINE_CACHE.keys().next().value;
|
|
122
|
+
FILE_LINE_CACHE.delete(oldest);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function showFileLines(repo, rev, path) {
|
|
127
|
+
const resolved = assertGitRepo(repo);
|
|
128
|
+
const safeRev = String(rev || "").trim();
|
|
129
|
+
const safePath = String(path || "").replace(/\\/g, "/").trim();
|
|
130
|
+
if (!safeRev || !/^[A-Za-z0-9._/\-]+$/.test(safeRev)) {
|
|
131
|
+
throw new Error("Rev inválido.");
|
|
132
|
+
}
|
|
133
|
+
if (!safePath || safePath.startsWith("/") || safePath.includes("..") || safePath.includes("\0")) {
|
|
134
|
+
throw new Error("Ruta inválida.");
|
|
135
|
+
}
|
|
136
|
+
const key = `${resolved}\0${safeRev}\0${safePath}`;
|
|
137
|
+
if (FILE_LINE_CACHE.has(key)) return FILE_LINE_CACHE.get(key);
|
|
138
|
+
let text;
|
|
139
|
+
try {
|
|
140
|
+
text = git(resolved, ["show", `${safeRev}:${safePath}`]);
|
|
141
|
+
} catch {
|
|
142
|
+
rememberFileLines(key, null);
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
if (text.includes("\0")) {
|
|
146
|
+
rememberFileLines(key, null);
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
const raw = text.replace(/\r\n/g, "\n");
|
|
150
|
+
const lines = raw.endsWith("\n") ? raw.slice(0, -1).split("\n") : raw.split("\n");
|
|
151
|
+
rememberFileLines(key, lines);
|
|
152
|
+
return lines;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function listBranches(repo) {
|
|
156
|
+
const raw = git(repo, [
|
|
157
|
+
"for-each-ref",
|
|
158
|
+
"--sort=-committerdate",
|
|
159
|
+
"--format=%(refname:short)",
|
|
160
|
+
"refs/heads",
|
|
161
|
+
"refs/remotes",
|
|
162
|
+
]);
|
|
163
|
+
const names = raw
|
|
164
|
+
.split("\n")
|
|
165
|
+
.map((s) => s.trim())
|
|
166
|
+
.filter(Boolean)
|
|
167
|
+
.map((name) => name.replace(/^origin\//, ""))
|
|
168
|
+
.filter((name) => name !== "HEAD" && !name.endsWith("/HEAD"));
|
|
169
|
+
|
|
170
|
+
// Dedup conservando el orden de for-each-ref (más reciente primero).
|
|
171
|
+
const unique = [...new Set(names)];
|
|
172
|
+
|
|
173
|
+
const current = git(repo, ["rev-parse", "--abbrev-ref", "HEAD"]).trim();
|
|
174
|
+
const bases = unique.filter((n) => n === "develop" || n === "main" || n === "master");
|
|
175
|
+
const defaultBase = bases.includes("develop")
|
|
176
|
+
? "develop"
|
|
177
|
+
: bases.includes("main")
|
|
178
|
+
? "main"
|
|
179
|
+
: bases[0] || "develop";
|
|
180
|
+
|
|
181
|
+
return { branches: unique, current, defaultBase };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** URL del remote (origin, o el primero disponible). Vacío si no hay. */
|
|
185
|
+
export function getRemoteUrl(repo, preferred = "origin") {
|
|
186
|
+
try {
|
|
187
|
+
const url = git(repo, ["remote", "get-url", preferred]).trim();
|
|
188
|
+
if (url) return url;
|
|
189
|
+
} catch {
|
|
190
|
+
/* sin origin */
|
|
191
|
+
}
|
|
192
|
+
try {
|
|
193
|
+
const remotes = git(repo, ["remote"])
|
|
194
|
+
.split("\n")
|
|
195
|
+
.map((s) => s.trim())
|
|
196
|
+
.filter(Boolean);
|
|
197
|
+
if (!remotes.length) return "";
|
|
198
|
+
return git(repo, ["remote", "get-url", remotes[0]]).trim();
|
|
199
|
+
} catch {
|
|
200
|
+
return "";
|
|
201
|
+
}
|
|
202
|
+
}
|
package/lib/git.test.mjs
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { fingerprint, reportId, tryResolveGitRoot } from "./git.mjs";
|
|
6
|
+
|
|
7
|
+
const branch = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
|
8
|
+
const base = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
|
|
9
|
+
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
10
|
+
|
|
11
|
+
test("reportId is short branch tip then base tip, hyphenated", () => {
|
|
12
|
+
assert.equal(reportId(branch, base), "aaaaaaaaaa-bbbbbbbbbb");
|
|
13
|
+
assert.equal(fingerprint(branch, base), "aaaaaaaaaa-bbbbbbbbbb");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("reportId rejects missing shas", () => {
|
|
17
|
+
assert.equal(fingerprint("", base), null);
|
|
18
|
+
assert.throws(() => reportId("", base), /Faltan SHAs/);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("tryResolveGitRoot finds repo from subdirectory", () => {
|
|
22
|
+
const root = tryResolveGitRoot(join(repoRoot, "lib"));
|
|
23
|
+
assert.ok(root);
|
|
24
|
+
assert.match(root.replace(/\\/g, "/"), /diff-review$/i);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("tryResolveGitRoot returns null outside a repo", () => {
|
|
28
|
+
assert.equal(tryResolveGitRoot(dirname(repoRoot)), null);
|
|
29
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
function tryParseJson(s) {
|
|
2
|
+
try {
|
|
3
|
+
return JSON.parse(s);
|
|
4
|
+
} catch {
|
|
5
|
+
return null;
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Extrae un objeto JSON del texto del modelo (fences, prosa alrededor, envelope). */
|
|
10
|
+
export function decodePayload(text) {
|
|
11
|
+
const raw = String(text || "").trim();
|
|
12
|
+
if (!raw) throw new Error("Respuesta vacía.");
|
|
13
|
+
|
|
14
|
+
const fence = raw.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
|
15
|
+
if (fence) {
|
|
16
|
+
const parsed = tryParseJson(fence[1].trim());
|
|
17
|
+
if (parsed && typeof parsed === "object") return parsed;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const direct = tryParseJson(raw);
|
|
21
|
+
if (direct && typeof direct === "object") return direct;
|
|
22
|
+
|
|
23
|
+
const start = raw.indexOf("{");
|
|
24
|
+
const end = raw.lastIndexOf("}");
|
|
25
|
+
if (start >= 0 && end > start) {
|
|
26
|
+
const parsed = tryParseJson(raw.slice(start, end + 1));
|
|
27
|
+
if (parsed && typeof parsed === "object") return parsed;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
throw new Error("No se pudo interpretar la respuesta como JSON.");
|
|
31
|
+
}
|