@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.
Files changed (95) hide show
  1. package/README.md +22 -6
  2. package/bin/diff-review.mjs +187 -19
  3. package/lib/clipboard.mjs +29 -0
  4. package/lib/config.mjs +233 -0
  5. package/lib/config.test.mjs +73 -0
  6. package/lib/extract.mjs +111 -0
  7. package/lib/git.mjs +202 -0
  8. package/lib/git.test.mjs +29 -0
  9. package/lib/json-payload.mjs +31 -0
  10. package/lib/json-payload.test.mjs +22 -0
  11. package/lib/output-name.mjs +56 -0
  12. package/lib/output-name.test.mjs +54 -0
  13. package/lib/pick-folder.mjs +4 -0
  14. package/lib/prompt.mjs +154 -0
  15. package/lib/remote-repo.mjs +182 -0
  16. package/lib/review.mjs +228 -0
  17. package/lib/review.test.mjs +160 -0
  18. package/lib/validate.mjs +221 -0
  19. package/lib/validate.test.mjs +85 -0
  20. package/lib/wait-output.mjs +104 -0
  21. package/package.json +2 -1
  22. package/web/build/client/_app/immutable/assets/2.Xnfq26gJ.css +1 -0
  23. package/web/build/client/_app/immutable/assets/2.Xnfq26gJ.css.br +0 -0
  24. package/web/build/client/_app/immutable/assets/2.Xnfq26gJ.css.gz +0 -0
  25. package/web/build/client/_app/immutable/chunks/{C5moC2DW.js → BaYDuTXu.js} +36 -29
  26. package/web/build/client/_app/immutable/chunks/BaYDuTXu.js.br +0 -0
  27. package/web/build/client/_app/immutable/chunks/BaYDuTXu.js.gz +0 -0
  28. package/web/build/client/_app/immutable/chunks/CAnYe5db.js +1 -0
  29. package/web/build/client/_app/immutable/chunks/CAnYe5db.js.br +1 -0
  30. package/web/build/client/_app/immutable/chunks/CAnYe5db.js.gz +0 -0
  31. package/web/build/client/_app/immutable/chunks/Gya4IU1I.js +2 -0
  32. package/web/build/client/_app/immutable/chunks/Gya4IU1I.js.br +0 -0
  33. package/web/build/client/_app/immutable/chunks/Gya4IU1I.js.gz +0 -0
  34. package/web/build/client/_app/immutable/entry/{app.Dei8qxo7.js → app.e96uPVtl.js} +2 -2
  35. package/web/build/client/_app/immutable/entry/app.e96uPVtl.js.br +0 -0
  36. package/web/build/client/_app/immutable/entry/app.e96uPVtl.js.gz +0 -0
  37. package/web/build/client/_app/immutable/entry/start.D-D-7QPy.js +1 -0
  38. package/web/build/client/_app/immutable/entry/start.D-D-7QPy.js.br +0 -0
  39. package/web/build/client/_app/immutable/entry/start.D-D-7QPy.js.gz +0 -0
  40. package/web/build/client/_app/immutable/nodes/1.V-g5-sfL.js +1 -0
  41. package/web/build/client/_app/immutable/nodes/1.V-g5-sfL.js.br +0 -0
  42. package/web/build/client/_app/immutable/nodes/1.V-g5-sfL.js.gz +0 -0
  43. package/web/build/client/_app/immutable/nodes/2.lcRePHZ5.js +66 -0
  44. package/web/build/client/_app/immutable/nodes/2.lcRePHZ5.js.br +0 -0
  45. package/web/build/client/_app/immutable/nodes/2.lcRePHZ5.js.gz +0 -0
  46. package/web/build/client/_app/immutable/nodes/3.GZPWeD9I.js +170 -0
  47. package/web/build/client/_app/immutable/nodes/3.GZPWeD9I.js.br +0 -0
  48. package/web/build/client/_app/immutable/nodes/3.GZPWeD9I.js.gz +0 -0
  49. package/web/build/client/_app/version.json +1 -1
  50. package/web/build/client/_app/version.json.br +0 -0
  51. package/web/build/client/_app/version.json.gz +0 -0
  52. package/web/build/handler.js +3 -3
  53. package/web/build/index.js +3 -3
  54. package/web/build/server/chunks/1-DCKScL3A.js +7 -0
  55. package/web/build/server/chunks/1-DCKScL3A.js.map +1 -0
  56. package/web/build/server/chunks/2-BpBQQ02B.js +7 -0
  57. package/web/build/server/chunks/2-BpBQQ02B.js.map +1 -0
  58. package/web/build/server/chunks/{3-Zc5_qwgV.js → 3-rGkcNCVz.js} +2 -2
  59. package/web/build/server/chunks/{3-Zc5_qwgV.js.map → 3-rGkcNCVz.js.map} +1 -1
  60. package/web/build/server/chunks/{handler-BmuqxCCf.js → handler-BaT-QavL.js} +3 -3
  61. package/web/build/server/chunks/{handler-BmuqxCCf.js.map → handler-BaT-QavL.js.map} +1 -1
  62. package/web/build/server/chunks/{index.js-41EcOndB.js → index.js-C-K0YDBJ.js} +150 -150
  63. package/web/build/server/chunks/index.js-C-K0YDBJ.js.map +1 -0
  64. package/web/build/server/chunks/{manifest.js-KnmnjP9i.js → manifest.js-CDz-Fem4.js} +6 -6
  65. package/web/build/server/chunks/{manifest.js-KnmnjP9i.js.map → manifest.js-CDz-Fem4.js.map} +1 -1
  66. package/web/build/server/chunks/{remote-1g4ayag-BgUNMUS8.js → remote-1g4ayag-DXdFe1BG.js} +645 -146
  67. package/web/build/server/chunks/remote-1g4ayag-DXdFe1BG.js.map +1 -0
  68. package/web/build/client/_app/immutable/assets/2.Du8E-7-D.css +0 -1
  69. package/web/build/client/_app/immutable/assets/2.Du8E-7-D.css.br +0 -0
  70. package/web/build/client/_app/immutable/assets/2.Du8E-7-D.css.gz +0 -0
  71. package/web/build/client/_app/immutable/chunks/C5moC2DW.js.br +0 -0
  72. package/web/build/client/_app/immutable/chunks/C5moC2DW.js.gz +0 -0
  73. package/web/build/client/_app/immutable/chunks/WxPrACSs.js +0 -2
  74. package/web/build/client/_app/immutable/chunks/WxPrACSs.js.br +0 -0
  75. package/web/build/client/_app/immutable/chunks/WxPrACSs.js.gz +0 -0
  76. package/web/build/client/_app/immutable/entry/app.Dei8qxo7.js.br +0 -0
  77. package/web/build/client/_app/immutable/entry/app.Dei8qxo7.js.gz +0 -0
  78. package/web/build/client/_app/immutable/entry/start.Fzxrn2l4.js +0 -1
  79. package/web/build/client/_app/immutable/entry/start.Fzxrn2l4.js.br +0 -1
  80. package/web/build/client/_app/immutable/entry/start.Fzxrn2l4.js.gz +0 -0
  81. package/web/build/client/_app/immutable/nodes/1.Cn_iktkl.js +0 -1
  82. package/web/build/client/_app/immutable/nodes/1.Cn_iktkl.js.br +0 -0
  83. package/web/build/client/_app/immutable/nodes/1.Cn_iktkl.js.gz +0 -0
  84. package/web/build/client/_app/immutable/nodes/2.vvYFHTy-.js +0 -66
  85. package/web/build/client/_app/immutable/nodes/2.vvYFHTy-.js.br +0 -0
  86. package/web/build/client/_app/immutable/nodes/2.vvYFHTy-.js.gz +0 -0
  87. package/web/build/client/_app/immutable/nodes/3.CeJ7dEsh.js +0 -170
  88. package/web/build/client/_app/immutable/nodes/3.CeJ7dEsh.js.br +0 -0
  89. package/web/build/client/_app/immutable/nodes/3.CeJ7dEsh.js.gz +0 -0
  90. package/web/build/server/chunks/1-Bjgeu1Iz.js +0 -7
  91. package/web/build/server/chunks/1-Bjgeu1Iz.js.map +0 -1
  92. package/web/build/server/chunks/2-BvMZU9fP.js +0 -7
  93. package/web/build/server/chunks/2-BvMZU9fP.js.map +0 -1
  94. package/web/build/server/chunks/index.js-41EcOndB.js.map +0 -1
  95. package/web/build/server/chunks/remote-1g4ayag-BgUNMUS8.js.map +0 -1
