@jpkme/diff-review 0.1.10 → 0.1.13

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 (159) hide show
  1. package/README.md +38 -15
  2. package/bin/diff-review.mjs +151 -34
  3. package/lib/acp-policy.mjs +223 -0
  4. package/lib/acp-policy.test.mjs +136 -0
  5. package/lib/acp-shell.mjs +409 -0
  6. package/lib/acp-shell.test.mjs +158 -0
  7. package/lib/acp.mjs +368 -0
  8. package/lib/acp.test.mjs +58 -0
  9. package/lib/agent-login.mjs +105 -0
  10. package/lib/agents.mjs +87 -14
  11. package/lib/agents.test.mjs +4 -6
  12. package/lib/check-report.mjs +30 -11
  13. package/lib/check-report.test.mjs +1 -0
  14. package/lib/extract-cache.test.mjs +162 -0
  15. package/lib/extract.mjs +197 -1
  16. package/lib/model-label.mjs +263 -0
  17. package/lib/model-label.test.mjs +77 -0
  18. package/lib/models-cache.mjs +75 -0
  19. package/lib/models-cache.test.mjs +50 -0
  20. package/lib/output-name.mjs +8 -0
  21. package/lib/prompt.mjs +168 -22
  22. package/lib/review-dir.mjs +108 -0
  23. package/lib/review-meta.mjs +5 -3
  24. package/lib/review-meta.test.mjs +11 -0
  25. package/lib/review-prep.mjs +63 -0
  26. package/lib/validate.mjs +2 -2
  27. package/lib/wait-output.mjs +46 -23
  28. package/lib/wait-output.test.mjs +24 -1
  29. package/package.json +3 -1
  30. package/web/build/client/_app/immutable/assets/{0.CLQiq3Ed.css → 0.BKRG1f36.css} +1 -1
  31. package/web/build/client/_app/immutable/assets/0.BKRG1f36.css.br +0 -0
  32. package/web/build/client/_app/immutable/assets/{0.CLQiq3Ed.css.gz → 0.BKRG1f36.css.gz} +0 -0
  33. package/web/build/client/_app/immutable/assets/2.tqrMRTM0.css +1 -0
  34. package/web/build/client/_app/immutable/assets/2.tqrMRTM0.css.br +0 -0
  35. package/web/build/client/_app/immutable/assets/2.tqrMRTM0.css.gz +0 -0
  36. package/web/build/client/_app/immutable/assets/3.BT54aVKb.css +1 -0
  37. package/web/build/client/_app/immutable/assets/3.BT54aVKb.css.br +0 -0
  38. package/web/build/client/_app/immutable/assets/3.BT54aVKb.css.gz +0 -0
  39. package/web/build/client/_app/immutable/assets/storage.DqYH0w6R.css +1 -0
  40. package/web/build/client/_app/immutable/assets/storage.DqYH0w6R.css.br +0 -0
  41. package/web/build/client/_app/immutable/assets/storage.DqYH0w6R.css.gz +0 -0
  42. package/web/build/client/_app/immutable/chunks/BXUWMim4.js +213 -0
  43. package/web/build/client/_app/immutable/chunks/BXUWMim4.js.br +0 -0
  44. package/web/build/client/_app/immutable/chunks/BXUWMim4.js.gz +0 -0
  45. package/web/build/client/_app/immutable/chunks/Bu1mm6Oz.js +1 -0
  46. package/web/build/client/_app/immutable/chunks/Bu1mm6Oz.js.br +0 -0
  47. package/web/build/client/_app/immutable/chunks/Bu1mm6Oz.js.gz +0 -0
  48. package/web/build/client/_app/immutable/chunks/D8WOD_eS.js +3 -0
  49. package/web/build/client/_app/immutable/chunks/D8WOD_eS.js.br +0 -0
  50. package/web/build/client/_app/immutable/chunks/D8WOD_eS.js.gz +0 -0
  51. package/web/build/client/_app/immutable/chunks/{BBWAo_yW.js → DPfXzQnm.js} +2 -2
  52. package/web/build/client/_app/immutable/chunks/DPfXzQnm.js.br +0 -0
  53. package/web/build/client/_app/immutable/chunks/DPfXzQnm.js.gz +0 -0
  54. package/web/build/client/_app/immutable/chunks/lUYXRDtZ.js +1 -0
  55. package/web/build/client/_app/immutable/chunks/lUYXRDtZ.js.br +0 -0
  56. package/web/build/client/_app/immutable/chunks/lUYXRDtZ.js.gz +0 -0
  57. package/web/build/client/_app/immutable/entry/app.CTgiuiU1.js +2 -0
  58. package/web/build/client/_app/immutable/entry/app.CTgiuiU1.js.br +0 -0
  59. package/web/build/client/_app/immutable/entry/app.CTgiuiU1.js.gz +0 -0
  60. package/web/build/client/_app/immutable/entry/start.CFSl0kht.js +1 -0
  61. package/web/build/client/_app/immutable/entry/start.CFSl0kht.js.br +0 -0
  62. package/web/build/client/_app/immutable/entry/start.CFSl0kht.js.gz +0 -0
  63. package/web/build/client/_app/immutable/nodes/0.Dj97JW46.js +1 -0
  64. package/web/build/client/_app/immutable/nodes/0.Dj97JW46.js.br +0 -0
  65. package/web/build/client/_app/immutable/nodes/0.Dj97JW46.js.gz +0 -0
  66. package/web/build/client/_app/immutable/nodes/1.0PQZpSbJ.js +1 -0
  67. package/web/build/client/_app/immutable/nodes/1.0PQZpSbJ.js.br +2 -0
  68. package/web/build/client/_app/immutable/nodes/1.0PQZpSbJ.js.gz +0 -0
  69. package/web/build/client/_app/immutable/nodes/2.DQaoQoy4.js +10 -0
  70. package/web/build/client/_app/immutable/nodes/2.DQaoQoy4.js.br +0 -0
  71. package/web/build/client/_app/immutable/nodes/2.DQaoQoy4.js.gz +0 -0
  72. package/web/build/client/_app/immutable/nodes/3.AciugOtL.js +171 -0
  73. package/web/build/client/_app/immutable/nodes/3.AciugOtL.js.br +0 -0
  74. package/web/build/client/_app/immutable/nodes/3.AciugOtL.js.gz +0 -0
  75. package/web/build/client/_app/version.json +1 -1
  76. package/web/build/client/_app/version.json.br +0 -0
  77. package/web/build/client/_app/version.json.gz +0 -0
  78. package/web/build/handler.js +3 -3
  79. package/web/build/index.js +3 -3
  80. package/web/build/server/chunks/0-DUStDOsr.js +11 -0
  81. package/web/build/server/chunks/{0-SseDy__O.js.map → 0-DUStDOsr.js.map} +1 -1
  82. package/web/build/server/chunks/1-C2qlInYk.js +7 -0
  83. package/web/build/server/chunks/1-C2qlInYk.js.map +1 -0
  84. package/web/build/server/chunks/2-DyE177MK.js +7 -0
  85. package/web/build/server/chunks/2-DyE177MK.js.map +1 -0
  86. package/web/build/server/chunks/3-SO3atuWO.js +7 -0
  87. package/web/build/server/chunks/3-SO3atuWO.js.map +1 -0
  88. package/web/build/server/chunks/_server.ts-CRNhzL2a.js +62 -0
  89. package/web/build/server/chunks/_server.ts-CRNhzL2a.js.map +1 -0
  90. package/web/build/server/chunks/agent-sessions-kcUaLhK5.js +2819 -0
  91. package/web/build/server/chunks/agent-sessions-kcUaLhK5.js.map +1 -0
  92. package/web/build/server/chunks/{remote-1g4ayag-B67QMR_2.js → git-review-CXKcjYgZ.js} +70 -704
  93. package/web/build/server/chunks/git-review-CXKcjYgZ.js.map +1 -0
  94. package/web/build/server/chunks/{handler-B5P65tLk.js → handler-_E0NucKN.js} +3 -3
  95. package/web/build/server/chunks/{handler-B5P65tLk.js.map → handler-_E0NucKN.js.map} +1 -1
  96. package/web/build/server/chunks/hooks.server-D7yPODoc.js +42 -0
  97. package/web/build/server/chunks/hooks.server-D7yPODoc.js.map +1 -0
  98. package/web/build/server/chunks/{index.js-CL95e0Gl.js → index.js-lMtDM8ZQ.js} +3 -5
  99. package/web/build/server/chunks/index.js-lMtDM8ZQ.js.map +1 -0
  100. package/web/build/server/chunks/manifest.js-BIm6jot5.js +58 -0
  101. package/web/build/server/chunks/manifest.js-BIm6jot5.js.map +1 -0
  102. package/web/build/server/chunks/remote-1g4ayag-DmqxwUIz.js +144 -0
  103. package/web/build/server/chunks/remote-1g4ayag-DmqxwUIz.js.map +1 -0
  104. package/web/build/server/chunks/remote-wh6t6j-DZk0W9Pp.js +162 -0
  105. package/web/build/server/chunks/remote-wh6t6j-DZk0W9Pp.js.map +1 -0
  106. package/web/build/server/chunks/validate-BsTKyhsq.js +1073 -0
  107. package/web/build/server/chunks/validate-BsTKyhsq.js.map +1 -0
  108. package/web/build/client/_app/immutable/assets/0.CLQiq3Ed.css.br +0 -0
  109. package/web/build/client/_app/immutable/assets/2.CNFDEnjx.css +0 -1
  110. package/web/build/client/_app/immutable/assets/2.CNFDEnjx.css.br +0 -0
  111. package/web/build/client/_app/immutable/assets/2.CNFDEnjx.css.gz +0 -0
  112. package/web/build/client/_app/immutable/assets/3.PHTPLlZ7.css +0 -1
  113. package/web/build/client/_app/immutable/assets/3.PHTPLlZ7.css.br +0 -0
  114. package/web/build/client/_app/immutable/assets/3.PHTPLlZ7.css.gz +0 -0
  115. package/web/build/client/_app/immutable/assets/ThemeToggle.DcT79vPK.css +0 -1
  116. package/web/build/client/_app/immutable/assets/ThemeToggle.DcT79vPK.css.br +0 -0
  117. package/web/build/client/_app/immutable/assets/ThemeToggle.DcT79vPK.css.gz +0 -0
  118. package/web/build/client/_app/immutable/chunks/BBWAo_yW.js.br +0 -0
  119. package/web/build/client/_app/immutable/chunks/BBWAo_yW.js.gz +0 -0
  120. package/web/build/client/_app/immutable/chunks/Dur-vdUf.js +0 -3
  121. package/web/build/client/_app/immutable/chunks/Dur-vdUf.js.br +0 -0
  122. package/web/build/client/_app/immutable/chunks/Dur-vdUf.js.gz +0 -0
  123. package/web/build/client/_app/immutable/chunks/QdZF3nkf.js +0 -176
  124. package/web/build/client/_app/immutable/chunks/QdZF3nkf.js.br +0 -0
  125. package/web/build/client/_app/immutable/chunks/QdZF3nkf.js.gz +0 -0
  126. package/web/build/client/_app/immutable/chunks/tYeNsyIK.js +0 -1
  127. package/web/build/client/_app/immutable/chunks/tYeNsyIK.js.br +0 -0
  128. package/web/build/client/_app/immutable/chunks/tYeNsyIK.js.gz +0 -0
  129. package/web/build/client/_app/immutable/entry/app.Dce6uJU3.js +0 -2
  130. package/web/build/client/_app/immutable/entry/app.Dce6uJU3.js.br +0 -0
  131. package/web/build/client/_app/immutable/entry/app.Dce6uJU3.js.gz +0 -0
  132. package/web/build/client/_app/immutable/entry/start.DnBSC38l.js +0 -1
  133. package/web/build/client/_app/immutable/entry/start.DnBSC38l.js.br +0 -0
  134. package/web/build/client/_app/immutable/entry/start.DnBSC38l.js.gz +0 -0
  135. package/web/build/client/_app/immutable/nodes/0.s5xmjXoS.js +0 -1
  136. package/web/build/client/_app/immutable/nodes/0.s5xmjXoS.js.br +0 -1
  137. package/web/build/client/_app/immutable/nodes/0.s5xmjXoS.js.gz +0 -0
  138. package/web/build/client/_app/immutable/nodes/1.5Iv4ssDT.js +0 -1
  139. package/web/build/client/_app/immutable/nodes/1.5Iv4ssDT.js.br +0 -0
  140. package/web/build/client/_app/immutable/nodes/1.5Iv4ssDT.js.gz +0 -0
  141. package/web/build/client/_app/immutable/nodes/2.BomzYVBF.js +0 -66
  142. package/web/build/client/_app/immutable/nodes/2.BomzYVBF.js.br +0 -0
  143. package/web/build/client/_app/immutable/nodes/2.BomzYVBF.js.gz +0 -0
  144. package/web/build/client/_app/immutable/nodes/3.CeUs4lBX.js +0 -170
  145. package/web/build/client/_app/immutable/nodes/3.CeUs4lBX.js.br +0 -0
  146. package/web/build/client/_app/immutable/nodes/3.CeUs4lBX.js.gz +0 -0
  147. package/web/build/server/chunks/0-SseDy__O.js +0 -11
  148. package/web/build/server/chunks/1-2OO2bb7H.js +0 -7
  149. package/web/build/server/chunks/1-2OO2bb7H.js.map +0 -1
  150. package/web/build/server/chunks/2-BJvuqKN6.js +0 -7
  151. package/web/build/server/chunks/2-BJvuqKN6.js.map +0 -1
  152. package/web/build/server/chunks/3-ZIl1rHef.js +0 -7
  153. package/web/build/server/chunks/3-ZIl1rHef.js.map +0 -1
  154. package/web/build/server/chunks/hooks.server-DERFlOe1.js +0 -14
  155. package/web/build/server/chunks/hooks.server-DERFlOe1.js.map +0 -1
  156. package/web/build/server/chunks/index.js-CL95e0Gl.js.map +0 -1
  157. package/web/build/server/chunks/manifest.js-uD_a0079.js +0 -50
  158. package/web/build/server/chunks/manifest.js-uD_a0079.js.map +0 -1
  159. package/web/build/server/chunks/remote-1g4ayag-B67QMR_2.js.map +0 -1
