@jpkme/diff-review 0.1.6 → 0.1.10

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 +32 -1
  2. package/bin/diff-review.mjs +227 -18
  3. package/lib/agents.mjs +319 -0
  4. package/lib/agents.test.mjs +195 -0
  5. package/lib/check-report.mjs +2 -1
  6. package/lib/check-report.test.mjs +1 -0
  7. package/lib/fingerprint.mjs +24 -0
  8. package/lib/git.mjs +2 -19
  9. package/lib/output-name.mjs +1 -1
  10. package/lib/prompt.mjs +19 -18
  11. package/lib/review-meta.mjs +33 -0
  12. package/lib/review-meta.test.mjs +30 -0
  13. package/lib/review.test.mjs +1 -0
  14. package/lib/validate.mjs +28 -11
  15. package/lib/validate.test.mjs +25 -0
  16. package/lib/wait-output.mjs +43 -17
  17. package/lib/wait-output.test.mjs +68 -0
  18. package/package.json +1 -1
  19. package/schema.json +9 -10
  20. package/web/build/client/_app/immutable/assets/2.CNFDEnjx.css +1 -0
  21. package/web/build/client/_app/immutable/assets/2.CNFDEnjx.css.br +0 -0
  22. package/web/build/client/_app/immutable/assets/2.CNFDEnjx.css.gz +0 -0
  23. package/web/build/client/_app/immutable/assets/{3.qhPDz1xQ.css → 3.PHTPLlZ7.css} +1 -1
  24. package/web/build/client/_app/immutable/assets/3.PHTPLlZ7.css.br +0 -0
  25. package/web/build/client/_app/immutable/assets/{3.qhPDz1xQ.css.gz → 3.PHTPLlZ7.css.gz} +0 -0
  26. package/web/build/client/_app/immutable/chunks/BBWAo_yW.js +2 -0
  27. package/web/build/client/_app/immutable/chunks/BBWAo_yW.js.br +0 -0
  28. package/web/build/client/_app/immutable/chunks/BBWAo_yW.js.gz +0 -0
  29. package/web/build/client/_app/immutable/chunks/{DRga3k74.js → QdZF3nkf.js} +35 -33
  30. package/web/build/client/_app/immutable/chunks/QdZF3nkf.js.br +0 -0
  31. package/web/build/client/_app/immutable/chunks/QdZF3nkf.js.gz +0 -0
  32. package/web/build/client/_app/immutable/entry/{app.BP_R_kTF.js → app.Dce6uJU3.js} +2 -2
  33. package/web/build/client/_app/immutable/entry/app.Dce6uJU3.js.br +0 -0
  34. package/web/build/client/_app/immutable/entry/app.Dce6uJU3.js.gz +0 -0
  35. package/web/build/client/_app/immutable/entry/start.DnBSC38l.js +1 -0
  36. package/web/build/client/_app/immutable/entry/start.DnBSC38l.js.br +0 -0
  37. package/web/build/client/_app/immutable/entry/start.DnBSC38l.js.gz +0 -0
  38. package/web/build/client/_app/immutable/nodes/1.5Iv4ssDT.js +1 -0
  39. package/web/build/client/_app/immutable/nodes/1.5Iv4ssDT.js.br +0 -0
  40. package/web/build/client/_app/immutable/nodes/1.5Iv4ssDT.js.gz +0 -0
  41. package/web/build/client/_app/immutable/nodes/2.BomzYVBF.js +66 -0
  42. package/web/build/client/_app/immutable/nodes/2.BomzYVBF.js.br +0 -0
  43. package/web/build/client/_app/immutable/nodes/2.BomzYVBF.js.gz +0 -0
  44. package/web/build/client/_app/immutable/nodes/{3.Bn5l-P1e.js → 3.CeUs4lBX.js} +2 -2
  45. package/web/build/client/_app/immutable/nodes/3.CeUs4lBX.js.br +0 -0
  46. package/web/build/client/_app/immutable/nodes/3.CeUs4lBX.js.gz +0 -0
  47. package/web/build/client/_app/version.json +1 -1
  48. package/web/build/client/_app/version.json.br +0 -0
  49. package/web/build/client/_app/version.json.gz +0 -0
  50. package/web/build/handler.js +3 -3
  51. package/web/build/index.js +3 -3
  52. package/web/build/server/chunks/1-2OO2bb7H.js +7 -0
  53. package/web/build/server/chunks/1-2OO2bb7H.js.map +1 -0
  54. package/web/build/server/chunks/2-BJvuqKN6.js +7 -0
  55. package/web/build/server/chunks/2-BJvuqKN6.js.map +1 -0
  56. package/web/build/server/chunks/3-ZIl1rHef.js +7 -0
  57. package/web/build/server/chunks/{3-DaeIJXI7.js.map → 3-ZIl1rHef.js.map} +1 -1
  58. package/web/build/server/chunks/{handler-2eew4UOl.js → handler-B5P65tLk.js} +3 -3
  59. package/web/build/server/chunks/{handler-2eew4UOl.js.map → handler-B5P65tLk.js.map} +1 -1
  60. package/web/build/server/chunks/{index.js-DbiNsix3.js → index.js-CL95e0Gl.js} +2 -2
  61. package/web/build/server/chunks/index.js-CL95e0Gl.js.map +1 -0
  62. package/web/build/server/chunks/{manifest.js-BYNXhn6E.js → manifest.js-uD_a0079.js} +6 -6
  63. package/web/build/server/chunks/{manifest.js-BYNXhn6E.js.map → manifest.js-uD_a0079.js.map} +1 -1
  64. package/web/build/server/chunks/{remote-1g4ayag-DESnyPRn.js → remote-1g4ayag-B67QMR_2.js} +154 -149
  65. package/web/build/server/chunks/remote-1g4ayag-B67QMR_2.js.map +1 -0
  66. package/web/build/client/_app/immutable/assets/2.Xnfq26gJ.css +0 -1
  67. package/web/build/client/_app/immutable/assets/2.Xnfq26gJ.css.br +0 -0
  68. package/web/build/client/_app/immutable/assets/2.Xnfq26gJ.css.gz +0 -0
  69. package/web/build/client/_app/immutable/assets/3.qhPDz1xQ.css.br +0 -0
  70. package/web/build/client/_app/immutable/chunks/Ber_0nz7.js +0 -1
  71. package/web/build/client/_app/immutable/chunks/Ber_0nz7.js.br +0 -2
  72. package/web/build/client/_app/immutable/chunks/Ber_0nz7.js.gz +0 -0
  73. package/web/build/client/_app/immutable/chunks/DRga3k74.js.br +0 -0
  74. package/web/build/client/_app/immutable/chunks/DRga3k74.js.gz +0 -0
  75. package/web/build/client/_app/immutable/chunks/zvcaSoGP.js +0 -2
  76. package/web/build/client/_app/immutable/chunks/zvcaSoGP.js.br +0 -0
  77. package/web/build/client/_app/immutable/chunks/zvcaSoGP.js.gz +0 -0
  78. package/web/build/client/_app/immutable/entry/app.BP_R_kTF.js.br +0 -0
  79. package/web/build/client/_app/immutable/entry/app.BP_R_kTF.js.gz +0 -0
  80. package/web/build/client/_app/immutable/entry/start.lXpYdwbt.js +0 -1
  81. package/web/build/client/_app/immutable/entry/start.lXpYdwbt.js.br +0 -1
  82. package/web/build/client/_app/immutable/entry/start.lXpYdwbt.js.gz +0 -0
  83. package/web/build/client/_app/immutable/nodes/1.CGJk77oo.js +0 -1
  84. package/web/build/client/_app/immutable/nodes/1.CGJk77oo.js.br +0 -3
  85. package/web/build/client/_app/immutable/nodes/1.CGJk77oo.js.gz +0 -0
  86. package/web/build/client/_app/immutable/nodes/2.Dl2xIvoV.js +0 -66
  87. package/web/build/client/_app/immutable/nodes/2.Dl2xIvoV.js.br +0 -0
  88. package/web/build/client/_app/immutable/nodes/2.Dl2xIvoV.js.gz +0 -0
  89. package/web/build/client/_app/immutable/nodes/3.Bn5l-P1e.js.br +0 -0
  90. package/web/build/client/_app/immutable/nodes/3.Bn5l-P1e.js.gz +0 -0
  91. package/web/build/server/chunks/1-CRg9pswO.js +0 -7
  92. package/web/build/server/chunks/1-CRg9pswO.js.map +0 -1
  93. package/web/build/server/chunks/2-BmYzresl.js +0 -7
  94. package/web/build/server/chunks/2-BmYzresl.js.map +0 -1
  95. package/web/build/server/chunks/3-DaeIJXI7.js +0 -7
  96. package/web/build/server/chunks/index.js-DbiNsix3.js.map +0 -1
  97. package/web/build/server/chunks/remote-1g4ayag-DESnyPRn.js.map +0 -1