@@ -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
+ }
@@ -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
+ }
@@ -0,0 +1,22 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { decodePayload } from "./json-payload.mjs";
4
+
5
+ test("decodePayload parses raw JSON", () => {
6
+ assert.deepEqual(decodePayload('{"intent":"hola mundo largo para cumplir minimo"}'), {
7
+ intent: "hola mundo largo para cumplir minimo",
8
+ });
9
+ });
10
+
11
+ test("decodePayload parses fenced JSON", () => {
12
+ assert.deepEqual(decodePayload('```json\n{"intent":"ok"}\n```'), { intent: "ok" });
13
+ });
14
+
15
+ test("decodePayload rescues JSON with prose around", () => {
16
+ assert.deepEqual(decodePayload("Here:\n{\"intent\":\"x\"}\nDone."), { intent: "x" });
17
+ });
18
+
19
+ test("decodePayload fails on garbage", () => {
20
+ assert.throws(() => decodePayload(""), /vacía/);
21
+ assert.throws(() => decodePayload("no json here"), /JSON/i);
22
+ });
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Nombre del JSON que escribe el agente.
3
+ * Preferido: tips cortos + stamp. Legacy sin tips sigue siendo válido al importar.
4
+ */
5
+ import { fingerprint } from "./git.mjs";
6
+
7
+ /** Prefijo del formato actual (con tips). */
8
+ export const OUTPUT_PREFIX = "diff-report";
9
+
10
+ /** Prefijo legacy (sin tips). */
11
+ export const OUTPUT_PREFIX_LEGACY = "diff-review-output";
12
+
13
+ /** Nombre legacy fijo. */
14
+ export const OUTPUT_FILENAME_LEGACY = `${OUTPUT_PREFIX_LEGACY}.json`;
15
+
16
+ /**
17
+ * Válidos:
18
+ * - diff-review-output.json
19
+ * - diff-review-output-YYYYMMDD-HHmmss.json
20
+ * - diff-report_{branchTip}-{baseTip}_YYYYMMDD-HHmmss.json
21
+ */
22
+ export const OUTPUT_FILENAME_RE =
23
+ /^(?:diff-review-output(?:-\d{8}-\d{6})?|diff-report_[0-9a-f]{7,12}-[0-9a-f]{7,12}_\d{8}-\d{6})\.json$/i;
24
+
25
+ /**
26
+ * @param {Date} [when]
27
+ * @returns {string} ej. 20260915-143045
28
+ */
29
+ export function formatOutputStamp(when = new Date()) {
30
+ const pad = (n) => String(n).padStart(2, "0");
31
+ const y = when.getFullYear();
32
+ const mo = pad(when.getMonth() + 1);
33
+ const d = pad(when.getDate());
34
+ const h = pad(when.getHours());
35
+ const mi = pad(when.getMinutes());
36
+ const s = pad(when.getSeconds());
37
+ return `${y}${mo}${d}-${h}${mi}${s}`;
38
+ }
39
+
40
+ /**
41
+ * @param {{ branchSha?: string, baseSha?: string, stamp?: string, when?: Date } | Date} [input]
42
+ * @returns {string}
43
+ * con tips: diff-report_aaaaaaaaaa-bbbbbbbbbb_20260915-143045.json
44
+ * sin tips: diff-review-output-20260915-143045.json
45
+ */
46
+ export function makeOutputFilename(input = {}) {
47
+ const opts = input instanceof Date ? { when: input } : input || {};
48
+ const stamp = opts.stamp || formatOutputStamp(opts.when ?? new Date());
49
+ const fp = fingerprint(opts.branchSha, opts.baseSha);
50
+ if (fp) return `${OUTPUT_PREFIX}_${fp}_${stamp}.json`;
51
+ return `${OUTPUT_PREFIX_LEGACY}-${stamp}.json`;
52
+ }
53
+
54
+ export function isOutputFilename(name) {
55
+ return OUTPUT_FILENAME_RE.test(String(name || "").trim());
56
+ }
@@ -0,0 +1,54 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import {
4
+ formatOutputStamp,
5
+ isOutputFilename,
6
+ makeOutputFilename,
7
+ OUTPUT_FILENAME_LEGACY,
8
+ } from "./output-name.mjs";
9
+
10
+ describe("output-name", () => {
11
+ const when = new Date(2026, 8, 15, 14, 30, 45);
12
+ const stamp = "20260915-143045";
13
+
14
+ it("formats stamp", () => {
15
+ assert.equal(formatOutputStamp(when), stamp);
16
+ });
17
+
18
+ it("builds name with short tip hashes", () => {
19
+ const name = makeOutputFilename({
20
+ branchSha: "aaaaaaaaaa1111111111",
21
+ baseSha: "bbbbbbbbbb2222222222",
22
+ when,
23
+ });
24
+ assert.equal(name, "diff-report_aaaaaaaaaa-bbbbbbbbbb_20260915-143045.json");
25
+ assert.equal(isOutputFilename(name), true);
26
+ });
27
+
28
+ it("reuses an explicit stamp", () => {
29
+ const name = makeOutputFilename({
30
+ branchSha: "abcdef0123456789",
31
+ baseSha: "fedcba9876543210",
32
+ stamp: "20260101-000000",
33
+ });
34
+ assert.equal(name, "diff-report_abcdef0123-fedcba9876_20260101-000000.json");
35
+ });
36
+
37
+ it("falls back to legacy stamp without tips", () => {
38
+ const name = makeOutputFilename({ when });
39
+ assert.equal(name, "diff-review-output-20260915-143045.json");
40
+ assert.equal(isOutputFilename(name), true);
41
+ });
42
+
43
+ it("accepts legacy names", () => {
44
+ assert.equal(OUTPUT_FILENAME_LEGACY, "diff-review-output.json");
45
+ assert.equal(isOutputFilename(OUTPUT_FILENAME_LEGACY), true);
46
+ assert.equal(isOutputFilename("diff-review-output-20260915-143045.json"), true);
47
+ });
48
+
49
+ it("rejects other names", () => {
50
+ assert.equal(isOutputFilename("review.json"), false);
51
+ assert.equal(isOutputFilename("diff-review-output-foo.json"), false);
52
+ assert.equal(isOutputFilename("diff-report_short-tips_20260915-143045.json"), false);
53
+ });
54
+ });
@@ -0,0 +1,4 @@
1
+ /** La app web usa web/src/lib/server/pick-folder.ts. */
2
+ export async function pickFolder() {
3
+ throw new Error("Usá la app web (pick-folder del server) para elegir carpeta.");
4
+ }
package/lib/prompt.mjs ADDED
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Prompt de review para el agente (compartido CLI + UI).
3
+ * Mantener alineado con el contrato de schema.json / validate.mjs.
4
+ */
5
+ import {
6
+ makeOutputFilename,
7
+ formatOutputStamp,
8
+ isOutputFilename,
9
+ OUTPUT_FILENAME_LEGACY,
10
+ } from "./output-name.mjs";
11
+
12
+ export {
13
+ makeOutputFilename,
14
+ formatOutputStamp,
15
+ isOutputFilename,
16
+ OUTPUT_FILENAME_LEGACY,
17
+ };
18
+
19
+ export const OUTPUT_SCHEMA_BLOCK = `{
20
+ "meta": {
21
+ "source": "local|url",
22
+ "repo": "<absolute path or git URL>",
23
+ "branch": "<branch under review>",
24
+ "base": "develop",
25
+ "remoteUrl": "<origin URL; optional when source=url>"
26
+ },
27
+ "intent": "string",
28
+ "groups": [
29
+ {
30
+ "id": "g1",
31
+ "kind": "feat|fix|refactor|perf|test|chore|docs|infra",
32
+ "title": "string ≤72 chars, imperative, no kind prefix",
33
+ "intent": "string"
34
+ }
35
+ ],
36
+ "blocks": [
37
+ {
38
+ "id": "b1",
39
+ "group": "g1",
40
+ "file": "relative/path.ext",
41
+ "lines": "L40-58",
42
+ "side": "new|old",
43
+ "start": 40,
44
+ "end": 58,
45
+ "op": "add|mod|del|rename",
46
+ "what": "string",
47
+ "why": "string",
48
+ "source": "code|commit|pr|issue|inferred"
49
+ }
50
+ ],
51
+ "findings": [
52
+ {
53
+ "id": "f1",
54
+ "class": "risk|quality",
55
+ "severity": "high|med|low|nit",
56
+ "blocking": false,
57
+ "kind": "bug|race|auth|security|api-break|missing-test|perf|maintainability|docs|other",
58
+ "file": "relative/path.ext",
59
+ "line": 87,
60
+ "block": "b1",
61
+ "what": "string",
62
+ "fix": "string"
63
+ }
64
+ ],
65
+ "skipped": [
66
+ { "file": "relative/path-or-glob", "reason": "generated|lockfile|format|vendored|binary|trivial|ignored" }
67
+ ],
68
+ "notes": ["optional short limitations"]
69
+ }`;
70
+
71
+ const RULES = `- Coverage (hard): every path in the diff MUST appear in "blocks" and/or "skipped". Prefer a skipped glob when many files share one reason.
72
+ - Do NOT invent files, hunks, or line ranges that are not in the diff.
73
+ - Line anchors: "lines"/"start"/"end"/"line" MUST match the chosen "side" ("new" = post-image / right side of @@; "old" = pre-image, typically deletions). One block per coherent change span — not one per line. Point findings at that block; leave "block" empty only if cross-cutting.
74
+ - Do NOT report: pre-existing code outside the diff; linter/formatter noise; naming/style prefs with no real consequence; intentional branch behavior; silenced lint rules; "missing docs/coverage" without a concrete broken scenario.
75
+ - Quality findings must name a concrete cost, not a vague feeling.
76
+ - Signal over volume: prefer fewer high-signal findings. Order findings by severity (high → nit). Use "nit" sparingly.
77
+ - "blocking": true ONLY if the branch should not merge as-is. Use only with class "risk".
78
+ - Write human-readable strings (intent, title, what, why, fix, notes) in Spanish. Keep enums/ids/paths/JSON keys exactly as in the schema.`;
79
+
80
+ function workflowLocal(base, branch) {
81
+ return `Workflow:
82
+ 1. Resolve the merge-base of \`${base}\` and \`${branch}\`.
83
+ 2. Run exactly: \`git diff ${base}...${branch}\` (three-dot / merge-base diff). Do not use two-dot unless three-dot is impossible.
84
+ 3. Read enough surrounding code (types, callers, tests) to judge behavior — not only the hunk lines.
85
+ 4. Emit the JSON file. Do not modify the repo.`;
86
+ }
87
+
88
+ function workflowUrl(base, branch) {
89
+ return `Workflow:
90
+ 1. If the repo is not local, clone it (or use whatever access you have). Do not modify it.
91
+ 2. Resolve the merge-base of \`${base}\` and \`${branch}\`.
92
+ 3. Run exactly: \`git diff ${base}...${branch}\` (three-dot / merge-base diff). Do not use two-dot unless three-dot is impossible.
93
+ 4. Read enough surrounding code (types, callers, tests) to judge behavior — not only the hunk lines.
94
+ 5. Emit the JSON file.`;
95
+ }
96
+
97
+ /**
98
+ * @param {{ source?: string, repo?: string, branch?: string, base?: string, remoteUrl?: string }} meta
99
+ */
100
+ export function outputSchemaBlock(meta) {
101
+ const source = meta.source === "url" ? "url" : "local";
102
+ const repo =
103
+ String(meta.repo || "").trim() || (source === "url" ? "<git URL>" : "<absolute repo path>");
104
+ const branch = String(meta.branch || "").trim() || "<branch under review>";
105
+ const base = String(meta.base || "").trim() || "develop";
106
+ const remoteUrl = String(meta.remoteUrl || "").trim() || (source === "url" ? repo : "");
107
+ const metaLines = [
108
+ ` "source": ${JSON.stringify(source)},`,
109
+ ` "repo": ${JSON.stringify(repo)},`,
110
+ ` "branch": ${JSON.stringify(branch)},`,
111
+ ` "base": ${JSON.stringify(base)}${remoteUrl ? "," : ""}`,
112
+ ];
113
+ if (remoteUrl) metaLines.push(` "remoteUrl": ${JSON.stringify(remoteUrl)}`);
114
+ const metaBlock = `"meta": {\n${metaLines.join("\n")}\n }`;
115
+ return OUTPUT_SCHEMA_BLOCK.replace(/"meta": \{[\s\S]*?\n \}/, metaBlock);
116
+ }
117
+
118
+ /**
119
+ * @param {{ source?: string, repo?: string, branch?: string, base?: string, remoteUrl?: string }} meta
120
+ * @param {{ outputFilename?: string }} [opts]
121
+ */
122
+ export function buildPrompt(meta, opts = {}) {
123
+ const outputFilename = opts.outputFilename || makeOutputFilename();
124
+ const repo =
125
+ String(meta.repo || "").trim() ||
126
+ (meta.source === "url" ? "<git URL>" : "<absolute repo path>");
127
+ const branch = String(meta.branch || "").trim() || "<branch under review>";
128
+ const base = String(meta.base || "").trim() || "develop";
129
+ const workflow = meta.source === "url" ? workflowUrl(base, branch) : workflowLocal(base, branch);
130
+
131
+ return `You are a senior code reviewer: strict but fair.
132
+
133
+ Repo: ${repo}
134
+ Branch under review: ${branch}
135
+ Base (merge-base): ${base}
136
+
137
+ ${workflow}
138
+
139
+ Rules:
140
+ ${RULES}
141
+
142
+ Write the result to \`${outputFilename}\` at the repo root.
143
+ - One JSON object only: first character \`{\`, last character \`}\`.
144
+ - No prose before/after, no markdown fences.
145
+ - Shape (copy "meta" exactly as given):
146
+
147
+ ${outputSchemaBlock(meta)}
148
+
149
+ Include "meta" with the source/repo/branch/base above (and origin "remoteUrl" when local). That lets someone reopen the report elsewhere without re-picking the repo.
150
+
151
+ Do NOT include diffs or a "files" array — the tool computes them with git when opening the report. Do not invent hunks or paste patches into the JSON.
152
+
153
+ When done, tell me that \`${outputFilename}\` was written.`;
154
+ }