package/lib/agents.mjs CHANGED
@@ -6,9 +6,21 @@
6
6
  * no se toca el portapapeles ni se choca con el límite de largo de la línea de comandos de los shims `.cmd`.
7
7
  */
8
8
  import { spawn } from "node:child_process";
9
- import { accessSync, constants, existsSync, readdirSync, readFileSync, statSync } from "node:fs";
10
- import { homedir } from "node:os";
9
+ import {
10
+ accessSync,
11
+ constants,
12
+ copyFileSync,
13
+ existsSync,
14
+ mkdirSync,
15
+ readdirSync,
16
+ readFileSync,
17
+ rmSync,
18
+ statSync,
19
+ writeFileSync,
20
+ } from "node:fs";
21
+ import { homedir, tmpdir } from "node:os";
11
22
  import { delimiter, join } from "node:path";
23
+ import { outputRelPath } from "./output-name.mjs";
12
24
 
13
25
  /**
14
26
  * @typedef {{ value: string, label: string, hint?: string }} ModelOption
@@ -21,7 +33,10 @@ import { delimiter, join } from "node:path";
21
33
  * models: ModelOption[],
22
34
  * configuredModel?: (ctx: DetectContext) => { model: string, source: string } | null,
23
35
  * args: (spec: RunSpec) => string[],
36
+ * acp?: (ctx: { path: string }) => AcpLaunch,
24
37
  * }} AgentDef
38
+ * @typedef {{ file: string, args: string[], env?: Record<string, string>, sessionMeta?: Record<string, unknown> }} AcpLaunch
39
+ * `env` se suma al del proceso; `sessionMeta` va como `_meta` en `session/new` / `session/load`.
25
40
  * @typedef {{ env: NodeJS.ProcessEnv, platform: NodeJS.Platform, home: string }} DetectContext
26
41
  * @typedef {{ def: AgentDef, path: string }} DetectedAgent
27
42
  */
