semantic-review 0.1.0
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/LICENSE +21 -0
- package/README.md +35 -0
- package/dist/cli.js +772 -0
- package/dist/semantic-review-darwin-arm64 +0 -0
- package/package.json +35 -0
- package/semantic-review.webp +0 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mikkel Malmberg
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
<img src="semantic-review.webp" width="452">
|
|
2
|
+
|
|
3
|
+
# semantic-review
|
|
4
|
+
|
|
5
|
+
> Tell me that again but _slowly_...
|
|
6
|
+
|
|
7
|
+
Semantic diff review for coding agents.
|
|
8
|
+
|
|
9
|
+
An LLM reads a git diff and reorganizes it into a **narrative HTML report** — grouped by concern, not alphabetically, with the interesting excerpts inline, highlighted and commentable. When the reviewer clicks **Done**, their comments print to `stdout` as plaintext for the agent that invoked it.
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
agent runs `semantic-review` ──► LLM analyzes the diff ──► browser opens the report
|
|
13
|
+
agent reads stdout ◄── plaintext feedback ◄── human comments, clicks Done
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Requires Node >= 20 (or [Bun](https://bun.sh)) and one backend: `ANTHROPIC_API_KEY`, or an installed `claude`, `codex`, `gemini`, or `pi` CLI.
|
|
17
|
+
|
|
18
|
+
```sh
|
|
19
|
+
npx semantic-review
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
Run it like `$ git diff`:
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
semantic-review # review uncommitted changes
|
|
27
|
+
semantic-review main...HEAD # review a branch
|
|
28
|
+
git diff -U10 | semantic-review # review any piped diff
|
|
29
|
+
semantic-review --with anthropic,codex # one analysis per backend, tabbed
|
|
30
|
+
semantic-review --model claude-sonnet-5 --effort medium
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Tell your agent (`CLAUDE.md`, `AGENTS.md`, …):
|
|
34
|
+
|
|
35
|
+
> After completing a substantial change, run `npx semantic-review` and wait for it to exit. Its stdout is the user's review feedback — treat each comment as a change request and address it.
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,772 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/diff.ts
|
|
4
|
+
function parseDiff(diff) {
|
|
5
|
+
const files = [];
|
|
6
|
+
let file = null;
|
|
7
|
+
let hunk = null;
|
|
8
|
+
let oldNo = 0;
|
|
9
|
+
let newNo = 0;
|
|
10
|
+
let hunkCount = 0;
|
|
11
|
+
const lines = diff.split("\n");
|
|
12
|
+
if (lines[lines.length - 1] === "") lines.pop();
|
|
13
|
+
for (const line of lines) {
|
|
14
|
+
if (line.startsWith("diff --git ")) {
|
|
15
|
+
const m = line.match(/^diff --git a\/(.*) b\/(.*)$/);
|
|
16
|
+
file = {
|
|
17
|
+
path: m ? m[2] : line.slice("diff --git ".length),
|
|
18
|
+
oldPath: m ? m[1] : "",
|
|
19
|
+
status: "modified",
|
|
20
|
+
hunks: []
|
|
21
|
+
};
|
|
22
|
+
files.push(file);
|
|
23
|
+
hunk = null;
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
if (!file) continue;
|
|
27
|
+
if (line.startsWith("new file mode")) file.status = "added";
|
|
28
|
+
else if (line.startsWith("deleted file mode")) file.status = "deleted";
|
|
29
|
+
else if (line.startsWith("rename from")) file.status = "renamed";
|
|
30
|
+
else if (line.startsWith("Binary files")) file.status = "binary";
|
|
31
|
+
const hm = line.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
|
|
32
|
+
if (hm) {
|
|
33
|
+
oldNo = parseInt(hm[1], 10);
|
|
34
|
+
newNo = parseInt(hm[2], 10);
|
|
35
|
+
hunk = { id: `h${++hunkCount}`, header: line, lines: [] };
|
|
36
|
+
file.hunks.push(hunk);
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
if (!hunk) continue;
|
|
40
|
+
if (line.startsWith("+")) {
|
|
41
|
+
hunk.lines.push({ kind: "add", oldNo: null, newNo: newNo++, text: line.slice(1) });
|
|
42
|
+
} else if (line.startsWith("-")) {
|
|
43
|
+
hunk.lines.push({ kind: "del", oldNo: oldNo++, newNo: null, text: line.slice(1) });
|
|
44
|
+
} else if (line.startsWith(" ") || line === "") {
|
|
45
|
+
hunk.lines.push({ kind: "context", oldNo: oldNo++, newNo: newNo++, text: line.slice(1) });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return files;
|
|
49
|
+
}
|
|
50
|
+
function hunkById(files) {
|
|
51
|
+
const map = /* @__PURE__ */ new Map();
|
|
52
|
+
for (const file of files) for (const hunk of file.hunks) map.set(hunk.id, { file, hunk });
|
|
53
|
+
return map;
|
|
54
|
+
}
|
|
55
|
+
function diffForModel(files) {
|
|
56
|
+
const parts = [];
|
|
57
|
+
for (const file of files) {
|
|
58
|
+
if (file.status === "binary") {
|
|
59
|
+
parts.push(`### ${file.path} (binary, ${file.status})`);
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
for (const hunk of file.hunks) {
|
|
63
|
+
parts.push(`### hunk ${hunk.id} \u2014 ${file.path} (${file.status}) ${hunk.header}`);
|
|
64
|
+
parts.push(
|
|
65
|
+
hunk.lines.map((l) => {
|
|
66
|
+
const no = l.newNo != null ? `${l.newNo}` : `-${l.oldNo}`;
|
|
67
|
+
const sign = l.kind === "add" ? "+" : l.kind === "del" ? "-" : " ";
|
|
68
|
+
return `${no}|${sign}${l.text}`;
|
|
69
|
+
}).join("\n")
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return parts.join("\n");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// src/backends.ts
|
|
77
|
+
import Anthropic from "@anthropic-ai/sdk";
|
|
78
|
+
import { zodOutputFormat } from "@anthropic-ai/sdk/helpers/zod";
|
|
79
|
+
|
|
80
|
+
// src/analysis.ts
|
|
81
|
+
import { z } from "zod";
|
|
82
|
+
var AnalysisSchema = z.object({
|
|
83
|
+
title: z.string(),
|
|
84
|
+
summary: z.string(),
|
|
85
|
+
diagram: z.string(),
|
|
86
|
+
sections: z.array(
|
|
87
|
+
z.object({
|
|
88
|
+
heading: z.string(),
|
|
89
|
+
intro: z.string(),
|
|
90
|
+
diagram: z.string(),
|
|
91
|
+
snippets: z.array(
|
|
92
|
+
z.object({
|
|
93
|
+
hunk_id: z.string(),
|
|
94
|
+
from: z.number().nullable(),
|
|
95
|
+
to: z.number().nullable(),
|
|
96
|
+
note: z.string()
|
|
97
|
+
})
|
|
98
|
+
)
|
|
99
|
+
})
|
|
100
|
+
),
|
|
101
|
+
notes: z.array(z.string())
|
|
102
|
+
});
|
|
103
|
+
function analysisPrompt(annotatedDiff) {
|
|
104
|
+
return `You are writing a short semantic review guide for a git diff. Traditional diffs list files alphabetically; your job is to reorganize the change into a brief narrative a reviewer can follow in a minute or two. The reviewer also has the full diff available below your guide, so you do NOT need to show everything \u2014 show only the lines that carry the meaning of the change.
|
|
105
|
+
|
|
106
|
+
Each hunk is labeled "hunk hN". Every line is prefixed with its line number in the new file ("42|+code"); deleted lines carry the old-file number prefixed with a minus ("-17|-code").
|
|
107
|
+
|
|
108
|
+
Produce a JSON object with exactly this shape:
|
|
109
|
+
|
|
110
|
+
{
|
|
111
|
+
"title": "short title for the change",
|
|
112
|
+
"summary": "1-2 sentence TL;DR: what the change does and why",
|
|
113
|
+
"diagram": "optional mermaid diagram source, or \\"\\"",
|
|
114
|
+
"sections": [
|
|
115
|
+
{
|
|
116
|
+
"heading": "...",
|
|
117
|
+
"intro": "1-2 sentences introducing this part of the change",
|
|
118
|
+
"diagram": "optional mermaid diagram source for this section, or \\"\\"",
|
|
119
|
+
"snippets": [
|
|
120
|
+
{ "hunk_id": "h3", "from": 40, "to": 48, "note": "one short line bridging to the next snippet (or \\"\\")" }
|
|
121
|
+
]
|
|
122
|
+
}
|
|
123
|
+
],
|
|
124
|
+
"notes": ["risks or things a reviewer should double-check", ...]
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
Rules:
|
|
128
|
+
- Be brief. Section intros are 1-2 sentences; snippet notes are one short line or "". No filler.
|
|
129
|
+
- "diagram" (top level and per section): include a small mermaid diagram ONLY when that part of the change is genuinely graph-shaped (data flowing through new pieces, call-order changes, moved responsibilities) \u2014 a "flowchart LR" or "sequenceDiagram" with 3-8 nodes, no styling directives. The top-level diagram is a whole-change overview; a section diagram covers just that section. Use "" when a diagram would not beat prose \u2014 most small changes need none, and few sections deserve their own.
|
|
130
|
+
- Snippets are EXCERPTS: pick the smallest line range that shows the interesting part (typically 3-12 lines), using the line-number prefixes. from/to use new-file numbers; for ranges of deleted lines use the old-file numbers (positive). Use null for both to include the whole hunk \u2014 only when the hunk is already small.
|
|
131
|
+
- Group by code path / concern, not by file. Order sections by importance: core change first.
|
|
132
|
+
- Purely mechanical changes (renames, lockfiles, formatting) need no snippet \u2014 mention them in one sentence in an intro or the summary.
|
|
133
|
+
- Prose is plain text; use backticks for identifiers. No markdown headings.
|
|
134
|
+
- "notes" is for behavior changes, missing tests, edge cases, inconsistencies. Empty array if none.
|
|
135
|
+
- Respond with ONLY the JSON object, no fences, no commentary.
|
|
136
|
+
|
|
137
|
+
The diff:
|
|
138
|
+
|
|
139
|
+
${annotatedDiff}`;
|
|
140
|
+
}
|
|
141
|
+
function extractAnalysis(raw) {
|
|
142
|
+
let text = raw.trim();
|
|
143
|
+
const fence = text.match(/```(?:json)?\s*([\s\S]*?)```/);
|
|
144
|
+
if (fence) text = fence[1].trim();
|
|
145
|
+
if (!text.startsWith("{")) {
|
|
146
|
+
const start = text.indexOf("{");
|
|
147
|
+
const end = text.lastIndexOf("}");
|
|
148
|
+
if (start === -1 || end === -1) throw new Error(`no JSON object found in output:
|
|
149
|
+
${raw.slice(0, 500)}`);
|
|
150
|
+
text = text.slice(start, end + 1);
|
|
151
|
+
}
|
|
152
|
+
return AnalysisSchema.parse(JSON.parse(text));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// src/proc.ts
|
|
156
|
+
import { spawn } from "node:child_process";
|
|
157
|
+
function run(argv, stdin) {
|
|
158
|
+
return new Promise((resolve, reject) => {
|
|
159
|
+
const proc = spawn(argv[0], argv.slice(1), { stdio: ["pipe", "pipe", "pipe"] });
|
|
160
|
+
let stdout = "";
|
|
161
|
+
let stderr = "";
|
|
162
|
+
proc.stdout.setEncoding("utf8").on("data", (chunk) => stdout += chunk);
|
|
163
|
+
proc.stderr.setEncoding("utf8").on("data", (chunk) => stderr += chunk);
|
|
164
|
+
proc.on("error", reject);
|
|
165
|
+
proc.on("close", (code) => resolve({ stdout, stderr, code: code ?? 1 }));
|
|
166
|
+
if (stdin !== void 0) proc.stdin.write(stdin);
|
|
167
|
+
proc.stdin.end();
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// src/backends.ts
|
|
172
|
+
function haveCommand(cmd) {
|
|
173
|
+
return run(["sh", "-c", `command -v ${cmd}`]).then(
|
|
174
|
+
({ code }) => code === 0,
|
|
175
|
+
() => false
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
function cliBackend(name, argv) {
|
|
179
|
+
return {
|
|
180
|
+
name,
|
|
181
|
+
available: () => haveCommand(argv({})[0]),
|
|
182
|
+
async analyze(annotatedDiff, opts) {
|
|
183
|
+
const { stdout, stderr, code } = await run(argv(opts), analysisPrompt(annotatedDiff));
|
|
184
|
+
if (code !== 0) throw new Error(`${name} exited ${code}: ${stderr.slice(0, 500)}`);
|
|
185
|
+
return extractAnalysis(stdout);
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
var anthropicBackend = {
|
|
190
|
+
name: "anthropic",
|
|
191
|
+
available: async () => !!(process.env.ANTHROPIC_API_KEY || process.env.ANTHROPIC_AUTH_TOKEN),
|
|
192
|
+
async analyze(annotatedDiff, opts) {
|
|
193
|
+
const client = new Anthropic();
|
|
194
|
+
const stream = client.messages.stream({
|
|
195
|
+
model: opts.model || process.env.SEMANTIC_REVIEW_MODEL || "claude-opus-5",
|
|
196
|
+
max_tokens: 32e3,
|
|
197
|
+
output_config: {
|
|
198
|
+
format: zodOutputFormat(AnalysisSchema),
|
|
199
|
+
// SDK typings lag the API: "xhigh" is valid but missing from the union.
|
|
200
|
+
...opts.effort ? { effort: opts.effort } : {}
|
|
201
|
+
},
|
|
202
|
+
messages: [{ role: "user", content: analysisPrompt(annotatedDiff) }]
|
|
203
|
+
});
|
|
204
|
+
const message = await stream.finalMessage();
|
|
205
|
+
const text = message.content.find((b) => b.type === "text");
|
|
206
|
+
if (!text || text.type !== "text") throw new Error(`no text in response (stop_reason: ${message.stop_reason})`);
|
|
207
|
+
return AnalysisSchema.parse(JSON.parse(text.text));
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
var modelFlag = (o, flag = "--model") => o.model ? [flag, o.model] : [];
|
|
211
|
+
var BACKENDS = {
|
|
212
|
+
anthropic: anthropicBackend,
|
|
213
|
+
claude: cliBackend("claude", (o) => ["claude", "-p", ...modelFlag(o)]),
|
|
214
|
+
codex: cliBackend("codex", (o) => ["codex", "exec", "--skip-git-repo-check", ...modelFlag(o, "-m"), "-"]),
|
|
215
|
+
gemini: cliBackend("gemini", (o) => ["gemini", ...modelFlag(o)]),
|
|
216
|
+
pi: cliBackend("pi", (o) => ["pi", "-p", "--no-session", "--no-tools", ...modelFlag(o)])
|
|
217
|
+
};
|
|
218
|
+
async function resolveBackends(requested) {
|
|
219
|
+
if (requested && requested.length > 0) {
|
|
220
|
+
return requested.map((name) => {
|
|
221
|
+
const backend = BACKENDS[name];
|
|
222
|
+
if (!backend) throw new Error(`unknown backend "${name}" (known: ${Object.keys(BACKENDS).join(", ")})`);
|
|
223
|
+
return backend;
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
for (const backend of Object.values(BACKENDS)) {
|
|
227
|
+
if (await backend.available()) return [backend];
|
|
228
|
+
}
|
|
229
|
+
throw new Error(
|
|
230
|
+
"no backend available: set ANTHROPIC_API_KEY, or install one of: claude, codex, gemini (or pass --with)"
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
async function runBackends(backends, annotatedDiff, opts = {}) {
|
|
234
|
+
const settled = await Promise.allSettled(
|
|
235
|
+
backends.map(async (b) => ({ backend: b.name, analysis: await b.analyze(annotatedDiff, opts) }))
|
|
236
|
+
);
|
|
237
|
+
const results = [];
|
|
238
|
+
for (let i = 0; i < settled.length; i++) {
|
|
239
|
+
const s = settled[i];
|
|
240
|
+
if (s.status === "fulfilled") results.push(s.value);
|
|
241
|
+
else console.error(`semantic-review: backend ${backends[i].name} failed: ${s.reason?.message ?? s.reason}`);
|
|
242
|
+
}
|
|
243
|
+
if (results.length === 0) throw new Error("all backends failed");
|
|
244
|
+
return results;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// src/render.ts
|
|
248
|
+
import { createHighlighter, bundledLanguages } from "shiki";
|
|
249
|
+
var LANG_BY_EXT = {
|
|
250
|
+
ts: "typescript",
|
|
251
|
+
tsx: "tsx",
|
|
252
|
+
js: "javascript",
|
|
253
|
+
jsx: "jsx",
|
|
254
|
+
mjs: "javascript",
|
|
255
|
+
cjs: "javascript",
|
|
256
|
+
rb: "ruby",
|
|
257
|
+
py: "python",
|
|
258
|
+
go: "go",
|
|
259
|
+
rs: "rust",
|
|
260
|
+
java: "java",
|
|
261
|
+
kt: "kotlin",
|
|
262
|
+
swift: "swift",
|
|
263
|
+
c: "c",
|
|
264
|
+
h: "c",
|
|
265
|
+
cc: "cpp",
|
|
266
|
+
cpp: "cpp",
|
|
267
|
+
hpp: "cpp",
|
|
268
|
+
cs: "csharp",
|
|
269
|
+
php: "php",
|
|
270
|
+
sh: "shellscript",
|
|
271
|
+
bash: "shellscript",
|
|
272
|
+
zsh: "shellscript",
|
|
273
|
+
fish: "fish",
|
|
274
|
+
html: "html",
|
|
275
|
+
erb: "erb",
|
|
276
|
+
css: "css",
|
|
277
|
+
scss: "scss",
|
|
278
|
+
json: "json",
|
|
279
|
+
yml: "yaml",
|
|
280
|
+
yaml: "yaml",
|
|
281
|
+
toml: "toml",
|
|
282
|
+
md: "markdown",
|
|
283
|
+
sql: "sql",
|
|
284
|
+
ex: "elixir",
|
|
285
|
+
exs: "elixir",
|
|
286
|
+
vue: "vue",
|
|
287
|
+
svelte: "svelte"
|
|
288
|
+
};
|
|
289
|
+
function langFor(path) {
|
|
290
|
+
const ext = path.split(".").pop()?.toLowerCase() ?? "";
|
|
291
|
+
const lang = LANG_BY_EXT[ext];
|
|
292
|
+
return lang && lang in bundledLanguages ? lang : "text";
|
|
293
|
+
}
|
|
294
|
+
var esc = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
295
|
+
function prose(text) {
|
|
296
|
+
return text.split(/\n\n+/).map((p) => `<p>${esc(p).replace(/`([^`]+)`/g, "<code>$1</code>")}</p>`).join("");
|
|
297
|
+
}
|
|
298
|
+
function tokenStyle(token) {
|
|
299
|
+
if (token.htmlStyle) {
|
|
300
|
+
if (typeof token.htmlStyle === "string") return token.htmlStyle;
|
|
301
|
+
return Object.entries(token.htmlStyle).map(([k, v]) => `${k}:${v}`).join(";");
|
|
302
|
+
}
|
|
303
|
+
return token.color ? `color:${token.color}` : "";
|
|
304
|
+
}
|
|
305
|
+
var CARD = "rounded-lg border border-stone-200 bg-white dark:border-stone-800 dark:bg-stone-900";
|
|
306
|
+
function renderHunk(hl, file, hunk, range) {
|
|
307
|
+
const lang = langFor(file.path);
|
|
308
|
+
const code = hunk.lines.map((l) => l.text).join("\n");
|
|
309
|
+
let tokenLines;
|
|
310
|
+
try {
|
|
311
|
+
tokenLines = hl.codeToTokens(code, {
|
|
312
|
+
lang,
|
|
313
|
+
themes: { light: "github-light", dark: "github-dark" }
|
|
314
|
+
}).tokens;
|
|
315
|
+
} catch {
|
|
316
|
+
tokenLines = hunk.lines.map((l) => [{ content: l.text }]);
|
|
317
|
+
}
|
|
318
|
+
let indices = hunk.lines.map((_, i) => i);
|
|
319
|
+
if (range) {
|
|
320
|
+
const within = (n) => n != null && n >= range.from && n <= range.to;
|
|
321
|
+
const sliced = indices.filter((i) => within(hunk.lines[i].newNo) || within(hunk.lines[i].oldNo));
|
|
322
|
+
if (sliced.length > 0) indices = sliced;
|
|
323
|
+
}
|
|
324
|
+
const elided = indices.length < hunk.lines.length;
|
|
325
|
+
const rows = indices.map((i) => {
|
|
326
|
+
const line = hunk.lines[i];
|
|
327
|
+
const tokens = tokenLines[i] ?? [{ content: line.text }];
|
|
328
|
+
const codeHtml = tokens.map((t) => `<span style="${esc(tokenStyle(t))}">${esc(t.content)}</span>`).join("") || " ";
|
|
329
|
+
const lineRef = line.newNo != null ? `${file.path}:${line.newNo}` : `${file.path}:${line.oldNo} (old)`;
|
|
330
|
+
return `<tr class="${line.kind}" data-ref="${esc(lineRef)}">
|
|
331
|
+
<td class="g">${line.oldNo ?? ""}</td><td class="g">${line.newNo ?? ""}</td>
|
|
332
|
+
<td class="m">${line.kind === "add" ? "+" : line.kind === "del" ? "\u2212" : ""}</td>
|
|
333
|
+
<td class="c"><button class="lc" title="Comment on this line">\uFF0B</button>${codeHtml}</td>
|
|
334
|
+
</tr>`;
|
|
335
|
+
});
|
|
336
|
+
return `<div class="hunk ${CARD} my-3 overflow-hidden">
|
|
337
|
+
<div class="flex gap-3 border-b border-stone-200 bg-stone-100 px-3 py-1.5 font-mono text-xs dark:border-stone-800 dark:bg-stone-950/60">
|
|
338
|
+
<span class="font-semibold">${esc(file.path)}</span>
|
|
339
|
+
<span class="uppercase tracking-wider text-stone-400">${elided ? "excerpt" : ""}</span>
|
|
340
|
+
<span class="text-stone-400">${esc(hunk.header)}</span>
|
|
341
|
+
</div>
|
|
342
|
+
<table class="diff"><tbody>${rows.join("")}</tbody></table>
|
|
343
|
+
</div>`;
|
|
344
|
+
}
|
|
345
|
+
async function renderReport(results, files) {
|
|
346
|
+
const langs = [...new Set(files.map((f) => langFor(f.path)).filter((l) => l !== "text"))];
|
|
347
|
+
const hl = await createHighlighter({ themes: ["github-light", "github-dark"], langs });
|
|
348
|
+
const hunks = hunkById(files);
|
|
349
|
+
const anyDiagram = results.some(
|
|
350
|
+
(r) => r.analysis.diagram.trim() || r.analysis.sections.some((s) => s.diagram.trim())
|
|
351
|
+
);
|
|
352
|
+
const diagramCard = (source) => source.trim() ? `<div class="${CARD} my-4 p-4"><pre class="mermaid">${esc(source)}</pre></div>` : "";
|
|
353
|
+
const agentOptions = results.map((r, i) => `<option value="${i}">${esc(r.backend)}</option>`).join("");
|
|
354
|
+
const panels = results.map((r, i) => {
|
|
355
|
+
const toc = r.analysis.sections.map((s, si) => `<a class="text-indigo-600 hover:underline dark:text-indigo-400" href="#p${i}s${si}">${esc(s.heading)}</a>`).join("");
|
|
356
|
+
const sections = r.analysis.sections.map((s, si) => {
|
|
357
|
+
const snippets = s.snippets.map((sn) => {
|
|
358
|
+
const found = hunks.get(sn.hunk_id);
|
|
359
|
+
if (!found) return `<p class="text-sm italic text-stone-400">unknown hunk ${esc(sn.hunk_id)}</p>`;
|
|
360
|
+
const range = sn.from != null && sn.to != null ? { from: sn.from, to: sn.to } : null;
|
|
361
|
+
const note = sn.note.trim() ? `<p class="note mb-4 text-[13.5px] text-stone-500 dark:text-stone-400">${esc(sn.note).replace(/`([^`]+)`/g, "<code>$1</code>")}</p>` : "";
|
|
362
|
+
return renderHunk(hl, found.file, found.hunk, range) + note;
|
|
363
|
+
}).join("");
|
|
364
|
+
return `<section id="p${i}s${si}" data-ctx="${esc(s.heading)}" class="mt-10">
|
|
365
|
+
<h2 class="font-serif text-xl">${esc(s.heading)}</h2>
|
|
366
|
+
<div class="prose-x">${prose(s.intro)}</div>
|
|
367
|
+
${diagramCard(s.diagram)}
|
|
368
|
+
${snippets}
|
|
369
|
+
</section>`;
|
|
370
|
+
}).join("");
|
|
371
|
+
const notes = r.analysis.notes.length ? `<section data-ctx="Agent's notes" class="agent-notes mt-10">
|
|
372
|
+
<div class="flex items-baseline justify-between gap-4">
|
|
373
|
+
<h2 class="font-serif text-xl">Agent’s notes</h2>
|
|
374
|
+
<label class="flex cursor-pointer items-center gap-1.5 text-sm text-stone-500 dark:text-stone-400">
|
|
375
|
+
<input type="checkbox" class="include-notes accent-indigo-600"> Include in review
|
|
376
|
+
</label>
|
|
377
|
+
</div>
|
|
378
|
+
<ul class="mt-2 list-disc space-y-1 pl-5">${r.analysis.notes.map((n) => `<li>${esc(n).replace(/`([^`]+)`/g, "<code>$1</code>")}</li>`).join("")}</ul>
|
|
379
|
+
</section>` : "";
|
|
380
|
+
return `<div class="panel${i === 0 ? " active" : ""}" data-panel="${i}" data-backend="${esc(r.backend)}">
|
|
381
|
+
<div class="tldr ${CARD} p-5" data-ctx="TL;DR">
|
|
382
|
+
<span class="text-xs font-bold uppercase tracking-wider text-indigo-600 dark:text-indigo-400">TL;DR</span>
|
|
383
|
+
<div class="prose-x">${prose(r.analysis.summary)}</div>
|
|
384
|
+
${r.analysis.sections.length > 1 ? `<nav class="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-sm">${toc}</nav>` : ""}
|
|
385
|
+
</div>
|
|
386
|
+
${diagramCard(r.analysis.diagram)}
|
|
387
|
+
${sections}${notes}
|
|
388
|
+
</div>`;
|
|
389
|
+
}).join("");
|
|
390
|
+
const fullDiff = files.map((file) => {
|
|
391
|
+
const adds = file.hunks.reduce((n, h) => n + h.lines.filter((l) => l.kind === "add").length, 0);
|
|
392
|
+
const dels = file.hunks.reduce((n, h) => n + h.lines.filter((l) => l.kind === "del").length, 0);
|
|
393
|
+
const body = file.status === "binary" ? `<p class="text-sm italic text-stone-400">binary file</p>` : file.hunks.map((h) => renderHunk(hl, file, h)).join("");
|
|
394
|
+
return `<details class="my-2" data-ctx="Full diff: ${esc(file.path)}">
|
|
395
|
+
<summary class="${CARD} flex cursor-pointer items-baseline gap-3 px-3 py-2">
|
|
396
|
+
<span class="flex-1 font-mono text-sm font-semibold">${esc(file.path)}</span>
|
|
397
|
+
<span class="text-xs text-stone-500">
|
|
398
|
+
<b class="font-semibold text-emerald-600 dark:text-emerald-400">+${adds}</b>
|
|
399
|
+
<b class="font-semibold text-red-600 dark:text-red-400">\u2212${dels}</b>${file.status !== "modified" ? ` \xB7 ${file.status}` : ""}</span>
|
|
400
|
+
</summary>
|
|
401
|
+
${body}
|
|
402
|
+
</details>`;
|
|
403
|
+
}).join("");
|
|
404
|
+
const title = results[0].analysis.title;
|
|
405
|
+
return `<!doctype html>
|
|
406
|
+
<html lang="en">
|
|
407
|
+
<head>
|
|
408
|
+
<meta charset="utf-8">
|
|
409
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
410
|
+
<title>${esc(title)} \u2014 semantic-review</title>
|
|
411
|
+
<script src="https://cdn.tailwindcss.com"></script>
|
|
412
|
+
<style>
|
|
413
|
+
/* Custom layer for what Tailwind doesn't cover cleanly: diff tables, shiki
|
|
414
|
+
dual themes, brevity-level visibility, and the dynamic comment UI. */
|
|
415
|
+
:root { --add-bg: #dafbe1; --del-bg: #ffebe9; color-scheme: light dark; }
|
|
416
|
+
@media (prefers-color-scheme: dark) {
|
|
417
|
+
:root { --add-bg: #12261e; --del-bg: #2d1214; }
|
|
418
|
+
.diff span[style] { color: var(--shiki-dark, inherit) !important; }
|
|
419
|
+
}
|
|
420
|
+
.prose-x p { margin: 6px 0; }
|
|
421
|
+
code { background: rgb(245 245 244 / .8); border: 1px solid rgb(214 211 209); border-radius: 4px;
|
|
422
|
+
padding: 1px 4px; font: 12.5px ui-monospace, SFMono-Regular, Menlo, monospace; }
|
|
423
|
+
@media (prefers-color-scheme: dark) { code { background: rgb(28 25 23); border-color: rgb(41 37 36); } }
|
|
424
|
+
.panel { display: none; } .panel.active { display: block; }
|
|
425
|
+
/* Brevity levels: tldr = summary + notes; walk = + sections; full = full diff only */
|
|
426
|
+
body.level-tldr section { display: none; }
|
|
427
|
+
body.level-tldr section[data-ctx="Agent's notes"] { display: block; }
|
|
428
|
+
body.level-tldr #fulldiff, body.level-walk #fulldiff { display: none; }
|
|
429
|
+
body.level-full .panel.active, body.level-full #agent { display: none; }
|
|
430
|
+
.levels button.active { background: rgb(231 229 228); color: inherit; font-weight: 600; }
|
|
431
|
+
@media (prefers-color-scheme: dark) { .levels button.active { background: rgb(41 37 36); } }
|
|
432
|
+
.diff { width: 100%; border-collapse: collapse; font: 12.5px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; }
|
|
433
|
+
.diff td { padding: 0 8px; white-space: pre-wrap; word-break: break-all; vertical-align: top; }
|
|
434
|
+
.diff .g { width: 1%; min-width: 34px; text-align: right; color: rgb(168 162 158); user-select: none;
|
|
435
|
+
white-space: nowrap; word-break: normal; }
|
|
436
|
+
.diff .m { width: 1%; user-select: none; color: rgb(168 162 158); }
|
|
437
|
+
.diff tr.add td { background: var(--add-bg); } .diff tr.del td { background: var(--del-bg); }
|
|
438
|
+
.diff .c { position: relative; padding-left: 26px; }
|
|
439
|
+
.lc { position: absolute; left: 2px; top: 1px; width: 18px; height: 18px; border-radius: 4px; border: 0;
|
|
440
|
+
background: #4f46e5; color: #fff; font-size: 12px; line-height: 1; cursor: pointer;
|
|
441
|
+
opacity: 0; transition: opacity .1s; padding: 0; }
|
|
442
|
+
tr:hover .lc { opacity: 1; }
|
|
443
|
+
.mermaid { display: flex; justify-content: center; }
|
|
444
|
+
#bubble { position: absolute; display: none; z-index: 10; background: #4f46e5; color: #fff; border: 0;
|
|
445
|
+
border-radius: 6px; padding: 5px 10px; font-size: 13px; cursor: pointer; box-shadow: 0 2px 8px rgba(0,0,0,.25); }
|
|
446
|
+
#composer blockquote, .comment-card blockquote { margin: 6px 0 8px; padding: 4px 8px;
|
|
447
|
+
border-left: 3px solid #4f46e5; font: 11.5px ui-monospace, Menlo, monospace;
|
|
448
|
+
white-space: pre-wrap; word-break: break-all; max-height: 90px; overflow: auto; }
|
|
449
|
+
.comment-card blockquote { border-left-color: rgb(214 211 209); color: rgb(120 113 108); overflow: hidden; }
|
|
450
|
+
</style>
|
|
451
|
+
</head>
|
|
452
|
+
<body class="level-walk bg-stone-50 font-sans text-slate-900 dark:bg-stone-950 dark:text-stone-100">
|
|
453
|
+
<header class="sticky top-0 z-20 flex items-center gap-3 border-b border-stone-200 bg-stone-50/90 px-6 py-2.5 backdrop-blur dark:border-stone-800 dark:bg-stone-950/90">
|
|
454
|
+
<span class="text-sm text-stone-400">semantic-review</span>
|
|
455
|
+
<h1 class="flex-1 truncate font-serif text-lg">${esc(title)}</h1>
|
|
456
|
+
${results.length > 1 ? `<select id="agent" title="Analysis by" class="rounded-lg border border-stone-200 bg-white px-2 py-1.5 text-sm dark:border-stone-800 dark:bg-stone-900">${agentOptions}</select>` : ""}
|
|
457
|
+
<div class="levels inline-flex overflow-hidden rounded-lg border border-stone-200 text-sm text-stone-500 dark:border-stone-800 dark:text-stone-400">
|
|
458
|
+
<button data-level="tldr" class="border-r border-stone-200 px-3.5 py-1.5 dark:border-stone-800">TL;DR</button>
|
|
459
|
+
<button data-level="walk" class="active border-r border-stone-200 px-3.5 py-1.5 dark:border-stone-800">Walkthrough</button>
|
|
460
|
+
<button data-level="full" class="px-3.5 py-1.5">Full diff</button>
|
|
461
|
+
</div>
|
|
462
|
+
<button id="done" class="rounded-lg bg-indigo-600 px-4 py-1.5 text-sm font-semibold text-white hover:bg-indigo-700">Done</button>
|
|
463
|
+
</header>
|
|
464
|
+
<main class="grid grid-cols-[minmax(0,1fr)_320px]">
|
|
465
|
+
<div id="report" class="max-w-4xl px-7 pb-24 pt-6">
|
|
466
|
+
${panels}
|
|
467
|
+
<div id="fulldiff" data-ctx="Full diff">
|
|
468
|
+
${fullDiff}
|
|
469
|
+
</div>
|
|
470
|
+
</div>
|
|
471
|
+
<aside class="sticky top-[53px] h-[calc(100vh-53px)] overflow-y-auto border-l border-stone-200 p-4 dark:border-stone-800">
|
|
472
|
+
<h3 class="mb-2 text-xs font-bold uppercase tracking-wider text-stone-400">Comments</h3>
|
|
473
|
+
<div id="comments"><p id="empty" class="text-sm text-stone-400">Select any text or hover a line and hit \uFF0B to comment.</p></div>
|
|
474
|
+
<h3 class="mb-2 mt-6 text-xs font-bold uppercase tracking-wider text-stone-400">Overall</h3>
|
|
475
|
+
<textarea id="overall" placeholder="Optional overall feedback\u2026" class="min-h-[70px] w-full resize-y rounded-lg border border-stone-200 bg-white p-2 text-sm dark:border-stone-800 dark:bg-stone-900"></textarea>
|
|
476
|
+
</aside>
|
|
477
|
+
</main>
|
|
478
|
+
<button id="bubble">\u{1F4AC} Comment</button>
|
|
479
|
+
<div id="composer" class="fixed bottom-5 right-[340px] z-30 hidden w-[380px] rounded-xl border border-stone-200 bg-white p-3 shadow-2xl dark:border-stone-800 dark:bg-stone-900">
|
|
480
|
+
<div class="ref mb-1.5 font-mono text-[11px] text-stone-400 [word-break:break-all]"></div>
|
|
481
|
+
<blockquote></blockquote>
|
|
482
|
+
<textarea placeholder="Write a comment\u2026 (\u2318\u23CE to save)" class="min-h-[80px] w-full resize-y rounded-lg border border-stone-200 bg-white p-2 text-sm dark:border-stone-700 dark:bg-stone-950"></textarea>
|
|
483
|
+
<div class="mt-2 flex justify-end gap-2 text-sm">
|
|
484
|
+
<button class="cancel rounded-lg border border-stone-200 px-3 py-1 dark:border-stone-700">Cancel</button>
|
|
485
|
+
<button class="save rounded-lg bg-indigo-600 px-3 py-1 font-semibold text-white">Save</button>
|
|
486
|
+
</div>
|
|
487
|
+
</div>
|
|
488
|
+
<div id="finished" class="hidden px-5 py-24 text-center">
|
|
489
|
+
<h2 class="font-serif text-2xl">Review sent \u2713</h2>
|
|
490
|
+
<p class="mt-2 text-stone-500">Feedback was delivered back to the agent. You can close this tab.</p>
|
|
491
|
+
</div>
|
|
492
|
+
<script type="module">
|
|
493
|
+
${anyDiagram ? `import mermaid from "https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs";
|
|
494
|
+
mermaid.initialize({
|
|
495
|
+
startOnLoad: false,
|
|
496
|
+
theme: matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "neutral",
|
|
497
|
+
securityLevel: "loose",
|
|
498
|
+
});
|
|
499
|
+
async function renderDiagrams() {
|
|
500
|
+
const nodes = [...document.querySelectorAll(".panel.active .mermaid:not([data-processed])")];
|
|
501
|
+
if (nodes.length) await mermaid.run({ nodes }).catch(console.error);
|
|
502
|
+
}
|
|
503
|
+
renderDiagrams();` : `function renderDiagrams() {}`}
|
|
504
|
+
|
|
505
|
+
const comments = [];
|
|
506
|
+
const $ = (s, el) => (el || document).querySelector(s);
|
|
507
|
+
const multiTab = ${results.length > 1 ? "true" : "false"};
|
|
508
|
+
|
|
509
|
+
// Agent selector
|
|
510
|
+
const agentSelect = $("#agent");
|
|
511
|
+
if (agentSelect) agentSelect.addEventListener("change", () => {
|
|
512
|
+
document.querySelectorAll(".panel").forEach(p =>
|
|
513
|
+
p.classList.toggle("active", p.dataset.panel === agentSelect.value));
|
|
514
|
+
renderDiagrams();
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
// Brevity levels
|
|
518
|
+
document.querySelectorAll(".levels button").forEach(btn => btn.addEventListener("click", () => {
|
|
519
|
+
document.querySelectorAll(".levels button").forEach(b => b.classList.toggle("active", b === btn));
|
|
520
|
+
document.body.className = document.body.className.replace(/level-\\w+/, "level-" + btn.dataset.level);
|
|
521
|
+
renderDiagrams();
|
|
522
|
+
}));
|
|
523
|
+
|
|
524
|
+
function currentBackend() {
|
|
525
|
+
const panel = $(".panel.active");
|
|
526
|
+
return panel ? panel.dataset.backend : "";
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// Comment composer
|
|
530
|
+
const composer = $("#composer");
|
|
531
|
+
let pending = null;
|
|
532
|
+
function openComposer(ref, quote) {
|
|
533
|
+
pending = { ref, quote, backend: currentBackend() };
|
|
534
|
+
$(".ref", composer).textContent = ref;
|
|
535
|
+
$("blockquote", composer).textContent = quote || "";
|
|
536
|
+
$("blockquote", composer).style.display = quote ? "" : "none";
|
|
537
|
+
$("textarea", composer).value = "";
|
|
538
|
+
composer.style.display = "block";
|
|
539
|
+
$("textarea", composer).focus();
|
|
540
|
+
}
|
|
541
|
+
function closeComposer() { composer.style.display = "none"; pending = null; }
|
|
542
|
+
$(".cancel", composer).addEventListener("click", closeComposer);
|
|
543
|
+
function saveComment() {
|
|
544
|
+
const text = $("textarea", composer).value.trim();
|
|
545
|
+
if (!text || !pending) return;
|
|
546
|
+
comments.push({ ...pending, text });
|
|
547
|
+
closeComposer();
|
|
548
|
+
renderComments();
|
|
549
|
+
}
|
|
550
|
+
$(".save", composer).addEventListener("click", saveComment);
|
|
551
|
+
$("textarea", composer).addEventListener("keydown", e => {
|
|
552
|
+
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") saveComment();
|
|
553
|
+
if (e.key === "Escape") closeComposer();
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
function renderComments() {
|
|
557
|
+
const box = $("#comments");
|
|
558
|
+
if (!comments.length) { box.innerHTML = '<p id="empty" class="text-sm text-stone-400">Select any text or hover a line and hit \uFF0B to comment.</p>'; return; }
|
|
559
|
+
box.innerHTML = "";
|
|
560
|
+
comments.forEach((c, i) => {
|
|
561
|
+
const card = document.createElement("div");
|
|
562
|
+
card.className = "comment-card mb-2.5 rounded-lg border border-stone-200 bg-white p-2.5 text-sm dark:border-stone-800 dark:bg-stone-900";
|
|
563
|
+
const ref = document.createElement("div");
|
|
564
|
+
ref.className = "font-mono text-[11px] text-stone-400 [word-break:break-all]";
|
|
565
|
+
ref.textContent = (multiTab && c.backend ? "[" + c.backend + "] " : "") + c.ref;
|
|
566
|
+
const del = document.createElement("button");
|
|
567
|
+
del.className = "float-right text-stone-400 hover:text-stone-600";
|
|
568
|
+
del.textContent = "\u2715";
|
|
569
|
+
del.addEventListener("click", () => { comments.splice(i, 1); renderComments(); });
|
|
570
|
+
card.append(del, ref);
|
|
571
|
+
if (c.quote) { const q = document.createElement("blockquote"); q.textContent = c.quote; card.append(q); }
|
|
572
|
+
const body = document.createElement("div"); body.textContent = c.text; card.append(body);
|
|
573
|
+
box.append(card);
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// Line comments
|
|
578
|
+
document.querySelectorAll(".lc").forEach(btn => btn.addEventListener("click", e => {
|
|
579
|
+
e.stopPropagation();
|
|
580
|
+
const tr = btn.closest("tr");
|
|
581
|
+
openComposer(tr.dataset.ref, tr.querySelector(".c").textContent.replace(/^\uFF0B/, ""));
|
|
582
|
+
}));
|
|
583
|
+
|
|
584
|
+
// Selection comments
|
|
585
|
+
const bubble = $("#bubble");
|
|
586
|
+
document.addEventListener("mouseup", e => {
|
|
587
|
+
if (composer.contains(e.target) || bubble.contains(e.target)) return;
|
|
588
|
+
setTimeout(() => {
|
|
589
|
+
const sel = window.getSelection();
|
|
590
|
+
if (!sel || sel.isCollapsed || !sel.toString().trim()) { bubble.style.display = "none"; return; }
|
|
591
|
+
const rect = sel.getRangeAt(0).getBoundingClientRect();
|
|
592
|
+
bubble.style.display = "block";
|
|
593
|
+
bubble.style.left = Math.max(8, rect.left + window.scrollX) + "px";
|
|
594
|
+
bubble.style.top = (rect.bottom + window.scrollY + 6) + "px";
|
|
595
|
+
}, 0);
|
|
596
|
+
});
|
|
597
|
+
bubble.addEventListener("click", () => {
|
|
598
|
+
const sel = window.getSelection();
|
|
599
|
+
if (!sel || sel.isCollapsed) return;
|
|
600
|
+
const quote = sel.toString().trim();
|
|
601
|
+
let node = sel.anchorNode;
|
|
602
|
+
if (node && node.nodeType === Node.TEXT_NODE) node = node.parentElement;
|
|
603
|
+
const tr = node && node.closest ? node.closest("tr[data-ref]") : null;
|
|
604
|
+
const section = node && node.closest ? node.closest("[data-ctx]") : null;
|
|
605
|
+
const ref = tr ? tr.dataset.ref : section ? "\xA7 " + section.dataset.ctx : "report";
|
|
606
|
+
bubble.style.display = "none";
|
|
607
|
+
sel.removeAllRanges();
|
|
608
|
+
openComposer(ref, quote);
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
// Done
|
|
612
|
+
$("#done").addEventListener("click", async () => {
|
|
613
|
+
const notes = [...document.querySelectorAll(".include-notes:checked")].map((cb) => {
|
|
614
|
+
const panel = cb.closest(".panel");
|
|
615
|
+
return {
|
|
616
|
+
backend: panel.dataset.backend,
|
|
617
|
+
items: [...panel.querySelectorAll(".agent-notes li")].map((li) => li.textContent.trim()),
|
|
618
|
+
};
|
|
619
|
+
});
|
|
620
|
+
const payload = { comments, overall: $("#overall").value.trim(), notes };
|
|
621
|
+
await fetch("/done", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) });
|
|
622
|
+
$("main").style.display = "none";
|
|
623
|
+
$("#finished").style.display = "block";
|
|
624
|
+
});
|
|
625
|
+
</script>
|
|
626
|
+
</body>
|
|
627
|
+
</html>`;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// src/server.ts
|
|
631
|
+
import { createServer } from "node:http";
|
|
632
|
+
import { spawn as spawn2 } from "node:child_process";
|
|
633
|
+
function serveReview(html, openBrowser) {
|
|
634
|
+
return new Promise((resolve) => {
|
|
635
|
+
const server = createServer((req, res) => {
|
|
636
|
+
const path = new URL(req.url ?? "/", "http://127.0.0.1").pathname;
|
|
637
|
+
if (path === "/" && req.method === "GET") {
|
|
638
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
639
|
+
res.end(html);
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
if (path === "/done" && req.method === "POST") {
|
|
643
|
+
let body = "";
|
|
644
|
+
req.setEncoding("utf8");
|
|
645
|
+
req.on("data", (chunk) => body += chunk);
|
|
646
|
+
req.on("end", () => {
|
|
647
|
+
const result = JSON.parse(body);
|
|
648
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
649
|
+
res.end(JSON.stringify({ ok: true }), () => {
|
|
650
|
+
server.close();
|
|
651
|
+
server.closeAllConnections();
|
|
652
|
+
resolve({ comments: result.comments ?? [], overall: result.overall ?? "", notes: result.notes ?? [] });
|
|
653
|
+
});
|
|
654
|
+
});
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
res.writeHead(404);
|
|
658
|
+
res.end("not found");
|
|
659
|
+
});
|
|
660
|
+
server.listen(0, "127.0.0.1", () => {
|
|
661
|
+
const { port } = server.address();
|
|
662
|
+
const url = `http://127.0.0.1:${port}/`;
|
|
663
|
+
console.error(`semantic-review: review at ${url} \u2014 waiting for Done\u2026`);
|
|
664
|
+
if (openBrowser) {
|
|
665
|
+
const opener = process.platform === "darwin" ? "open" : "xdg-open";
|
|
666
|
+
spawn2(opener, [url], { stdio: "ignore" }).on("error", () => {
|
|
667
|
+
});
|
|
668
|
+
}
|
|
669
|
+
});
|
|
670
|
+
});
|
|
671
|
+
}
|
|
672
|
+
function formatReview(result, multiTab) {
|
|
673
|
+
const lines = [];
|
|
674
|
+
const notes = result.notes ?? [];
|
|
675
|
+
if (result.comments.length === 0 && !result.overall) {
|
|
676
|
+
lines.push("Review complete: approved, no comments.");
|
|
677
|
+
} else {
|
|
678
|
+
lines.push(`Review feedback (${result.comments.length} comment${result.comments.length === 1 ? "" : "s"}):`);
|
|
679
|
+
result.comments.forEach((c, i) => {
|
|
680
|
+
const tag = multiTab && c.backend ? `[${c.backend}] ` : "";
|
|
681
|
+
lines.push("", `${i + 1}. ${tag}${c.ref}`);
|
|
682
|
+
if (c.quote) lines.push(...c.quote.split("\n").map((q) => ` > ${q}`));
|
|
683
|
+
lines.push(...c.text.split("\n").map((t) => ` ${t}`));
|
|
684
|
+
});
|
|
685
|
+
if (result.overall) lines.push("", `Overall: ${result.overall}`);
|
|
686
|
+
}
|
|
687
|
+
for (const n of notes) {
|
|
688
|
+
lines.push("", multiTab ? `Agent's notes [${n.backend}]:` : "Agent's notes:");
|
|
689
|
+
lines.push(...n.items.map((item) => `- ${item}`));
|
|
690
|
+
}
|
|
691
|
+
return lines.join("\n");
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// src/cli.ts
|
|
695
|
+
var USAGE = `usage: semantic-review [options] [git diff args...]
|
|
696
|
+
|
|
697
|
+
Reads the diff from stdin if piped, otherwise runs \`git diff <args>\`
|
|
698
|
+
(default: git diff HEAD). Prints the human's review feedback to stdout.
|
|
699
|
+
|
|
700
|
+
options:
|
|
701
|
+
--with <backend,...> backends: ${Object.keys(BACKENDS).join(", ")} (default: auto-detect)
|
|
702
|
+
--model <id> model for the anthropic backend (default: claude-opus-5,
|
|
703
|
+
env SEMANTIC_REVIEW_MODEL; e.g. claude-sonnet-5, claude-haiku-4-5)
|
|
704
|
+
--effort <level> low|medium|high|xhigh|max (anthropic backend; default: API default)
|
|
705
|
+
--no-open don't open the browser
|
|
706
|
+
|
|
707
|
+
examples:
|
|
708
|
+
semantic-review review uncommitted changes
|
|
709
|
+
semantic-review main...HEAD review a branch
|
|
710
|
+
git diff -U10 | semantic-review review a piped diff with more context
|
|
711
|
+
semantic-review --with anthropic,codex two analyses, tabbed
|
|
712
|
+
semantic-review --model claude-sonnet-5 --effort medium cheaper/faster analysis`;
|
|
713
|
+
async function getDiff(gitArgs) {
|
|
714
|
+
if (!process.stdin.isTTY) {
|
|
715
|
+
let piped = "";
|
|
716
|
+
for await (const chunk of process.stdin.setEncoding("utf8")) piped += chunk;
|
|
717
|
+
if (piped.trim()) return piped;
|
|
718
|
+
}
|
|
719
|
+
const args = gitArgs.length > 0 ? gitArgs : ["HEAD"];
|
|
720
|
+
const { stdout, stderr, code } = await run(["git", "diff", "--no-color", ...args]);
|
|
721
|
+
if (code !== 0) throw new Error(`git diff failed: ${stderr.trim()}`);
|
|
722
|
+
return stdout;
|
|
723
|
+
}
|
|
724
|
+
async function main() {
|
|
725
|
+
const argv = process.argv.slice(2);
|
|
726
|
+
const gitArgs = [];
|
|
727
|
+
let withBackends = null;
|
|
728
|
+
let openBrowser = true;
|
|
729
|
+
let model;
|
|
730
|
+
let effort;
|
|
731
|
+
for (let i = 0; i < argv.length; i++) {
|
|
732
|
+
const arg = argv[i];
|
|
733
|
+
if (arg === "-h" || arg === "--help") {
|
|
734
|
+
console.log(USAGE);
|
|
735
|
+
return;
|
|
736
|
+
} else if (arg === "--no-open") {
|
|
737
|
+
openBrowser = false;
|
|
738
|
+
} else if (arg === "--with") {
|
|
739
|
+
withBackends = (argv[++i] ?? "").split(",").map((s) => s.trim()).filter(Boolean);
|
|
740
|
+
} else if (arg === "--model") {
|
|
741
|
+
model = argv[++i];
|
|
742
|
+
} else if (arg === "--effort") {
|
|
743
|
+
const level = argv[++i];
|
|
744
|
+
if (!["low", "medium", "high", "xhigh", "max"].includes(level ?? "")) {
|
|
745
|
+
throw new Error(`invalid --effort "${level}" (low|medium|high|xhigh|max)`);
|
|
746
|
+
}
|
|
747
|
+
effort = level;
|
|
748
|
+
} else {
|
|
749
|
+
gitArgs.push(arg);
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
const diff = await getDiff(gitArgs);
|
|
753
|
+
const files = parseDiff(diff);
|
|
754
|
+
const hunkCount = files.reduce((n, f) => n + f.hunks.length, 0);
|
|
755
|
+
if (hunkCount === 0) {
|
|
756
|
+
console.error("semantic-review: no changes to review");
|
|
757
|
+
process.exit(1);
|
|
758
|
+
}
|
|
759
|
+
const backends = await resolveBackends(withBackends);
|
|
760
|
+
console.error(
|
|
761
|
+
`semantic-review: analyzing ${hunkCount} hunk${hunkCount === 1 ? "" : "s"} across ${files.length} file${files.length === 1 ? "" : "s"} with ${backends.map((b) => b.name).join(", ")}\u2026`
|
|
762
|
+
);
|
|
763
|
+
const annotated = diffForModel(files);
|
|
764
|
+
const results = await runBackends(backends, annotated, { model, effort });
|
|
765
|
+
const html = await renderReport(results, files);
|
|
766
|
+
const review = await serveReview(html, openBrowser);
|
|
767
|
+
console.log(formatReview(review, results.length > 1));
|
|
768
|
+
}
|
|
769
|
+
main().catch((err) => {
|
|
770
|
+
console.error(`semantic-review: ${err.message ?? err}`);
|
|
771
|
+
process.exit(1);
|
|
772
|
+
});
|
|
Binary file
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "semantic-review",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Semantic diff review for coding agents: an LLM turns a git diff into a narrative HTML report, a human comments on it, and the feedback comes back as plaintext.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": "github:mikker/semantic-review",
|
|
8
|
+
"bin": {
|
|
9
|
+
"semantic-review": "dist/cli.js"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"dist",
|
|
13
|
+
"semantic-review.webp"
|
|
14
|
+
],
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=20"
|
|
17
|
+
},
|
|
18
|
+
"scripts": {
|
|
19
|
+
"build": "esbuild src/cli.ts --bundle --packages=external --platform=node --format=esm --outfile=dist/cli.js",
|
|
20
|
+
"prepublishOnly": "npm run build",
|
|
21
|
+
"test": "bun test",
|
|
22
|
+
"eval": "bun evals/run.ts",
|
|
23
|
+
"typecheck": "bunx tsc --noEmit"
|
|
24
|
+
},
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"@anthropic-ai/sdk": "^0.74.0",
|
|
27
|
+
"shiki": "^3.13.0",
|
|
28
|
+
"zod": "^4"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@types/bun": "^1.3.0",
|
|
32
|
+
"esbuild": "^0.28.1",
|
|
33
|
+
"typescript": "^5.9.0"
|
|
34
|
+
}
|
|
35
|
+
}
|
|
Binary file
|