@jpkme/diff-review 0.1.5 → 0.1.7

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 (85) hide show
  1. package/README.md +33 -2
  2. package/bin/diff-review.mjs +230 -18
  3. package/lib/agents.mjs +304 -0
  4. package/lib/agents.test.mjs +195 -0
  5. package/lib/check-report.mjs +213 -0
  6. package/lib/check-report.test.mjs +46 -0
  7. package/lib/fingerprint.mjs +24 -0
  8. package/lib/git.mjs +2 -19
  9. package/lib/output-name.mjs +1 -1
  10. package/lib/prompt.mjs +22 -9
  11. package/lib/wait-output.mjs +43 -17
  12. package/lib/wait-output.test.mjs +67 -0
  13. package/package.json +1 -1
  14. package/web/build/client/_app/immutable/assets/2.COsJT9ly.css +1 -0
  15. package/web/build/client/_app/immutable/assets/2.COsJT9ly.css.br +0 -0
  16. package/web/build/client/_app/immutable/assets/2.COsJT9ly.css.gz +0 -0
  17. package/web/build/client/_app/immutable/chunks/C4--ziCr.js +2 -0
  18. package/web/build/client/_app/immutable/chunks/C4--ziCr.js.br +0 -0
  19. package/web/build/client/_app/immutable/chunks/C4--ziCr.js.gz +0 -0
  20. package/web/build/client/_app/immutable/chunks/{CdHZDAAo.js → D8sAxZVo.js} +38 -30
  21. package/web/build/client/_app/immutable/chunks/D8sAxZVo.js.br +0 -0
  22. package/web/build/client/_app/immutable/chunks/D8sAxZVo.js.gz +0 -0
  23. package/web/build/client/_app/immutable/entry/{app.BRzz8J51.js → app.Dl9OTE-n.js} +2 -2
  24. package/web/build/client/_app/immutable/entry/app.Dl9OTE-n.js.br +0 -0
  25. package/web/build/client/_app/immutable/entry/app.Dl9OTE-n.js.gz +0 -0
  26. package/web/build/client/_app/immutable/entry/start.CDz8Qs8Q.js +1 -0
  27. package/web/build/client/_app/immutable/entry/start.CDz8Qs8Q.js.br +1 -0
  28. package/web/build/client/_app/immutable/entry/start.CDz8Qs8Q.js.gz +0 -0
  29. package/web/build/client/_app/immutable/nodes/1.B1c4AH1e.js +1 -0
  30. package/web/build/client/_app/immutable/nodes/1.B1c4AH1e.js.br +0 -0
  31. package/web/build/client/_app/immutable/nodes/1.B1c4AH1e.js.gz +0 -0
  32. package/web/build/client/_app/immutable/nodes/2.Dx-oOpEf.js +66 -0
  33. package/web/build/client/_app/immutable/nodes/2.Dx-oOpEf.js.br +0 -0
  34. package/web/build/client/_app/immutable/nodes/2.Dx-oOpEf.js.gz +0 -0
  35. package/web/build/client/_app/immutable/nodes/{3.ByTmpJQZ.js → 3.C-USexyX.js} +2 -2
  36. package/web/build/client/_app/immutable/nodes/3.C-USexyX.js.br +0 -0
  37. package/web/build/client/_app/immutable/nodes/3.C-USexyX.js.gz +0 -0
  38. package/web/build/client/_app/version.json +1 -1
  39. package/web/build/client/_app/version.json.br +0 -0
  40. package/web/build/client/_app/version.json.gz +0 -0
  41. package/web/build/handler.js +3 -3
  42. package/web/build/index.js +3 -3
  43. package/web/build/server/chunks/1-0X6Jp9nH.js +7 -0
  44. package/web/build/server/chunks/1-0X6Jp9nH.js.map +1 -0
  45. package/web/build/server/chunks/2-HlcVALg6.js +7 -0
  46. package/web/build/server/chunks/2-HlcVALg6.js.map +1 -0
  47. package/web/build/server/chunks/{3-DZ5PBhOc.js → 3-BN1eS7L2.js} +2 -2
  48. package/web/build/server/chunks/{3-DZ5PBhOc.js.map → 3-BN1eS7L2.js.map} +1 -1
  49. package/web/build/server/chunks/{handler-DD4vP3bU.js → handler-cq3QoEzW.js} +3 -3
  50. package/web/build/server/chunks/{handler-DD4vP3bU.js.map → handler-cq3QoEzW.js.map} +1 -1
  51. package/web/build/server/chunks/{index.js-IhFPHwmp.js → index.js-D4qkHe3r.js} +2 -2
  52. package/web/build/server/chunks/{index.js-IhFPHwmp.js.map → index.js-D4qkHe3r.js.map} +1 -1
  53. package/web/build/server/chunks/{manifest.js-Dk3RuAA5.js → manifest.js-DQHMKyJh.js} +6 -6
  54. package/web/build/server/chunks/{manifest.js-Dk3RuAA5.js.map → manifest.js-DQHMKyJh.js.map} +1 -1
  55. package/web/build/server/chunks/{remote-1g4ayag-BRL-uaKE.js → remote-1g4ayag-B8KZPTkn.js} +154 -149
  56. package/web/build/server/chunks/remote-1g4ayag-B8KZPTkn.js.map +1 -0
  57. package/web/build/client/_app/immutable/assets/2.Xnfq26gJ.css +0 -1
  58. package/web/build/client/_app/immutable/assets/2.Xnfq26gJ.css.br +0 -0
  59. package/web/build/client/_app/immutable/assets/2.Xnfq26gJ.css.gz +0 -0
  60. package/web/build/client/_app/immutable/chunks/CO0mUwMJ.js +0 -1
  61. package/web/build/client/_app/immutable/chunks/CO0mUwMJ.js.br +0 -2
  62. package/web/build/client/_app/immutable/chunks/CO0mUwMJ.js.gz +0 -0
  63. package/web/build/client/_app/immutable/chunks/CdHZDAAo.js.br +0 -0
  64. package/web/build/client/_app/immutable/chunks/CdHZDAAo.js.gz +0 -0
  65. package/web/build/client/_app/immutable/chunks/DzqTpz7_.js +0 -2
  66. package/web/build/client/_app/immutable/chunks/DzqTpz7_.js.br +0 -0
  67. package/web/build/client/_app/immutable/chunks/DzqTpz7_.js.gz +0 -0
  68. package/web/build/client/_app/immutable/entry/app.BRzz8J51.js.br +0 -0
  69. package/web/build/client/_app/immutable/entry/app.BRzz8J51.js.gz +0 -0
  70. package/web/build/client/_app/immutable/entry/start.BSioRkqK.js +0 -1
  71. package/web/build/client/_app/immutable/entry/start.BSioRkqK.js.br +0 -0
  72. package/web/build/client/_app/immutable/entry/start.BSioRkqK.js.gz +0 -0
  73. package/web/build/client/_app/immutable/nodes/1.ws8Khan5.js +0 -1
  74. package/web/build/client/_app/immutable/nodes/1.ws8Khan5.js.br +0 -1
  75. package/web/build/client/_app/immutable/nodes/1.ws8Khan5.js.gz +0 -0
  76. package/web/build/client/_app/immutable/nodes/2.CsD5xqRB.js +0 -66
  77. package/web/build/client/_app/immutable/nodes/2.CsD5xqRB.js.br +0 -0
  78. package/web/build/client/_app/immutable/nodes/2.CsD5xqRB.js.gz +0 -0
  79. package/web/build/client/_app/immutable/nodes/3.ByTmpJQZ.js.br +0 -0
  80. package/web/build/client/_app/immutable/nodes/3.ByTmpJQZ.js.gz +0 -0
  81. package/web/build/server/chunks/1-qrn24yN5.js +0 -7
  82. package/web/build/server/chunks/1-qrn24yN5.js.map +0 -1
  83. package/web/build/server/chunks/2-C7_Loqyn.js +0 -7
  84. package/web/build/server/chunks/2-C7_Loqyn.js.map +0 -1
  85. package/web/build/server/chunks/remote-1g4ayag-BRL-uaKE.js.map +0 -1
