@jpkme/diff-review 0.1.3 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -6
- package/bin/diff-review.mjs +187 -19
- package/lib/clipboard.mjs +29 -0
- package/lib/config.mjs +233 -0
- package/lib/config.test.mjs +73 -0
- package/lib/extract.mjs +111 -0
- package/lib/git.mjs +202 -0
- package/lib/git.test.mjs +29 -0
- package/lib/json-payload.mjs +31 -0
- package/lib/json-payload.test.mjs +22 -0
- package/lib/output-name.mjs +56 -0
- package/lib/output-name.test.mjs +54 -0
- package/lib/pick-folder.mjs +4 -0
- package/lib/prompt.mjs +154 -0
- package/lib/remote-repo.mjs +182 -0
- package/lib/review.mjs +228 -0
- package/lib/review.test.mjs +160 -0
- package/lib/validate.mjs +221 -0
- package/lib/validate.test.mjs +85 -0
- package/lib/wait-output.mjs +104 -0
- package/package.json +2 -1
- package/web/build/client/_app/immutable/assets/2.Xnfq26gJ.css +1 -0
- package/web/build/client/_app/immutable/assets/2.Xnfq26gJ.css.br +0 -0
- package/web/build/client/_app/immutable/assets/2.Xnfq26gJ.css.gz +0 -0
- package/web/build/client/_app/immutable/chunks/{C5moC2DW.js → BaYDuTXu.js} +36 -29
- package/web/build/client/_app/immutable/chunks/BaYDuTXu.js.br +0 -0
- package/web/build/client/_app/immutable/chunks/BaYDuTXu.js.gz +0 -0
- package/web/build/client/_app/immutable/chunks/CAnYe5db.js +1 -0
- package/web/build/client/_app/immutable/chunks/CAnYe5db.js.br +1 -0
- package/web/build/client/_app/immutable/chunks/CAnYe5db.js.gz +0 -0
- package/web/build/client/_app/immutable/chunks/Gya4IU1I.js +2 -0
- package/web/build/client/_app/immutable/chunks/Gya4IU1I.js.br +0 -0
- package/web/build/client/_app/immutable/chunks/Gya4IU1I.js.gz +0 -0
- package/web/build/client/_app/immutable/entry/{app.Dei8qxo7.js → app.e96uPVtl.js} +2 -2
- package/web/build/client/_app/immutable/entry/app.e96uPVtl.js.br +0 -0
- package/web/build/client/_app/immutable/entry/app.e96uPVtl.js.gz +0 -0
- package/web/build/client/_app/immutable/entry/start.D-D-7QPy.js +1 -0
- package/web/build/client/_app/immutable/entry/start.D-D-7QPy.js.br +0 -0
- package/web/build/client/_app/immutable/entry/start.D-D-7QPy.js.gz +0 -0
- package/web/build/client/_app/immutable/nodes/1.V-g5-sfL.js +1 -0
- package/web/build/client/_app/immutable/nodes/1.V-g5-sfL.js.br +0 -0
- package/web/build/client/_app/immutable/nodes/1.V-g5-sfL.js.gz +0 -0
- package/web/build/client/_app/immutable/nodes/2.lcRePHZ5.js +66 -0
- package/web/build/client/_app/immutable/nodes/2.lcRePHZ5.js.br +0 -0
- package/web/build/client/_app/immutable/nodes/2.lcRePHZ5.js.gz +0 -0
- package/web/build/client/_app/immutable/nodes/3.GZPWeD9I.js +170 -0
- package/web/build/client/_app/immutable/nodes/3.GZPWeD9I.js.br +0 -0
- package/web/build/client/_app/immutable/nodes/3.GZPWeD9I.js.gz +0 -0
- package/web/build/client/_app/version.json +1 -1
- package/web/build/client/_app/version.json.br +0 -0
- package/web/build/client/_app/version.json.gz +0 -0
- package/web/build/handler.js +3 -3
- package/web/build/index.js +3 -3
- package/web/build/server/chunks/1-DCKScL3A.js +7 -0
- package/web/build/server/chunks/1-DCKScL3A.js.map +1 -0
- package/web/build/server/chunks/2-BpBQQ02B.js +7 -0
- package/web/build/server/chunks/2-BpBQQ02B.js.map +1 -0
- package/web/build/server/chunks/{3-Zc5_qwgV.js → 3-rGkcNCVz.js} +2 -2
- package/web/build/server/chunks/{3-Zc5_qwgV.js.map → 3-rGkcNCVz.js.map} +1 -1
- package/web/build/server/chunks/{handler-BmuqxCCf.js → handler-BaT-QavL.js} +3 -3
- package/web/build/server/chunks/{handler-BmuqxCCf.js.map → handler-BaT-QavL.js.map} +1 -1
- package/web/build/server/chunks/{index.js-41EcOndB.js → index.js-C-K0YDBJ.js} +150 -150
- package/web/build/server/chunks/index.js-C-K0YDBJ.js.map +1 -0
- package/web/build/server/chunks/{manifest.js-KnmnjP9i.js → manifest.js-CDz-Fem4.js} +6 -6
- package/web/build/server/chunks/{manifest.js-KnmnjP9i.js.map → manifest.js-CDz-Fem4.js.map} +1 -1
- package/web/build/server/chunks/{remote-1g4ayag-BgUNMUS8.js → remote-1g4ayag-DXdFe1BG.js} +645 -146
- package/web/build/server/chunks/remote-1g4ayag-DXdFe1BG.js.map +1 -0
- package/web/build/client/_app/immutable/assets/2.Du8E-7-D.css +0 -1
- package/web/build/client/_app/immutable/assets/2.Du8E-7-D.css.br +0 -0
- package/web/build/client/_app/immutable/assets/2.Du8E-7-D.css.gz +0 -0
- package/web/build/client/_app/immutable/chunks/C5moC2DW.js.br +0 -0
- package/web/build/client/_app/immutable/chunks/C5moC2DW.js.gz +0 -0
- package/web/build/client/_app/immutable/chunks/WxPrACSs.js +0 -2
- package/web/build/client/_app/immutable/chunks/WxPrACSs.js.br +0 -0
- package/web/build/client/_app/immutable/chunks/WxPrACSs.js.gz +0 -0
- package/web/build/client/_app/immutable/entry/app.Dei8qxo7.js.br +0 -0
- package/web/build/client/_app/immutable/entry/app.Dei8qxo7.js.gz +0 -0
- package/web/build/client/_app/immutable/entry/start.Fzxrn2l4.js +0 -1
- package/web/build/client/_app/immutable/entry/start.Fzxrn2l4.js.br +0 -1
- package/web/build/client/_app/immutable/entry/start.Fzxrn2l4.js.gz +0 -0
- package/web/build/client/_app/immutable/nodes/1.Cn_iktkl.js +0 -1
- package/web/build/client/_app/immutable/nodes/1.Cn_iktkl.js.br +0 -0
- package/web/build/client/_app/immutable/nodes/1.Cn_iktkl.js.gz +0 -0
- package/web/build/client/_app/immutable/nodes/2.vvYFHTy-.js +0 -66
- package/web/build/client/_app/immutable/nodes/2.vvYFHTy-.js.br +0 -0
- package/web/build/client/_app/immutable/nodes/2.vvYFHTy-.js.gz +0 -0
- package/web/build/client/_app/immutable/nodes/3.CeJ7dEsh.js +0 -170
- package/web/build/client/_app/immutable/nodes/3.CeJ7dEsh.js.br +0 -0
- package/web/build/client/_app/immutable/nodes/3.CeJ7dEsh.js.gz +0 -0
- package/web/build/server/chunks/1-Bjgeu1Iz.js +0 -7
- package/web/build/server/chunks/1-Bjgeu1Iz.js.map +0 -1
- package/web/build/server/chunks/2-BvMZU9fP.js +0 -7
- package/web/build/server/chunks/2-BvMZU9fP.js.map +0 -1
- package/web/build/server/chunks/index.js-41EcOndB.js.map +0 -1
- package/web/build/server/chunks/remote-1g4ayag-BgUNMUS8.js.map +0 -1
package/lib/validate.mjs
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
export const GROUP_KINDS = ["feat", "fix", "refactor", "perf", "test", "chore", "docs", "infra"];
|
|
2
|
+
export const BLOCK_OPS = ["add", "mod", "del", "rename"];
|
|
3
|
+
export const WHY_SOURCES = ["code", "commit", "pr", "issue", "inferred"];
|
|
4
|
+
export const FINDING_CLASSES = ["risk", "quality"];
|
|
5
|
+
export const FINDING_SEVERITIES = ["high", "med", "low", "nit"];
|
|
6
|
+
export const FINDING_KINDS = [
|
|
7
|
+
"bug", "race", "auth", "security", "api-break",
|
|
8
|
+
"missing-test", "perf", "maintainability", "docs", "other",
|
|
9
|
+
];
|
|
10
|
+
export const SKIP_REASONS = ["generated", "lockfile", "format", "vendored", "binary", "trivial", "ignored"];
|
|
11
|
+
|
|
12
|
+
/** Lo que tiene que devolver el modelo en un turno. El resto lo agrega el pipeline. */
|
|
13
|
+
export const REVIEW_SCHEMA = {
|
|
14
|
+
$id: "diff-review",
|
|
15
|
+
type: "object",
|
|
16
|
+
required: ["intent", "groups", "blocks"],
|
|
17
|
+
additionalProperties: true,
|
|
18
|
+
properties: {
|
|
19
|
+
intent: { type: "string", minLength: 40 },
|
|
20
|
+
groups: {
|
|
21
|
+
type: "array",
|
|
22
|
+
minItems: 1,
|
|
23
|
+
items: {
|
|
24
|
+
type: "object",
|
|
25
|
+
required: ["id", "kind", "title", "intent"],
|
|
26
|
+
additionalProperties: true,
|
|
27
|
+
properties: {
|
|
28
|
+
id: { type: "string", minLength: 1 },
|
|
29
|
+
kind: { type: "string", enum: GROUP_KINDS },
|
|
30
|
+
title: { type: "string", minLength: 1 },
|
|
31
|
+
intent: { type: "string" },
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
blocks: {
|
|
36
|
+
type: "array",
|
|
37
|
+
minItems: 1,
|
|
38
|
+
items: {
|
|
39
|
+
type: "object",
|
|
40
|
+
required: ["id", "group", "file", "lines", "op", "what"],
|
|
41
|
+
additionalProperties: true,
|
|
42
|
+
properties: {
|
|
43
|
+
id: { type: "string", minLength: 1 },
|
|
44
|
+
group: { type: "string", minLength: 1 },
|
|
45
|
+
file: { type: "string", minLength: 1 },
|
|
46
|
+
lines: { type: "string", minLength: 1 },
|
|
47
|
+
op: { type: "string", enum: BLOCK_OPS },
|
|
48
|
+
what: { type: "string", minLength: 1, maxLength: 120 },
|
|
49
|
+
why: { type: "string", maxLength: 120 },
|
|
50
|
+
source: { type: "string", enum: WHY_SOURCES },
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
findings: {
|
|
55
|
+
type: "array",
|
|
56
|
+
items: {
|
|
57
|
+
type: "object",
|
|
58
|
+
required: ["class", "severity", "kind", "file", "what"],
|
|
59
|
+
additionalProperties: true,
|
|
60
|
+
properties: {
|
|
61
|
+
id: { type: "string" },
|
|
62
|
+
class: { type: "string", enum: FINDING_CLASSES },
|
|
63
|
+
severity: { type: "string", enum: FINDING_SEVERITIES },
|
|
64
|
+
blocking: { type: "boolean" },
|
|
65
|
+
kind: { type: "string", enum: FINDING_KINDS },
|
|
66
|
+
file: { type: "string", minLength: 1 },
|
|
67
|
+
line: { type: ["integer", "null"] },
|
|
68
|
+
block: { type: "string" },
|
|
69
|
+
what: { type: "string", minLength: 1, maxLength: 120 },
|
|
70
|
+
fix: { type: "string", maxLength: 120 },
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
skipped: {
|
|
75
|
+
type: "array",
|
|
76
|
+
items: {
|
|
77
|
+
type: "object",
|
|
78
|
+
required: ["file", "reason"],
|
|
79
|
+
additionalProperties: true,
|
|
80
|
+
properties: {
|
|
81
|
+
file: { type: "string", minLength: 1 },
|
|
82
|
+
reason: { type: "string", enum: SKIP_REASONS },
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
function typeOf(value) {
|
|
90
|
+
if (value === null) return "null";
|
|
91
|
+
if (Array.isArray(value)) return "array";
|
|
92
|
+
if (typeof value === "number" && Number.isInteger(value)) return "integer";
|
|
93
|
+
return typeof value;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function typeMatches(expected, actual) {
|
|
97
|
+
const options = Array.isArray(expected) ? expected : [expected];
|
|
98
|
+
if (options.includes(actual)) return true;
|
|
99
|
+
// TOON has no integer/number split; an integer is a valid number.
|
|
100
|
+
return options.includes("number") && actual === "integer";
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Minimal JSON Schema checker (type, required, properties, items, enum, minLength, minItems). */
|
|
104
|
+
export function validateAgainst(schema, data, path = "$") {
|
|
105
|
+
const errors = [];
|
|
106
|
+
if (!schema || typeof schema !== "object") return errors;
|
|
107
|
+
const actual = typeOf(data);
|
|
108
|
+
if (schema.type && !typeMatches(schema.type, actual)) {
|
|
109
|
+
errors.push(`${path}: se esperaba ${[].concat(schema.type).join("|")}, llegó ${actual}`);
|
|
110
|
+
return errors;
|
|
111
|
+
}
|
|
112
|
+
if (actual === "string" && schema.minLength && data.length < schema.minLength) {
|
|
113
|
+
errors.push(`${path}: texto demasiado corto (mínimo ${schema.minLength})`);
|
|
114
|
+
}
|
|
115
|
+
if (actual === "string" && schema.maxLength && data.length > schema.maxLength) {
|
|
116
|
+
errors.push(`${path}: texto demasiado largo (máximo ${schema.maxLength})`);
|
|
117
|
+
}
|
|
118
|
+
if (schema.enum && (actual === "string" || actual === "number") && !schema.enum.includes(data)) {
|
|
119
|
+
errors.push(`${path}: valor no permitido (${data}); usá uno de: ${schema.enum.join(", ")}`);
|
|
120
|
+
}
|
|
121
|
+
if (actual === "array") {
|
|
122
|
+
if (schema.minItems && data.length < schema.minItems) {
|
|
123
|
+
errors.push(`${path}: se esperaban al menos ${schema.minItems} ítems`);
|
|
124
|
+
}
|
|
125
|
+
if (schema.items) {
|
|
126
|
+
data.forEach((item, i) => errors.push(...validateAgainst(schema.items, item, `${path}[${i}]`)));
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (actual === "object") {
|
|
130
|
+
for (const key of schema.required || []) {
|
|
131
|
+
if (data[key] === undefined) errors.push(`${path}: falta ${key}`);
|
|
132
|
+
}
|
|
133
|
+
for (const [key, child] of Object.entries(schema.properties || {})) {
|
|
134
|
+
if (data[key] === undefined) continue;
|
|
135
|
+
errors.push(...validateAgainst(child, data[key], `${path}.${key}`));
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return errors;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Integridad referencial y cobertura. El schema solo mira formas; esto mira que
|
|
143
|
+
* los ids cierren y que no haya quedado un archivo sin explicar, que es el
|
|
144
|
+
* error que de verdad arruina la review.
|
|
145
|
+
*/
|
|
146
|
+
export function reviewIssues(payload, knownPaths = []) {
|
|
147
|
+
const errors = [];
|
|
148
|
+
const groups = Array.isArray(payload?.groups) ? payload.groups : [];
|
|
149
|
+
const blocks = Array.isArray(payload?.blocks) ? payload.blocks : [];
|
|
150
|
+
const findings = Array.isArray(payload?.findings) ? payload.findings : [];
|
|
151
|
+
const skipped = Array.isArray(payload?.skipped) ? payload.skipped : [];
|
|
152
|
+
|
|
153
|
+
const groupIds = new Set(groups.map((g) => g.id));
|
|
154
|
+
const blockIds = new Set(blocks.map((b) => b.id));
|
|
155
|
+
const known = new Set(knownPaths);
|
|
156
|
+
|
|
157
|
+
const dupGroups = groups.length - groupIds.size;
|
|
158
|
+
if (dupGroups > 0) errors.push(`hay ${dupGroups} id de tema repetido`);
|
|
159
|
+
const dupBlocks = blocks.length - blockIds.size;
|
|
160
|
+
if (dupBlocks > 0) errors.push(`hay ${dupBlocks} id de bloque repetido`);
|
|
161
|
+
|
|
162
|
+
for (const block of blocks) {
|
|
163
|
+
if (!groupIds.has(block.group)) {
|
|
164
|
+
errors.push(`el bloque ${block.id} apunta al tema inexistente ${block.group}`);
|
|
165
|
+
}
|
|
166
|
+
if (known.size && !known.has(block.file)) {
|
|
167
|
+
errors.push(`el bloque ${block.id} apunta a un archivo que no está en el diff: ${block.file}`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const usedGroups = new Set(blocks.map((b) => b.group));
|
|
172
|
+
for (const group of groups) {
|
|
173
|
+
if (!usedGroups.has(group.id)) errors.push(`el tema ${group.id} no tiene ningún bloque`);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
for (const finding of findings) {
|
|
177
|
+
if (finding.block && !blockIds.has(finding.block)) {
|
|
178
|
+
errors.push(`el hallazgo ${finding.id || finding.what} apunta al bloque inexistente ${finding.block}`);
|
|
179
|
+
}
|
|
180
|
+
if (known.size && !known.has(finding.file)) {
|
|
181
|
+
errors.push(`el hallazgo ${finding.id || finding.what} apunta a un archivo fuera del diff: ${finding.file}`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (known.size) {
|
|
186
|
+
const covered = new Set([...blocks.map((b) => b.file), ...skipped.map((s) => s.file)]);
|
|
187
|
+
const missing = [...known].filter((path) => !covered.has(path));
|
|
188
|
+
if (missing.length) {
|
|
189
|
+
errors.push(
|
|
190
|
+
`quedaron ${missing.length} archivo(s) sin bloque ni skipped: ${missing.slice(0, 8).join(", ")}`,
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return errors;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** JSON output contract (English instructions; Spanish text fields). */
|
|
199
|
+
export function jsonContract() {
|
|
200
|
+
return `OUTPUT: a single JSON object only. No markdown, no fences, no prose before or after.
|
|
201
|
+
TEXT LANGUAGE: Spanish (rioplatense, technical) for intent, groups.intent, blocks.what/why, findings.what/fix. Keep code identifiers untranslated.
|
|
202
|
+
|
|
203
|
+
{
|
|
204
|
+
"intent": "2-4 sentences on what the branch solves",
|
|
205
|
+
"groups": [{ "id": "g1", "kind": "feat", "title": "scope: summary ≤72 chars (do not repeat kind)", "intent": "why this theme exists" }],
|
|
206
|
+
"blocks": [{ "id": "b1", "group": "g1", "file": "exact/path.ts", "lines": "L40-58", "op": "mod", "what": "≤120 chars", "why": "≤120 chars", "source": "code" }],
|
|
207
|
+
"findings": [{ "id": "f1", "class": "risk", "severity": "high", "blocking": true, "kind": "bug", "file": "path.ts", "line": 42, "block": "b1", "what": "≤120 chars", "fix": "≤120 chars" }],
|
|
208
|
+
"skipped": [{ "file": "bun.lock", "reason": "lockfile" }]
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
Enums:
|
|
212
|
+
- groups.kind: ${GROUP_KINDS.join(" | ")}
|
|
213
|
+
- blocks.op: ${BLOCK_OPS.join(" | ")}
|
|
214
|
+
- blocks.source: ${WHY_SOURCES.join(" | ")}
|
|
215
|
+
- findings.class: ${FINDING_CLASSES.join(" | ")}
|
|
216
|
+
- findings.severity: ${FINDING_SEVERITIES.join(" | ")}
|
|
217
|
+
- findings.kind: ${FINDING_KINDS.join(" | ")}
|
|
218
|
+
- findings.blocking: boolean
|
|
219
|
+
- findings.line: integer or null
|
|
220
|
+
- skipped.reason: ${SKIP_REASONS.join(" | ")}`;
|
|
221
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { REVIEW_SCHEMA, reviewIssues, jsonContract, validateAgainst } from "./validate.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: "feat(product): CRUD de depósitos", intent: "base de todo" }],
|
|
8
|
+
blocks: [
|
|
9
|
+
{ id: "b1", group: "g1", file: "src/a.ts", lines: "L1-20", op: "add", what: "controller nuevo", why: "", source: "code" },
|
|
10
|
+
],
|
|
11
|
+
findings: [],
|
|
12
|
+
skipped: [],
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("validateAgainst acepta una review bien formada", () => {
|
|
16
|
+
assert.deepEqual(validateAgainst(REVIEW_SCHEMA, valid()), []);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("validateAgainst exige una intención de verdad", () => {
|
|
20
|
+
const errors = validateAgainst(REVIEW_SCHEMA, { ...valid(), intent: "corto" });
|
|
21
|
+
assert.ok(errors.some((e) => e.includes("intent")));
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("validateAgainst rechaza un kind de grupo inventado", () => {
|
|
25
|
+
const doc = valid();
|
|
26
|
+
doc.groups[0].kind = "feature";
|
|
27
|
+
const errors = validateAgainst(REVIEW_SCHEMA, doc);
|
|
28
|
+
assert.ok(errors.some((e) => e.includes("kind")));
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("validateAgainst rechaza un bloque sin rango de líneas", () => {
|
|
32
|
+
const doc = valid();
|
|
33
|
+
delete doc.blocks[0].lines;
|
|
34
|
+
assert.ok(validateAgainst(REVIEW_SCHEMA, doc).some((e) => e.includes("lines")));
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("validateAgainst acepta los dos ejes de hallazgo", () => {
|
|
38
|
+
const doc = valid();
|
|
39
|
+
doc.findings = [
|
|
40
|
+
{ id: "f1", class: "risk", severity: "high", blocking: true, kind: "bug", file: "src/a.ts", line: 4, what: "rompe" },
|
|
41
|
+
{ id: "f2", class: "quality", severity: "low", blocking: false, kind: "maintainability", file: "src/a.ts", line: null, what: "duplicado" },
|
|
42
|
+
];
|
|
43
|
+
assert.deepEqual(validateAgainst(REVIEW_SCHEMA, doc), []);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("reviewIssues detecta un bloque que apunta a un grupo inexistente", () => {
|
|
47
|
+
const doc = valid();
|
|
48
|
+
doc.blocks[0].group = "g9";
|
|
49
|
+
const errors = reviewIssues(doc, ["src/a.ts"]);
|
|
50
|
+
assert.ok(errors.some((e) => e.includes("g9")));
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("reviewIssues detecta un archivo del diff sin bloque ni skipped", () => {
|
|
54
|
+
const errors = reviewIssues(valid(), ["src/a.ts", "src/olvidado.ts"]);
|
|
55
|
+
assert.ok(errors.some((e) => e.includes("olvidado")));
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("reviewIssues acepta un archivo cubierto por skipped", () => {
|
|
59
|
+
const doc = valid();
|
|
60
|
+
doc.skipped = [{ file: "bun.lock", reason: "lockfile" }];
|
|
61
|
+
assert.deepEqual(reviewIssues(doc, ["src/a.ts", "bun.lock"]), []);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("reviewIssues detecta grupos huérfanos y hallazgos colgados", () => {
|
|
65
|
+
const doc = valid();
|
|
66
|
+
doc.groups.push({ id: "g2", kind: "test", title: "t", intent: "x" });
|
|
67
|
+
doc.findings = [{ id: "f1", class: "risk", severity: "high", blocking: true, kind: "bug", file: "src/a.ts", block: "b9", what: "x" }];
|
|
68
|
+
const errors = reviewIssues(doc, ["src/a.ts"]);
|
|
69
|
+
assert.ok(errors.some((e) => e.includes("g2")));
|
|
70
|
+
assert.ok(errors.some((e) => e.includes("b9")));
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("reviewIssues detecta ids repetidos", () => {
|
|
74
|
+
const doc = valid();
|
|
75
|
+
doc.blocks.push({ ...doc.blocks[0] });
|
|
76
|
+
assert.ok(reviewIssues(doc, ["src/a.ts"]).some((e) => e.includes("repetido")));
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("jsonContract nombra la estructura y los enums", () => {
|
|
80
|
+
const text = jsonContract();
|
|
81
|
+
assert.match(text, /JSON object/);
|
|
82
|
+
assert.match(text, /groups\.kind/);
|
|
83
|
+
assert.match(text, /findings/);
|
|
84
|
+
assert.match(text, /maintainability/);
|
|
85
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Espera a que aparezca un JSON de review válido en el repo.
|
|
3
|
+
*/
|
|
4
|
+
import { existsSync, readFileSync, watch } from "node:fs";
|
|
5
|
+
import { basename, join } from "node:path";
|
|
6
|
+
import { decodePayload } from "./json-payload.mjs";
|
|
7
|
+
import { REVIEW_SCHEMA, validateAgainst } from "./validate.mjs";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @param {string} repoRoot
|
|
11
|
+
* @param {string} filename
|
|
12
|
+
* @param {{ onInvalid?: (errors: string[]) => void, pollMs?: number }} [opts]
|
|
13
|
+
* @returns {Promise<{ path: string, payload: unknown }>}
|
|
14
|
+
*/
|
|
15
|
+
export function waitForReviewOutput(repoRoot, filename, opts = {}) {
|
|
16
|
+
const target = join(repoRoot, filename);
|
|
17
|
+
const name = basename(filename);
|
|
18
|
+
const pollMs = opts.pollMs ?? 800;
|
|
19
|
+
let lastInvalidKey = "";
|
|
20
|
+
|
|
21
|
+
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);
|
|
37
|
+
if (errors.length) {
|
|
38
|
+
const key = errors.join("\n");
|
|
39
|
+
if (key !== lastInvalidKey) {
|
|
40
|
+
lastInvalidKey = key;
|
|
41
|
+
opts.onInvalid?.(errors);
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
return { path: target, payload };
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
return new Promise((resolve, reject) => {
|
|
49
|
+
let settled = false;
|
|
50
|
+
/** @type {import('node:fs').FSWatcher | null} */
|
|
51
|
+
let watcher = null;
|
|
52
|
+
/** @type {ReturnType<typeof setInterval> | null} */
|
|
53
|
+
let timer = null;
|
|
54
|
+
|
|
55
|
+
const finish = (result) => {
|
|
56
|
+
if (settled) return;
|
|
57
|
+
settled = true;
|
|
58
|
+
if (timer) clearInterval(timer);
|
|
59
|
+
try {
|
|
60
|
+
watcher?.close();
|
|
61
|
+
} catch {
|
|
62
|
+
/* ignore */
|
|
63
|
+
}
|
|
64
|
+
resolve(result);
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const fail = (err) => {
|
|
68
|
+
if (settled) return;
|
|
69
|
+
settled = true;
|
|
70
|
+
if (timer) clearInterval(timer);
|
|
71
|
+
try {
|
|
72
|
+
watcher?.close();
|
|
73
|
+
} catch {
|
|
74
|
+
/* ignore */
|
|
75
|
+
}
|
|
76
|
+
reject(err);
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const tick = () => {
|
|
80
|
+
try {
|
|
81
|
+
const hit = tryRead();
|
|
82
|
+
if (hit) finish(hit);
|
|
83
|
+
} catch (err) {
|
|
84
|
+
fail(err);
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
tick();
|
|
89
|
+
if (settled) return;
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
watcher = watch(repoRoot, { persistent: true }, (_event, changed) => {
|
|
93
|
+
if (!changed || String(changed) === name || String(changed).endsWith(name)) tick();
|
|
94
|
+
});
|
|
95
|
+
watcher.on("error", () => {
|
|
96
|
+
/* poll sigue */
|
|
97
|
+
});
|
|
98
|
+
} catch {
|
|
99
|
+
/* solo poll */
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
timer = setInterval(tick, pollMs);
|
|
103
|
+
});
|
|
104
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jpkme/diff-review",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
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": {
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
},
|
|
9
9
|
"files": [
|
|
10
10
|
"bin",
|
|
11
|
+
"lib",
|
|
11
12
|
"web/build",
|
|
12
13
|
"schema.json",
|
|
13
14
|
"README.md",
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
.page.svelte-1uha8ag{flex-direction:column;min-height:100vh;display:flex}.toast.svelte-1uha8ag{z-index:40;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}}
|
|
Binary file
|
|
Binary file
|