@jpkme/diff-review 0.1.3 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/README.md +22 -6
  2. package/bin/diff-review.mjs +328 -18
  3. package/lib/autocomplete-es.mjs +143 -0
  4. package/lib/clipboard.mjs +29 -0
  5. package/lib/config.mjs +233 -0
  6. package/lib/config.test.mjs +73 -0
  7. package/lib/extract.mjs +111 -0
  8. package/lib/git.mjs +202 -0
  9. package/lib/git.test.mjs +29 -0
  10. package/lib/json-payload.mjs +31 -0
  11. package/lib/json-payload.test.mjs +22 -0
  12. package/lib/output-name.mjs +44 -0
  13. package/lib/output-name.test.mjs +37 -0
  14. package/lib/pick-folder.mjs +4 -0
  15. package/lib/prompt.mjs +160 -0
  16. package/lib/remote-repo.mjs +182 -0
  17. package/lib/review.mjs +228 -0
  18. package/lib/review.test.mjs +160 -0
  19. package/lib/validate.mjs +221 -0
  20. package/lib/validate.test.mjs +85 -0
  21. package/lib/wait-output.mjs +104 -0
  22. package/package.json +4 -2
  23. package/schema.json +12 -0
  24. package/web/build/client/_app/immutable/assets/2.Xnfq26gJ.css +1 -0
  25. package/web/build/client/_app/immutable/assets/2.Xnfq26gJ.css.br +0 -0
  26. package/web/build/client/_app/immutable/assets/2.Xnfq26gJ.css.gz +0 -0
  27. package/web/build/client/_app/immutable/chunks/CO0mUwMJ.js +1 -0
  28. package/web/build/client/_app/immutable/chunks/CO0mUwMJ.js.br +2 -0
  29. package/web/build/client/_app/immutable/chunks/CO0mUwMJ.js.gz +0 -0
  30. package/web/build/client/_app/immutable/chunks/{C5moC2DW.js → CdHZDAAo.js} +42 -32
  31. package/web/build/client/_app/immutable/chunks/CdHZDAAo.js.br +0 -0
  32. package/web/build/client/_app/immutable/chunks/CdHZDAAo.js.gz +0 -0
  33. package/web/build/client/_app/immutable/chunks/DzqTpz7_.js +2 -0
  34. package/web/build/client/_app/immutable/chunks/DzqTpz7_.js.br +0 -0
  35. package/web/build/client/_app/immutable/chunks/DzqTpz7_.js.gz +0 -0
  36. package/web/build/client/_app/immutable/entry/{app.Dei8qxo7.js → app.BRzz8J51.js} +2 -2
  37. package/web/build/client/_app/immutable/entry/app.BRzz8J51.js.br +0 -0
  38. package/web/build/client/_app/immutable/entry/app.BRzz8J51.js.gz +0 -0
  39. package/web/build/client/_app/immutable/entry/start.BSioRkqK.js +1 -0
  40. package/web/build/client/_app/immutable/entry/start.BSioRkqK.js.br +0 -0
  41. package/web/build/client/_app/immutable/entry/start.BSioRkqK.js.gz +0 -0
  42. package/web/build/client/_app/immutable/nodes/1.ws8Khan5.js +1 -0
  43. package/web/build/client/_app/immutable/nodes/1.ws8Khan5.js.br +1 -0
  44. package/web/build/client/_app/immutable/nodes/1.ws8Khan5.js.gz +0 -0
  45. package/web/build/client/_app/immutable/nodes/2.CsD5xqRB.js +66 -0
  46. package/web/build/client/_app/immutable/nodes/2.CsD5xqRB.js.br +0 -0
  47. package/web/build/client/_app/immutable/nodes/2.CsD5xqRB.js.gz +0 -0
  48. package/web/build/client/_app/immutable/nodes/3.ByTmpJQZ.js +170 -0
  49. package/web/build/client/_app/immutable/nodes/3.ByTmpJQZ.js.br +0 -0
  50. package/web/build/client/_app/immutable/nodes/3.ByTmpJQZ.js.gz +0 -0
  51. package/web/build/client/_app/version.json +1 -1
  52. package/web/build/client/_app/version.json.br +0 -0
  53. package/web/build/client/_app/version.json.gz +0 -0
  54. package/web/build/handler.js +3 -3
  55. package/web/build/index.js +3 -3
  56. package/web/build/server/chunks/1-qrn24yN5.js +7 -0
  57. package/web/build/server/chunks/1-qrn24yN5.js.map +1 -0
  58. package/web/build/server/chunks/2-C7_Loqyn.js +7 -0
  59. package/web/build/server/chunks/2-C7_Loqyn.js.map +1 -0
  60. package/web/build/server/chunks/{3-Zc5_qwgV.js → 3-DZ5PBhOc.js} +2 -2
  61. package/web/build/server/chunks/{3-Zc5_qwgV.js.map → 3-DZ5PBhOc.js.map} +1 -1
  62. package/web/build/server/chunks/{handler-BmuqxCCf.js → handler-DD4vP3bU.js} +3 -3
  63. package/web/build/server/chunks/{handler-BmuqxCCf.js.map → handler-DD4vP3bU.js.map} +1 -1
  64. package/web/build/server/chunks/{index.js-41EcOndB.js → index.js-IhFPHwmp.js} +150 -150
  65. package/web/build/server/chunks/index.js-IhFPHwmp.js.map +1 -0
  66. package/web/build/server/chunks/{manifest.js-KnmnjP9i.js → manifest.js-Dk3RuAA5.js} +6 -6
  67. package/web/build/server/chunks/{manifest.js-KnmnjP9i.js.map → manifest.js-Dk3RuAA5.js.map} +1 -1
  68. package/web/build/server/chunks/{remote-1g4ayag-BgUNMUS8.js → remote-1g4ayag-BRL-uaKE.js} +632 -146
  69. package/web/build/server/chunks/remote-1g4ayag-BRL-uaKE.js.map +1 -0
  70. package/web/build/client/_app/immutable/assets/2.Du8E-7-D.css +0 -1
  71. package/web/build/client/_app/immutable/assets/2.Du8E-7-D.css.br +0 -0
  72. package/web/build/client/_app/immutable/assets/2.Du8E-7-D.css.gz +0 -0
  73. package/web/build/client/_app/immutable/chunks/C5moC2DW.js.br +0 -0
  74. package/web/build/client/_app/immutable/chunks/C5moC2DW.js.gz +0 -0
  75. package/web/build/client/_app/immutable/chunks/WxPrACSs.js +0 -2
  76. package/web/build/client/_app/immutable/chunks/WxPrACSs.js.br +0 -0
  77. package/web/build/client/_app/immutable/chunks/WxPrACSs.js.gz +0 -0
  78. package/web/build/client/_app/immutable/entry/app.Dei8qxo7.js.br +0 -0
  79. package/web/build/client/_app/immutable/entry/app.Dei8qxo7.js.gz +0 -0
  80. package/web/build/client/_app/immutable/entry/start.Fzxrn2l4.js +0 -1
  81. package/web/build/client/_app/immutable/entry/start.Fzxrn2l4.js.br +0 -1
  82. package/web/build/client/_app/immutable/entry/start.Fzxrn2l4.js.gz +0 -0
  83. package/web/build/client/_app/immutable/nodes/1.Cn_iktkl.js +0 -1
  84. package/web/build/client/_app/immutable/nodes/1.Cn_iktkl.js.br +0 -0
  85. package/web/build/client/_app/immutable/nodes/1.Cn_iktkl.js.gz +0 -0
  86. package/web/build/client/_app/immutable/nodes/2.vvYFHTy-.js +0 -66
  87. package/web/build/client/_app/immutable/nodes/2.vvYFHTy-.js.br +0 -0
  88. package/web/build/client/_app/immutable/nodes/2.vvYFHTy-.js.gz +0 -0
  89. package/web/build/client/_app/immutable/nodes/3.CeJ7dEsh.js +0 -170
  90. package/web/build/client/_app/immutable/nodes/3.CeJ7dEsh.js.br +0 -0
  91. package/web/build/client/_app/immutable/nodes/3.CeJ7dEsh.js.gz +0 -0
  92. package/web/build/server/chunks/1-Bjgeu1Iz.js +0 -7
  93. package/web/build/server/chunks/1-Bjgeu1Iz.js.map +0 -1
  94. package/web/build/server/chunks/2-BvMZU9fP.js +0 -7
  95. package/web/build/server/chunks/2-BvMZU9fP.js.map +0 -1
  96. package/web/build/server/chunks/index.js-41EcOndB.js.map +0 -1
  97. package/web/build/server/chunks/remote-1g4ayag-BgUNMUS8.js.map +0 -1