@@ -78,6 +93,32 @@ function versionedBinaries(root, exe) {
78
93
  return out;
79
94
  }
80
95
 
96
+ /**
97
+ * Carpeta de configuración para Cursor por ACP: la del usuario sin su allowlist. Cursor aprueba solo lo que matchee
98
+ * `permissions.allow` mirando el nombre del comando (`Shell(echo)` deja pasar `echo x > archivo`); vaciándola,
99
+ * cada comando pasa por la política de diff-review. El login no vive ahí, así que se conserva. El modelo elegido
100
+ * por ACP se guarda en esta carpeta y no pisa el default del usuario.
101
+ */
102
+ export function cursorAcpConfigDir(ctx) {
103
+ const c = context(ctx);
104
+ const userDir = c.env.CURSOR_CONFIG_DIR?.trim() || (c.env.XDG_CONFIG_HOME?.trim() ? join(c.env.XDG_CONFIG_HOME, "cursor") : join(c.home, ".cursor"));
105
+ const dir = join(tmpdir(), "diff-review", "cursor-config");
106
+ mkdirSync(dir, { recursive: true });
107
+ const config = readJson(join(userDir, "cli-config.json")) ?? {};
108
+ writeFileSync(
109
+ join(dir, "cli-config.json"),
110
+ JSON.stringify({ ...config, permissions: { allow: [], deny: config.permissions?.deny ?? [] }, approvalMode: "allowlist" }, null, 2),
111
+ );
112
+ // El modelo que elige ACP queda en acp-config.json: arrancar siempre desde el default del usuario.
113
+ const userAcp = join(userDir, "acp-config.json");
114
+ if (existsSync(userAcp)) copyFileSync(userAcp, join(dir, "acp-config.json"));
115
+ else rmSync(join(dir, "acp-config.json"), { force: true });
116
+ return dir;
117
+ }
118
+
119
+ /** Adaptador ACP de Claude Code (antes `@zed-industries/claude-agent-acp`). */
120
+ export const CLAUDE_ACP_PACKAGE = "@agentclientprotocol/claude-agent-acp@^0.91.0";
121
+
81
122
  /** @type {AgentDef[]} */