package/lib/prompt.mjs CHANGED
@@ -21,16 +21,16 @@ export const OUTPUT_SCHEMA_BLOCK = `{
21
21
  "branch": "<branch under review>",
22
22
  "base": "develop",
23
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>"
24
+ "generatedAt": "<ISO-8601 datetime; optional>",
25
+ "agent": "<required: product name you are, e.g. Claude Code>",
26
+ "model": "<required: exact model id you are running>"
27
27
  },
28
28
  "intent": "string",
29
29
  "groups": [
30
30
  {
31
31
  "id": "g1",
32
32
  "kind": "feat|fix|refactor|perf|test|chore|docs|infra",
33
- "title": "string ≤72 chars, imperative, no kind prefix",
33
+ "title": "string, aim for ≤72 chars, imperative, no kind prefix",
34
34
  "intent": "string"
35
35
  }
36
36
  ],
@@ -44,8 +44,8 @@ export const OUTPUT_SCHEMA_BLOCK = `{
44
44
  "start": 40,
45
45
  "end": 58,
46
46
  "op": "add|mod|del|rename",
47
- "what": "string",
48
- "why": "string",
47
+ "what": "string, aim for ≤120 chars",
48
+ "why": "string, aim for ≤120 chars",
49
49
  "source": "code|commit|pr|issue|inferred"
50
50
  }
51
51
  ],
@@ -59,8 +59,8 @@ export const OUTPUT_SCHEMA_BLOCK = `{
59
59
  "file": "relative/path.ext",
60
60
  "line": 87,
61
61
  "block": "b1",
62
- "what": "string",
63
- "fix": "string"
62
+ "what": "string, aim for ≤120 chars",
63
+ "fix": "string, aim for ≤120 chars"
64
64
  }
65
65
  ],
66
66
  "skipped": [
@@ -71,18 +71,19 @@ export const OUTPUT_SCHEMA_BLOCK = `{
71
71
 
72
72
  const RULES = `- Coverage (hard): every path in the diff MUST appear in "blocks" and/or "skipped". Prefer a skipped glob when many files share one reason.
73
73
  - Do NOT invent files, hunks, or line ranges that are not in the diff.
74
- - Line anchors: "lines"/"start"/"end"/"line" MUST match the chosen "side" ("new" = post-image / right side of @@; "old" = pre-image, typically deletions). One block per coherent change span — not one per line. Point findings at that block; leave "block" empty only if cross-cutting.
74
+ - Line anchors: "lines"/"start"/"end"/"line" MUST match the chosen "side" ("new" = post-image / right side of @@; "old" = pre-image, typically deletions). One block per coherent change span, not one per line. Point findings at that block; leave "block" empty only if cross-cutting.
75
75
  - Do NOT report: pre-existing code outside the diff; linter/formatter noise; naming/style prefs with no real consequence; intentional branch behavior; silenced lint rules; "missing docs/coverage" without a concrete broken scenario.
76
76
  - Quality findings must name a concrete cost, not a vague feeling.
77
- - Signal over volume: prefer fewer high-signal findings. Order findings by severity (high → nit). Use "nit" sparingly.
77
+ - Signal over volume: prefer fewer high-signal findings. Order findings by severity (high, then nit). Use "nit" sparingly.
78
78
  - "blocking": true ONLY if the branch should not merge as-is. Use only with class "risk".
79
- - Write human-readable strings (intent, title, what, why, fix, notes) in Spanish. Keep enums/ids/paths/JSON keys exactly as in the schema.`;
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
+ - Length is a target, not a check: aim for title ≤72 characters and what/why/fix ≤120 characters. Longer text is still valid. Do not rewrite the file only to shorten it.`;
80
81
 
81
82
  function workflowLocal(base, branch) {
82
83
  return `Workflow:
83
84
  1. Resolve the merge-base of \`${base}\` and \`${branch}\`.
84
85
  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
+ 3. Read enough surrounding code (types, callers, tests) to judge behavior, not only the hunk lines.
86
87
  4. Emit the JSON file. Do not modify the repo.
87
88
  5. Validate it with the command in the output section. If it prints INVALID, follow "How to continue" and re-run until it prints OK.`;
88
89
  }