@@ -0,0 +1,213 @@
1
+ /**
2
+ * Valida un JSON de review y arma un texto accionable para el agente.
3
+ */
4
+ import { existsSync, readFileSync } from "node:fs";
5
+ import { isAbsolute, resolve } from "node:path";
6
+ import { globMatch } from "./config.mjs";
7
+ import { extractBranchDiff } from "./extract.mjs";
8
+ import { tryResolveGitRoot } from "./git.mjs";
9
+ import { decodePayload } from "./json-payload.mjs";
10
+ import { REVIEW_SCHEMA, reviewIssues, validateAgainst } from "./validate.mjs";
11
+
12
+ const MISSING_LIMIT = 40;
13
+
14
+ /**
15
+ * @param {unknown} payload
16
+ * @param {string[]} [paths] rutas del diff (sin ignorados)
17
+ * @returns {{ ok: boolean, errors: string[], notes: string[] }}
18
+ */
19
+ export function checkPayload(payload, paths = []) {
20
+ const errors = [...validateAgainst(REVIEW_SCHEMA, payload), ...reviewIssues(payload)];
21
+ const notes = [];
22
+ const known = (paths || []).map((p) => String(p || "").replaceAll("\\", "/")).filter(Boolean);
23
+
24
+ if (known.length) {
25
+ const blocks = Array.isArray(payload?.blocks) ? payload.blocks : [];
26
+ const findings = Array.isArray(payload?.findings) ? payload.findings : [];
27
+ const skipped = Array.isArray(payload?.skipped) ? payload.skipped : [];
28
+ const skippedPatterns = skipped.map((s) => String(s?.file || "")).filter(Boolean);
29
+
30
+ const covers = (file) => {
31
+ const norm = String(file || "").replaceAll("\\", "/");
32
+ if (blocks.some((b) => String(b?.file || "").replaceAll("\\", "/") === norm)) return true;
33
+ return skippedPatterns.some((pattern) => {
34
+ const p = pattern.replaceAll("\\", "/");
35
+ return p === norm || globMatch(norm, p);
36
+ });
37
+ };
38
+
39
+ for (const block of blocks) {
40
+ const file = String(block?.file || "").replaceAll("\\", "/");
41
+ if (file && !known.includes(file)) {
42
+ errors.push(`el bloque ${block.id || file} apunta a un archivo que no está en el diff: ${file}`);
43
+ }
44
+ }
45
+ for (const finding of findings) {
46
+ const file = String(finding?.file || "").replaceAll("\\", "/");
47
+ if (file && !known.includes(file)) {
48
+ errors.push(
49
+ `el hallazgo ${finding.id || finding.what || file} apunta a un archivo fuera del diff: ${file}`
50
+ );
51
+ }
52
+ }
53
+
54
+ const missing = known.filter((file) => !covers(file));
55
+ if (missing.length) {
56
+ const shown = missing.slice(0, MISSING_LIMIT);
57
+ const rest = missing.length - shown.length;
58
+ errors.push(
59
+ `quedaron ${missing.length} archivo(s) del diff sin bloque ni skipped: ${shown.join(", ")}${
60
+ rest > 0 ? ` … y ${rest} más` : ""
61
+ }`
62
+ );
63
+ }
64
+ } else {
65
+ notes.push("No contrasté cobertura contra git (sin lista de paths del diff).");
66
+ }
67
+
68
+ return { ok: errors.length === 0, errors, notes };
69
+ }
70
+
71
+ /**
72
+ * @param {string} filePath
73
+ * @param {{ repo?: string, branch?: string, base?: string, cwd?: string }} [opts]
74
+ */
75
+ export function checkReportFile(filePath, opts = {}) {
76
+ const cwd = opts.cwd || process.cwd();
77
+ const abs = isAbsolute(filePath) ? filePath : resolve(cwd, filePath);
78
+ if (!existsSync(abs)) {
79
+ return {
80
+ ok: false,
81
+ file: abs,
82
+ errors: [`No existe el archivo: ${abs}`],
83
+ notes: [],
84
+ usage: true,
85
+ };
86
+ }
87
+
88
+ let payload;
89
+ try {
90
+ payload = decodePayload(readFileSync(abs, "utf8"));
91
+ } catch (err) {
92
+ return {
93
+ ok: false,
94
+ file: abs,
95
+ errors: [err instanceof Error ? err.message : String(err)],
96
+ notes: [],
97
+ };
98
+ }
99
+
100
+ const meta = payload && typeof payload === "object" ? payload.meta || {} : {};
101
+ const branch = String(opts.branch || meta.branch || "").trim();
102
+ const base = String(opts.base || meta.base || "develop").trim();
103
+ const repoHint = String(opts.repo || meta.repo || "").trim();
104
+ const repo =
105
+ (repoHint && !/^(https?:\/\/|git@|ssh:\/\/)/i.test(repoHint) && tryResolveGitRoot(repoHint)) ||
106
+ tryResolveGitRoot(cwd);
107
+
108
+ /** @type {string[]} */
109
+ let paths = [];
110
+ /** @type {string[]} */
111
+ const errors = [];
112
+
113
+ if (!branch) {
114
+ errors.push("Falta meta.branch (o --branch) para contrastar el diff.");
115
+ } else if (!repo) {
116
+ errors.push(
117
+ "No encontré un repo git local (meta.repo o el directorio actual). Sin eso no puedo verificar cobertura."
118
+ );
119
+ } else {
120
+ try {
121
+ const extracted = extractBranchDiff({ repo, branch, base });
122
+ paths = (extracted.files || []).map((f) => f.path);
123
+ } catch (err) {
124
+ errors.push(
125
+ `No pude listar el diff con git (${err instanceof Error ? err.message : String(err)}). Revisá meta.repo, meta.branch y meta.base.`
126
+ );
127
+ }
128
+ }
129
+
130
+ const result = checkPayload(payload, paths);
131
+ const allErrors = [...result.errors, ...errors];
132
+ return { ok: allErrors.length === 0, errors: allErrors, notes: result.notes, file: abs };
133
+ }
134
+
135
+ /**
136
+ * @param {string} file
137
+ * @param {string} command
138
+ * @param {{ ok: boolean, errors: string[], notes: string[] }} result
139
+ */
140
+ export function formatCheckReport(file, command, result) {
141
+ if (result.ok) {
142
+ const extra = result.notes?.length ? `\n${result.notes.map((n) => `Note: ${n}`).join("\n")}` : "";
143
+ return `OK: ${file}${extra}\nSchema and references passed. You can stop.`;
144
+ }
145
+
146
+ const problems = (result.errors || []).map((e) => `- ${e}`).join("\n");
147
+ const notes = (result.notes || []).map((n) => `Note: ${n}`).join("\n");
148
+ const lines = [
149
+ `INVALID: ${file}`,
150
+ "",
151
+ "The JSON is not ready. Edit this same file until the command prints OK. Do not create a new filename.",
152
+ "",
153
+ "Problems:",
154
+ problems || "- (unknown)",
155
+ ];
156
+ if (notes) lines.push("", notes);
157
+ lines.push(
158
+ "",
159
+ "How to continue:",
160
+ "- Fix every problem above in place. Keep existing meta/intent/groups/blocks/findings; only change what is wrong or missing.",
161
+ '- Every diff path must appear in "blocks" or "skipped" (a skipped glob counts).',
162
+ "- what/why/fix stay ≤120 chars; intent ≥40 chars; enums exactly as in the schema.",
163
+ "- Human-readable strings in Spanish.",
164
+ `- Re-run exactly: \`${command}\``,
165
+ "- Stop only when that command prints OK."
166
+ );
167
+ return lines.join("\n");
168
+ }
169
+
170
+ /**
171
+ * @param {string[]} argv args después de `validate`
172
+ * @param {{ stdout?: (s: string) => void, stderr?: (s: string) => void, cwd?: string }} [io]
173
+ * @returns {number} exit code
174
+ */
175
+ export function runValidateCli(argv, io = {}) {
176
+ const stdout = io.stdout || ((s) => console.log(s));
177
+ const stderr = io.stderr || ((s) => console.error(s));
178
+ const fileFlag = [];
179
+ /** @type {{ repo?: string, branch?: string, base?: string }} */
180
+ const opts = { cwd: io.cwd };
181
+
182
+ for (let i = 0; i < argv.length; i++) {
183
+ const a = argv[i];
184
+ if (a === "-h" || a === "--help") {
185
+ stdout(`Usage: npx --yes @jpkme/diff-review validate <file.json> [--repo <path>] [--branch <b>] [--base <b>]
186
+
187
+ Exit 0 and "OK" when the JSON matches the review contract (and diff coverage, when git can list paths).
188
+ Exit 1 with problems and how to continue otherwise.`);
189
+ return 0;
190
+ }
191
+ if (a === "--repo") opts.repo = argv[++i];
192
+ else if (a === "--branch") opts.branch = argv[++i];
193
+ else if (a === "--base") opts.base = argv[++i];
194
+ else if (a.startsWith("-")) {
195
+ stderr(`Opción desconocida: ${a}`);
196
+ return 2;
197
+ } else fileFlag.push(a);
198
+ }
199
+
200
+ const file = fileFlag[0];
201
+ if (!file) {
202
+ stderr("Falta el archivo JSON. Uso: diff-review validate <file.json>");
203
+ return 2;
204
+ }
205
+
206
+ const command = `npx --yes @jpkme/diff-review validate ${file}`;
207
+ const result = checkReportFile(file, opts);
208
+ const text = formatCheckReport(result.file || file, command, result);
209
+ if (result.ok) stdout(text);
210
+ else stderr(text);
211
+ if (result.usage) return 2;
212
+ return result.ok ? 0 : 1;
213
+ }
@@ -0,0 +1,46 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+ import { checkPayload, formatCheckReport } from "./check-report.mjs";
4
+
5
+ const valid = () => ({
6
+ intent: "Agrega el ABM de depósitos locales end-to-end para dejar de administrarlos por SQL.",
7
+ groups: [{ id: "g1", kind: "feat", title: "CRUD de depósitos", intent: "base de todo" }],
8
+ blocks: [
9
+ {
10
+ id: "b1",
11
+ group: "g1",
12
+ file: "src/a.ts",
13
+ lines: "L1-20",
14
+ op: "add",
15
+ what: "controller nuevo",
16
+ why: "",
17
+ source: "code",
18
+ },
19
+ ],
20
+ findings: [],
21
+ skipped: [{ file: "**/*.lock", reason: "lockfile" }],
22
+ });
23
+
24
+ test("checkPayload acepta cobertura exacta y por glob", () => {
25
+ const result = checkPayload(valid(), ["src/a.ts", "bun.lock"]);
26
+ assert.equal(result.ok, true);
27
+ assert.deepEqual(result.errors, []);
28
+ });
29
+
30
+ test("checkPayload lista archivos del diff sin cubrir", () => {
31
+ const result = checkPayload(valid(), ["src/a.ts", "src/olvidado.ts"]);
32
+ assert.equal(result.ok, false);
33
+ assert.ok(result.errors.some((e) => e.includes("src/olvidado.ts")));
34
+ });
35
+
36
+ test("formatCheckReport explica cómo seguir", () => {
37
+ const text = formatCheckReport("diff-rev_a_b.json", "npx --yes @jpkme/diff-review validate diff-rev_a_b.json", {
38
+ ok: false,
39
+ errors: ["$.intent: texto demasiado corto"],
40
+ notes: [],
41
+ });
42
+ assert.match(text, /INVALID/);
43
+ assert.match(text, /How to continue/);
44
+ assert.match(text, /npx --yes @jpkme\/diff-review validate/);
45
+ assert.match(text, /same file/);
46
+ });
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Tips cortos de SHA. Sin APIs de Node: la UI lo importa para armar el nombre del JSON.
3
+ */
4
+
5
+ export function shortSha(sha, n = 10) {
6
+ const hex = String(sha || "")
7
+ .toLowerCase()
8
+ .replace(/[^a-f0-9]/g, "");
9
+ return hex.length >= 7 ? hex.slice(0, n) : "";
10
+ }
11
+
12
+ export function fingerprint(branchSha, baseSha) {
13
+ const a = shortSha(branchSha);
14
+ const b = shortSha(baseSha);
15
+ if (!a || !b) return null;
16
+ return `${a}-${b}`;
17
+ }
18
+
19
+ /** File/URL id: short tip of the reviewed branch + short tip of the base. */
20
+ export function reportId(branchSha, baseSha) {
21
+ const id = fingerprint(branchSha, baseSha);
22
+ if (!id) throw new Error("Faltan SHAs de los tips para el id del reporte.");
23
+ return id;
24
+ }
package/lib/git.mjs CHANGED
@@ -2,6 +2,8 @@ import { execFileSync } from "node:child_process";
2
2
  import { existsSync } from "node:fs";