82
123
  export const AGENTS = [
83
124
  {
@@ -118,8 +159,26 @@ export const AGENTS = [
118
159
  "Glob",
119
160
  "Bash(git:*)",
120
161
  "Bash(npx --yes @jpkme/diff-review validate:*)",
121
- `Edit(./${outputFilename})`,
162
+ `Edit(./${outputRelPath(outputFilename)})`,
122
163
  ],
164
+ // Claude Code no habla ACP solo: el adaptador oficial lo envuelve (usa el login de Claude Code del usuario).
165
+ acp: () => {
166
+ const local = findOnPath("claude-agent-acp", context());
167
+ return {
168
+ ...(local ? { file: local, args: [] } : { file: "npx", args: ["--yes", CLAUDE_ACP_PACKAGE] }),
169
+ sessionMeta: {
170
+ claudeCode: {
171
+ options: {
172
+ // Solo las tools que una review necesita (nada de web, subagentes ni MCP).
173
+ tools: ["Read", "Grep", "Glob", "Bash", "Edit", "Write"],
174
+ // Sin la configuración del usuario ni la del repo: su allowlist aprobaría comandos sin pasar por la
175
+ // política de diff-review (y un repo podría traer la suya en .claude/settings.json).
176
+ settingSources: [],
177
+ },
178
+ },
179
+ },
180
+ };
181
+ },
123
182
  },
124
183
  {
125
184
  id: "codex",
@@ -157,6 +216,8 @@ export const AGENTS = [
157
216
  // Con -p lee el prompt de stdin. Sin --force solo corre lo que esté en la allowlist del usuario
158
217
  // (`~/.cursor/cli-config.json`) y bloquearía el validate; no hay flag para pasar una allowlist.
159
218
  args: ({ model }) => ["-p", "--output-format", "text", "--trust", "--force", ...(model ? ["--model", model] : [])],
219
+ // Modo ACP nativo. Con su configuración aislada (ver cursorAcpConfigDir), cada comando pide permiso.
220
+ acp: ({ path }) => ({ file: path, args: ["acp"], env: { CURSOR_CONFIG_DIR: cursorAcpConfigDir() } }),
160
221
  },
161
222
  {
162
223
  id: "copilot",
@@ -180,8 +241,10 @@ export const AGENTS = [
180
241
  "--allow-tool",
181
242
  "shell(npx:*)",
182
243
  "--allow-tool",
183
- `write(${outputFilename})`,
244
+ `write(${outputRelPath(outputFilename)})`,
184
245
  ],
246
+ // Modo ACP nativo. Su opción de sesión `allow_all` queda apagada: cada permiso pasa por nuestra política.
247
+ acp: ({ path }) => ({ file: path, args: ["--acp"] }),
185
248
  },
186
249
  {
187
250
  id: "gemini",
@@ -253,12 +316,10 @@ export function detectAgents(ctx) {
253
316
 
254
317
  /** Valor del select para "no pasar --model" (que el agente use el suyo). */
255
318
  export const AGENT_DEFAULT_MODEL = "";
256
- /** Valor del select para escribir un id de modelo a mano. */
257
- export const OTHER_MODEL = "__other__";
258
319
 
259
320
  /**
260
- * Opciones del select de modelo: primero el default del agente (marcado como inicial), después los conocidos y
261
- * "Otro…". Si el default configurado coincide con uno conocido, no se repite.
321
+ * Opciones del select de modelo: primero el default del agente (marcado como inicial), después los conocidos.
322
+ * Si el default configurado coincide con uno conocido, no se repite.
262
323
  * @param {AgentDef} def
263
324
  * @param {Partial<DetectContext>} [ctx]
264
325
  * @returns {{ options: ModelOption[], initialValue: string }}
@@ -270,7 +331,7 @@ export function modelChoices(def, ctx) {
270
331
  : { value: AGENT_DEFAULT_MODEL, label: "Por defecto del agente", hint: "no se pasa --model" };
271
332
  const rest = def.models.filter((m) => m.value !== configured?.model);
272
333
  return {
273
- options: [first, ...rest, { value: OTHER_MODEL, label: "Otro…", hint: "escribir el id del modelo" }],
334
+ options: [first, ...rest],
274
335
  initialValue: AGENT_DEFAULT_MODEL,
275
336
  };
276
337
  }
@@ -295,6 +356,22 @@ function quoteForCmd(arg) {
295
356
  return /^[\w./:@=-]+$/.test(arg) ? arg : `"${arg.replace(/"/g, '""')}"`;
296
357
  }
297
358
 
359
+ /**
360
+ * `spawn` que también lanza los shims `.cmd`/`.bat` de npm en Windows (y `npx` a secas, que es uno de ellos).
361
+ * @param {string} file
362
+ * @param {string[]} args
363
+ * @param {import('node:child_process').SpawnOptions} options
364
+ */
365
+ export function spawnCommand(file, args, options) {
366
+ const resolved =
367
+ process.platform === "win32" && !/[\\/]/.test(file) ? (findOnPath(file, context()) ?? file) : file;
368
+ // Node no lanza .cmd/.bat sin shell (CVE-2024-27980); con shell hay que citar a mano.
369
+ const viaShell = process.platform === "win32" && /\.(cmd|bat)$/i.test(resolved);
370
+ return viaShell
371
+ ? spawn([resolved, ...args].map(quoteForCmd).join(" "), { ...options, shell: true })
372
+ : spawn(resolved, args, options);
373
+ }
374
+
298
375
  /**
299
376
  * Lanza el agente en el repo con el prompt por stdin; stdout/stderr van a la terminal.
300
377
  * @param {DetectedAgent} agent
@@ -303,11 +380,7 @@ function quoteForCmd(arg) {
303
380
  */
304
381
  export function runAgent(agent, { cwd, prompt, model, outputFilename }) {
305
382
  const args = agent.def.args({ model, outputFilename });
306
- // Node no lanza .cmd/.bat sin shell (CVE-2024-27980); con shell hay que citar a mano.
307
- const viaShell = process.platform === "win32" && /\.(cmd|bat)$/i.test(agent.path);
308
- const child = viaShell
309
- ? spawn([agent.path, ...args].map(quoteForCmd).join(" "), { cwd, shell: true, stdio: ["pipe", "inherit", "inherit"] })
310
- : spawn(agent.path, args, { cwd, stdio: ["pipe", "inherit", "inherit"] });
383
+ const child = spawnCommand(agent.path, args, { cwd, stdio: ["pipe", "inherit", "inherit"] });
311
384
  // Si el agente sale antes de leer stdin (p. ej. un modelo inválido), el EPIPE no debe tirar abajo la CLI.
312
385
  child.stdin?.on("error", () => {});
313
386
  child.stdin?.end(prompt);
@@ -9,7 +9,6 @@ import {
9
9
  detectAgents,
10
10
  findOnPath,
11
11
  modelChoices,
12
- OTHER_MODEL,
13
12
  topLevelTomlModel,
14
13
  } from "./agents.mjs";
15
14
 
@@ -78,16 +77,15 @@ describe("detectAgents", () => {
78
77
  });
79
78
 
80
79
  describe("modelChoices", () => {
81
- it("sin modelo configurado: 'Por defecto del agente' primero y como inicial, y 'Otro…' al final", () => {
80
+ it("sin modelo configurado: 'Por defecto del agente' primero y como inicial", () => {
82
81
  const { options, initialValue } = modelChoices(claude, { env: {}, platform: "linux", home: sandbox() });
83
82
  assert.equal(initialValue, AGENT_DEFAULT_MODEL);
84
83
  assert.equal(options[0].value, AGENT_DEFAULT_MODEL);
85
84
  assert.equal(options[0].label, "Por defecto del agente");
86
85
  assert.deepEqual(
87
- options.slice(1, -1).map((o) => o.value),
86
+ options.slice(1).map((o) => o.value),
88
87
  ["opus", "sonnet", "haiku"]
89
88
  );
90
- assert.equal(options.at(-1).value, OTHER_MODEL);
91
89
  });
92
90
 
93
91
  it("muestra el modelo configurado en ~/.claude/settings.json y no lo repite en la lista", () => {
@@ -127,7 +125,7 @@ describe("args", () => {
127
125
  it("claude: -p, --model solo si se eligió uno, y escritura limitada al JSON del review", () => {
128
126
  const args = claude.args({ model: "opus", outputFilename: "diff-rev_a_b.json" });
129
127
  assert.deepEqual(args.slice(0, 3), ["-p", "--model", "opus"]);
130
- assert.ok(args.includes("Edit(./diff-rev_a_b.json)"));
128
+ assert.ok(args.includes("Edit(./.diff-review/diff-rev_a_b.json)"));
131
129
  assert.ok(!claude.args({ outputFilename: "x.json" }).includes("--model"));
132
130
  });
133
131
 
@@ -189,7 +187,7 @@ describe("copilot", () => {
189
187
  const args = copilot.args({ model: "auto", outputFilename: "diff-rev_a_b.json" });
190
188
  assert.ok(!args.includes("-p"));
191
189
  assert.deepEqual(args.slice(0, 3), ["-s", "--model", "auto"]);
192
- assert.ok(args.includes("write(diff-rev_a_b.json)"));
190
+ assert.ok(args.includes("write(.diff-review/diff-rev_a_b.json)"));
193
191
  assert.ok(!args.includes("--allow-all-tools"));
194
192
  });
195
193
  });
@@ -2,13 +2,20 @@
2
2
  * Valida un JSON de review y arma un texto accionable para el agente.
3
3
  */
4
4
  import { existsSync, readFileSync } from "node:fs";
5
- import { isAbsolute, resolve } from "node:path";
5
+ import { basename, dirname, isAbsolute, resolve } from "node:path";
6
6
  import { globMatch } from "./config.mjs";
7
- import { extractBranchDiff } from "./extract.mjs";
7
+ import { loadBranchDiff } from "./extract.mjs";
8
8
  import { tryResolveGitRoot } from "./git.mjs";
9
9
  import { decodePayload } from "./json-payload.mjs";
10
+ import { OUTPUT_DIR } from "./output-name.mjs";
10
11
  import { REVIEW_SCHEMA, reviewIssues, validateAgainst } from "./validate.mjs";
11
12
 
13
+ /** Raíz del repo a partir del JSON (`.diff-review/foo.json` → parent, o la carpeta del archivo legacy). */
14
+ function repoRootFromReviewFile(absJson) {
15
+ const dir = dirname(absJson);
16
+ return basename(dir) === OUTPUT_DIR ? dirname(dir) : dir;
17
+ }
18
+
12
19
  const MISSING_LIMIT = 40;
13
20
 
14
21
  /**
@@ -70,7 +77,8 @@ export function checkPayload(payload, paths = []) {
70
77
 
71
78
  /**
72
79
  * @param {string} filePath
73
- * @param {{ repo?: string, branch?: string, base?: string, cwd?: string }} [opts]
80
+ * @param {{ repo?: string, branch?: string, base?: string, cwd?: string, paths?: string[] }} [opts]
81
+ * `paths`: lista ya conocida (p. ej. de la sesión); si viene, no se lee el `.diff` ni se llama a git.
74
82
  */
75
83
  export function checkReportFile(filePath, opts = {}) {
76
84
  const cwd = opts.cwd || process.cwd();
@@ -101,28 +109,38 @@ export function checkReportFile(filePath, opts = {}) {
101
109
  const branch = String(opts.branch || meta.branch || "").trim();
102
110
  const base = String(opts.base || meta.base || "develop").trim();
103
111
  const repoHint = String(opts.repo || meta.repo || "").trim();
104
- const repo =
105
- (repoHint && !/^(https?:\/\/|git@|ssh:\/\/)/i.test(repoHint) && tryResolveGitRoot(repoHint)) ||
106
- tryResolveGitRoot(cwd);
112
+ const fromHint =
113
+ repoHint && !/^(https?:\/\/|git@|ssh:\/\/)/i.test(repoHint)
114
+ ? tryResolveGitRoot(repoHint) || (existsSync(repoHint) ? resolve(repoHint) : null)
115
+ : null;
116
+ const repo = fromHint || tryResolveGitRoot(cwd) || repoRootFromReviewFile(abs);
107
117
 
108
118
  /** @type {string[]} */
109
- let paths = [];
119
+ let paths = Array.isArray(opts.paths) ? opts.paths.map(String).filter(Boolean) : [];
110
120
  /** @type {string[]} */
111
121
  const errors = [];
112
122
 
113
- if (!branch) {
123
+ if (paths.length) {
124
+ /* cobertura ya resuelta por el caller */
125
+ } else if (!branch) {
114
126
  errors.push("Falta meta.branch (o --branch) para contrastar el diff.");
115
127
  } else if (!repo) {
116
128
  errors.push(
117
- "No encontré un repo git local (meta.repo o el directorio actual). Sin eso no puedo verificar cobertura."
129
+ "No encontré un repo local (meta.repo, el directorio actual, o la carpeta del JSON). Sin eso no puedo verificar cobertura."
118
130
  );
119
131
  } else {
120
132
  try {
121
- const extracted = extractBranchDiff({ repo, branch, base });
133
+ // Preferí el companion `.diff` (mismo stem que el JSON); solo cae a `git diff` si falta.
134
+ const extracted = loadBranchDiff({
135
+ repo,
136
+ branch,
137
+ base,
138
+ filename: basename(abs),
139
+ });
122
140
  paths = (extracted.files || []).map((f) => f.path);
123
141
  } catch (err) {
124
142
  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.`
143
+ `No pude listar el diff (${err instanceof Error ? err.message : String(err)}). Revisá meta.repo, meta.branch y meta.base, o el companion .diff.`
126
144
  );
127
145
  }
128
146
  }
@@ -159,6 +177,7 @@ export function formatCheckReport(file, command, result) {
159
177
  "How to continue:",
160
178
  "- Fix every problem above in place. Keep existing meta/intent/groups/blocks/findings; only change what is wrong or missing.",
161
179
  '- Every diff path must appear in "blocks" or "skipped" (a skipped glob counts).',
180
+ "- If a problem lists missing paths: ADD a block or a skipped entry for each exact path. Do not only bump meta.generatedAt, notes, or claim the file is already complete.",
162
181
  "- meta.agent and meta.model are required (product name and the exact model id you are running).",
163
182
  "- intent ≥40 chars; enums exactly as in the schema.",
164
183
  "- Human-readable strings in Spanish.",
@@ -44,4 +44,5 @@ test("formatCheckReport explica cómo seguir", () => {
44
44
  assert.match(text, /How to continue/);
45
45
  assert.match(text, /npx --yes @jpkme\/diff-review validate/);
46
46
  assert.match(text, /same file/);
47
+ assert.match(text, /generatedAt/);
47
48
  });
@@ -0,0 +1,162 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { afterEach, test } from "node:test";
6
+ import { extractBranchDiff, parseGitDiffFiles } from "./extract.mjs";
7
+ import { OUTPUT_DIR } from "./output-name.mjs";
8
+ import { companionPaths, ensureReviewDir, writeDiffText } from "./review-dir.mjs";
9
+ import { checkReportFile } from "./check-report.mjs";
10
+
11
+ const sample = [
12
+ "diff --git a/src/a.ts b/src/a.ts",
13
+ "index 111..222 100644",
14
+ "--- a/src/a.ts",
15
+ "+++ b/src/a.ts",
16
+ "@@ -1,2 +1,3 @@",
17
+ " keep",
18
+ "-old",
19
+ "+new",
20
+ "+extra",
21
+ "diff --git a/package-lock.json b/package-lock.json",
22
+ "index 1..2 100644",
23
+ "--- a/package-lock.json",
24
+ "+++ b/package-lock.json",
25
+ "@@ -1 +1 @@",
26
+ "-{}",
27
+ "+{ }",
28
+ "diff --git a/old.ts b/new.ts",
29
+ "similarity index 90%",
30
+ "rename from old.ts",
31
+ "rename to new.ts",
32
+ "index 1..2 100644",
33
+ "--- a/old.ts",
34
+ "+++ b/new.ts",
35
+ "@@ -1 +1 @@",
36
+ "-x",
37
+ "+y",
38
+ ].join("\n");
39
+
40
+ const dirs = [];
41
+ afterEach(() => {
42
+ for (const d of dirs.splice(0)) {
43
+ try {
44
+ rmSync(d, { recursive: true, force: true });
45
+ } catch {
46
+ /* ignore */
47
+ }
48
+ }
49
+ });
50
+
51
+ test("parseGitDiffFiles splits paths and change types", () => {
52
+ const files = parseGitDiffFiles(sample);
53
+ assert.equal(files.length, 3);
54
+ assert.equal(files[0].path, "src/a.ts");
55
+ assert.equal(files[0].changeType, "modified");
56
+ assert.match(files[0].hunkDiff, /^@@/);
57
+ assert.equal(files[1].path, "package-lock.json");
58
+ assert.equal(files[2].changeType, "renamed");
59
+ assert.equal(files[2].path, "new.ts");
60
+ assert.equal(files[2].oldPath, "old.ts");
61
+ });
62
+
63
+ test("extractBranchDiff with cachedDiff applies DEFAULT_IGNORE", () => {
64
+ const repo = mkdtempSync(join(tmpdir(), "diff-review-cache-"));
65
+ dirs.push(repo);
66
+ mkdirSync(join(repo, "src"), { recursive: true });
67
+ // repo path only used for config/guidelines; no git needed when cachedDiff is set
68
+ const skeleton = extractBranchDiff({
69
+ repo,
70
+ branch: "feature",
71
+ base: "develop",
72
+ cachedDiff: sample,
73
+ });
74
+ assert.deepEqual(
75
+ skeleton.files.map((f) => f.path).sort(),
76
+ ["new.ts", "src/a.ts"]
77
+ );
78
+ assert.ok(skeleton.context.ignored.includes("package-lock.json"));
79
+ assert.match(skeleton.files.find((f) => f.path === "src/a.ts").diff, /^@@/);
80
+ });
81
+
82
+ test("checkReportFile reuses companion .diff paths without inventing coverage", () => {
83
+ const repo = mkdtempSync(join(tmpdir(), "diff-review-check-"));
84
+ dirs.push(repo);
85
+ ensureReviewDir(repo);
86
+ const filename = "diff-rev_aaaaaaaaaa_bbbbbbbbbb.json";
87
+ writeDiffText(repo, filename, sample);
88
+ const payload = {
89
+ meta: {
90
+ agent: "Cursor",
91
+ model: "composer",
92
+ repo,
93
+ branch: "feature",
94
+ base: "develop",
95
+ },
96
+ intent: "Cambia src/a.ts y renombra old.ts para dejar el módulo listo en la rama.",
97
+ groups: [{ id: "g1", kind: "feat", title: "Ajuste", intent: "base" }],
98
+ blocks: [
99
+ {
100
+ id: "b1",
101
+ group: "g1",
102
+ file: "src/a.ts",
103
+ lines: "L1-3",
104
+ op: "mod",
105
+ what: "cambia la lógica",
106
+ why: "hace falta",
107
+ source: "code",
108
+ },
109
+ {
110
+ id: "b2",
111
+ group: "g1",
112
+ file: "new.ts",
113
+ lines: "L1",
114
+ op: "rename",
115
+ what: "renombra el módulo",
116
+ why: "nombre más claro",
117
+ source: "code",
118
+ },
119
+ ],
120
+ findings: [],
121
+ skipped: [{ file: "package-lock.json", reason: "lockfile" }],
122
+ };
123
+ const jsonPath = companionPaths(repo, filename).json;
124
+ writeFileSync(jsonPath, JSON.stringify(payload));
125
+ // Sin git: loadBranchDiff lee el .diff y aplica ignore (lockfile fuera de paths).
126
+ const result = checkReportFile(jsonPath, { repo, branch: "feature", base: "develop" });
127
+ assert.equal(result.ok, true, result.errors.join("; "));
128
+ });
129
+
130
+ test("buildPrompt with precomputed context skips merge-base rediscovery", async () => {
131
+ const { buildPrompt } = await import("./prompt.mjs");
132
+ const text = buildPrompt(
133
+ { source: "local", repo: "/tmp/r", branch: "feat", base: "develop" },
134
+ {
135
+ outputFilename: "diff-rev_aaaaaaaaaa_bbbbbbbbbb.json",
136
+ diffRelPath: ".diff-review/diff-rev_aaaaaaaaaa_bbbbbbbbbb.diff",
137
+ mergeBase: "abc123",
138
+ branchSha: "def456",
139
+ baseSha: "789abc",
140
+ files: [{ path: "src/a.ts", changeType: "modified" }],
141
+ ignored: ["package-lock.json"],
142
+ ignoreGlobs: ["**/package-lock.json"],
143
+ }
144
+ );
145
+ assert.match(text, /Precomputed git context/);
146
+ assert.match(text, /diff-rev_aaaaaaaaaa_bbbbbbbbbb\.diff/);
147
+ assert.match(text, /Merge-base SHA: `abc123`/);
148
+ assert.match(text, /modified: src\/a\.ts/);
149
+ assert.doesNotMatch(text, /Resolve the merge-base/);
150
+ assert.doesNotMatch(text, /Run exactly: `git diff/);
151
+ });
152
+
153
+ test("buildPrompt acp omits validate CLI", async () => {
154
+ const { buildPrompt, validateCliCommand } = await import("./prompt.mjs");
155
+ const filename = "diff-rev_aaaaaaaaaa_bbbbbbbbbb.json";
156
+ const text = buildPrompt(
157
+ { source: "local", repo: "/tmp/r", branch: "feat", base: "develop" },
158
+ { outputFilename: filename, acp: true, diffRelPath: `.${OUTPUT_DIR}/${filename.replace(/\.json$/, ".diff")}` }
159
+ );
160
+ assert.doesNotMatch(text, new RegExp(validateCliCommand(filename).replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
161
+ assert.match(text, /host validates/i);
162
+ });