@@ -98,7 +99,7 @@ function workflowUrl(base, branch) {
98
99
  1. If the repo is not local, clone it (or use whatever access you have). Do not modify it.
99
100
  2. Resolve the merge-base of \`${base}\` and \`${branch}\`.
100
101
  3. Run exactly: \`git diff ${base}...${branch}\` (three-dot / merge-base diff). Do not use two-dot unless three-dot is impossible.
101
- 4. Read enough surrounding code (types, callers, tests) to judge behavior — not only the hunk lines.
102
+ 4. Read enough surrounding code (types, callers, tests) to judge behavior, not only the hunk lines.
102
103
  5. Emit the JSON file.
103
104
  6. Validate it with the command in the output section. If it prints INVALID, follow "How to continue" and re-run until it prints OK.`;
104
105
  }
@@ -122,8 +123,8 @@ export function outputSchemaBlock(meta) {
122
123
  if (remoteUrl) metaLines.push(` "remoteUrl": ${JSON.stringify(remoteUrl)},`);
123
124
  metaLines.push(
124
125
  ` "generatedAt": "<ISO-8601 datetime>",`,
125
- ` "agent": "<tool name, e.g. Claude Code / Cursor>",`,
126
- ` "model": "<model id>"`
126
+ ` "agent": "<required: product name you are, e.g. Claude Code>",`,
127
+ ` "model": "<required: exact model id you are running>"`
127
128
  );
128
129
  const metaBlock = `"meta": {\n${metaLines.join("\n")}\n }`;
129
130
  return OUTPUT_SCHEMA_BLOCK.replace(/"meta": \{[\s\S]*?\n \}/, metaBlock);
@@ -156,13 +157,13 @@ ${RULES}
156
157
  Write the result to \`${outputFilename}\` at the repo root.
157
158
  - One JSON object only: first character \`{\`, last character \`}\`.
158
159
  - No prose before/after, no markdown fences.
159
- - Shape (copy "meta" source/repo/branch/base/remoteUrl as given; fill generatedAt/agent/model):
160
+ - Shape (copy "meta" source/repo/branch/base/remoteUrl as given; "agent" and "model" are required):
160
161
 
161
162
  ${outputSchemaBlock(meta)}
162
163
 
163
- Include "meta" with the source/repo/branch/base above (and origin "remoteUrl" when local). Also set informational fields: "generatedAt" (ISO-8601 now), "agent" (tool name), "model" (model id). That lets someone reopen the report elsewhere without re-picking the repo.
164
+ Include "meta" with the source/repo/branch/base above (and origin "remoteUrl" when local). "agent" and "model" are required: set "agent" to the product you are (Claude Code, Cursor, Codex, Copilot, Gemini, …) and "model" to the exact model id you are running. Do not leave placeholders or omit them; validation fails without both. "generatedAt" is optional (ISO-8601 now).
164
165
 
165
- Do NOT include diffs or a "files" array — the tool computes them with git when opening the report. Do not invent hunks or paste patches into the JSON.
166
+ Do NOT include diffs or a "files" array. The tool computes them with git when opening the report. Do not invent hunks or paste patches into the JSON.
166
167
 
167
168
  After writing the file, validate it (npx so it works even if \`diff-review\` is not on PATH):
168
169
 
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Estampa en el JSON quién generó la review. La CLI lo sabe mejor que el modelo.
3
+ */
4
+ import { readFileSync, writeFileSync } from "node:fs";
5
+ import { decodePayload } from "./json-payload.mjs";
6
+
7
+ /**
8
+ * @param {string} filePath
9
+ * @param {{ agent?: string, model?: string }} provenance
10
+ * @returns {boolean}
11
+ */
12
+ export function stampReviewProvenance(filePath, { agent, model } = {}) {
13
+ const agentName = String(agent || "").trim();
14
+ const modelId = String(model || "").trim();
15
+ if (!agentName && !modelId) return false;
16
+
17
+ let payload;
18
+ try {
19
+ payload = decodePayload(readFileSync(filePath, "utf8"));
20
+ } catch {
21
+ return false;
22
+ }
23
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) return false;
24
+
25
+ const current = payload.meta;
26
+ const meta =
27
+ current && typeof current === "object" && !Array.isArray(current) ? { ...current } : {};
28
+ if (agentName) meta.agent = agentName;
29
+ if (modelId) meta.model = modelId;
30
+ payload.meta = meta;
31
+ writeFileSync(filePath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
32
+ return true;
33
+ }
@@ -0,0 +1,30 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { stampReviewProvenance } from "./review-meta.mjs";
7
+
8
+ test("stampReviewProvenance escribe agente y modelo sin perder el resto", () => {
9
+ const dir = mkdtempSync(join(tmpdir(), "diff-review-meta-"));
10
+ const file = join(dir, "diff-rev_a_b.json");
11
+ writeFileSync(file, JSON.stringify({ intent: "algo concreto", meta: { branch: "feat" } }));
12
+
13
+ assert.equal(stampReviewProvenance(file, { agent: "Claude Code", model: "opus" }), true);
14
+
15
+ const saved = JSON.parse(readFileSync(file, "utf8"));
16
+ assert.equal(saved.intent, "algo concreto");
17
+ assert.deepEqual(saved.meta, { branch: "feat", agent: "Claude Code", model: "opus" });
18
+ });
19
+
20
+ test("stampReviewProvenance no pisa el modelo si no hay uno", () => {
21
+ const dir = mkdtempSync(join(tmpdir(), "diff-review-meta-"));
22
+ const file = join(dir, "diff-rev_a_b.json");
23
+ writeFileSync(file, JSON.stringify({ meta: { model: "sonnet" }, intent: "x" }));
24
+
25
+ stampReviewProvenance(file, { agent: "Codex" });
26
+
27
+ const saved = JSON.parse(readFileSync(file, "utf8"));
28
+ assert.equal(saved.meta.agent, "Codex");
29
+ assert.equal(saved.meta.model, "sonnet");
30
+ });
@@ -132,6 +132,7 @@ test("buildReview deduplica hallazgos repetidos y limpia referencias colgadas",
132
132
 
133
133
  test("una respuesta JSON del modelo atraviesa decode, validación y normalización", () => {
134
134
  const answer = JSON.stringify({
135
+ meta: { agent: "Claude Code", model: "claude-opus" },
135
136
  intent: "Hace que las búsquedas con varias palabras matcheen los tokens en cualquier orden.",
136
137
  groups: [
137
138
  { id: "g1", kind: "feat", title: "feat(search): matchear tokens", intent: "el cambio" },
package/lib/validate.mjs CHANGED
@@ -13,9 +13,18 @@ export const SKIP_REASONS = ["generated", "lockfile", "format", "vendored", "bin
13
13
  export const REVIEW_SCHEMA = {
14
14
  $id: "diff-review",
15
15
  type: "object",
16
- required: ["intent", "groups", "blocks"],
16
+ required: ["meta", "intent", "groups", "blocks"],
17
17
  additionalProperties: true,
18
18
  properties: {
19
+ meta: {
20
+ type: "object",
21
+ required: ["agent", "model"],
22
+ additionalProperties: true,
23
+ properties: {
24
+ agent: { type: "string", minLength: 1 },
25
+ model: { type: "string", minLength: 1 },
26
+ },
27
+ },
19
28
  intent: { type: "string", minLength: 40 },
20
29
  groups: {
21
30
  type: "array",
@@ -45,8 +54,8 @@ export const REVIEW_SCHEMA = {
45
54
  file: { type: "string", minLength: 1 },
46
55
  lines: { type: "string", minLength: 1 },
47
56
  op: { type: "string", enum: BLOCK_OPS },
48
- what: { type: "string", minLength: 1, maxLength: 120 },
49
- why: { type: "string", maxLength: 120 },
57
+ what: { type: "string", minLength: 1 },
58
+ why: { type: "string" },
50
59
  source: { type: "string", enum: WHY_SOURCES },
51
60
  },
52
61
  },
@@ -66,8 +75,8 @@ export const REVIEW_SCHEMA = {
66
75
  file: { type: "string", minLength: 1 },
67
76
  line: { type: ["integer", "null"] },
68
77
  block: { type: "string" },
69
- what: { type: "string", minLength: 1, maxLength: 120 },
70
- fix: { type: "string", maxLength: 120 },
78
+ what: { type: "string", minLength: 1 },
79
+ fix: { type: "string" },
71
80
  },
72
81
  },
73
82
  },
@@ -112,9 +121,6 @@ export function validateAgainst(schema, data, path = "$") {
112
121
  if (actual === "string" && schema.minLength && data.length < schema.minLength) {
113
122
  errors.push(`${path}: texto demasiado corto (mínimo ${schema.minLength})`);
114
123
  }
115
- if (actual === "string" && schema.maxLength && data.length > schema.maxLength) {
116
- errors.push(`${path}: texto demasiado largo (máximo ${schema.maxLength})`);
117
- }
118
124
  if (schema.enum && (actual === "string" || actual === "number") && !schema.enum.includes(data)) {
119
125
  errors.push(`${path}: valor no permitido (${data}); usá uno de: ${schema.enum.join(", ")}`);
120
126
  }
@@ -145,6 +151,16 @@ export function validateAgainst(schema, data, path = "$") {
145
151
  */
146
152
  export function reviewIssues(payload, knownPaths = []) {
147
153
  const errors = [];
154
+ const meta = payload?.meta && typeof payload.meta === "object" ? payload.meta : {};
155
+ const agentName = typeof meta.agent === "string" ? meta.agent.trim() : "";
156
+ const modelId = typeof meta.model === "string" ? meta.model.trim() : "";
157
+ if (!agentName || agentName.startsWith("<")) {
158
+ errors.push("meta.agent es obligatorio: nombre del producto (Claude Code, Cursor, Codex, …), sin placeholder");
159
+ }
160
+ if (!modelId || modelId.startsWith("<")) {
161
+ errors.push("meta.model es obligatorio: id exacto del modelo, sin placeholder");
162
+ }
163
+
148
164
  const groups = Array.isArray(payload?.groups) ? payload.groups : [];
149
165
  const blocks = Array.isArray(payload?.blocks) ? payload.blocks : [];
150
166
  const findings = Array.isArray(payload?.findings) ? payload.findings : [];
@@ -202,9 +218,10 @@ TEXT LANGUAGE: Spanish (rioplatense, technical) for intent, groups.intent, block
202
218
 
203
219
  {
204
220
  "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" }],
221
+ "groups": [{ "id": "g1", "kind": "feat", "title": "scope: summary (do not repeat kind)", "intent": "why this theme exists" }],
222
+ "meta": { "agent": "<required: product name, e.g. Claude Code>", "model": "<required: exact model id>" },
223
+ "blocks": [{ "id": "b1", "group": "g1", "file": "exact/path.ts", "lines": "L40-58", "op": "mod", "what": "what changed", "why": "why", "source": "code" }],
224
+ "findings": [{ "id": "f1", "class": "risk", "severity": "high", "blocking": true, "kind": "bug", "file": "path.ts", "line": 42, "block": "b1", "what": "what is wrong", "fix": "what to do" }],
208
225
  "skipped": [{ "file": "bun.lock", "reason": "lockfile" }]
209
226
  }
210
227
 
@@ -3,6 +3,7 @@ import assert from "node:assert/strict";
3
3
  import { REVIEW_SCHEMA, reviewIssues, jsonContract, validateAgainst } from "./validate.mjs";
4
4
 
5
5
  const valid = () => ({
6
+ meta: { agent: "Claude Code", model: "claude-opus" },
6
7
  intent: "Agrega el ABM de depósitos locales end-to-end para dejar de administrarlos por SQL.",
7
8
  groups: [{ id: "g1", kind: "feat", title: "feat(product): CRUD de depósitos", intent: "base de todo" }],
8
9
  blocks: [
@@ -16,6 +17,30 @@ test("validateAgainst acepta una review bien formada", () => {
16
17
  assert.deepEqual(validateAgainst(REVIEW_SCHEMA, valid()), []);
17
18
  });
18
19
 
20
+ test("validateAgainst exige meta.agent y meta.model", () => {
21
+ const missing = valid();
22
+ delete missing.meta;
23
+ assert.ok(validateAgainst(REVIEW_SCHEMA, missing).some((e) => e.includes("meta")));
24
+ assert.ok(reviewIssues(missing).some((e) => e.includes("meta.agent")));
25
+ assert.ok(reviewIssues(missing).some((e) => e.includes("meta.model")));
26
+
27
+ const blank = valid();
28
+ blank.meta = { agent: " ", model: "<required: exact model id>" };
29
+ assert.ok(reviewIssues(blank).some((e) => e.includes("meta.agent")));
30
+ assert.ok(reviewIssues(blank).some((e) => e.includes("meta.model")));
31
+ });
32
+
33
+ test("validateAgainst no limita el largo de what, why ni fix", () => {
34
+ const doc = valid();
35
+ const long = "x".repeat(500);
36
+ doc.blocks[0].what = long;
37
+ doc.blocks[0].why = long;
38
+ doc.findings = [
39
+ { id: "f1", class: "risk", severity: "high", blocking: true, kind: "bug", file: "src/a.ts", line: 4, what: long, fix: long },
40
+ ];
41
+ assert.deepEqual(validateAgainst(REVIEW_SCHEMA, doc), []);
42
+ });
43
+
19
44
  test("validateAgainst exige una intención de verdad", () => {
20
45
  const errors = validateAgainst(REVIEW_SCHEMA, { ...valid(), intent: "corto" });
21
46
  assert.ok(errors.some((e) => e.includes("intent")));
@@ -1,15 +1,53 @@
1
1
  /**
2
2
  * Espera a que aparezca un JSON de review válido en el repo.
3
3
  */
4
- import { existsSync, readFileSync, watch } from "node:fs";
4
+ import { existsSync, readFileSync, statSync, watch } from "node:fs";
5
5
  import { basename, join } from "node:path";
6
6
  import { decodePayload } from "./json-payload.mjs";
7
7
  import { REVIEW_SCHEMA, validateAgainst } from "./validate.mjs";
8
8
 
9
9
  /**
10
+ * Lee y valida el JSON. `null` si no está, está vacío, no parsea o es anterior a `since` (ms).
11
+ * @param {string} target
12
+ * @param {number} [since]
13
+ * @returns {{ payload: unknown, errors: string[] } | null}
14
+ */
15
+ function readReview(target, since) {
16
+ if (!existsSync(target)) return null;
17
+ let text;
18
+ try {
19
+ if (since != null && statSync(target).mtimeMs < since) return null;
20
+ text = readFileSync(target, "utf8");
21
+ } catch {
22
+ return null;
23
+ }
24
+ if (!text.trim()) return null;
25
+ let payload;
26
+ try {
27
+ payload = decodePayload(text);
28
+ } catch {
29
+ return null;
30
+ }
31
+ return { payload, errors: validateAgainst(REVIEW_SCHEMA, payload) };
32
+ }
33
+
34
+ /**
35
+ * JSON de review válido que ya está en el repo (p. ej. de una corrida anterior sobre los mismos commits).
10
36
  * @param {string} repoRoot
11
37
  * @param {string} filename
12
- * @param {{ onInvalid?: (errors: string[]) => void, pollMs?: number }} [opts]
38
+ * @returns {{ path: string, payload: unknown } | null}
39
+ */
40
+ export function existingReviewOutput(repoRoot, filename) {
41
+ const target = join(repoRoot, filename);
42
+ const read = readReview(target);
43
+ return read && !read.errors.length ? { path: target, payload: read.payload } : null;
44
+ }
45
+
46
+ /**
47
+ * @param {string} repoRoot
48
+ * @param {string} filename
49
+ * @param {{ onInvalid?: (errors: string[]) => void, pollMs?: number, since?: number }} [opts]
50
+ * `since`: ignorar el archivo si se modificó antes (ms epoch), para no tomar uno de una corrida anterior.
13
51
  * @returns {Promise<{ path: string, payload: unknown }>}
14
52
  */
15
53
  export function waitForReviewOutput(repoRoot, filename, opts = {}) {
@@ -19,21 +57,9 @@ export function waitForReviewOutput(repoRoot, filename, opts = {}) {
19
57
  let lastInvalidKey = "";
20
58
 
21
59
  const tryRead = () => {
22
- if (!existsSync(target)) return null;
23
- let text;
24
- try {
25
- text = readFileSync(target, "utf8");
26
- } catch {
27
- return null;
28
- }
29
- if (!text.trim()) return null;
30
- let payload;
31
- try {
32
- payload = decodePayload(text);
33
- } catch {
34
- return null;
35
- }
36
- const errors = validateAgainst(REVIEW_SCHEMA, payload);
60
+ const read = readReview(target, opts.since);
61
+ if (!read) return null;
62
+ const { payload, errors } = read;
37
63
  if (errors.length) {
38
64
  const key = errors.join("\n");
39
65
  if (key !== lastInvalidKey) {
@@ -0,0 +1,68 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtempSync, utimesSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { describe, it } from "node:test";
6
+ import { existingReviewOutput, waitForReviewOutput } from "./wait-output.mjs";
7
+
8
+ const valid = () => ({
9
+ meta: { agent: "Codex", model: "gpt-5" },
10
+ intent: "Agrega el ABM de depósitos locales end-to-end para dejar de administrarlos por SQL.",
11
+ groups: [{ id: "g1", kind: "feat", title: "feat(product): CRUD de depósitos", intent: "base de todo" }],
12
+ blocks: [
13
+ { id: "b1", group: "g1", file: "src/a.ts", lines: "L1-20", op: "add", what: "controller nuevo", why: "", source: "code" },
14
+ ],
15
+ findings: [],
16
+ skipped: [],
17
+ });
18
+
19
+ const FILE = "diff-rev_a_b.json";
20
+
21
+ function repoWith(payload) {
22
+ const repo = mkdtempSync(join(tmpdir(), "diff-review-wait-"));
23
+ if (payload !== undefined) writeFileSync(join(repo, FILE), JSON.stringify(payload));
24
+ return repo;
25
+ }
26
+
27
+ /** Atrasa el mtime del archivo, como si viniera de una corrida anterior. */
28
+ function age(repo, ms) {
29
+ const past = new Date(Date.now() - ms);
30
+ utimesSync(join(repo, FILE), past, past);
31
+ }
32
+
33
+ describe("existingReviewOutput", () => {
34
+ it("devuelve el JSON si existe y es válido", () => {
35
+ const repo = repoWith(valid());
36
+ assert.equal(existingReviewOutput(repo, FILE)?.path, join(repo, FILE));
37
+ });
38
+
39
+ it("null si no existe o es inválido", () => {
40
+ assert.equal(existingReviewOutput(repoWith(), FILE), null);
41
+ assert.equal(existingReviewOutput(repoWith({ intent: "corto" }), FILE), null);
42
+ });
43
+ });
44
+
45
+ describe("waitForReviewOutput", () => {
46
+ it("sin since toma un JSON válido que ya estaba", async () => {
47
+ const repo = repoWith(valid());
48
+ age(repo, 60_000);
49
+ const result = await waitForReviewOutput(repo, FILE, { pollMs: 20 });
50
+ assert.equal(result.path, join(repo, FILE));
51
+ });
52
+
53
+ it("con since ignora el JSON anterior y resuelve cuando se reescribe", async () => {
54
+ const repo = repoWith(valid());
55
+ age(repo, 60_000);
56
+ let resolved = false;
57
+ const waiting = waitForReviewOutput(repo, FILE, { pollMs: 20, since: Date.now() - 1000 }).then((r) => {
58
+ resolved = true;
59
+ return r;
60
+ });
61
+ await new Promise((r) => setTimeout(r, 100));
62
+ assert.equal(resolved, false);
63
+
64
+ writeFileSync(join(repo, FILE), JSON.stringify(valid()));
65
+ const result = await waiting;
66
+ assert.equal(result.path, join(repo, FILE));
67
+ });
68
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jpkme/diff-review",
3
- "version": "0.1.6",
3
+ "version": "0.1.10",
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": {
package/schema.json CHANGED
@@ -4,12 +4,12 @@
4
4
  "title": "Diff Review Document",
5
5
  "description": "JSON que escribe el agente (meta/intent/groups/blocks/findings/skipped). La UI completa files con git al abrir el reporte; no hace falta que el modelo incluya diffs.",
6
6
  "type": "object",
7
- "required": ["intent", "groups", "blocks", "findings"],
7
+ "required": ["meta", "intent", "groups", "blocks", "findings"],
8
8
  "properties": {
9
9
  "meta": {
10
10
  "type": "object",
11
11
  "description": "Contexto del repo para reabrir el reporte sin rellenar el formulario. Copiá source/repo/branch/base del prompt.",
12
- "required": ["source", "repo", "branch", "base"],
12
+ "required": ["source", "repo", "branch", "base", "agent", "model"],
13
13
  "properties": {
14
14
  "source": { "type": "string", "enum": ["local", "url"] },
15
15
  "repo": {
@@ -28,11 +28,13 @@
28
28
  },
29
29
  "agent": {
30
30
  "type": "string",
31
- "description": "Nombre del tool/agente (informativo), ej. Claude Code / Cursor."
31
+ "minLength": 1,
32
+ "description": "Nombre del producto que escribió la review, ej. Claude Code o Cursor. Obligatorio."
32
33
  },
33
34
  "model": {
34
35
  "type": "string",
35
- "description": "Id del modelo usado (informativo)."
36
+ "minLength": 1,
37
+ "description": "Id exacto del modelo que escribió la review. Obligatorio."
36
38
  }
37
39
  }
38
40
  },
@@ -57,10 +59,9 @@
57
59
  },
58
60
  "title": {
59
61
  "type": "string",
60
- "maxLength": 72,
61
62
  "description": "Resumen del tema en una línea sin repetir kind (el chip ya lo muestra), ej. 'warehouses: CRUD de depósitos locales' o 'product: alta de depósitos'."
62
63
  },
63
- "intent": { "type": "string", "maxLength": 240, "description": "Por qué existe este tema." }
64
+ "intent": { "type": "string", "description": "Por qué existe este tema." }
64
65
  }
65
66
  }
66
67
  },
@@ -86,12 +87,10 @@
86
87
  "op": { "type": "string", "enum": ["add", "mod", "del", "rename"] },
87
88
  "what": {
88
89
  "type": "string",
89
- "maxLength": 200,
90
90
  "description": "Qué cambia este bloque."
91
91
  },
92
92
  "why": {
93
93
  "type": "string",
94
- "maxLength": 200,
95
94
  "description": "Por qué. Vacío solo si no es inferible."
96
95
  },
97
96
  "source": {
@@ -123,8 +122,8 @@
123
122
  "file": { "type": "string" },
124
123
  "line": { "type": ["integer", "null"], "description": "Línea de la post-imagen (columna derecha / @@ +N)." },
125
124
  "block": { "type": ["string", "null"], "description": "Id del bloque, o vacío/null si es transversal." },
126
- "what": { "type": "string", "maxLength": 300 },
127
- "fix": { "type": "string", "maxLength": 300, "description": "Acción concreta." }
125
+ "what": { "type": "string" },
126
+ "fix": { "type": "string", "description": "Acción concreta." }
128
127
  }
129
128
  }
130
129
  },
@@ -0,0 +1 @@
1
+ .page.svelte-1uha8ag{flex-direction:column;min-height:100vh;display:flex}.diff-backdrop.svelte-1uha8ag{z-index:80;background:color-mix(in srgb, var(--bg) 88%, transparent);-webkit-backdrop-filter:blur(28px);place-items:center;padding:24px;display:grid;position:fixed;inset:0}.diff-modal.svelte-1uha8ag{background:var(--bg-card);border:1px solid var(--border);text-align:center;width:min(380px,100%);padding:28px 24px;box-shadow:0 16px 48px #00000047}.diff-modal.svelte-1uha8ag p:where(.svelte-1uha8ag){color:var(--text);margin:14px 0 0;font-size:15px}.diff-spinner.svelte-1uha8ag{border:2px solid var(--border);border-top-color:var(--accent);border-radius:50%;width:28px;height:28px;margin:0 auto;animation:.7s linear infinite svelte-1uha8ag-diff-spin}@keyframes svelte-1uha8ag-diff-spin{to{transform:rotate(360deg)}}.toast.svelte-1uha8ag{z-index:50;border:1px solid var(--border);background:var(--bg-card);color:var(--text);padding:10px 16px;font-size:13px;position:fixed;top:16px;left:50%;transform:translate(-50%);box-shadow:0 8px 24px #00000029}.topbar.svelte-1uha8ag{border-bottom:1px solid var(--border);background:var(--header-bg);z-index:5;justify-content:space-between;align-items:center;gap:12px;padding:16px 28px;display:flex;position:sticky;top:0}.brand.svelte-1uha8ag{color:var(--text);align-items:center;gap:10px;text-decoration:none;display:flex}.brand-dot.svelte-1uha8ag{background:linear-gradient(135deg,#6ea8fe,#c9a5ff);border-radius:50%;flex-shrink:0;width:9px;height:9px;box-shadow:0 0 12px #6ea8fe88}.title-block.svelte-1uha8ag{text-align:left;flex-direction:column;line-height:1.25;display:flex}.title-block.svelte-1uha8ag span:where(.svelte-1uha8ag):first-child{font-size:15px;font-weight:700}.title-block.svelte-1uha8ag small:where(.svelte-1uha8ag){color:var(--text-faint);font-size:11.5px;font-weight:400}.workspace.svelte-1uha8ag{flex:1;grid-template-columns:minmax(0,1fr) minmax(300px,380px);align-items:start;gap:28px;width:100%;max-width:1280px;margin:0 auto;padding:22px 28px 40px;display:grid;overflow:auto}.setup.svelte-1uha8ag{min-width:0}h2.svelte-1uha8ag{letter-spacing:.06em;text-transform:uppercase;color:var(--text-faint);margin:0 0 4px;font-size:13px;font-weight:600}.result-head.svelte-1uha8ag{margin-top:24px}.section-head.svelte-1uha8ag{justify-content:space-between;align-items:baseline;margin-top:22px;display:flex}.hint-text.svelte-1uha8ag{color:var(--text-dim);margin:0 0 12px;font-size:12.5px;line-height:1.5}.hint-text.svelte-1uha8ag code:where(.svelte-1uha8ag){font-family:var(--mono);color:var(--text);font-size:12px}.detect-banner.svelte-1uha8ag,.detect-wait.svelte-1uha8ag{color:var(--text-dim);margin:0 0 10px;font-size:13px}button.file-link.svelte-1uha8ag{color:var(--accent);font:inherit;cursor:pointer;background:0 0;border:0;padding:0;text-decoration:underline}.error-line.svelte-1uha8ag{color:var(--danger);margin:0 0 10px;font-size:12px}.ok-line.svelte-1uha8ag{color:var(--ok);margin:0 0 10px;font-size:12px}.row.svelte-1uha8ag{flex-wrap:wrap;gap:8px;display:flex}.repo-setup.svelte-1uha8ag{flex-direction:column;gap:10px;display:flex}.field.svelte-1uha8ag{flex-direction:column;flex:1;gap:4px;min-width:180px;display:flex}.repo-field.svelte-1uha8ag{flex:none;width:100%;min-width:0}.branches-row.svelte-1uha8ag .field:where(.svelte-1uha8ag){flex:1;min-width:160px}.field.svelte-1uha8ag span:where(.svelte-1uha8ag){color:var(--text-faint);font-size:11px}.repo-row.svelte-1uha8ag{gap:6px;min-width:0;display:flex}.repo-row.svelte-1uha8ag input[type=text]:where(.svelte-1uha8ag){flex:1;min-width:0}.repo-row.svelte-1uha8ag button:where(.svelte-1uha8ag){flex-shrink:0}.source-toggle.svelte-1uha8ag{margin-bottom:10px}input[type=text].svelte-1uha8ag,select.svelte-1uha8ag{background:var(--bg-card);border:1px solid var(--border);flex:1;min-width:0;padding:9px 12px}button.svelte-1uha8ag{background:var(--bg-card);border:1px solid var(--border);color:var(--text);padding:9px 12px;font-size:12.5px}button.svelte-1uha8ag:hover:not(:disabled){border-color:var(--accent)}button.svelte-1uha8ag:disabled{opacity:.5;cursor:not-allowed}.mode-toggle.svelte-1uha8ag{border:1px solid var(--border);width:fit-content;margin-bottom:10px;display:flex;overflow:hidden}.mode-toggle.svelte-1uha8ag button:where(.svelte-1uha8ag){background:0 0;border:0;padding:6px 12px;font-size:12px}.mode-toggle.svelte-1uha8ag button:where(.svelte-1uha8ag)+button:where(.svelte-1uha8ag){border-left:1px solid var(--border)}.mode-toggle.svelte-1uha8ag button.on:where(.svelte-1uha8ag){background:var(--accent-soft)}textarea.svelte-1uha8ag{resize:vertical;background:var(--bg);border:1px solid var(--border);width:100%;height:220px;font-family:var(--mono);color:var(--text);padding:12px;font-size:12px;line-height:1.5}.command-box.svelte-1uha8ag{background:var(--bg);border:1px solid var(--border);padding:10px 12px;overflow:auto}.command-box.svelte-1uha8ag code:where(.svelte-1uha8ag){font-family:var(--mono);color:var(--text);white-space:nowrap;font-size:12.5px}.actions.svelte-1uha8ag{gap:8px;margin-top:10px;display:flex}button.primary.svelte-1uha8ag{background:var(--btn-primary-bg);border-color:var(--btn-primary-border);color:var(--btn-primary-text);font-weight:600}button.ghost.svelte-1uha8ag{color:var(--text-dim);background:0 0}.skill-block.svelte-1uha8ag{border-top:1px solid var(--border);margin-top:16px;padding-top:14px}.skill-block.svelte-1uha8ag h3:where(.svelte-1uha8ag){letter-spacing:.06em;text-transform:uppercase;color:var(--text-faint);margin:0 0 4px;font-size:11.5px;font-weight:650}.skill-buttons.svelte-1uha8ag{flex-wrap:wrap;gap:8px;display:flex}.coverage-box.svelte-1uha8ag{border:1px solid var(--border);border-left:3px solid var(--sev-med);background:var(--bg-card);flex-direction:column;gap:10px;margin-top:12px;padding:12px 14px;display:flex}.coverage-head.svelte-1uha8ag{flex-wrap:wrap;align-items:baseline;gap:8px;display:flex}.coverage-head.svelte-1uha8ag strong:where(.svelte-1uha8ag){color:var(--sev-med);font-size:13px}.coverage-head.svelte-1uha8ag span:where(.svelte-1uha8ag){color:var(--text-faint);font-size:11.5px}.coverage-box.svelte-1uha8ag summary:where(.svelte-1uha8ag){color:var(--text-dim);cursor:pointer;font-size:12px}.coverage-list.svelte-1uha8ag{max-height:180px;font-family:var(--mono);color:var(--text-faint);margin:8px 0 0;padding-left:18px;font-size:11px;overflow:auto}.coverage-box.svelte-1uha8ag textarea:where(.svelte-1uha8ag){resize:vertical;background:var(--bg);border:1px solid var(--border);width:100%;font-family:var(--mono);color:var(--text);padding:10px;font-size:11.5px;line-height:1.5}.coverage-actions.svelte-1uha8ag{flex-wrap:wrap;gap:8px;display:flex}.dropzone.svelte-1uha8ag{border:1.5px dashed var(--border);text-align:center;background:var(--bg-elev);padding:26px;transition:background .15s,border-color .15s}.dropzone.drag.svelte-1uha8ag{border-color:var(--accent);background:var(--accent-soft)}.dropzone.busy.svelte-1uha8ag{opacity:.7}.dropzone.svelte-1uha8ag p:where(.svelte-1uha8ag){color:var(--text-dim);margin:0 0 6px;font-size:14px}.visually-hidden.svelte-1uha8ag{clip:rect(0 0 0 0);width:1px;height:1px;position:absolute;overflow:hidden}.drop-errors.svelte-1uha8ag{background:var(--danger-bg);color:var(--danger);margin-top:10px;padding:10px 12px;font-size:12.5px}.drop-errors.svelte-1uha8ag strong:where(.svelte-1uha8ag){margin-bottom:4px;display:block}.drop-errors.svelte-1uha8ag ul:where(.svelte-1uha8ag){margin:0;padding-left:18px;line-height:1.6}.reports-col.svelte-1uha8ag{min-width:0;max-height:calc(100vh - 110px);position:sticky;top:88px;overflow:auto}.report-list.svelte-1uha8ag{flex-direction:column;gap:8px;margin:0;padding:0;list-style:none;display:flex}.report-item.svelte-1uha8ag{border:1px solid var(--border);background:var(--bg-card);align-items:stretch;display:flex;position:relative;overflow:visible}.report-item.svelte-1uha8ag:hover{background:var(--bg-card-hover);border-color:#33507a}.report-card.svelte-1uha8ag{min-width:0;color:var(--text);flex:1;padding:12px 8px 12px 14px;text-decoration:none;display:block}.report-actions.svelte-1uha8ag{flex-shrink:0;padding:8px 8px 0 0;position:relative}.kebab.svelte-1uha8ag{width:26px;height:26px;color:var(--text-faint);cursor:pointer;background:0 0;border:1px solid #0000;justify-content:center;align-items:center;padding:0;display:inline-flex}.kebab.svelte-1uha8ag:hover{border-color:var(--border);color:var(--text);background:var(--bg)}.report-menu.svelte-1uha8ag{z-index:40;border:1px solid var(--border);background:var(--bg-card);width:148px;padding:4px;position:fixed;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}.report-prov.svelte-1uha8ag{min-width:0;color:var(--text-dim);align-items:center;gap:6px;margin-top:4px;font-size:12px;display:flex}.report-prov.svelte-1uha8ag .prov-agent:where(.svelte-1uha8ag){text-overflow:ellipsis;white-space:nowrap;flex:0 auto;min-width:0;overflow:hidden}.report-prov.svelte-1uha8ag .prov-sep:where(.svelte-1uha8ag){color:var(--text-faint);flex-shrink:0}.report-prov.svelte-1uha8ag .prov-model:where(.svelte-1uha8ag){min-width:0;font-family:var(--mono);text-overflow:ellipsis;white-space:nowrap;flex:auto;font-size:11.5px;overflow:hidden}.when.svelte-1uha8ag{color:var(--text-dim);font-size:12px}.report-meta.svelte-1uha8ag{color:var(--text-dim);justify-content:space-between;align-items:baseline;gap:12px;margin-top:6px;font-size:12px;display:flex}.report-base.svelte-1uha8ag{text-overflow:ellipsis;white-space:nowrap;min-width:0;overflow:hidden}.base-arrow.svelte-1uha8ag{color:var(--text-faint)}.report-counts.svelte-1uha8ag{flex-shrink:0;align-items:baseline;gap:8px;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}}