@jpkme/diff-review 0.1.3 → 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/chunks/{C5moC2DW.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.Dei8qxo7.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-Zc5_qwgV.js → 3-rGkcNCVz.js} +2 -2
- package/web/build/server/chunks/{3-Zc5_qwgV.js.map → 3-rGkcNCVz.js.map} +1 -1
- package/web/build/server/chunks/{handler-BmuqxCCf.js → handler-BaT-QavL.js} +3 -3
- package/web/build/server/chunks/{handler-BmuqxCCf.js.map → handler-BaT-QavL.js.map} +1 -1
- package/web/build/server/chunks/{index.js-41EcOndB.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-KnmnjP9i.js → manifest.js-CDz-Fem4.js} +6 -6
- package/web/build/server/chunks/{manifest.js-KnmnjP9i.js.map → manifest.js-CDz-Fem4.js.map} +1 -1
- package/web/build/server/chunks/{remote-1g4ayag-BgUNMUS8.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/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
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import { existsSync, mkdirSync, rmSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { git } from "./git.mjs";
|
|
7
|
+
|
|
8
|
+
const CACHE_ROOT = join(tmpdir(), "diff-review-clones");
|
|
9
|
+
const MAX_DEEPEN_ROUNDS = 8;
|
|
10
|
+
const DEEPEN_BY = "50";
|
|
11
|
+
|
|
12
|
+
export function looksLikeGitUrl(value) {
|
|
13
|
+
const v = String(value || "").trim();
|
|
14
|
+
if (!v) return false;
|
|
15
|
+
if (/^(https?:\/\/|git@|ssh:\/\/)/i.test(v)) return true;
|
|
16
|
+
if (/\.git$/i.test(v) && !/^[A-Za-z]:/.test(v) && !v.startsWith("/") && !v.startsWith("\\")) {
|
|
17
|
+
return true;
|
|
18
|
+
}
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function normalizeGitUrl(url) {
|
|
23
|
+
const raw = String(url || "").trim();
|
|
24
|
+
if (!raw) throw new Error("Indicá la URL del repositorio.");
|
|
25
|
+
if (!looksLikeGitUrl(raw)) {
|
|
26
|
+
throw new Error(
|
|
27
|
+
`"${raw}" no parece una URL git. Usá https://…, git@… o ssh://…`,
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
if (/^(file:|javascript:|data:)/i.test(raw)) {
|
|
31
|
+
throw new Error("Esquema de URL no permitido.");
|
|
32
|
+
}
|
|
33
|
+
return raw.replace(/\/$/, "");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function cacheDirFor(url) {
|
|
37
|
+
const key = createHash("sha1").update(url).digest("hex").slice(0, 16);
|
|
38
|
+
return join(CACHE_ROOT, key);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function runGit(args, { cwd, timeout = 5 * 60 * 1000 } = {}) {
|
|
42
|
+
return execFileSync("git", args, {
|
|
43
|
+
cwd,
|
|
44
|
+
encoding: "utf8",
|
|
45
|
+
windowsHide: true,
|
|
46
|
+
timeout,
|
|
47
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Lista heads del remote sin clonar. */
|
|
52
|
+
export function listRemoteBranches(url) {
|
|
53
|
+
const remote = normalizeGitUrl(url);
|
|
54
|
+
let raw;
|
|
55
|
+
try {
|
|
56
|
+
raw = runGit(["ls-remote", "--heads", remote]);
|
|
57
|
+
} catch (err) {
|
|
58
|
+
const stderr = String(/** @type {{ stderr?: string }} */ (err).stderr || err.message || err);
|
|
59
|
+
throw new Error("No se pudo listar branches del remote: " + stderr.split("\n").find(Boolean));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const branches = [
|
|
63
|
+
...new Set(
|
|
64
|
+
raw
|
|
65
|
+
.split("\n")
|
|
66
|
+
.map((line) => line.trim())
|
|
67
|
+
.filter(Boolean)
|
|
68
|
+
.map((line) => {
|
|
69
|
+
const ref = line.split("\t")[1] || "";
|
|
70
|
+
return ref.replace(/^refs\/heads\//, "");
|
|
71
|
+
})
|
|
72
|
+
.filter(Boolean),
|
|
73
|
+
),
|
|
74
|
+
].sort((a, b) => a.localeCompare(b));
|
|
75
|
+
|
|
76
|
+
if (!branches.length) {
|
|
77
|
+
throw new Error("El remote no expone branches (refs/heads).");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const defaultBase = branches.includes("develop")
|
|
81
|
+
? "develop"
|
|
82
|
+
: branches.includes("main")
|
|
83
|
+
? "main"
|
|
84
|
+
: branches.includes("master")
|
|
85
|
+
? "master"
|
|
86
|
+
: branches[0];
|
|
87
|
+
|
|
88
|
+
return { branches, current: "", defaultBase };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function fetchRef(repo, ref) {
|
|
92
|
+
const name = String(ref || "").trim();
|
|
93
|
+
if (!name) return;
|
|
94
|
+
try {
|
|
95
|
+
runGit(["rev-parse", "--verify", name], { cwd: repo });
|
|
96
|
+
return;
|
|
97
|
+
} catch {
|
|
98
|
+
/* falta localmente */
|
|
99
|
+
}
|
|
100
|
+
runGit(
|
|
101
|
+
["fetch", "--depth", DEEPEN_BY, "origin", `refs/heads/${name}:refs/heads/${name}`],
|
|
102
|
+
{ cwd: repo },
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function deepenUntilMergeBase(repo, base, branch) {
|
|
107
|
+
for (let i = 0; i < MAX_DEEPEN_ROUNDS; i++) {
|
|
108
|
+
try {
|
|
109
|
+
git(repo, ["merge-base", base, branch]);
|
|
110
|
+
return;
|
|
111
|
+
} catch {
|
|
112
|
+
runGit(
|
|
113
|
+
[
|
|
114
|
+
"fetch",
|
|
115
|
+
"--deepen",
|
|
116
|
+
DEEPEN_BY,
|
|
117
|
+
"origin",
|
|
118
|
+
`refs/heads/${base}:refs/heads/${base}`,
|
|
119
|
+
`refs/heads/${branch}:refs/heads/${branch}`,
|
|
120
|
+
],
|
|
121
|
+
{ cwd: repo },
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
throw new Error(
|
|
126
|
+
`No se encontró merge-base entre "${base}" y "${branch}" (historial shallow insuficiente). Probá con más historia o usá una carpeta local.`,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Asegura un worktree local (cache en tmp) con branch y base disponibles para diff.
|
|
132
|
+
* @returns {string} ruta absoluta del clone
|
|
133
|
+
*/
|
|
134
|
+
export function ensureRemoteWorktree(url, branch, base = "develop") {
|
|
135
|
+
const remote = normalizeGitUrl(url);
|
|
136
|
+
const b = String(branch || "").trim();
|
|
137
|
+
const baseRef = String(base || "develop").trim() || "develop";
|
|
138
|
+
if (!b) throw new Error("Falta el branch a revisar.");
|
|
139
|
+
|
|
140
|
+
mkdirSync(CACHE_ROOT, { recursive: true });
|
|
141
|
+
const dir = cacheDirFor(remote);
|
|
142
|
+
const gitDir = join(dir, ".git");
|
|
143
|
+
|
|
144
|
+
if (!existsSync(gitDir)) {
|
|
145
|
+
if (existsSync(dir)) rmSync(dir, { recursive: true, force: true });
|
|
146
|
+
try {
|
|
147
|
+
runGit(
|
|
148
|
+
[
|
|
149
|
+
"clone",
|
|
150
|
+
"--filter=blob:none",
|
|
151
|
+
"--depth",
|
|
152
|
+
DEEPEN_BY,
|
|
153
|
+
"--branch",
|
|
154
|
+
b,
|
|
155
|
+
remote,
|
|
156
|
+
dir,
|
|
157
|
+
],
|
|
158
|
+
{ timeout: 10 * 60 * 1000 },
|
|
159
|
+
);
|
|
160
|
+
} catch (err) {
|
|
161
|
+
if (existsSync(dir)) rmSync(dir, { recursive: true, force: true });
|
|
162
|
+
const stderr = String(/** @type {{ stderr?: string }} */ (err).stderr || err.message || err);
|
|
163
|
+
throw new Error("No se pudo clonar el repo: " + stderr.split("\n").find(Boolean));
|
|
164
|
+
}
|
|
165
|
+
} else {
|
|
166
|
+
try {
|
|
167
|
+
runGit(["remote", "set-url", "origin", remote], { cwd: dir });
|
|
168
|
+
} catch {
|
|
169
|
+
/* remote ya ok */
|
|
170
|
+
}
|
|
171
|
+
fetchRef(dir, b);
|
|
172
|
+
try {
|
|
173
|
+
runGit(["checkout", "--force", b], { cwd: dir });
|
|
174
|
+
} catch {
|
|
175
|
+
/* detached / already on branch */
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (baseRef !== b) fetchRef(dir, baseRef);
|
|
180
|
+
deepenUntilMergeBase(dir, baseRef, b);
|
|
181
|
+
return dir;
|
|
182
|
+
}
|
package/lib/review.mjs
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BLOCK_OPS,
|
|
3
|
+
FINDING_CLASSES,
|
|
4
|
+
FINDING_KINDS,
|
|
5
|
+
FINDING_SEVERITIES,
|
|
6
|
+
GROUP_KINDS,
|
|
7
|
+
SKIP_REASONS,
|
|
8
|
+
WHY_SOURCES,
|
|
9
|
+
} from "./validate.mjs";
|
|
10
|
+
|
|
11
|
+
const MAX = {
|
|
12
|
+
intent: 700,
|
|
13
|
+
groupTitle: 100,
|
|
14
|
+
groupIntent: 300,
|
|
15
|
+
what: 240,
|
|
16
|
+
why: 240,
|
|
17
|
+
finding: 360,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
function text(value, max) {
|
|
21
|
+
const raw = String(value ?? "").replace(/\s+/g, " ").trim();
|
|
22
|
+
return raw.length > max ? `${raw.slice(0, max - 1).trimEnd()}…` : raw;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function oneOf(value, allowed, fallback) {
|
|
26
|
+
const raw = String(value ?? "").trim().toLowerCase();
|
|
27
|
+
return allowed.includes(raw) ? raw : fallback;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Rango de líneas de un bloque. `L40-58` es post-imagen (lo que quedó);
|
|
32
|
+
* `-L88-95` es pre-imagen, que es la única forma de señalar líneas borradas.
|
|
33
|
+
*/
|
|
34
|
+
export function parseLineRange(value) {
|
|
35
|
+
const raw = String(value ?? "").trim();
|
|
36
|
+
const match = /^(-)?L?(\d+)(?:\s*[-–]\s*L?(\d+))?$/i.exec(raw);
|
|
37
|
+
if (!match) return null;
|
|
38
|
+
const start = Number(match[2]);
|
|
39
|
+
const end = match[3] ? Number(match[3]) : start;
|
|
40
|
+
return {
|
|
41
|
+
side: match[1] ? "old" : "new",
|
|
42
|
+
start: Math.min(start, end),
|
|
43
|
+
end: Math.max(start, end),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function formatLineRange(range) {
|
|
48
|
+
if (!range) return "";
|
|
49
|
+
const prefix = range.side === "old" ? "-L" : "L";
|
|
50
|
+
return range.start === range.end ? `${prefix}${range.start}` : `${prefix}${range.start}-${range.end}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function countHunks(files) {
|
|
54
|
+
let total = 0;
|
|
55
|
+
for (const file of files || []) {
|
|
56
|
+
for (const line of String(file.diff || "").split("\n")) {
|
|
57
|
+
if (line.startsWith("@@")) total += 1;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return total;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function normalizeGroups(raw) {
|
|
64
|
+
const seen = new Set();
|
|
65
|
+
const groups = [];
|
|
66
|
+
for (const item of raw || []) {
|
|
67
|
+
const id = String(item?.id || "").trim();
|
|
68
|
+
if (!id || seen.has(id)) continue;
|
|
69
|
+
seen.add(id);
|
|
70
|
+
groups.push({
|
|
71
|
+
id,
|
|
72
|
+
kind: oneOf(item.kind, GROUP_KINDS, "chore"),
|
|
73
|
+
title: text(item.title, MAX.groupTitle) || `${oneOf(item.kind, GROUP_KINDS, "chore")}: cambios`,
|
|
74
|
+
intent: text(item.intent, MAX.groupIntent),
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
return groups;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function normalizeBlocks(raw, { groupIds, knownPaths }) {
|
|
81
|
+
const seen = new Set();
|
|
82
|
+
const blocks = [];
|
|
83
|
+
for (const item of raw || []) {
|
|
84
|
+
const id = String(item?.id || "").trim();
|
|
85
|
+
const group = String(item?.group || "").trim();
|
|
86
|
+
const file = String(item?.file || "").trim();
|
|
87
|
+
if (!id || seen.has(id)) continue;
|
|
88
|
+
if (!groupIds.has(group)) continue;
|
|
89
|
+
if (knownPaths.size && !knownPaths.has(file)) continue;
|
|
90
|
+
const range = parseLineRange(item.lines);
|
|
91
|
+
seen.add(id);
|
|
92
|
+
blocks.push({
|
|
93
|
+
id,
|
|
94
|
+
group,
|
|
95
|
+
file,
|
|
96
|
+
lines: range ? formatLineRange(range) : text(item.lines, 24),
|
|
97
|
+
side: range?.side || "new",
|
|
98
|
+
start: range?.start ?? 0,
|
|
99
|
+
end: range?.end ?? 0,
|
|
100
|
+
op: oneOf(item.op, BLOCK_OPS, "mod"),
|
|
101
|
+
what: text(item.what, MAX.what),
|
|
102
|
+
why: text(item.why, MAX.why),
|
|
103
|
+
source: oneOf(item.source, WHY_SOURCES, "inferred"),
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
blocks.sort((a, b) => a.file.localeCompare(b.file) || a.start - b.start);
|
|
107
|
+
return blocks;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function findingKey(finding) {
|
|
111
|
+
return [finding.file, finding.line ?? "", finding.kind, finding.what].join("|");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function normalizeFindings(raw, { blockIds, knownPaths }) {
|
|
115
|
+
const seen = new Set();
|
|
116
|
+
const findings = [];
|
|
117
|
+
for (const item of raw || []) {
|
|
118
|
+
const file = String(item?.file || item?.path || "").trim();
|
|
119
|
+
const what = text(item?.what || item?.title, MAX.finding);
|
|
120
|
+
if (!file || !what) continue;
|
|
121
|
+
if (knownPaths.size && !knownPaths.has(file)) continue;
|
|
122
|
+
const cls = oneOf(item.class, FINDING_CLASSES, "risk");
|
|
123
|
+
const severity = oneOf(item.severity, FINDING_SEVERITIES, "med");
|
|
124
|
+
const block = String(item?.block || "").trim();
|
|
125
|
+
const line = Number.isFinite(Number(item?.line)) ? Number(item.line) : null;
|
|
126
|
+
const finding = {
|
|
127
|
+
id: "",
|
|
128
|
+
class: cls,
|
|
129
|
+
severity,
|
|
130
|
+
// Una mejora de calidad nunca frena un merge, diga lo que diga el modelo;
|
|
131
|
+
// un riesgo alto sí, aunque el modelo se haya olvidado de marcarlo.
|
|
132
|
+
blocking: cls === "risk" && (item?.blocking === true || severity === "high"),
|
|
133
|
+
kind: oneOf(item.kind, FINDING_KINDS, "other"),
|
|
134
|
+
file,
|
|
135
|
+
line,
|
|
136
|
+
block: blockIds.has(block) ? block : "",
|
|
137
|
+
what,
|
|
138
|
+
fix: text(item.fix || item.detail, MAX.finding),
|
|
139
|
+
};
|
|
140
|
+
const key = findingKey(finding);
|
|
141
|
+
if (seen.has(key)) continue;
|
|
142
|
+
seen.add(key);
|
|
143
|
+
findings.push(finding);
|
|
144
|
+
}
|
|
145
|
+
const rank = { high: 0, med: 1, low: 2, nit: 3 };
|
|
146
|
+
findings.sort(
|
|
147
|
+
(a, b) =>
|
|
148
|
+
Number(b.blocking) - Number(a.blocking) ||
|
|
149
|
+
(a.class === b.class ? 0 : a.class === "risk" ? -1 : 1) ||
|
|
150
|
+
rank[a.severity] - rank[b.severity] ||
|
|
151
|
+
a.file.localeCompare(b.file),
|
|
152
|
+
);
|
|
153
|
+
findings.forEach((finding, i) => {
|
|
154
|
+
finding.id = `f${i + 1}`;
|
|
155
|
+
});
|
|
156
|
+
return findings;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function normalizeSkipped(raw, ignored) {
|
|
160
|
+
const byFile = new Map();
|
|
161
|
+
for (const path of ignored || []) {
|
|
162
|
+
byFile.set(String(path), { file: String(path), reason: "ignored" });
|
|
163
|
+
}
|
|
164
|
+
for (const item of raw || []) {
|
|
165
|
+
const file = String(item?.file || "").trim();
|
|
166
|
+
if (!file) continue;
|
|
167
|
+
byFile.set(file, { file, reason: oneOf(item.reason, SKIP_REASONS, "trivial") });
|
|
168
|
+
}
|
|
169
|
+
return [...byFile.values()];
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Tema donde se dibuja el archivo: el que aporta más bloques, desempatando por orden de tema. */
|
|
173
|
+
export function dominantGroup(blocks, path, groupOrder) {
|
|
174
|
+
const tally = new Map();
|
|
175
|
+
for (const block of blocks) {
|
|
176
|
+
if (block.file !== path) continue;
|
|
177
|
+
tally.set(block.group, (tally.get(block.group) || 0) + 1);
|
|
178
|
+
}
|
|
179
|
+
if (!tally.size) return "";
|
|
180
|
+
return [...tally.entries()].sort(
|
|
181
|
+
(a, b) => b[1] - a[1] || groupOrder.indexOf(a[0]) - groupOrder.indexOf(b[0]),
|
|
182
|
+
)[0][0];
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Documento final: lo que dijo el modelo, saneado y cruzado contra los archivos
|
|
187
|
+
* reales del diff. Todo lo que no cierra se descarta acá en vez de romper la UI.
|
|
188
|
+
*/
|
|
189
|
+
export function buildReview({ skeleton, payload, notes = [] }) {
|
|
190
|
+
const files = skeleton.files || [];
|
|
191
|
+
const knownPaths = new Set(files.map((f) => f.path));
|
|
192
|
+
|
|
193
|
+
const groups = normalizeGroups(payload?.groups);
|
|
194
|
+
const groupIds = new Set(groups.map((g) => g.id));
|
|
195
|
+
const blocks = normalizeBlocks(payload?.blocks, { groupIds, knownPaths });
|
|
196
|
+
const blockIds = new Set(blocks.map((b) => b.id));
|
|
197
|
+
const findings = normalizeFindings(payload?.findings, { blockIds, knownPaths });
|
|
198
|
+
const skipped = normalizeSkipped(payload?.skipped, skeleton.context?.ignored);
|
|
199
|
+
|
|
200
|
+
const usedGroups = new Set(blocks.map((b) => b.group));
|
|
201
|
+
const liveGroups = groups.filter((g) => usedGroups.has(g.id));
|
|
202
|
+
const groupOrder = liveGroups.map((g) => g.id);
|
|
203
|
+
|
|
204
|
+
const skippedFiles = new Set(skipped.map((s) => s.file));
|
|
205
|
+
const orphans = files.filter((f) => !blocks.some((b) => b.file === f.path) && !skippedFiles.has(f.path));
|
|
206
|
+
if (orphans.length) {
|
|
207
|
+
notes = [...notes, `${orphans.length} archivo(s) quedaron sin explicación del modelo.`];
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const decorated = files.map((file) => ({
|
|
211
|
+
...file,
|
|
212
|
+
group: dominantGroup(blocks, file.path, groupOrder),
|
|
213
|
+
}));
|
|
214
|
+
|
|
215
|
+
return {
|
|
216
|
+
...skeleton,
|
|
217
|
+
version: 2,
|
|
218
|
+
intent: text(payload?.intent, MAX.intent),
|
|
219
|
+
groups: liveGroups,
|
|
220
|
+
blocks,
|
|
221
|
+
findings,
|
|
222
|
+
skipped,
|
|
223
|
+
files: decorated,
|
|
224
|
+
stats: { ...skeleton.stats, hunks: countHunks(files) },
|
|
225
|
+
notes,
|
|
226
|
+
generatedAt: new Date().toISOString(),
|
|
227
|
+
};
|
|
228
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import {
|
|
4
|
+
buildReview,
|
|
5
|
+
countHunks,
|
|
6
|
+
dominantGroup,
|
|
7
|
+
formatLineRange,
|
|
8
|
+
parseLineRange,
|
|
9
|
+
} from "./review.mjs";
|
|
10
|
+
import { decodePayload } from "./json-payload.mjs";
|
|
11
|
+
import { REVIEW_SCHEMA, reviewIssues, validateAgainst } from "./validate.mjs";
|
|
12
|
+
|
|
13
|
+
const skeleton = () => ({
|
|
14
|
+
branch: "feat/x",
|
|
15
|
+
baseBranch: "develop",
|
|
16
|
+
stats: { filesChanged: 2, insertions: 10, deletions: 2 },
|
|
17
|
+
context: { ignored: ["bun.lock"] },
|
|
18
|
+
files: [
|
|
19
|
+
{ path: "src/a.ts", changeType: "modified", diff: "@@ -1,3 +1,5 @@\n+uno\n@@ -20,2 +22,4 @@\n+dos" },
|
|
20
|
+
{ path: "src/a.test.ts", changeType: "added", diff: "@@ -0,0 +1,9 @@\n+test" },
|
|
21
|
+
],
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const payload = () => ({
|
|
25
|
+
intent: "Agrega el ABM de depósitos locales end-to-end para dejar de administrarlos a mano por SQL.",
|
|
26
|
+
groups: [
|
|
27
|
+
{ id: "g1", kind: "feat", title: "feat(product): CRUD de depósitos", intent: "la funcionalidad" },
|
|
28
|
+
{ id: "g2", kind: "test", title: "test(warehouses): cubrir el CRUD", intent: "red de seguridad" },
|
|
29
|
+
],
|
|
30
|
+
blocks: [
|
|
31
|
+
{ id: "b1", group: "g1", file: "src/a.ts", lines: "L22-25", op: "add", what: "endpoint nuevo", why: "hacía falta", source: "code" },
|
|
32
|
+
{ id: "b2", group: "g1", file: "src/a.ts", lines: "L1-5", op: "mod", what: "registra el service", why: "", source: "inferred" },
|
|
33
|
+
{ id: "b3", group: "g2", file: "src/a.test.ts", lines: "L1-9", op: "add", what: "tests del CRUD", why: "", source: "code" },
|
|
34
|
+
],
|
|
35
|
+
findings: [],
|
|
36
|
+
skipped: [],
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("parseLineRange entiende post-imagen, pre-imagen y línea suelta", () => {
|
|
40
|
+
assert.deepEqual(parseLineRange("L40-58"), { side: "new", start: 40, end: 58 });
|
|
41
|
+
assert.deepEqual(parseLineRange("-L88-95"), { side: "old", start: 88, end: 95 });
|
|
42
|
+
assert.deepEqual(parseLineRange("L7"), { side: "new", start: 7, end: 7 });
|
|
43
|
+
assert.deepEqual(parseLineRange("12-9"), { side: "new", start: 9, end: 12 });
|
|
44
|
+
assert.equal(parseLineRange("por ahí"), null);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("formatLineRange redondea el viaje de ida y vuelta", () => {
|
|
48
|
+
assert.equal(formatLineRange(parseLineRange("40-58")), "L40-58");
|
|
49
|
+
assert.equal(formatLineRange(parseLineRange("-L88")), "-L88");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("countHunks cuenta las cabeceras @@", () => {
|
|
53
|
+
assert.equal(countHunks(skeleton().files), 3);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("dominantGroup gana por cantidad de bloques", () => {
|
|
57
|
+
const blocks = [
|
|
58
|
+
{ file: "a.ts", group: "g2" },
|
|
59
|
+
{ file: "a.ts", group: "g1" },
|
|
60
|
+
{ file: "a.ts", group: "g1" },
|
|
61
|
+
];
|
|
62
|
+
assert.equal(dominantGroup(blocks, "a.ts", ["g1", "g2"]), "g1");
|
|
63
|
+
assert.equal(dominantGroup(blocks, "otro.ts", ["g1", "g2"]), "");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("buildReview arma el documento y ubica cada archivo en su grupo dominante", () => {
|
|
67
|
+
const doc = buildReview({ skeleton: skeleton(), payload: payload() });
|
|
68
|
+
assert.equal(doc.version, 2);
|
|
69
|
+
assert.equal(doc.groups.length, 2);
|
|
70
|
+
assert.equal(doc.blocks.length, 3);
|
|
71
|
+
assert.equal(doc.stats.hunks, 3);
|
|
72
|
+
assert.equal(doc.files.find((f) => f.path === "src/a.ts").group, "g1");
|
|
73
|
+
assert.equal(doc.files.find((f) => f.path === "src/a.test.ts").group, "g2");
|
|
74
|
+
assert.deepEqual(doc.notes, []);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("buildReview ordena los bloques por archivo y línea", () => {
|
|
78
|
+
const doc = buildReview({ skeleton: skeleton(), payload: payload() });
|
|
79
|
+
assert.deepEqual(doc.blocks.map((b) => b.id), ["b3", "b2", "b1"]);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("buildReview descarta bloques que apuntan a grupos o archivos fantasma", () => {
|
|
83
|
+
const bad = payload();
|
|
84
|
+
bad.blocks.push({ id: "b9", group: "g7", file: "src/a.ts", lines: "L1", op: "mod", what: "x" });
|
|
85
|
+
bad.blocks.push({ id: "b8", group: "g1", file: "src/inventado.ts", lines: "L1", op: "mod", what: "x" });
|
|
86
|
+
const doc = buildReview({ skeleton: skeleton(), payload: bad });
|
|
87
|
+
assert.deepEqual(doc.blocks.map((b) => b.id).sort(), ["b1", "b2", "b3"]);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("buildReview tira los grupos sin bloques", () => {
|
|
91
|
+
const bad = payload();
|
|
92
|
+
bad.groups.push({ id: "g3", kind: "chore", title: "sobra", intent: "" });
|
|
93
|
+
const doc = buildReview({ skeleton: skeleton(), payload: bad });
|
|
94
|
+
assert.deepEqual(doc.groups.map((g) => g.id), ["g1", "g2"]);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("buildReview avisa cuando un archivo quedó sin explicar", () => {
|
|
98
|
+
const bad = payload();
|
|
99
|
+
bad.blocks = bad.blocks.filter((b) => b.file !== "src/a.test.ts");
|
|
100
|
+
const doc = buildReview({ skeleton: skeleton(), payload: bad });
|
|
101
|
+
assert.ok(doc.notes[0].includes("sin explicación"));
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("buildReview suma los archivos ignorados a skipped", () => {
|
|
105
|
+
const doc = buildReview({ skeleton: skeleton(), payload: payload() });
|
|
106
|
+
assert.deepEqual(doc.skipped, [{ file: "bun.lock", reason: "ignored" }]);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("buildReview conserva los nit de calidad pero no los deja bloquear", () => {
|
|
110
|
+
const withFindings = payload();
|
|
111
|
+
withFindings.findings = [
|
|
112
|
+
{ class: "quality", severity: "nit", blocking: true, kind: "maintainability", file: "src/a.ts", line: 3, what: "nombre confuso", fix: "renombrar" },
|
|
113
|
+
{ class: "risk", severity: "high", kind: "bug", file: "src/a.ts", line: 22, block: "b1", what: "explota con null", fix: "guardar" },
|
|
114
|
+
];
|
|
115
|
+
const doc = buildReview({ skeleton: skeleton(), payload: withFindings });
|
|
116
|
+
assert.equal(doc.findings.length, 2);
|
|
117
|
+
assert.equal(doc.findings[0].id, "f1");
|
|
118
|
+
assert.equal(doc.findings[0].kind, "bug");
|
|
119
|
+
assert.equal(doc.findings[0].blocking, true);
|
|
120
|
+
assert.equal(doc.findings[1].severity, "nit");
|
|
121
|
+
assert.equal(doc.findings[1].blocking, false);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("buildReview deduplica hallazgos repetidos y limpia referencias colgadas", () => {
|
|
125
|
+
const withFindings = payload();
|
|
126
|
+
const one = { class: "risk", severity: "med", kind: "bug", file: "src/a.ts", line: 5, what: "mismo", block: "b404" };
|
|
127
|
+
withFindings.findings = [one, { ...one }];
|
|
128
|
+
const doc = buildReview({ skeleton: skeleton(), payload: withFindings });
|
|
129
|
+
assert.equal(doc.findings.length, 1);
|
|
130
|
+
assert.equal(doc.findings[0].block, "");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("una respuesta JSON del modelo atraviesa decode, validación y normalización", () => {
|
|
134
|
+
const answer = JSON.stringify({
|
|
135
|
+
intent: "Hace que las búsquedas con varias palabras matcheen los tokens en cualquier orden.",
|
|
136
|
+
groups: [
|
|
137
|
+
{ id: "g1", kind: "feat", title: "feat(search): matchear tokens", intent: "el cambio" },
|
|
138
|
+
{ id: "g2", kind: "test", title: "test(search): cubrir tokens", intent: "red de seguridad" },
|
|
139
|
+
],
|
|
140
|
+
blocks: [
|
|
141
|
+
{ id: "b1", group: "g1", file: "src/a.ts", lines: "L22-25", op: "add", what: "tokens AND", why: "antes frase exacta", source: "commit" },
|
|
142
|
+
{ id: "b2", group: "g1", file: "src/a.ts", lines: "L1-5", op: "mod", what: "registra rewriter", why: "", source: "inferred" },
|
|
143
|
+
{ id: "b3", group: "g2", file: "src/a.test.ts", lines: "L1-9", op: "add", what: "tests", why: "", source: "code" },
|
|
144
|
+
],
|
|
145
|
+
findings: [
|
|
146
|
+
{ id: "f1", class: "risk", severity: "high", blocking: true, kind: "bug", file: "src/a.ts", line: 23, block: "b1", what: "AND vacío", fix: "cortar temprano" },
|
|
147
|
+
{ id: "f2", class: "quality", severity: "low", blocking: false, kind: "maintainability", file: "src/a.ts", line: 3, block: "b2", what: "split repetido", fix: "extraer tokenize" },
|
|
148
|
+
],
|
|
149
|
+
skipped: [],
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
const payload = decodePayload(answer);
|
|
153
|
+
assert.deepEqual(validateAgainst(REVIEW_SCHEMA, payload), []);
|
|
154
|
+
assert.deepEqual(reviewIssues(payload, ["src/a.ts", "src/a.test.ts"]), []);
|
|
155
|
+
|
|
156
|
+
const doc = buildReview({ skeleton: skeleton(), payload });
|
|
157
|
+
assert.equal(doc.groups.length, 2);
|
|
158
|
+
assert.equal(doc.blocks.length, 3);
|
|
159
|
+
assert.deepEqual(doc.notes, []);
|
|
160
|
+
});
|