@yangzhe1991/dsh-code-review 0.2.1 → 0.3.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/README.md +12 -1
- package/README.zh.md +14 -1
- package/lib/client.js +252 -14
- package/lib/client.js.map +3 -3
- package/lib/index.js +133 -6
- package/lib/index.js.map +2 -2
- package/lib/types/index.d.ts +7 -0
- package/package.json +6 -6
package/lib/index.js
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import { mkdir, readdir, readFile, stat, unlink, writeFile } from "node:fs/promises";
|
|
4
|
+
import { homedir, tmpdir } from "node:os";
|
|
4
5
|
import { isAbsolute, join, resolve } from "node:path";
|
|
5
6
|
var DIFF_ROUTE_PATH = "/dsh-code-review/diff";
|
|
7
|
+
var AI_REVIEW_ROUTE_PATH = "/dsh-code-review/ai-review";
|
|
6
8
|
var MAX_DIFF_BYTES = 5 * 1024 * 1024;
|
|
9
|
+
var MAX_REVIEW_FILES = 1e3;
|
|
10
|
+
var REVIEW_FILE_TTL_MS = 24 * 60 * 60 * 1e3;
|
|
11
|
+
var REVIEW_FILE_PREFIX = "review-";
|
|
7
12
|
function resolveDiffPath(raw) {
|
|
8
13
|
let p = raw.trim();
|
|
9
14
|
if (p === "" || p.includes("\0")) return null;
|
|
@@ -60,23 +65,145 @@ async function handleDiffRequest(req, res) {
|
|
|
60
65
|
}
|
|
61
66
|
sendJson(res, 200, { ok: true, text: result.text });
|
|
62
67
|
}
|
|
68
|
+
function looksLikeDiffText(text) {
|
|
69
|
+
const lines = text.split("\n");
|
|
70
|
+
if (lines.some((line) => line.startsWith("diff --git "))) return true;
|
|
71
|
+
const hasHunk = lines.some((line) => /^@@ -\d+/.test(line));
|
|
72
|
+
const hasFileMark = lines.some((line) => /^(---|\+\+\+) \S/.test(line));
|
|
73
|
+
return hasHunk && hasFileMark;
|
|
74
|
+
}
|
|
75
|
+
function reviewDir() {
|
|
76
|
+
return join(tmpdir(), "dsh-code-review");
|
|
77
|
+
}
|
|
78
|
+
async function pruneReviewFiles() {
|
|
79
|
+
const dir = reviewDir();
|
|
80
|
+
const entries = await readdir(dir).catch(() => []);
|
|
81
|
+
const cutoff = Date.now() - REVIEW_FILE_TTL_MS;
|
|
82
|
+
await Promise.all(entries.map(async (name) => {
|
|
83
|
+
if (!name.startsWith(REVIEW_FILE_PREFIX) || !name.endsWith(".diff")) return;
|
|
84
|
+
try {
|
|
85
|
+
const st = await stat(join(dir, name));
|
|
86
|
+
if (st.mtimeMs < cutoff) await unlink(join(dir, name));
|
|
87
|
+
} catch {
|
|
88
|
+
}
|
|
89
|
+
}));
|
|
90
|
+
}
|
|
91
|
+
async function saveReviewDiff(text) {
|
|
92
|
+
if (text.trim() === "") return { ok: false, error: "empty-text", status: 400 };
|
|
93
|
+
if (Buffer.byteLength(text, "utf8") > MAX_DIFF_BYTES) return { ok: false, error: "too-large", status: 413 };
|
|
94
|
+
if (!looksLikeDiffText(text)) return { ok: false, error: "not-a-diff", status: 400 };
|
|
95
|
+
try {
|
|
96
|
+
const dir = reviewDir();
|
|
97
|
+
await mkdir(dir, { recursive: true, mode: 448 });
|
|
98
|
+
await pruneReviewFiles();
|
|
99
|
+
const entries = await readdir(dir);
|
|
100
|
+
const count = entries.filter((name) => name.startsWith(REVIEW_FILE_PREFIX) && name.endsWith(".diff")).length;
|
|
101
|
+
if (count >= MAX_REVIEW_FILES) return { ok: false, error: "too-many-files", status: 429 };
|
|
102
|
+
const path = join(dir, `${REVIEW_FILE_PREFIX}${Date.now()}-${randomBytes(5).toString("hex")}.diff`);
|
|
103
|
+
await writeFile(path, text, { mode: 384 });
|
|
104
|
+
return { ok: true, path };
|
|
105
|
+
} catch (err) {
|
|
106
|
+
console.warn("[dsh-code-review] AI \u5BA1\u67E5\u6682\u5B58\u5199\u5165\u5931\u8D25:", err);
|
|
107
|
+
return { ok: false, error: "write-error", status: 500 };
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
function readJsonBody(req, maxBytes) {
|
|
111
|
+
return new Promise((resolvePromise) => {
|
|
112
|
+
const declared = Number(req.headers["content-length"] ?? "0");
|
|
113
|
+
if (Number.isFinite(declared) && declared > maxBytes) {
|
|
114
|
+
resolvePromise({ ok: false, status: 413, error: "too-large" });
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
const chunks = [];
|
|
118
|
+
let total = 0;
|
|
119
|
+
let settled = false;
|
|
120
|
+
const finish = (result) => {
|
|
121
|
+
if (settled) return;
|
|
122
|
+
settled = true;
|
|
123
|
+
resolvePromise(result);
|
|
124
|
+
};
|
|
125
|
+
req.on("data", (chunk) => {
|
|
126
|
+
total += chunk.length;
|
|
127
|
+
if (total > maxBytes) {
|
|
128
|
+
req.destroy();
|
|
129
|
+
finish({ ok: false, status: 413, error: "too-large" });
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
chunks.push(chunk);
|
|
133
|
+
});
|
|
134
|
+
req.on("end", () => {
|
|
135
|
+
try {
|
|
136
|
+
finish({ ok: true, value: JSON.parse(Buffer.concat(chunks).toString("utf8")) });
|
|
137
|
+
} catch {
|
|
138
|
+
finish({ ok: false, status: 400, error: "invalid-json" });
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
req.on("error", () => finish({ ok: false, status: 400, error: "read-error" }));
|
|
142
|
+
req.on("close", () => finish({ ok: false, status: 400, error: "read-error" }));
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
async function handleAiReviewRequest(req, res) {
|
|
146
|
+
if (req.method !== "POST") {
|
|
147
|
+
sendJson(res, 405, { ok: false, error: "method-not-allowed" });
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
const contentType = req.headers["content-type"] ?? "";
|
|
151
|
+
if (!contentType.toLowerCase().startsWith("application/json")) {
|
|
152
|
+
sendJson(res, 415, { ok: false, error: "unsupported-media-type" });
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
const fetchSite = req.headers["sec-fetch-site"];
|
|
156
|
+
if (typeof fetchSite === "string" && fetchSite.toLowerCase() === "cross-site") {
|
|
157
|
+
sendJson(res, 403, { ok: false, error: "cross-site-denied" });
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
const body = await readJsonBody(req, MAX_DIFF_BYTES);
|
|
161
|
+
if (!body.ok) {
|
|
162
|
+
sendJson(res, body.status, { ok: false, error: body.error });
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
const text = body.value.text;
|
|
166
|
+
if (typeof text !== "string") {
|
|
167
|
+
sendJson(res, 400, { ok: false, error: "invalid-json" });
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
const result = await saveReviewDiff(text);
|
|
171
|
+
if (!result.ok) {
|
|
172
|
+
sendJson(res, result.status, { ok: false, error: result.error });
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
sendJson(res, 200, { ok: true, path: result.path });
|
|
176
|
+
}
|
|
63
177
|
function apply(ctx) {
|
|
64
178
|
ctx.inject(["webServer"], (srv) => {
|
|
65
179
|
srv.effect(() => {
|
|
66
|
-
const
|
|
180
|
+
const unregisterDiff = srv.webServer.register({
|
|
67
181
|
kind: "exact",
|
|
68
182
|
path: DIFF_ROUTE_PATH,
|
|
69
183
|
handler: handleDiffRequest
|
|
70
184
|
});
|
|
71
|
-
|
|
72
|
-
|
|
185
|
+
const unregisterAi = srv.webServer.register({
|
|
186
|
+
kind: "exact",
|
|
187
|
+
path: AI_REVIEW_ROUTE_PATH,
|
|
188
|
+
handler: handleAiReviewRequest
|
|
189
|
+
});
|
|
190
|
+
return () => {
|
|
191
|
+
unregisterDiff();
|
|
192
|
+
unregisterAi();
|
|
193
|
+
};
|
|
194
|
+
}, `dsh-code-review: ${DIFF_ROUTE_PATH} + ${AI_REVIEW_ROUTE_PATH} routes`);
|
|
73
195
|
});
|
|
74
196
|
}
|
|
75
197
|
export {
|
|
198
|
+
AI_REVIEW_ROUTE_PATH,
|
|
76
199
|
DIFF_ROUTE_PATH,
|
|
77
200
|
MAX_DIFF_BYTES,
|
|
201
|
+
MAX_REVIEW_FILES,
|
|
202
|
+
REVIEW_FILE_TTL_MS,
|
|
78
203
|
apply,
|
|
79
204
|
loadDiffText,
|
|
80
|
-
|
|
205
|
+
looksLikeDiffText,
|
|
206
|
+
resolveDiffPath,
|
|
207
|
+
saveReviewDiff
|
|
81
208
|
};
|
|
82
209
|
//# sourceMappingURL=index.js.map
|
package/lib/index.js.map
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../src/index.ts"],
|
|
4
|
-
"sourcesContent": ["/**\n * dsh-code-review \u63D2\u4EF6,node \u534A(\u5BBF\u4E3B\u4FA7)\u3002\n *\n * \u804C\u8D23:\u7ED9\u5BBF\u4E3B Web \u670D\u52A1\u5668\u6CE8\u518C\u4E00\u4E2A\u300C\u672C\u5730 diff \u6587\u4EF6\u8BFB\u53D6\u300D\u8DEF\u7531,\u4F9B\u6D4F\u89C8\u5668\u534A\n * \u7684\u300C\u8DEF\u5F84\u67E5\u770B chip\u300D\u70B9\u51FB\u65F6\u8BFB\u53D6\u4F1A\u8BDD\u91CC\u51FA\u73B0\u7684\u672C\u5730\u8DEF\u5F84\u6307\u5411\u7684 .diff/.patch\n * \u6587\u4EF6\u5185\u5BB9(\u6D4F\u89C8\u5668\u65E0\u6CD5\u76F4\u63A5\u8BFB\u672C\u5730\u6587\u4EF6,\u5FC5\u987B\u7531\u5BBF\u4E3B\u4EE3\u8BFB)\u3002\n *\n * \u5B89\u5168\u8FB9\u754C(\u53EA\u8BFB\u672C\u5730 diff \u6587\u4EF6,\u6700\u5C0F\u66B4\u9732\u9762):\n * - \u4EC5 GET;\u8DEF\u5F84\u5FC5\u987B\u7EDD\u5BF9(\u652F\u6301 ~ \u5C55\u5F00)\u3001\u4E14\u4EE5 .diff/.patch \u7ED3\u5C3E;\n * - \u6587\u4EF6\u5FC5\u987B\u5B58\u5728\u3001\u662F\u666E\u901A\u6587\u4EF6\u3001\u5927\u5C0F \u2264 5MB;\n * - \u53EA\u8FD4\u56DE JSON \u6587\u672C\u5185\u5BB9,\u4E0D\u8BBE CORS(\u4EFB\u4F55\u8DE8\u6E90\u9875\u9762\u90FD\u8BFB\u4E0D\u5230\u54CD\u5E94\u4F53);\n * - \u54CD\u5E94 no-store,\u4E0D\u843D\u4EFB\u4F55\u7F13\u5B58\u3002\n *\n * \u8DEF\u7531\u6CE8\u518C\u4E0E webServer \u5C31\u7EEA\u89E3\u8026:\u7528 ctx.inject \u5EF6\u8FDF\u5230\u670D\u52A1\u53EF\u7528\u65F6\u6267\u884C,\n * webServer \u7F3A\u5931\u7684\u88C5\u914D(\u5982 headless)\u91CC\u56DE\u8C03\u4E0D\u6267\u884C,\u63D2\u4EF6\u884C\u672C\u8EAB\u4E0D\u62A5\u9519\u3002\n */\nimport { stat, readFile } from 'node:fs/promises'\nimport type { IncomingMessage, ServerResponse } from 'node:http'\nimport { homedir } from 'node:os'\nimport { isAbsolute, join, resolve } from 'node:path'\n\n/** \u6D4F\u89C8\u5668\u534A\u8BF7\u6C42\u7684\u56FA\u5B9A\u8DEF\u7531\u8DEF\u5F84(\u4E0E\u63D2\u4EF6 bundle \u8DEF\u5F84 /plugins/* \u4E0D\u51B2\u7A81)\u3002 */\nexport const DIFF_ROUTE_PATH = '/dsh-code-review/diff'\n/** \u5355\u6587\u4EF6\u8BFB\u53D6\u4E0A\u9650(5MB,\u9632\u8BEF\u70B9\u8D85\u5927\u6587\u4EF6\u62D6\u57AE\u5BBF\u4E3B)\u3002 */\nexport const MAX_DIFF_BYTES = 5 * 1024 * 1024\n\n// \u2014\u2014 \u6700\u5C0F\u4E0A\u4E0B\u6587\u5F62\u72B6(\u53EA\u53D6\u7528\u5230\u7684 cordis \u6210\u5458,\u4E0D\u5F15\u5165\u5B98\u65B9\u7C7B\u578B\u4F9D\u8D56)\u2014\u2014\n\n/** webServer \u670D\u52A1\u7684\u6700\u5C0F\u5F62\u72B6(register \u6CE8\u518C\u547D\u540D\u8DEF\u7531)\u3002 */\nexport interface WebServerLike {\n register(route: {\n kind: 'exact'\n path: string\n handler: (req: IncomingMessage, res: ServerResponse) => void | Promise<void>\n }): () => void\n}\n\n/** \u63D2\u4EF6 apply \u6536\u5230\u7684\u5BBF\u4E3B\u4E0A\u4E0B\u6587\u6700\u5C0F\u5F62\u72B6(cordis Context \u7ED3\u6784\u517C\u5BB9)\u3002 */\nexport interface CodeReviewCtx {\n /** \u670D\u52A1\u53EF\u7528\u65F6\u6267\u884C\u56DE\u8C03(webServer \u7F3A\u5931\u65F6\u56DE\u8C03\u4E0D\u6267\u884C,\u4E0D\u963B\u585E\u63D2\u4EF6)\u3002 */\n inject(\n deps: readonly string[],\n callback: (ctx: CodeReviewCtx & { webServer: WebServerLike }) => unknown,\n ): unknown\n /** \u6CE8\u518C\u4E00\u4E2A\u4F5C\u7528\u57DF effect,\u8FD4\u56DE\u7684 disposer \u5728\u63D2\u4EF6\u5378\u8F7D\u65F6\u6267\u884C\u3002 */\n effect(fn: () => void | (() => void), name?: string): unknown\n}\n\n// \u2014\u2014 \u672C\u5730 diff \u6587\u4EF6\u8BFB\u53D6(\u7EAF\u51FD\u6570,\u5355\u6D4B\u8986\u76D6)\u2014\u2014\n\n/** \u8DEF\u5F84\u89E3\u6790\u5931\u8D25\u7684\u539F\u56E0(\u4F9B\u6D4B\u8BD5\u4E0E\u9519\u8BEF\u54CD\u5E94\u4F7F\u7528)\u3002 */\nexport type DiffLoadError = 'invalid-path' | 'not-found' | 'not-file' | 'too-large' | 'read-error'\n\n/** loadDiffText \u7684\u8FD4\u56DE:\u6210\u529F\u5E26\u6587\u672C;\u5931\u8D25\u5E26\u539F\u56E0\u4E0E HTTP \u72B6\u6001\u7801\u3002 */\nexport type DiffLoadResult =\n | { ok: true; text: string }\n | { ok: false; error: DiffLoadError; status: number }\n\n/**\n * \u6821\u9A8C\u5E76\u89C4\u8303\u5316\u6D4F\u89C8\u5668\u4F20\u6765\u7684\u8DEF\u5F84\u5B57\u7B26\u4E32\u3002\n *\n * \u89C4\u5219:trim \u540E\u5FC5\u987B\u975E\u7A7A\u3001\u4E0D\u542B NUL;\u652F\u6301 `~` \u524D\u7F00\u5C55\u5F00\u4E3A home \u76EE\u5F55;\n * \u5C55\u5F00\u540E\u5FC5\u987B\u662F\u7EDD\u5BF9\u8DEF\u5F84\u4E14\u4EE5 .diff/.patch \u7ED3\u5C3E(\u5927\u5C0F\u5199\u4E0D\u654F\u611F)\u3002\n * \u8FD4\u56DE\u89C4\u8303\u5316\u540E\u7684\u7EDD\u5BF9\u8DEF\u5F84,\u4E0D\u5408\u6CD5\u8FD4\u56DE null\u3002\u4E0D\u89E6\u76D8\u3002\n */\nexport function resolveDiffPath(raw: string): string | null {\n let p = raw.trim()\n if (p === '' || p.includes('\\0')) return null\n if (p.startsWith('~/')) p = join(homedir(), p.slice(2))\n if (!isAbsolute(p)) return null\n if (!/\\.(?:diff|patch)$/i.test(p)) return null\n return resolve(p)\n}\n\n/**\n * \u8BFB\u53D6\u4E00\u4E2A\u5DF2\u6821\u9A8C\u7684\u672C\u5730 diff \u6587\u4EF6\u5185\u5BB9(UTF-8)\u3002\n * \u5931\u8D25\u539F\u56E0\u6620\u5C04:\u4E0D\u5B58\u5728/\u89E3\u5F15\u7528\u5931\u8D25 \u2192 not-found;\u4E0D\u662F\u666E\u901A\u6587\u4EF6 \u2192 not-file;\n * \u8D85\u8FC7\u5927\u5C0F\u4E0A\u9650 \u2192 too-large;\u5176\u4ED6 IO \u9519\u8BEF \u2192 read-error\u3002\n */\nexport async function loadDiffText(absPath: string): Promise<DiffLoadResult> {\n try {\n const st = await stat(absPath)\n if (!st.isFile()) return { ok: false, error: 'not-file', status: 400 }\n if (st.size > MAX_DIFF_BYTES) return { ok: false, error: 'too-large', status: 413 }\n const text = await readFile(absPath, 'utf8')\n return { ok: true, text }\n } catch (err) {\n if ((err as NodeJS.ErrnoException).code === 'ENOENT' || (err as NodeJS.ErrnoException).code === 'ENOTDIR') {\n return { ok: false, error: 'not-found', status: 404 }\n }\n return { ok: false, error: 'read-error', status: 500 }\n }\n}\n\n/** \u53D1\u9001 JSON \u54CD\u5E94(\u7EDF\u4E00\u5934:no-store + nosniff,\u4E0D\u8BBE CORS)\u3002 */\nfunction sendJson(res: ServerResponse, status: number, body: unknown): void {\n const payload = JSON.stringify(body)\n res.writeHead(status, {\n 'Content-Type': 'application/json; charset=utf-8',\n 'Content-Length': Buffer.byteLength(payload),\n 'Cache-Control': 'no-store',\n 'X-Content-Type-Options': 'nosniff',\n })\n res.end(payload)\n}\n\n/**\n * \u8DEF\u7531\u5904\u7406\u5668:\u89E3\u6790 ?path= \u53C2\u6570 \u2192 \u6821\u9A8C \u2192 \u8BFB\u6587\u4EF6 \u2192 \u8FD4\u56DE\n * { ok: true, text } \u6216 { ok: false, error }\u3002\n */\nasync function handleDiffRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {\n if (req.method !== 'GET' && req.method !== 'HEAD') {\n sendJson(res, 405, { ok: false, error: 'method-not-allowed' })\n return\n }\n let raw = ''\n try {\n raw = new URL(req.url ?? '/', 'http://localhost').searchParams.get('path') ?? ''\n } catch {\n sendJson(res, 400, { ok: false, error: 'invalid-path' })\n return\n }\n const abs = resolveDiffPath(raw)\n if (abs === null) {\n sendJson(res, 400, { ok: false, error: 'invalid-path' })\n return\n }\n const result = await loadDiffText(abs)\n if (!result.ok) {\n sendJson(res, result.status, { ok: false, error: result.error })\n return\n }\n sendJson(res, 200, { ok: true, text: result.text })\n}\n\n/**\n * \u63D2\u4EF6 apply:\u6CE8\u518C\u672C\u5730 diff \u6587\u4EF6\u8BFB\u53D6\u8DEF\u7531\u3002webServer \u53EF\u7528\u65F6\u6267\u884C;\n * \u7F3A\u5931\u65F6\u9759\u9ED8\u8DF3\u8FC7(\u56DE\u8C03\u4E0D\u6267\u884C),\u6D4F\u89C8\u5668\u534A\u7684\u8DEF\u5F84 chip \u4F1A\u663E\u793A\u8BFB\u53D6\u5931\u8D25\u3002\n */\nexport function apply(ctx: CodeReviewCtx): void {\n ctx.inject(['webServer'], (srv) => {\n srv.effect(() => {\n // register \u8FD4\u56DE disposer;\u63D2\u4EF6\u5378\u8F7D/HMR \u91CD\u8F7D\u65F6\u81EA\u52A8\u6458\u9664\u8DEF\u7531\u3002\n const unregister = srv.webServer.register({\n kind: 'exact',\n path: DIFF_ROUTE_PATH,\n handler: handleDiffRequest,\n })\n return unregister\n }, `dsh-code-review: ${DIFF_ROUTE_PATH} diff file route`)\n })\n}\n"],
|
|
5
|
-
"mappings": ";
|
|
4
|
+
"sourcesContent": ["/**\n * dsh-code-review \u63D2\u4EF6,node \u534A(\u5BBF\u4E3B\u4FA7)\u3002\n *\n * \u804C\u8D23(\u4E24\u4E2A\u8DEF\u7531):\n * 1. \u300C\u672C\u5730 diff \u6587\u4EF6\u8BFB\u53D6\u300DGET /dsh-code-review/diff:\u6D4F\u89C8\u5668\u534A\u7684\u300C\u8DEF\u5F84\n * \u67E5\u770B chip\u300D\u70B9\u51FB\u65F6\u8BFB\u53D6\u4F1A\u8BDD\u91CC\u51FA\u73B0\u7684\u672C\u5730\u8DEF\u5F84\u6307\u5411\u7684 .diff/.patch \u6587\u4EF6\n * \u5185\u5BB9(\u6D4F\u89C8\u5668\u65E0\u6CD5\u76F4\u63A5\u8BFB\u672C\u5730\u6587\u4EF6,\u5FC5\u987B\u7531\u5BBF\u4E3B\u4EE3\u8BFB)\u3002\n * 2. \u300CAI \u5BA1\u67E5\u6682\u5B58\u300DPOST /dsh-code-review/ai-review:\u6D4F\u89C8\u5668\u534A\u300CAI \u5BA1\u67E5\u300D\n * \u6309\u94AE\u70B9\u51FB\u65F6,\u628A\u4F1A\u8BDD\u91CC\u7684 diff \u6587\u672C\u6682\u5B58\u4E3A\u4E34\u65F6\u6587\u4EF6\u5E76\u8FD4\u56DE\u7EDD\u5BF9\u8DEF\u5F84 \u2014\u2014\n * \u4E3B agent \u968F\u540E\u521B\u5EFA\u5E72\u51C0\u4E0A\u4E0B\u6587\u7684 sub agent,\u628A\u8BE5\u8DEF\u5F84\u5199\u8FDB\u4EFB\u52A1\u8BF4\u660E,\n * sub agent \u76F4\u63A5\u8BFB\u6587\u4EF6\u62FF diff(prompt \u77ED\u5C0F,\u4E3B\u5BF9\u8BDD\u4E0A\u4E0B\u6587\u4E0D\u88AB\u5927\n * diff \u6C61\u67D3;diff \u4E0D\u5185\u8054\u8FDB\u7528\u6237\u6D88\u606F,\u4E5F\u5C31\u4E0D\u7ECF\u8FC7 composer \u7684\n * \u5F15\u7528/@ \u89E3\u6790)\u3002\n *\n * \u5B89\u5168\u8FB9\u754C(\u6700\u5C0F\u66B4\u9732\u9762):\n * - GET \u4EC5\u8BFB:\u8DEF\u5F84\u5FC5\u987B\u7EDD\u5BF9(\u652F\u6301 ~ \u5C55\u5F00)\u3001\u4E14\u4EE5 .diff/.patch \u7ED3\u5C3E;\n * \u6587\u4EF6\u5FC5\u987B\u5B58\u5728\u3001\u662F\u666E\u901A\u6587\u4EF6\u3001\u5927\u5C0F \u2264 5MB;\n * - POST \u4EC5\u5199\u81EA\u6709\u4E34\u65F6\u76EE\u5F55:\u5185\u5BB9\u5FC5\u987B\u5F62\u4F3C git diff\u3001\u5927\u5C0F \u2264 5MB\u3001\u76EE\u5F55\u5185\n * \u6587\u4EF6\u6570\u6709\u4E0A\u9650,\u6587\u4EF6\u540D\u670D\u52A1\u7AEF\u751F\u6210(\u8C03\u7528\u65B9\u4E0D\u53EF\u63A7\u8DEF\u5F84),0600 \u6743\u9650,\n * \u8FC7\u671F(24h)\u81EA\u52A8\u6E05\u7406;\n * - \u53EA\u6536\u53D1 JSON,\u4E0D\u8BBE CORS(\u4EFB\u4F55\u8DE8\u6E90\u9875\u9762\u90FD\u8BFB\u4E0D\u5230\u54CD\u5E94\u4F53);\n * - \u54CD\u5E94 no-store,\u4E0D\u843D\u4EFB\u4F55\u7F13\u5B58\u3002\n *\n * \u8DEF\u7531\u6CE8\u518C\u4E0E webServer \u5C31\u7EEA\u89E3\u8026:\u7528 ctx.inject \u5EF6\u8FDF\u5230\u670D\u52A1\u53EF\u7528\u65F6\u6267\u884C,\n * webServer \u7F3A\u5931\u7684\u88C5\u914D(\u5982 headless)\u91CC\u56DE\u8C03\u4E0D\u6267\u884C,\u63D2\u4EF6\u884C\u672C\u8EAB\u4E0D\u62A5\u9519\u3002\n */\nimport { randomBytes } from 'node:crypto'\nimport { mkdir, readdir, readFile, stat, unlink, writeFile } from 'node:fs/promises'\nimport type { IncomingMessage, ServerResponse } from 'node:http'\nimport { homedir, tmpdir } from 'node:os'\nimport { isAbsolute, join, resolve } from 'node:path'\n\n/** \u6D4F\u89C8\u5668\u534A\u8BF7\u6C42\u7684\u56FA\u5B9A\u8DEF\u7531\u8DEF\u5F84(\u4E0E\u63D2\u4EF6 bundle \u8DEF\u5F84 /plugins/* \u4E0D\u51B2\u7A81)\u3002 */\nexport const DIFF_ROUTE_PATH = '/dsh-code-review/diff'\n/** AI \u5BA1\u67E5\u6682\u5B58\u8DEF\u7531:POST diff \u6587\u672C,\u8FD4\u56DE\u6682\u5B58\u6587\u4EF6\u7EDD\u5BF9\u8DEF\u5F84\u3002 */\nexport const AI_REVIEW_ROUTE_PATH = '/dsh-code-review/ai-review'\n/** \u5355\u6587\u4EF6\u8BFB\u53D6\u4E0A\u9650(5MB,\u9632\u8BEF\u70B9\u8D85\u5927\u6587\u4EF6\u62D6\u57AE\u5BBF\u4E3B)\u3002 */\nexport const MAX_DIFF_BYTES = 5 * 1024 * 1024\n/** AI \u5BA1\u67E5\u6682\u5B58\u76EE\u5F55\u5185\u7684\u6587\u4EF6\u6570\u4E0A\u9650(\u9632\u5237\u63A5\u53E3\u5199\u6EE1\u78C1\u76D8)\u3002 */\nexport const MAX_REVIEW_FILES = 1000\n/** AI \u5BA1\u67E5\u6682\u5B58\u6587\u4EF6\u7684\u4FDD\u7559\u65F6\u957F(\u8D85\u8FC7\u5373\u968F\u4E0B\u6B21\u5199\u5165\u6E05\u7406)\u3002 */\nexport const REVIEW_FILE_TTL_MS = 24 * 60 * 60 * 1000\n/** \u6682\u5B58\u6587\u4EF6\u540D\u524D\u7F00(\u6E05\u7406\u65F6\u53EA\u5339\u914D\u672C\u63D2\u4EF6\u547D\u540D\u7EA6\u5B9A\u7684\u6587\u4EF6)\u3002 */\nconst REVIEW_FILE_PREFIX = 'review-'\n\n// \u2014\u2014 \u6700\u5C0F\u4E0A\u4E0B\u6587\u5F62\u72B6(\u53EA\u53D6\u7528\u5230\u7684 cordis \u6210\u5458,\u4E0D\u5F15\u5165\u5B98\u65B9\u7C7B\u578B\u4F9D\u8D56)\u2014\u2014\n\n/** webServer \u670D\u52A1\u7684\u6700\u5C0F\u5F62\u72B6(register \u6CE8\u518C\u547D\u540D\u8DEF\u7531)\u3002 */\nexport interface WebServerLike {\n register(route: {\n kind: 'exact'\n path: string\n handler: (req: IncomingMessage, res: ServerResponse) => void | Promise<void>\n }): () => void\n}\n\n/** \u63D2\u4EF6 apply \u6536\u5230\u7684\u5BBF\u4E3B\u4E0A\u4E0B\u6587\u6700\u5C0F\u5F62\u72B6(cordis Context \u7ED3\u6784\u517C\u5BB9)\u3002 */\nexport interface CodeReviewCtx {\n /** \u670D\u52A1\u53EF\u7528\u65F6\u6267\u884C\u56DE\u8C03(webServer \u7F3A\u5931\u65F6\u56DE\u8C03\u4E0D\u6267\u884C,\u4E0D\u963B\u585E\u63D2\u4EF6)\u3002 */\n inject(\n deps: readonly string[],\n callback: (ctx: CodeReviewCtx & { webServer: WebServerLike }) => unknown,\n ): unknown\n /** \u6CE8\u518C\u4E00\u4E2A\u4F5C\u7528\u57DF effect,\u8FD4\u56DE\u7684 disposer \u5728\u63D2\u4EF6\u5378\u8F7D\u65F6\u6267\u884C\u3002 */\n effect(fn: () => void | (() => void), name?: string): unknown\n}\n\n// \u2014\u2014 \u672C\u5730 diff \u6587\u4EF6\u8BFB\u53D6(\u7EAF\u51FD\u6570,\u5355\u6D4B\u8986\u76D6)\u2014\u2014\n\n/** \u8DEF\u5F84\u89E3\u6790\u5931\u8D25\u7684\u539F\u56E0(\u4F9B\u6D4B\u8BD5\u4E0E\u9519\u8BEF\u54CD\u5E94\u4F7F\u7528)\u3002 */\nexport type DiffLoadError = 'invalid-path' | 'not-found' | 'not-file' | 'too-large' | 'read-error'\n\n/** loadDiffText \u7684\u8FD4\u56DE:\u6210\u529F\u5E26\u6587\u672C;\u5931\u8D25\u5E26\u539F\u56E0\u4E0E HTTP \u72B6\u6001\u7801\u3002 */\nexport type DiffLoadResult =\n | { ok: true; text: string }\n | { ok: false; error: DiffLoadError; status: number }\n\n/**\n * \u6821\u9A8C\u5E76\u89C4\u8303\u5316\u6D4F\u89C8\u5668\u4F20\u6765\u7684\u8DEF\u5F84\u5B57\u7B26\u4E32\u3002\n *\n * \u89C4\u5219:trim \u540E\u5FC5\u987B\u975E\u7A7A\u3001\u4E0D\u542B NUL;\u652F\u6301 `~` \u524D\u7F00\u5C55\u5F00\u4E3A home \u76EE\u5F55;\n * \u5C55\u5F00\u540E\u5FC5\u987B\u662F\u7EDD\u5BF9\u8DEF\u5F84\u4E14\u4EE5 .diff/.patch \u7ED3\u5C3E(\u5927\u5C0F\u5199\u4E0D\u654F\u611F)\u3002\n * \u8FD4\u56DE\u89C4\u8303\u5316\u540E\u7684\u7EDD\u5BF9\u8DEF\u5F84,\u4E0D\u5408\u6CD5\u8FD4\u56DE null\u3002\u4E0D\u89E6\u76D8\u3002\n */\nexport function resolveDiffPath(raw: string): string | null {\n let p = raw.trim()\n if (p === '' || p.includes('\\0')) return null\n if (p.startsWith('~/')) p = join(homedir(), p.slice(2))\n if (!isAbsolute(p)) return null\n if (!/\\.(?:diff|patch)$/i.test(p)) return null\n return resolve(p)\n}\n\n/**\n * \u8BFB\u53D6\u4E00\u4E2A\u5DF2\u6821\u9A8C\u7684\u672C\u5730 diff \u6587\u4EF6\u5185\u5BB9(UTF-8)\u3002\n * \u5931\u8D25\u539F\u56E0\u6620\u5C04:\u4E0D\u5B58\u5728/\u89E3\u5F15\u7528\u5931\u8D25 \u2192 not-found;\u4E0D\u662F\u666E\u901A\u6587\u4EF6 \u2192 not-file;\n * \u8D85\u8FC7\u5927\u5C0F\u4E0A\u9650 \u2192 too-large;\u5176\u4ED6 IO \u9519\u8BEF \u2192 read-error\u3002\n */\nexport async function loadDiffText(absPath: string): Promise<DiffLoadResult> {\n try {\n const st = await stat(absPath)\n if (!st.isFile()) return { ok: false, error: 'not-file', status: 400 }\n if (st.size > MAX_DIFF_BYTES) return { ok: false, error: 'too-large', status: 413 }\n const text = await readFile(absPath, 'utf8')\n return { ok: true, text }\n } catch (err) {\n if ((err as NodeJS.ErrnoException).code === 'ENOENT' || (err as NodeJS.ErrnoException).code === 'ENOTDIR') {\n return { ok: false, error: 'not-found', status: 404 }\n }\n return { ok: false, error: 'read-error', status: 500 }\n }\n}\n\n/** \u53D1\u9001 JSON \u54CD\u5E94(\u7EDF\u4E00\u5934:no-store + nosniff,\u4E0D\u8BBE CORS)\u3002 */\nfunction sendJson(res: ServerResponse, status: number, body: unknown): void {\n const payload = JSON.stringify(body)\n res.writeHead(status, {\n 'Content-Type': 'application/json; charset=utf-8',\n 'Content-Length': Buffer.byteLength(payload),\n 'Cache-Control': 'no-store',\n 'X-Content-Type-Options': 'nosniff',\n })\n res.end(payload)\n}\n\n/**\n * \u8DEF\u7531\u5904\u7406\u5668:\u89E3\u6790 ?path= \u53C2\u6570 \u2192 \u6821\u9A8C \u2192 \u8BFB\u6587\u4EF6 \u2192 \u8FD4\u56DE\n * { ok: true, text } \u6216 { ok: false, error }\u3002\n */\nasync function handleDiffRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {\n if (req.method !== 'GET' && req.method !== 'HEAD') {\n sendJson(res, 405, { ok: false, error: 'method-not-allowed' })\n return\n }\n let raw = ''\n try {\n raw = new URL(req.url ?? '/', 'http://localhost').searchParams.get('path') ?? ''\n } catch {\n sendJson(res, 400, { ok: false, error: 'invalid-path' })\n return\n }\n const abs = resolveDiffPath(raw)\n if (abs === null) {\n sendJson(res, 400, { ok: false, error: 'invalid-path' })\n return\n }\n const result = await loadDiffText(abs)\n if (!result.ok) {\n sendJson(res, result.status, { ok: false, error: result.error })\n return\n }\n sendJson(res, 200, { ok: true, text: result.text })\n}\n\n// \u2014\u2014 AI \u5BA1\u67E5\u6682\u5B58(\u7EAF\u51FD\u6570 + IO,\u5355\u6D4B\u8986\u76D6)\u2014\u2014\n\n/**\n * \u6587\u672C\u662F\u5426\u5F62\u4F3C git diff(\u670D\u52A1\u7AEF\u5165\u53E3\u6821\u9A8C)\u3002\n *\n * \u5224\u5B9A\u53E3\u5F84\u4E0E\u6D4F\u89C8\u5668\u534A diff-parse.ts \u7684 isDiffText \u5B8C\u5168\u4E00\u81F4(\u4ECE\u6E90\u7801\u6284,\n * \u4E0D\u51ED\u5370\u8C61\u5199):\u6709 `diff --git ` \u5934\u5373\u771F;\u5426\u5219\u8981\u6C42\u300Chunk \u5934 + \u6587\u4EF6\u6807\u8BB0\u300D\n * \u540C\u65F6\u51FA\u73B0\u3002POST \u662F\u7F51\u7EDC\u5165\u53E3,\u4E0D\u80FD\u4FE1\u4EFB\u5BA2\u6237\u7AEF\u5DF2\u7ECF\u6821\u9A8C\u8FC7\u3002\n */\nexport function looksLikeDiffText(text: string): boolean {\n const lines = text.split('\\n')\n if (lines.some((line) => line.startsWith('diff --git '))) return true\n const hasHunk = lines.some((line) => /^@@ -\\d+/.test(line))\n const hasFileMark = lines.some((line) => /^(---|\\+\\+\\+) \\S/.test(line))\n return hasHunk && hasFileMark\n}\n\n/** AI \u5BA1\u67E5\u6682\u5B58\u76EE\u5F55(\u7CFB\u7EDF\u4E34\u65F6\u76EE\u5F55\u4E0B\u7684\u81EA\u6709\u5B50\u76EE\u5F55)\u3002 */\nfunction reviewDir(): string {\n return join(tmpdir(), 'dsh-code-review')\n}\n\n/**\n * \u6E05\u7406\u6682\u5B58\u76EE\u5F55\u91CC\u8FC7\u671F\u7684 review \u6587\u4EF6(mtime \u8D85\u8FC7 TTL)\u3002\n * \u9010\u4E2A\u72EC\u7ACB try/catch:\u5355\u4E2A\u6587\u4EF6\u5220\u9664\u5931\u8D25(\u5982\u5E76\u53D1\u5220\u9664\u7ADE\u6001)\u4E0D\u5F71\u54CD\u5176\u4F59\u3002\n * \u53EA\u5339\u914D\u672C\u63D2\u4EF6\u7684\u547D\u540D\u524D\u7F00,\u7EDD\u4E0D\u89E6\u78B0\u76EE\u5F55\u91CC\u5176\u4ED6\u6765\u6E90\u7684\u6587\u4EF6\u3002\n */\nasync function pruneReviewFiles(): Promise<void> {\n const dir = reviewDir()\n const entries = await readdir(dir).catch(() => [] as string[])\n const cutoff = Date.now() - REVIEW_FILE_TTL_MS\n await Promise.all(entries.map(async (name) => {\n if (!name.startsWith(REVIEW_FILE_PREFIX) || !name.endsWith('.diff')) return\n try {\n const st = await stat(join(dir, name))\n if (st.mtimeMs < cutoff) await unlink(join(dir, name))\n } catch {\n // \u6587\u4EF6\u6D88\u5931/\u4E0D\u53EF stat:\u65E0\u9700\u5904\u7406(\u8FC7\u671F\u6E05\u7406\u662F\u5C3D\u529B\u800C\u4E3A)\u3002\n }\n }))\n}\n\n/** saveReviewDiff \u7684\u5931\u8D25\u539F\u56E0(\u4F9B\u6D4B\u8BD5\u4E0E\u9519\u8BEF\u54CD\u5E94\u4F7F\u7528)\u3002 */\nexport type ReviewSaveError = 'empty-text' | 'not-a-diff' | 'too-large' | 'too-many-files' | 'write-error'\n\n/** saveReviewDiff \u7684\u8FD4\u56DE:\u6210\u529F\u5E26\u6682\u5B58\u8DEF\u5F84;\u5931\u8D25\u5E26\u539F\u56E0\u4E0E HTTP \u72B6\u6001\u7801\u3002 */\nexport type ReviewSaveResult =\n | { ok: true; path: string }\n | { ok: false; error: ReviewSaveError; status: number }\n\n/**\n * \u6821\u9A8C\u5E76\u628A diff \u6587\u672C\u6682\u5B58\u4E3A\u4E34\u65F6\u6587\u4EF6,\u8FD4\u56DE\u7EDD\u5BF9\u8DEF\u5F84\u3002\n *\n * \u6587\u4EF6\u540D\u670D\u52A1\u7AEF\u751F\u6210(\u65F6\u95F4\u6233 + \u968F\u673A\u540E\u7F00),\u8C03\u7528\u65B9\u4E0D\u80FD\u6307\u5B9A\u8DEF\u5F84;\u6743\u9650 0600\u3002\n * \u5199\u5165\u524D\u5148\u505A\u8FC7\u671F\u6E05\u7406\u5E76\u68C0\u67E5\u6587\u4EF6\u6570\u4E0A\u9650(\u9632\u63A5\u53E3\u88AB\u5237\u5199\u6EE1\u78C1\u76D8)\u3002\n */\nexport async function saveReviewDiff(text: string): Promise<ReviewSaveResult> {\n if (text.trim() === '') return { ok: false, error: 'empty-text', status: 400 }\n if (Buffer.byteLength(text, 'utf8') > MAX_DIFF_BYTES) return { ok: false, error: 'too-large', status: 413 }\n if (!looksLikeDiffText(text)) return { ok: false, error: 'not-a-diff', status: 400 }\n try {\n const dir = reviewDir()\n // 0700:\u76EE\u5F55\u5F52\u5BBF\u4E3B\u8FDB\u7A0B\u7528\u6237\u79C1\u6709,\u5176\u4ED6\u7CFB\u7EDF\u7528\u6237\u4E0D\u53EF\u5217\u4E3E/\u8BFB\u5199\u3002\n await mkdir(dir, { recursive: true, mode: 0o700 })\n await pruneReviewFiles()\n const entries = await readdir(dir)\n const count = entries.filter((name) => name.startsWith(REVIEW_FILE_PREFIX) && name.endsWith('.diff')).length\n if (count >= MAX_REVIEW_FILES) return { ok: false, error: 'too-many-files', status: 429 }\n const path = join(dir, `${REVIEW_FILE_PREFIX}${Date.now()}-${randomBytes(5).toString('hex')}.diff`)\n await writeFile(path, text, { mode: 0o600 })\n return { ok: true, path }\n } catch (err) {\n console.warn('[dsh-code-review] AI \u5BA1\u67E5\u6682\u5B58\u5199\u5165\u5931\u8D25:', err)\n return { ok: false, error: 'write-error', status: 500 }\n }\n}\n\n/**\n * \u8BFB\u53D6\u8BF7\u6C42\u4F53(JSON),\u5E26\u5B57\u8282\u4E0A\u9650:\n * Content-Length \u9884\u68C0 + \u6D41\u5F0F\u7D2F\u8BA1\u53CC\u91CD\u9650\u5236,\u8D85\u9650\u5373\u65AD\u5F00\u5E76\u8FD4\u56DE 413,\n * \u9632\u6B62\u6076\u610F/\u5F02\u5E38\u7684\u5927\u8BF7\u6C42\u4F53\u5360\u5185\u5B58\u3002\n * \u5BA2\u6237\u7AEF\u4E2D\u9014\u65AD\u5F00\u65F6 node \u53EA\u4FDD\u8BC1\u53D1 'close'(\u4E0D\u4FDD\u8BC1 'error'),\u8865\u4E0A\n * close \u515C\u5E95\u8BA9 promise \u5FC5\u7136 settle(finish \u6709 settled \u5B88\u536B,\u6B63\u5E38\n * \u7ED3\u675F\u540E\u5230\u8FBE\u7684 close \u662F\u5E42\u7B49\u7A7A\u64CD\u4F5C,res \u4E0D\u4F1A\u60AC\u6302)\u3002\n */\nfunction readJsonBody(req: IncomingMessage, maxBytes: number): Promise<{ ok: true; value: unknown } | { ok: false; status: number; error: string }> {\n return new Promise((resolvePromise) => {\n const declared = Number(req.headers['content-length'] ?? '0')\n if (Number.isFinite(declared) && declared > maxBytes) {\n resolvePromise({ ok: false, status: 413, error: 'too-large' })\n return\n }\n const chunks: Buffer[] = []\n let total = 0\n let settled = false\n const finish = (result: { ok: true; value: unknown } | { ok: false; status: number; error: string }): void => {\n if (settled) return\n settled = true\n resolvePromise(result)\n }\n req.on('data', (chunk: Buffer) => {\n total += chunk.length\n // \u6D41\u5F0F\u7D2F\u8BA1\u4E0A\u9650:chunked \u4F20\u8F93\u6CA1\u6709 Content-Length,\u53EA\u80FD\u8FB9\u6536\u8FB9\u5224\u3002\n if (total > maxBytes) {\n req.destroy()\n finish({ ok: false, status: 413, error: 'too-large' })\n return\n }\n chunks.push(chunk)\n })\n req.on('end', () => {\n try {\n finish({ ok: true, value: JSON.parse(Buffer.concat(chunks).toString('utf8')) })\n } catch {\n finish({ ok: false, status: 400, error: 'invalid-json' })\n }\n })\n req.on('error', () => finish({ ok: false, status: 400, error: 'read-error' }))\n // \u5BA2\u6237\u7AEF\u65AD\u5F00\u515C\u5E95:\u6CA1\u6709 end/error \u7684\u8FDE\u63A5\u65AD\u5F00\u4E5F\u8981 settle\u3002\n req.on('close', () => finish({ ok: false, status: 400, error: 'read-error' }))\n })\n}\n\n/**\n * AI \u5BA1\u67E5\u6682\u5B58\u8DEF\u7531\u5904\u7406\u5668:POST JSON { text } \u2192 \u6821\u9A8C\u5F62\u4F3C diff \u2192\n * \u6682\u5B58\u4E34\u65F6\u6587\u4EF6 \u2192 \u8FD4\u56DE { ok: true, path }\u3002\n *\n * \u540C\u6E90\u5B88\u536B(\u4E3A\u4EC0\u4E48\u5199\u8DEF\u7531\u5FC5\u987B\u505A\u800C GET \u4E0D\u7528):\n * - \u5BBF\u4E3B\u5BF9\u547D\u540D\u8DEF\u7531\u662F\u65E0\u9274\u6743\u76F4\u8C03,\u672C\u8DEF\u7531\u662F\u300C\u6301\u4E45\u5199\u300D,\u6076\u610F\u7F51\u9875\u53EF\u7528\n * text/plain \u7684 no-cors fetch(\u7B80\u5355\u8BF7\u6C42,\u514D CORS \u9884\u68C0)\u8DE8\u6E90\u704C\u6570\u636E;\n * GET \u8DEF\u7531\u8DE8\u6E90\u672C\u5C31\u8BFB\u4E0D\u5230\u54CD\u5E94\u4F53(SOP),\u6CA1\u6709\u7B49\u4EF7\u66B4\u9732\u3002\n * - Content-Type \u9650\u5B9A application/json:\u8DE8\u6E90 JS \u8BBE\u4E0D\u4E86\u8FD9\u4E2A\u5934(\u4F1A\u8F6C\n * \u9884\u68C0,\u800C\u672C\u8DEF\u7531\u65E0 CORS \u653E\u884C \u2192 \u9884\u68C0\u5931\u8D25),\u5EC9\u4EF7 CSRF \u8DEF\u5F84\u5373\u88AB\u5173\u6B7B;\n * \u63D2\u4EF6\u81EA\u8EAB\u7684 fetch \u6052\u540C\u6E90\u3001\u6052\u5E26\u6B64\u5934,\u4E0D\u53D7\u5F71\u54CD\u3002\n * - Sec-Fetch-Site \u4E3A cross-site \u76F4\u63A5\u62D2\u7EDD;\u5934\u7F3A\u5931\u65F6\u653E\u884C(curl \u8C03\u8BD5\u4E0E\n * \u4E0D\u53D1\u9001\u8BE5\u5934\u7684\u8001\u6D4F\u89C8\u5668\u517C\u5BB9)\u3002\n * - \u6B8B\u4F59\u98CE\u9669(DNS rebinding \u5BF9\u4EE5\u4E0A\u68C0\u67E5\u514D\u75AB)\u9700\u8981 Host \u6821\u9A8C\u624D\u80FD\u5173\u6B7B,\n * \u4F46\u90A3\u4F1A\u7834\u574F\u975E\u56DE\u73AF\u7ED1\u5B9A\u7684\u90E8\u7F72\u5F62\u6001,\u8BB0 backlog \u4E0D\u5728\u672C\u6B21\u5904\u7406\u3002\n */\nasync function handleAiReviewRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {\n if (req.method !== 'POST') {\n sendJson(res, 405, { ok: false, error: 'method-not-allowed' })\n return\n }\n const contentType = req.headers['content-type'] ?? ''\n if (!contentType.toLowerCase().startsWith('application/json')) {\n sendJson(res, 415, { ok: false, error: 'unsupported-media-type' })\n return\n }\n const fetchSite = req.headers['sec-fetch-site']\n if (typeof fetchSite === 'string' && fetchSite.toLowerCase() === 'cross-site') {\n sendJson(res, 403, { ok: false, error: 'cross-site-denied' })\n return\n }\n const body = await readJsonBody(req, MAX_DIFF_BYTES)\n if (!body.ok) {\n sendJson(res, body.status, { ok: false, error: body.error })\n return\n }\n const text = (body.value as { text?: unknown }).text\n if (typeof text !== 'string') {\n sendJson(res, 400, { ok: false, error: 'invalid-json' })\n return\n }\n const result = await saveReviewDiff(text)\n if (!result.ok) {\n sendJson(res, result.status, { ok: false, error: result.error })\n return\n }\n sendJson(res, 200, { ok: true, path: result.path })\n}\n\n/**\n * \u63D2\u4EF6 apply:\u6CE8\u518C\u4E24\u4E2A\u8DEF\u7531\u3002webServer \u53EF\u7528\u65F6\u6267\u884C;\u7F3A\u5931\u65F6\u9759\u9ED8\u8DF3\u8FC7\n * (\u56DE\u8C03\u4E0D\u6267\u884C),\u6D4F\u89C8\u5668\u534A\u7684\u8DEF\u5F84 chip / AI \u5BA1\u67E5\u6309\u94AE\u4F1A\u663E\u793A\u8BFB\u53D6\u5931\u8D25\u3002\n */\nexport function apply(ctx: CodeReviewCtx): void {\n ctx.inject(['webServer'], (srv) => {\n srv.effect(() => {\n // register \u8FD4\u56DE disposer;\u63D2\u4EF6\u5378\u8F7D/HMR \u91CD\u8F7D\u65F6\u81EA\u52A8\u6458\u9664\u8DEF\u7531\u3002\n const unregisterDiff = srv.webServer.register({\n kind: 'exact',\n path: DIFF_ROUTE_PATH,\n handler: handleDiffRequest,\n })\n const unregisterAi = srv.webServer.register({\n kind: 'exact',\n path: AI_REVIEW_ROUTE_PATH,\n handler: handleAiReviewRequest,\n })\n return () => {\n unregisterDiff()\n unregisterAi()\n }\n }, `dsh-code-review: ${DIFF_ROUTE_PATH} + ${AI_REVIEW_ROUTE_PATH} routes`)\n })\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/lib/types/index.d.ts
CHANGED
|
@@ -10,9 +10,16 @@ export interface CodeReviewCtx {
|
|
|
10
10
|
effect(fn: () => void | (() => void), name?: string): unknown;
|
|
11
11
|
}
|
|
12
12
|
export declare const DIFF_ROUTE_PATH: string;
|
|
13
|
+
export declare const AI_REVIEW_ROUTE_PATH: string;
|
|
13
14
|
export declare const MAX_DIFF_BYTES: number;
|
|
15
|
+
export declare const MAX_REVIEW_FILES: number;
|
|
16
|
+
export declare const REVIEW_FILE_TTL_MS: number;
|
|
14
17
|
export type DiffLoadError = 'invalid-path' | 'not-found' | 'not-file' | 'too-large' | 'read-error';
|
|
15
18
|
export type DiffLoadResult = { ok: true; text: string } | { ok: false; error: DiffLoadError; status: number };
|
|
16
19
|
export declare function resolveDiffPath(raw: string): string | null;
|
|
17
20
|
export declare function loadDiffText(absPath: string): Promise<DiffLoadResult>;
|
|
21
|
+
export declare function looksLikeDiffText(text: string): boolean;
|
|
22
|
+
export type ReviewSaveError = 'empty-text' | 'not-a-diff' | 'too-large' | 'too-many-files' | 'write-error';
|
|
23
|
+
export type ReviewSaveResult = { ok: true; path: string } | { ok: false; error: ReviewSaveError; status: number };
|
|
24
|
+
export declare function saveReviewDiff(text: string): Promise<ReviewSaveResult>;
|
|
18
25
|
export declare function apply(ctx: CodeReviewCtx): void;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yangzhe1991/dsh-code-review",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "DSH Web 插件:git diff 一键打开独立双列 Code Review
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "DSH Web 插件:git diff 一键打开独立双列 Code Review 页(语法高亮、行内评论、中英双语),并支持一键创建干净上下文的 sub agent 独立审查代码、与主 agent 讨论到达成一致;本地 .diff/.patch 路径自动变查看按钮 | DSH web plugin: one-click standalone two-column code review page for git diff (syntax highlighting, inline comments, bilingual UI), plus one-click clean-context subagent review that discusses with the main agent until consensus; local .diff/.patch paths become view buttons",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
7
7
|
},
|
|
@@ -56,10 +56,10 @@
|
|
|
56
56
|
},
|
|
57
57
|
"peerDependencies": {
|
|
58
58
|
"@deepseek-ai/cordis": "^4.0.2",
|
|
59
|
-
"@deepseek-ai/dsh-client-ui-conversation": "^0.1.3-alpha.2",
|
|
60
|
-
"@deepseek-ai/dsh-client-ui-layout": "^0.1.3-alpha.2",
|
|
61
|
-
"@deepseek-ai/dsh-client-ui-renderer": "^0.1.3-alpha.2",
|
|
62
|
-
"@deepseek-ai/dsh-client-ui-slots": "^0.1.3-alpha.2",
|
|
59
|
+
"@deepseek-ai/dsh-client-ui-conversation": "^0.1.3-alpha.2 || ^0.2.0-rc.1",
|
|
60
|
+
"@deepseek-ai/dsh-client-ui-layout": "^0.1.3-alpha.2 || ^0.2.0-rc.1",
|
|
61
|
+
"@deepseek-ai/dsh-client-ui-renderer": "^0.1.3-alpha.2 || ^0.2.0-rc.1",
|
|
62
|
+
"@deepseek-ai/dsh-client-ui-slots": "^0.1.3-alpha.2 || ^0.2.0-rc.1",
|
|
63
63
|
"react": "^18.2.0"
|
|
64
64
|
},
|
|
65
65
|
"devDependencies": {
|