3
3
  import { join, resolve } from "node:path";
4
4
 
5
+ export { fingerprint, reportId } from "./fingerprint.mjs";
6
+
5
7
  export function assertGitRepo(repo) {
6
8
  const raw = String(repo || "").trim();
7
9
  if (!raw) {
@@ -74,25 +76,6 @@ export function branchTips(repo, branch, base) {
74
76
  };
75
77
  }
76
78
 
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
79
  export function commitLog(repo, mergeBase, branch, limit = 25) {
97
80
  try {
98
81
  return git(repo, [
@@ -2,7 +2,7 @@
2
2
  * Nombre del JSON que escribe el agente.
3
3
  * Preferido: tips cortos branch/base. Legacy (con stamp o sin tips) sigue válido al importar.
4
4
  */
5
- import { fingerprint } from "./git.mjs";
5
+ import { fingerprint } from "./fingerprint.mjs";
6
6
 
7
7
  /** Prefijo del formato actual. */
8
8
  export const OUTPUT_PREFIX = "diff-rev";
package/lib/prompt.mjs CHANGED
@@ -23,7 +23,7 @@ export const OUTPUT_SCHEMA_BLOCK = `{
23
23
  "remoteUrl": "<origin URL; optional when source=url>",
24
24
  "generatedAt": "<ISO-8601 datetime; optional, informational>",
25
25
  "agent": "<tool name e.g. Claude Code / Cursor; optional, informational>",
26
- "model": "<model id e.g. claude-opus-…; optional, informational>"
26
+ "model": "<model id e.g. claude-opus; optional, informational>"
27
27
  },
28
28
  "intent": "string",
29
29
  "groups": [
@@ -71,10 +71,10 @@ export const OUTPUT_SCHEMA_BLOCK = `{
71
71
 
72
72
  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.
73
73
  - Do NOT invent files, hunks, or line ranges that are not in the diff.
74
- - 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
+ - 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.
75
75
  - 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.
76
76
  - Quality findings must name a concrete cost, not a vague feeling.
77
- - Signal over volume: prefer fewer high-signal findings. Order findings by severity (high → nit). Use "nit" sparingly.
77
+ - Signal over volume: prefer fewer high-signal findings. Order findings by severity (high, then nit). Use "nit" sparingly.
78
78
  - "blocking": true ONLY if the branch should not merge as-is. Use only with class "risk".
79
79
  - Write human-readable strings (intent, title, what, why, fix, notes) in Spanish. Keep enums/ids/paths/JSON keys exactly as in the schema.`;
80
80
 
@@ -82,8 +82,15 @@ function workflowLocal(base, branch) {
82
82
  return `Workflow:
83
83
  1. Resolve the merge-base of \`${base}\` and \`${branch}\`.
84
84
  2. Run exactly: \`git diff ${base}...${branch}\` (three-dot / merge-base diff). Do not use two-dot unless three-dot is impossible.
85
- 3. Read enough surrounding code (types, callers, tests) to judge behavior — not only the hunk lines.
86
- 4. Emit the JSON file. Do not modify the repo.`;
85
+ 3. Read enough surrounding code (types, callers, tests) to judge behavior, not only the hunk lines.
86
+ 4. Emit the JSON file. Do not modify the repo.
87
+ 5. Validate it with the command in the output section. If it prints INVALID, follow "How to continue" and re-run until it prints OK.`;
88
+ }
89
+
90
+ /** Comando que el agente debe correr; npx por si el bin no está en PATH. */
91
+ export function validateCliCommand(filename) {
92
+ const file = String(filename || "diff-rev_<branchTip>_<baseTip>.json").trim();
93
+ return `npx --yes @jpkme/diff-review validate ${file}`;
87
94
  }
88
95
 
89
96
  function workflowUrl(base, branch) {
@@ -91,8 +98,9 @@ function workflowUrl(base, branch) {
91
98
  1. If the repo is not local, clone it (or use whatever access you have). Do not modify it.
92
99
  2. Resolve the merge-base of \`${base}\` and \`${branch}\`.
93
100
  3. Run exactly: \`git diff ${base}...${branch}\` (three-dot / merge-base diff). Do not use two-dot unless three-dot is impossible.
94
- 4. Read enough surrounding code (types, callers, tests) to judge behavior — not only the hunk lines.
95
- 5. Emit the JSON file.`;
101
+ 4. Read enough surrounding code (types, callers, tests) to judge behavior, not only the hunk lines.
102
+ 5. Emit the JSON file.
103
+ 6. Validate it with the command in the output section. If it prints INVALID, follow "How to continue" and re-run until it prints OK.`;
96
104
  }
97
105
 
98
106
  /**
@@ -154,7 +162,12 @@ ${outputSchemaBlock(meta)}
154
162
 
155
163
  Include "meta" with the source/repo/branch/base above (and origin "remoteUrl" when local). Also set informational fields: "generatedAt" (ISO-8601 now), "agent" (tool name), "model" (model id). That lets someone reopen the report elsewhere without re-picking the repo.
156
164
 
157
- 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.
165
+ 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.
166
+
167
+ After writing the file, validate it (npx so it works even if \`diff-review\` is not on PATH):
168
+
169
+ \`${validateCliCommand(outputFilename)}\`
158
170
 
159
- When done, tell me that \`${outputFilename}\` was written.`;
171
+ - Exit 0 and a line starting with \`OK\` means the JSON is acceptable. Then tell me that \`${outputFilename}\` was written.
172
+ - Exit 1 prints \`INVALID\`, the problems, and how to continue. Fix the same file and re-run that exact command until it prints OK. Do not stop before OK.`;
160
173
  }
@@ -1,15 +1,53 @@
1
1
  /**
2
2
  * Espera a que aparezca un JSON de review válido en el repo.
3
3
  */
4
- import { existsSync, readFileSync, watch } from "node:fs";
4
+ import { existsSync, readFileSync, statSync, watch } from "node:fs";
5
5
  import { basename, join } from "node:path";
6
6
  import { decodePayload } from "./json-payload.mjs";
7
7
  import { REVIEW_SCHEMA, validateAgainst } from "./validate.mjs";
8
8
 
9
9
  /**
10
+ * Lee y valida el JSON. `null` si no está, está vacío, no parsea o es anterior a `since` (ms).
11
+ * @param {string} target
12
+ * @param {number} [since]
13
+ * @returns {{ payload: unknown, errors: string[] } | null}
14
+ */
15
+ function readReview(target, since) {
16
+ if (!existsSync(target)) return null;
17
+ let text;
18
+ try {
19
+ if (since != null && statSync(target).mtimeMs < since) return null;
20
+ text = readFileSync(target, "utf8");
21
+ } catch {
22
+ return null;
23
+ }
24
+ if (!text.trim()) return null;
25
+ let payload;
26
+ try {
27
+ payload = decodePayload(text);
28
+ } catch {
29
+ return null;
30
+ }
31
+ return { payload, errors: validateAgainst(REVIEW_SCHEMA, payload) };
32
+ }
33
+
34
+ /**
35
+ * JSON de review válido que ya está en el repo (p. ej. de una corrida anterior sobre los mismos commits).
10
36
  * @param {string} repoRoot
11
37
  * @param {string} filename
12
- * @param {{ onInvalid?: (errors: string[]) => void, pollMs?: number }} [opts]
38
+ * @returns {{ path: string, payload: unknown } | null}
39
+ */
40
+ export function existingReviewOutput(repoRoot, filename) {
41
+ const target = join(repoRoot, filename);
42
+ const read = readReview(target);
43
+ return read && !read.errors.length ? { path: target, payload: read.payload } : null;
44
+ }
45
+
46
+ /**
47
+ * @param {string} repoRoot
48
+ * @param {string} filename
49
+ * @param {{ onInvalid?: (errors: string[]) => void, pollMs?: number, since?: number }} [opts]
50
+ * `since`: ignorar el archivo si se modificó antes (ms epoch), para no tomar uno de una corrida anterior.
13
51
  * @returns {Promise<{ path: string, payload: unknown }>}
14
52
  */
15
53
  export function waitForReviewOutput(repoRoot, filename, opts = {}) {
@@ -19,21 +57,9 @@ export function waitForReviewOutput(repoRoot, filename, opts = {}) {
19
57
  let lastInvalidKey = "";
20
58
 
21
59
  const tryRead = () => {
22
- if (!existsSync(target)) return null;
23
- let text;
24
- try {
25
- text = readFileSync(target, "utf8");
26
- } catch {
27
- return null;
28
- }
29
- if (!text.trim()) return null;
30
- let payload;
31
- try {
32
- payload = decodePayload(text);
33
- } catch {
34
- return null;
35
- }
36
- const errors = validateAgainst(REVIEW_SCHEMA, payload);
60
+ const read = readReview(target, opts.since);
61
+ if (!read) return null;
62
+ const { payload, errors } = read;
37
63
  if (errors.length) {
38
64
  const key = errors.join("\n");
39
65
  if (key !== lastInvalidKey) {
@@ -0,0 +1,67 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtempSync, utimesSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { describe, it } from "node:test";
6
+ import { existingReviewOutput, waitForReviewOutput } from "./wait-output.mjs";
7
+
8
+ const valid = () => ({
9
+ intent: "Agrega el ABM de depósitos locales end-to-end para dejar de administrarlos por SQL.",
10
+ groups: [{ id: "g1", kind: "feat", title: "feat(product): CRUD de depósitos", intent: "base de todo" }],
11
+ blocks: [
12
+ { id: "b1", group: "g1", file: "src/a.ts", lines: "L1-20", op: "add", what: "controller nuevo", why: "", source: "code" },
13
+ ],
14
+ findings: [],
15
+ skipped: [],
16
+ });
17
+
18
+ const FILE = "diff-rev_a_b.json";
19
+
20
+ function repoWith(payload) {
21
+ const repo = mkdtempSync(join(tmpdir(), "diff-review-wait-"));
22
+ if (payload !== undefined) writeFileSync(join(repo, FILE), JSON.stringify(payload));
23
+ return repo;
24
+ }
25
+
26
+ /** Atrasa el mtime del archivo, como si viniera de una corrida anterior. */
27
+ function age(repo, ms) {
28
+ const past = new Date(Date.now() - ms);
29
+ utimesSync(join(repo, FILE), past, past);
30
+ }
31
+
32
+ describe("existingReviewOutput", () => {
33
+ it("devuelve el JSON si existe y es válido", () => {
34
+ const repo = repoWith(valid());
35
+ assert.equal(existingReviewOutput(repo, FILE)?.path, join(repo, FILE));
36
+ });
37
+
38
+ it("null si no existe o es inválido", () => {
39
+ assert.equal(existingReviewOutput(repoWith(), FILE), null);
40
+ assert.equal(existingReviewOutput(repoWith({ intent: "corto" }), FILE), null);
41
+ });
42
+ });
43
+
44
+ describe("waitForReviewOutput", () => {
45
+ it("sin since toma un JSON válido que ya estaba", async () => {
46
+ const repo = repoWith(valid());
47
+ age(repo, 60_000);
48
+ const result = await waitForReviewOutput(repo, FILE, { pollMs: 20 });
49
+ assert.equal(result.path, join(repo, FILE));
50
+ });
51
+
52
+ it("con since ignora el JSON anterior y resuelve cuando se reescribe", async () => {
53
+ const repo = repoWith(valid());
54
+ age(repo, 60_000);
55
+ let resolved = false;
56
+ const waiting = waitForReviewOutput(repo, FILE, { pollMs: 20, since: Date.now() - 1000 }).then((r) => {
57
+ resolved = true;
58
+ return r;
59
+ });
60
+ await new Promise((r) => setTimeout(r, 100));
61
+ assert.equal(resolved, false);
62
+
63
+ writeFileSync(join(repo, FILE), JSON.stringify(valid()));
64
+ const result = await waiting;
65
+ assert.equal(result.path, join(repo, FILE));
66
+ });
67
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jpkme/diff-review",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "Local UI to prompt a coding agent for a branch review and browse the result.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1 @@
1
+ .page.svelte-1uha8ag{flex-direction:column;min-height:100vh;display:flex}.diff-backdrop.svelte-1uha8ag{z-index:80;background:color-mix(in srgb, var(--bg) 88%, transparent);-webkit-backdrop-filter:blur(28px);place-items:center;padding:24px;display:grid;position:fixed;inset:0}.diff-modal.svelte-1uha8ag{background:var(--bg-card);border:1px solid var(--border);text-align:center;width:min(380px,100%);padding:28px 24px;box-shadow:0 16px 48px #00000047}.diff-modal.svelte-1uha8ag p:where(.svelte-1uha8ag){color:var(--text);margin:14px 0 0;font-size:15px}.diff-spinner.svelte-1uha8ag{border:2px solid var(--border);border-top-color:var(--accent);border-radius:50%;width:28px;height:28px;margin:0 auto;animation:.7s linear infinite svelte-1uha8ag-diff-spin}@keyframes svelte-1uha8ag-diff-spin{to{transform:rotate(360deg)}}.toast.svelte-1uha8ag{z-index:50;border:1px solid var(--border);background:var(--bg-card);color:var(--text);padding:10px 16px;font-size:13px;position:fixed;top:16px;left:50%;transform:translate(-50%);box-shadow:0 8px 24px #00000029}.topbar.svelte-1uha8ag{border-bottom:1px solid var(--border);background:var(--header-bg);z-index:5;justify-content:space-between;align-items:center;gap:12px;padding:16px 28px;display:flex;position:sticky;top:0}.brand.svelte-1uha8ag{color:var(--text);align-items:center;gap:10px;text-decoration:none;display:flex}.brand-dot.svelte-1uha8ag{background:linear-gradient(135deg,#6ea8fe,#c9a5ff);border-radius:50%;flex-shrink:0;width:9px;height:9px;box-shadow:0 0 12px #6ea8fe88}.title-block.svelte-1uha8ag{text-align:left;flex-direction:column;line-height:1.25;display:flex}.title-block.svelte-1uha8ag span:where(.svelte-1uha8ag):first-child{font-size:15px;font-weight:700}.title-block.svelte-1uha8ag small:where(.svelte-1uha8ag){color:var(--text-faint);font-size:11.5px;font-weight:400}.workspace.svelte-1uha8ag{flex:1;grid-template-columns:minmax(0,1fr) minmax(300px,380px);align-items:start;gap:28px;width:100%;max-width:1280px;margin:0 auto;padding:22px 28px 40px;display:grid;overflow:auto}.setup.svelte-1uha8ag{min-width:0}h2.svelte-1uha8ag{letter-spacing:.06em;text-transform:uppercase;color:var(--text-faint);margin:0 0 4px;font-size:13px;font-weight:600}.result-head.svelte-1uha8ag{margin-top:24px}.section-head.svelte-1uha8ag{justify-content:space-between;align-items:baseline;margin-top:22px;display:flex}.hint-text.svelte-1uha8ag{color:var(--text-dim);margin:0 0 12px;font-size:12.5px;line-height:1.5}.hint-text.svelte-1uha8ag code:where(.svelte-1uha8ag){font-family:var(--mono);color:var(--text);font-size:12px}.detect-banner.svelte-1uha8ag,.detect-wait.svelte-1uha8ag{color:var(--text-dim);margin:0 0 10px;font-size:13px}button.file-link.svelte-1uha8ag{color:var(--accent);font:inherit;cursor:pointer;background:0 0;border:0;padding:0;text-decoration:underline}.error-line.svelte-1uha8ag{color:var(--danger);margin:0 0 10px;font-size:12px}.ok-line.svelte-1uha8ag{color:var(--ok);margin:0 0 10px;font-size:12px}.row.svelte-1uha8ag{flex-wrap:wrap;gap:8px;display:flex}.repo-setup.svelte-1uha8ag{flex-direction:column;gap:10px;display:flex}.field.svelte-1uha8ag{flex-direction:column;flex:1;gap:4px;min-width:180px;display:flex}.repo-field.svelte-1uha8ag{flex:none;width:100%;min-width:0}.branches-row.svelte-1uha8ag .field:where(.svelte-1uha8ag){flex:1;min-width:160px}.field.svelte-1uha8ag span:where(.svelte-1uha8ag){color:var(--text-faint);font-size:11px}.repo-row.svelte-1uha8ag{gap:6px;min-width:0;display:flex}.repo-row.svelte-1uha8ag input[type=text]:where(.svelte-1uha8ag){flex:1;min-width:0}.repo-row.svelte-1uha8ag button:where(.svelte-1uha8ag){flex-shrink:0}.source-toggle.svelte-1uha8ag{margin-bottom:10px}input[type=text].svelte-1uha8ag,select.svelte-1uha8ag{background:var(--bg-card);border:1px solid var(--border);flex:1;min-width:0;padding:9px 12px}button.svelte-1uha8ag{background:var(--bg-card);border:1px solid var(--border);color:var(--text);padding:9px 12px;font-size:12.5px}button.svelte-1uha8ag:hover:not(:disabled){border-color:var(--accent)}button.svelte-1uha8ag:disabled{opacity:.5;cursor:not-allowed}.mode-toggle.svelte-1uha8ag{border:1px solid var(--border);width:fit-content;margin-bottom:10px;display:flex;overflow:hidden}.mode-toggle.svelte-1uha8ag button:where(.svelte-1uha8ag){background:0 0;border:0;padding:6px 12px;font-size:12px}.mode-toggle.svelte-1uha8ag button:where(.svelte-1uha8ag)+button:where(.svelte-1uha8ag){border-left:1px solid var(--border)}.mode-toggle.svelte-1uha8ag button.on:where(.svelte-1uha8ag){background:var(--accent-soft)}textarea.svelte-1uha8ag{resize:vertical;background:var(--bg);border:1px solid var(--border);width:100%;height:220px;font-family:var(--mono);color:var(--text);padding:12px;font-size:12px;line-height:1.5}.command-box.svelte-1uha8ag{background:var(--bg);border:1px solid var(--border);padding:10px 12px;overflow:auto}.command-box.svelte-1uha8ag code:where(.svelte-1uha8ag){font-family:var(--mono);color:var(--text);white-space:nowrap;font-size:12.5px}.actions.svelte-1uha8ag{gap:8px;margin-top:10px;display:flex}button.primary.svelte-1uha8ag{background:var(--btn-primary-bg);border-color:var(--btn-primary-border);color:var(--btn-primary-text);font-weight:600}button.ghost.svelte-1uha8ag{color:var(--text-dim);background:0 0}.skill-block.svelte-1uha8ag{border-top:1px solid var(--border);margin-top:16px;padding-top:14px}.skill-block.svelte-1uha8ag h3:where(.svelte-1uha8ag){letter-spacing:.06em;text-transform:uppercase;color:var(--text-faint);margin:0 0 4px;font-size:11.5px;font-weight:650}.skill-buttons.svelte-1uha8ag{flex-wrap:wrap;gap:8px;display:flex}.coverage-box.svelte-1uha8ag{border:1px solid var(--border);border-left:3px solid var(--sev-med);background:var(--bg-card);flex-direction:column;gap:10px;margin-top:12px;padding:12px 14px;display:flex}.coverage-head.svelte-1uha8ag{flex-wrap:wrap;align-items:baseline;gap:8px;display:flex}.coverage-head.svelte-1uha8ag strong:where(.svelte-1uha8ag){color:var(--sev-med);font-size:13px}.coverage-head.svelte-1uha8ag span:where(.svelte-1uha8ag){color:var(--text-faint);font-size:11.5px}.coverage-box.svelte-1uha8ag summary:where(.svelte-1uha8ag){color:var(--text-dim);cursor:pointer;font-size:12px}.coverage-list.svelte-1uha8ag{max-height:180px;font-family:var(--mono);color:var(--text-faint);margin:8px 0 0;padding-left:18px;font-size:11px;overflow:auto}.coverage-box.svelte-1uha8ag textarea:where(.svelte-1uha8ag){resize:vertical;background:var(--bg);border:1px solid var(--border);width:100%;font-family:var(--mono);color:var(--text);padding:10px;font-size:11.5px;line-height:1.5}.coverage-actions.svelte-1uha8ag{flex-wrap:wrap;gap:8px;display:flex}.dropzone.svelte-1uha8ag{border:1.5px dashed var(--border);text-align:center;background:var(--bg-elev);padding:26px;transition:background .15s,border-color .15s}.dropzone.drag.svelte-1uha8ag{border-color:var(--accent);background:var(--accent-soft)}.dropzone.busy.svelte-1uha8ag{opacity:.7}.dropzone.svelte-1uha8ag p:where(.svelte-1uha8ag){color:var(--text-dim);margin:0 0 6px;font-size:14px}.visually-hidden.svelte-1uha8ag{clip:rect(0 0 0 0);width:1px;height:1px;position:absolute;overflow:hidden}.drop-errors.svelte-1uha8ag{background:var(--danger-bg);color:var(--danger);margin-top:10px;padding:10px 12px;font-size:12.5px}.drop-errors.svelte-1uha8ag strong:where(.svelte-1uha8ag){margin-bottom:4px;display:block}.drop-errors.svelte-1uha8ag ul:where(.svelte-1uha8ag){margin:0;padding-left:18px;line-height:1.6}.reports-col.svelte-1uha8ag{min-width:0;max-height:calc(100vh - 110px);position:sticky;top:88px;overflow:auto}.report-list.svelte-1uha8ag{flex-direction:column;gap:8px;margin:0;padding:0;list-style:none;display:flex}.report-item.svelte-1uha8ag{border:1px solid var(--border);background:var(--bg-card);align-items:stretch;display:flex;position:relative;overflow:visible}.report-item.svelte-1uha8ag:hover{background:var(--bg-card-hover);border-color:#33507a}.report-card.svelte-1uha8ag{min-width:0;color:var(--text);flex:1;padding:12px 8px 12px 14px;text-decoration:none;display:block}.report-actions.svelte-1uha8ag{flex-shrink:0;padding:8px 8px 0 0;position:relative}.kebab.svelte-1uha8ag{width:26px;height:26px;color:var(--text-faint);cursor:pointer;background:0 0;border:1px solid #0000;justify-content:center;align-items:center;padding:0;display:inline-flex}.kebab.svelte-1uha8ag:hover{border-color:var(--border);color:var(--text);background:var(--bg)}.report-menu.svelte-1uha8ag{z-index:30;border:1px solid var(--border);background:var(--bg-card);min-width:140px;padding:4px;position:absolute;top:auto;bottom:calc(100% - 2px);right:8px;box-shadow:0 8px 24px #0000002e}.report-menu.svelte-1uha8ag .menu-item:where(.svelte-1uha8ag){width:100%;color:var(--text);text-align:left;cursor:pointer;background:0 0;border:0;padding:8px 10px;font-size:12.5px;display:block}.report-menu.svelte-1uha8ag .menu-item:where(.svelte-1uha8ag):hover{background:var(--accent-soft)}.report-menu.svelte-1uha8ag .menu-item.danger:where(.svelte-1uha8ag){color:var(--danger)}.report-top.svelte-1uha8ag{justify-content:space-between;align-items:baseline;gap:12px;display:flex}.report-top.svelte-1uha8ag strong:where(.svelte-1uha8ag){font-size:14px}.when.svelte-1uha8ag{color:var(--text-dim);font-size:12px}.report-meta.svelte-1uha8ag{color:var(--text-dim);flex-wrap:wrap;align-items:center;gap:8px;margin-top:6px;font-size:12px;display:flex}.pill.svelte-1uha8ag{font-family:var(--mono);font-size:11px}.pill.bad.svelte-1uha8ag{color:var(--danger)}.report-intent.svelte-1uha8ag{color:var(--text-dim);-webkit-line-clamp:2;line-clamp:2;-webkit-box-orient:vertical;margin:8px 0 0;font-size:12.5px;line-height:1.45;display:-webkit-box;overflow:hidden}@media (width<=960px){.workspace.svelte-1uha8ag{grid-template-columns:1fr}.reports-col.svelte-1uha8ag{max-height:none;position:static}}