@@ -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,44 @@
1
+ /**
2
+ * Nombre del JSON que escribe el agente.
3
+ * Preferido: tips cortos branch/base. Legacy (con stamp o sin tips) sigue válido al importar.
4
+ */
5
+ import { fingerprint } from "./git.mjs";
6
+
7
+ /** Prefijo del formato actual. */
8
+ export const OUTPUT_PREFIX = "diff-rev";
9
+
10
+ /** Prefijo legacy (sin tips / con stamp). */
11
+ export const OUTPUT_PREFIX_LEGACY = "diff-review-output";
12
+ export const OUTPUT_PREFIX_REPORT = "diff-report";
13
+
14
+ /** Nombre legacy fijo. */
15
+ export const OUTPUT_FILENAME_LEGACY = `${OUTPUT_PREFIX_LEGACY}.json`;
16
+
17
+ /**
18
+ * Válidos:
19
+ * - diff-rev_{branchTip}_{baseTip}.json
20
+ * - diff-report_{branchTip}-{baseTip}_YYYYMMDD-HHmmss.json (legacy)
21
+ * - diff-review-output.json / diff-review-output-YYYYMMDD-HHmmss.json (legacy)
22
+ */
23
+ export const OUTPUT_FILENAME_RE =
24
+ /^(?:diff-rev_[0-9a-f]{7,12}_[0-9a-f]{7,12}|diff-report_[0-9a-f]{7,12}-[0-9a-f]{7,12}_\d{8}-\d{6}|diff-review-output(?:-\d{8}-\d{6})?)\.json$/i;
25
+
26
+ /**
27
+ * @param {{ branchSha?: string, baseSha?: string }} [input]
28
+ * @returns {string}
29
+ * con tips: diff-rev_aaaaaaaaaa_bbbbbbbbbb.json
30
+ * sin tips: diff-review-output.json
31
+ */
32
+ export function makeOutputFilename(input = {}) {
33
+ const opts = input instanceof Date ? {} : input || {};
34
+ const fp = fingerprint(opts.branchSha, opts.baseSha);
35
+ if (fp) {
36
+ const [branchTip, baseTip] = fp.split("-");
37
+ if (branchTip && baseTip) return `${OUTPUT_PREFIX}_${branchTip}_${baseTip}.json`;
38
+ }
39
+ return OUTPUT_FILENAME_LEGACY;
40
+ }
41
+
42
+ export function isOutputFilename(name) {
43
+ return OUTPUT_FILENAME_RE.test(String(name || "").trim());
44
+ }
@@ -0,0 +1,37 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import {
4
+ isOutputFilename,
5
+ makeOutputFilename,
6
+ OUTPUT_FILENAME_LEGACY,
7
+ } from "./output-name.mjs";
8
+
9
+ describe("output-name", () => {
10
+ it("builds name with short tip hashes", () => {
11
+ const name = makeOutputFilename({
12
+ branchSha: "aaaaaaaaaa1111111111",
13
+ baseSha: "bbbbbbbbbb2222222222",
14
+ });
15
+ assert.equal(name, "diff-rev_aaaaaaaaaa_bbbbbbbbbb.json");
16
+ assert.equal(isOutputFilename(name), true);
17
+ });
18
+
19
+ it("falls back to legacy without tips", () => {
20
+ assert.equal(makeOutputFilename({}), OUTPUT_FILENAME_LEGACY);
21
+ assert.equal(isOutputFilename(OUTPUT_FILENAME_LEGACY), true);
22
+ });
23
+
24
+ it("accepts legacy stamped and report names", () => {
25
+ assert.equal(isOutputFilename("diff-review-output-20260915-143045.json"), true);
26
+ assert.equal(
27
+ isOutputFilename("diff-report_aaaaaaaaaa-bbbbbbbbbb_20260915-143045.json"),
28
+ true
29
+ );
30
+ });
31
+
32
+ it("rejects other names", () => {
33
+ assert.equal(isOutputFilename("review.json"), false);
34
+ assert.equal(isOutputFilename("diff-review-output-foo.json"), false);
35
+ assert.equal(isOutputFilename("diff-rev_short_tips.json"), false);
36
+ });
37
+ });
@@ -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,160 @@
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
+ isOutputFilename,
8
+ OUTPUT_FILENAME_LEGACY,
9
+ } from "./output-name.mjs";
10
+
11
+ export {
12
+ makeOutputFilename,
13
+ isOutputFilename,
14
+ OUTPUT_FILENAME_LEGACY,
15
+ };
16
+
17
+ export const OUTPUT_SCHEMA_BLOCK = `{
18
+ "meta": {
19
+ "source": "local|url",
20
+ "repo": "<absolute path or git URL>",
21
+ "branch": "<branch under review>",
22
+ "base": "develop",
23
+ "remoteUrl": "<origin URL; optional when source=url>",
24
+ "generatedAt": "<ISO-8601 datetime; optional, informational>",
25
+ "agent": "<tool name e.g. Claude Code / Cursor; optional, informational>",
26
+ "model": "<model id e.g. claude-opus-…; optional, informational>"
27
+ },
28
+ "intent": "string",
29
+ "groups": [
30
+ {
31
+ "id": "g1",
32
+ "kind": "feat|fix|refactor|perf|test|chore|docs|infra",
33
+ "title": "string ≤72 chars, imperative, no kind prefix",
34
+ "intent": "string"
35
+ }
36
+ ],
37
+ "blocks": [
38
+ {
39
+ "id": "b1",
40
+ "group": "g1",
41
+ "file": "relative/path.ext",
42
+ "lines": "L40-58",
43
+ "side": "new|old",
44
+ "start": 40,
45
+ "end": 58,
46
+ "op": "add|mod|del|rename",
47
+ "what": "string",
48
+ "why": "string",
49
+ "source": "code|commit|pr|issue|inferred"
50
+ }
51
+ ],
52
+ "findings": [
53
+ {
54
+ "id": "f1",
55
+ "class": "risk|quality",
56
+ "severity": "high|med|low|nit",
57
+ "blocking": false,
58
+ "kind": "bug|race|auth|security|api-break|missing-test|perf|maintainability|docs|other",
59
+ "file": "relative/path.ext",
60
+ "line": 87,
61
+ "block": "b1",
62
+ "what": "string",
63
+ "fix": "string"
64
+ }
65
+ ],
66
+ "skipped": [
67
+ { "file": "relative/path-or-glob", "reason": "generated|lockfile|format|vendored|binary|trivial|ignored" }
68
+ ],
69
+ "notes": ["optional short limitations"]
70
+ }`;
71
+
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
+ - 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.
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
+ - 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.
78
+ - "blocking": true ONLY if the branch should not merge as-is. Use only with class "risk".
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
+
81
+ function workflowLocal(base, branch) {
82
+ return `Workflow:
83
+ 1. Resolve the merge-base of \`${base}\` and \`${branch}\`.
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.`;
87
+ }
88
+
89
+ function workflowUrl(base, branch) {
90
+ return `Workflow:
91
+ 1. If the repo is not local, clone it (or use whatever access you have). Do not modify it.
92
+ 2. Resolve the merge-base of \`${base}\` and \`${branch}\`.
93
+ 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.`;
96
+ }
97
+
98
+ /**
99
+ * @param {{ source?: string, repo?: string, branch?: string, base?: string, remoteUrl?: string }} meta
100
+ */
101
+ export function outputSchemaBlock(meta) {
102
+ const source = meta.source === "url" ? "url" : "local";
103
+ const repo =
104
+ String(meta.repo || "").trim() || (source === "url" ? "<git URL>" : "<absolute repo path>");
105
+ const branch = String(meta.branch || "").trim() || "<branch under review>";
106
+ const base = String(meta.base || "").trim() || "develop";
107
+ const remoteUrl = String(meta.remoteUrl || "").trim() || (source === "url" ? repo : "");
108
+ const metaLines = [
109
+ ` "source": ${JSON.stringify(source)},`,
110
+ ` "repo": ${JSON.stringify(repo)},`,
111
+ ` "branch": ${JSON.stringify(branch)},`,
112
+ ` "base": ${JSON.stringify(base)},`,
113
+ ];
114
+ if (remoteUrl) metaLines.push(` "remoteUrl": ${JSON.stringify(remoteUrl)},`);
115
+ metaLines.push(
116
+ ` "generatedAt": "<ISO-8601 datetime>",`,
117
+ ` "agent": "<tool name, e.g. Claude Code / Cursor>",`,
118
+ ` "model": "<model id>"`
119
+ );
120
+ const metaBlock = `"meta": {\n${metaLines.join("\n")}\n }`;
121
+ return OUTPUT_SCHEMA_BLOCK.replace(/"meta": \{[\s\S]*?\n \}/, metaBlock);
122
+ }
123
+
124
+ /**
125
+ * @param {{ source?: string, repo?: string, branch?: string, base?: string, remoteUrl?: string }} meta
126
+ * @param {{ outputFilename?: string }} [opts]
127
+ */
128
+ export function buildPrompt(meta, opts = {}) {
129
+ const outputFilename = opts.outputFilename || makeOutputFilename();
130
+ const repo =
131
+ String(meta.repo || "").trim() ||
132
+ (meta.source === "url" ? "<git URL>" : "<absolute repo path>");
133
+ const branch = String(meta.branch || "").trim() || "<branch under review>";
134
+ const base = String(meta.base || "").trim() || "develop";
135
+ const workflow = meta.source === "url" ? workflowUrl(base, branch) : workflowLocal(base, branch);
136
+
137
+ return `You are a senior code reviewer: strict but fair.
138
+
139
+ Repo: ${repo}
140
+ Branch under review: ${branch}
141
+ Base (merge-base): ${base}
142
+
143
+ ${workflow}
144
+
145
+ Rules:
146
+ ${RULES}
147
+
148
+ Write the result to \`${outputFilename}\` at the repo root.
149
+ - One JSON object only: first character \`{\`, last character \`}\`.
150
+ - No prose before/after, no markdown fences.
151
+ - Shape (copy "meta" source/repo/branch/base/remoteUrl as given; fill generatedAt/agent/model):
152
+
153
+ ${outputSchemaBlock(meta)}
154
+
155
+ 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
+
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.
158
+
159
+ When done, tell me that \`${outputFilename}\` was written.`;
160
+ }
@@ -0,0 +1,182 @@
1
+ import { createHash } from "node:crypto";
2
+ import { execFileSync } from "node:child_process";
3
+ import { existsSync, mkdirSync, rmSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { tmpdir } from "node:os";
6
+ import { git } from "./git.mjs";
7
+
8
+ const CACHE_ROOT = join(tmpdir(), "diff-review-clones");
9
+ const MAX_DEEPEN_ROUNDS = 8;
10
+ const DEEPEN_BY = "50";
11
+
12
+ export function looksLikeGitUrl(value) {
13
+ const v = String(value || "").trim();
14
+ if (!v) return false;
15
+ if (/^(https?:\/\/|git@|ssh:\/\/)/i.test(v)) return true;
16
+ if (/\.git$/i.test(v) && !/^[A-Za-z]:/.test(v) && !v.startsWith("/") && !v.startsWith("\\")) {
17
+ return true;
18
+ }
19
+ return false;
20
+ }
21
+
22
+ export function normalizeGitUrl(url) {
23
+ const raw = String(url || "").trim();
24
+ if (!raw) throw new Error("Indicá la URL del repositorio.");
25
+ if (!looksLikeGitUrl(raw)) {
26
+ throw new Error(
27
+ `"${raw}" no parece una URL git. Usá https://…, git@… o ssh://…`,
28
+ );
29
+ }
30
+ if (/^(file:|javascript:|data:)/i.test(raw)) {
31
+ throw new Error("Esquema de URL no permitido.");
32
+ }
33
+ return raw.replace(/\/$/, "");
34
+ }
35
+
36
+ function cacheDirFor(url) {
37
+ const key = createHash("sha1").update(url).digest("hex").slice(0, 16);
38
+ return join(CACHE_ROOT, key);
39
+ }
40
+
41
+ function runGit(args, { cwd, timeout = 5 * 60 * 1000 } = {}) {
42
+ return execFileSync("git", args, {
43
+ cwd,
44
+ encoding: "utf8",
45
+ windowsHide: true,
46
+ timeout,
47
+ maxBuffer: 64 * 1024 * 1024,
48
+ });
49
+ }
50
+
51
+ /** Lista heads del remote sin clonar. */
52
+ export function listRemoteBranches(url) {
53
+ const remote = normalizeGitUrl(url);
54
+ let raw;
55
+ try {
56
+ raw = runGit(["ls-remote", "--heads", remote]);
57
+ } catch (err) {
58
+ const stderr = String(/** @type {{ stderr?: string }} */ (err).stderr || err.message || err);
59
+ throw new Error("No se pudo listar branches del remote: " + stderr.split("\n").find(Boolean));
60
+ }
61
+
62
+ const branches = [
63
+ ...new Set(
64
+ raw
65
+ .split("\n")
66
+ .map((line) => line.trim())
67
+ .filter(Boolean)
68
+ .map((line) => {
69
+ const ref = line.split("\t")[1] || "";
70
+ return ref.replace(/^refs\/heads\//, "");
71
+ })
72
+ .filter(Boolean),
73
+ ),
74
+ ].sort((a, b) => a.localeCompare(b));
75
+
76
+ if (!branches.length) {
77
+ throw new Error("El remote no expone branches (refs/heads).");
78
+ }
79
+
80
+ const defaultBase = branches.includes("develop")
81
+ ? "develop"
82
+ : branches.includes("main")
83
+ ? "main"
84
+ : branches.includes("master")
85
+ ? "master"
86
+ : branches[0];
87
+
88
+ return { branches, current: "", defaultBase };
89
+ }
90
+
91
+ function fetchRef(repo, ref) {
92
+ const name = String(ref || "").trim();
93
+ if (!name) return;
94
+ try {
95
+ runGit(["rev-parse", "--verify", name], { cwd: repo });
96
+ return;
97
+ } catch {
98
+ /* falta localmente */
99
+ }
100
+ runGit(
101
+ ["fetch", "--depth", DEEPEN_BY, "origin", `refs/heads/${name}:refs/heads/${name}`],
102
+ { cwd: repo },
103
+ );
104
+ }
105
+
106
+ function deepenUntilMergeBase(repo, base, branch) {
107
+ for (let i = 0; i < MAX_DEEPEN_ROUNDS; i++) {
108
+ try {
109
+ git(repo, ["merge-base", base, branch]);
110
+ return;
111
+ } catch {
112
+ runGit(
113
+ [
114
+ "fetch",
115
+ "--deepen",
116
+ DEEPEN_BY,
117
+ "origin",
118
+ `refs/heads/${base}:refs/heads/${base}`,
119
+ `refs/heads/${branch}:refs/heads/${branch}`,
120
+ ],
121
+ { cwd: repo },
122
+ );
123
+ }
124
+ }
125
+ throw new Error(
126
+ `No se encontró merge-base entre "${base}" y "${branch}" (historial shallow insuficiente). Probá con más historia o usá una carpeta local.`,
127
+ );
128
+ }
129
+
130
+ /**
131
+ * Asegura un worktree local (cache en tmp) con branch y base disponibles para diff.
132
+ * @returns {string} ruta absoluta del clone
133
+ */
134
+ export function ensureRemoteWorktree(url, branch, base = "develop") {
135
+ const remote = normalizeGitUrl(url);
136
+ const b = String(branch || "").trim();
137
+ const baseRef = String(base || "develop").trim() || "develop";
138
+ if (!b) throw new Error("Falta el branch a revisar.");
139
+
140
+ mkdirSync(CACHE_ROOT, { recursive: true });
141
+ const dir = cacheDirFor(remote);
142
+ const gitDir = join(dir, ".git");
143
+
144
+ if (!existsSync(gitDir)) {
145
+ if (existsSync(dir)) rmSync(dir, { recursive: true, force: true });
146
+ try {
147
+ runGit(
148
+ [
149
+ "clone",
150
+ "--filter=blob:none",
151
+ "--depth",
152
+ DEEPEN_BY,
153
+ "--branch",
154
+ b,
155
+ remote,
156
+ dir,
157
+ ],
158
+ { timeout: 10 * 60 * 1000 },
159
+ );
160
+ } catch (err) {
161
+ if (existsSync(dir)) rmSync(dir, { recursive: true, force: true });
162
+ const stderr = String(/** @type {{ stderr?: string }} */ (err).stderr || err.message || err);
163
+ throw new Error("No se pudo clonar el repo: " + stderr.split("\n").find(Boolean));
164
+ }
165
+ } else {
166
+ try {
167
+ runGit(["remote", "set-url", "origin", remote], { cwd: dir });
168
+ } catch {
169
+ /* remote ya ok */
170
+ }
171
+ fetchRef(dir, b);
172
+ try {
173
+ runGit(["checkout", "--force", b], { cwd: dir });
174
+ } catch {
175
+ /* detached / already on branch */
176
+ }
177
+ }
178
+
179
+ if (baseRef !== b) fetchRef(dir, baseRef);
180
+ deepenUntilMergeBase(dir, baseRef, b);
181
+ return dir;
182
+ }
package/lib/review.mjs ADDED
@@ -0,0 +1,228 @@
1
+ import {
2
+ BLOCK_OPS,
3
+ FINDING_CLASSES,
4
+ FINDING_KINDS,
5
+ FINDING_SEVERITIES,
6
+ GROUP_KINDS,
7
+ SKIP_REASONS,
8
+ WHY_SOURCES,
9
+ } from "./validate.mjs";
10
+
11
+ const MAX = {
12
+ intent: 700,
13
+ groupTitle: 100,
14
+ groupIntent: 300,
15
+ what: 240,
16
+ why: 240,
17
+ finding: 360,
18
+ };
19
+
20
+ function text(value, max) {
21
+ const raw = String(value ?? "").replace(/\s+/g, " ").trim();
22
+ return raw.length > max ? `${raw.slice(0, max - 1).trimEnd()}…` : raw;
23
+ }
24
+
25
+ function oneOf(value, allowed, fallback) {
26
+ const raw = String(value ?? "").trim().toLowerCase();
27
+ return allowed.includes(raw) ? raw : fallback;
28
+ }
29
+
30
+ /**
31
+ * Rango de líneas de un bloque. `L40-58` es post-imagen (lo que quedó);
32
+ * `-L88-95` es pre-imagen, que es la única forma de señalar líneas borradas.
33
+ */
34
+ export function parseLineRange(value) {
35
+ const raw = String(value ?? "").trim();
36
+ const match = /^(-)?L?(\d+)(?:\s*[-–]\s*L?(\d+))?$/i.exec(raw);
37
+ if (!match) return null;
38
+ const start = Number(match[2]);
39
+ const end = match[3] ? Number(match[3]) : start;
40
+ return {
41
+ side: match[1] ? "old" : "new",
42
+ start: Math.min(start, end),
43
+ end: Math.max(start, end),
44
+ };
45
+ }
46
+
47
+ export function formatLineRange(range) {
48
+ if (!range) return "";
49
+ const prefix = range.side === "old" ? "-L" : "L";
50
+ return range.start === range.end ? `${prefix}${range.start}` : `${prefix}${range.start}-${range.end}`;
51
+ }
52
+
53
+ export function countHunks(files) {
54
+ let total = 0;
55
+ for (const file of files || []) {
56
+ for (const line of String(file.diff || "").split("\n")) {
57
+ if (line.startsWith("@@")) total += 1;
58
+ }
59
+ }
60
+ return total;
61
+ }
62
+
63
+ function normalizeGroups(raw) {
64
+ const seen = new Set();
65
+ const groups = [];
66
+ for (const item of raw || []) {
67
+ const id = String(item?.id || "").trim();
68
+ if (!id || seen.has(id)) continue;
69
+ seen.add(id);
70
+ groups.push({
71
+ id,
72
+ kind: oneOf(item.kind, GROUP_KINDS, "chore"),
73
+ title: text(item.title, MAX.groupTitle) || `${oneOf(item.kind, GROUP_KINDS, "chore")}: cambios`,
74
+ intent: text(item.intent, MAX.groupIntent),
75
+ });
76
+ }
77
+ return groups;
78
+ }
79
+
80
+ function normalizeBlocks(raw, { groupIds, knownPaths }) {
81
+ const seen = new Set();
82
+ const blocks = [];
83
+ for (const item of raw || []) {
84
+ const id = String(item?.id || "").trim();
85
+ const group = String(item?.group || "").trim();
86
+ const file = String(item?.file || "").trim();
87
+ if (!id || seen.has(id)) continue;
88
+ if (!groupIds.has(group)) continue;
89
+ if (knownPaths.size && !knownPaths.has(file)) continue;
90
+ const range = parseLineRange(item.lines);
91
+ seen.add(id);
92
+ blocks.push({
93
+ id,
94
+ group,
95
+ file,
96
+ lines: range ? formatLineRange(range) : text(item.lines, 24),
97
+ side: range?.side || "new",
98
+ start: range?.start ?? 0,
99
+ end: range?.end ?? 0,
100
+ op: oneOf(item.op, BLOCK_OPS, "mod"),
101
+ what: text(item.what, MAX.what),
102
+ why: text(item.why, MAX.why),
103
+ source: oneOf(item.source, WHY_SOURCES, "inferred"),
104
+ });
105
+ }
106
+ blocks.sort((a, b) => a.file.localeCompare(b.file) || a.start - b.start);
107
+ return blocks;
108
+ }
109
+
110
+ function findingKey(finding) {
111
+ return [finding.file, finding.line ?? "", finding.kind, finding.what].join("|");
112
+ }
113
+
114
+ function normalizeFindings(raw, { blockIds, knownPaths }) {
115
+ const seen = new Set();
116
+ const findings = [];
117
+ for (const item of raw || []) {
118
+ const file = String(item?.file || item?.path || "").trim();
119
+ const what = text(item?.what || item?.title, MAX.finding);
120
+ if (!file || !what) continue;
121
+ if (knownPaths.size && !knownPaths.has(file)) continue;
122
+ const cls = oneOf(item.class, FINDING_CLASSES, "risk");
123
+ const severity = oneOf(item.severity, FINDING_SEVERITIES, "med");
124
+ const block = String(item?.block || "").trim();
125
+ const line = Number.isFinite(Number(item?.line)) ? Number(item.line) : null;
126
+ const finding = {
127
+ id: "",
128
+ class: cls,
129
+ severity,
130
+ // Una mejora de calidad nunca frena un merge, diga lo que diga el modelo;
131
+ // un riesgo alto sí, aunque el modelo se haya olvidado de marcarlo.
132
+ blocking: cls === "risk" && (item?.blocking === true || severity === "high"),
133
+ kind: oneOf(item.kind, FINDING_KINDS, "other"),
134
+ file,
135
+ line,
136
+ block: blockIds.has(block) ? block : "",
137
+ what,
138
+ fix: text(item.fix || item.detail, MAX.finding),
139
+ };
140
+ const key = findingKey(finding);
141
+ if (seen.has(key)) continue;
142
+ seen.add(key);
143
+ findings.push(finding);
144
+ }
145
+ const rank = { high: 0, med: 1, low: 2, nit: 3 };
146
+ findings.sort(
147
+ (a, b) =>
148
+ Number(b.blocking) - Number(a.blocking) ||
149
+ (a.class === b.class ? 0 : a.class === "risk" ? -1 : 1) ||
150
+ rank[a.severity] - rank[b.severity] ||
151
+ a.file.localeCompare(b.file),
152
+ );
153
+ findings.forEach((finding, i) => {
154
+ finding.id = `f${i + 1}`;
155
+ });
156
+ return findings;
157
+ }
158
+
159
+ function normalizeSkipped(raw, ignored) {
160
+ const byFile = new Map();
161
+ for (const path of ignored || []) {
162
+ byFile.set(String(path), { file: String(path), reason: "ignored" });
163
+ }
164
+ for (const item of raw || []) {
165
+ const file = String(item?.file || "").trim();
166
+ if (!file) continue;
167
+ byFile.set(file, { file, reason: oneOf(item.reason, SKIP_REASONS, "trivial") });
168
+ }
169
+ return [...byFile.values()];
170
+ }
171
+
172
+ /** Tema donde se dibuja el archivo: el que aporta más bloques, desempatando por orden de tema. */
173
+ export function dominantGroup(blocks, path, groupOrder) {
174
+ const tally = new Map();
175
+ for (const block of blocks) {
176
+ if (block.file !== path) continue;
177
+ tally.set(block.group, (tally.get(block.group) || 0) + 1);
178
+ }
179
+ if (!tally.size) return "";
180
+ return [...tally.entries()].sort(
181
+ (a, b) => b[1] - a[1] || groupOrder.indexOf(a[0]) - groupOrder.indexOf(b[0]),
182
+ )[0][0];
183
+ }
184
+
185
+ /**
186
+ * Documento final: lo que dijo el modelo, saneado y cruzado contra los archivos
187
+ * reales del diff. Todo lo que no cierra se descarta acá en vez de romper la UI.
188
+ */
189
+ export function buildReview({ skeleton, payload, notes = [] }) {
190
+ const files = skeleton.files || [];
191
+ const knownPaths = new Set(files.map((f) => f.path));
192
+
193
+ const groups = normalizeGroups(payload?.groups);
194
+ const groupIds = new Set(groups.map((g) => g.id));
195
+ const blocks = normalizeBlocks(payload?.blocks, { groupIds, knownPaths });
196
+ const blockIds = new Set(blocks.map((b) => b.id));
197
+ const findings = normalizeFindings(payload?.findings, { blockIds, knownPaths });
198
+ const skipped = normalizeSkipped(payload?.skipped, skeleton.context?.ignored);
199
+
200
+ const usedGroups = new Set(blocks.map((b) => b.group));
201
+ const liveGroups = groups.filter((g) => usedGroups.has(g.id));
202
+ const groupOrder = liveGroups.map((g) => g.id);
203
+
204
+ const skippedFiles = new Set(skipped.map((s) => s.file));
205
+ const orphans = files.filter((f) => !blocks.some((b) => b.file === f.path) && !skippedFiles.has(f.path));
206
+ if (orphans.length) {
207
+ notes = [...notes, `${orphans.length} archivo(s) quedaron sin explicación del modelo.`];
208
+ }
209
+
210
+ const decorated = files.map((file) => ({
211
+ ...file,
212
+ group: dominantGroup(blocks, file.path, groupOrder),
213
+ }));
214
+
215
+ return {
216
+ ...skeleton,
217
+ version: 2,
218
+ intent: text(payload?.intent, MAX.intent),
219
+ groups: liveGroups,
220
+ blocks,
221
+ findings,
222
+ skipped,
223
+ files: decorated,
224
+ stats: { ...skeleton.stats, hunks: countHunks(files) },
225
+ notes,
226
+ generatedAt: new Date().toISOString(),
227
+ };
228
+ }