dependfix 0.0.0 → 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/dist/cli.mjs ADDED
@@ -0,0 +1,2530 @@
1
+ import { defineCommand, parseArgs } from "citty";
2
+ import { AppError, createEmptyRunSummary, createLogger, filterAlerts, generateJsonReport, generateMarkdownReport, isValidRepoIdentifier, limitAlerts, prioritizeAlerts, toAppError, toErrorMessage, writeReport } from "@dependfix/core";
3
+ import { Octokit } from "@octokit/rest";
4
+ import { execSync, spawn } from "node:child_process";
5
+ import { createHash } from "node:crypto";
6
+ import { copyFileSync, existsSync, readFileSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ import { createInterface } from "node:readline";
9
+ import YAML from "yaml";
10
+
11
+ //#region \0rolldown/runtime.js
12
+ var __commonJSMin = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports);
13
+
14
+ //#endregion
15
+ //#region src/github/client.ts
16
+ /**
17
+ * 创建已认证的 Octokit 实例。
18
+ *
19
+ * 调用方直接使用 `octokit.rest.*` 访问所有已类型化的 GitHub REST API。
20
+ * 分页使用 `octokit.paginate()` 自动合并多页结果。
21
+ *
22
+ * @example
23
+ * ```typescript
24
+ * const octokit = createGitHubClient({ token: 'ghp_xxxx' })
25
+ *
26
+ * // 仓库信息
27
+ * const { data: repo } = await octokit.rest.repos.get({ owner: 'foo', repo: 'bar' })
28
+ *
29
+ * // Dependabot 告警(自动分页)
30
+ * const alerts = await octokit.paginate(
31
+ * octokit.rest.dependabot.listAlertsForRepo,
32
+ * { owner: 'foo', repo: 'bar', state: 'open', per_page: 100 },
33
+ * )
34
+ * ```
35
+ */
36
+ function createGitHubClient(options) {
37
+ return new Octokit({
38
+ auth: options.token,
39
+ baseUrl: options.baseUrl ?? "https://api.github.com"
40
+ });
41
+ }
42
+
43
+ //#endregion
44
+ //#region ../../node_modules/.pnpm/deprecation@2.3.1/node_modules/deprecation/dist-node/index.js
45
+ var require_dist_node$1 = /* @__PURE__ */ __commonJSMin(((exports) => {
46
+ Object.defineProperty(exports, "__esModule", { value: true });
47
+ var Deprecation = class extends Error {
48
+ constructor(message) {
49
+ super(message);
50
+ /* istanbul ignore next */
51
+ if (Error.captureStackTrace) Error.captureStackTrace(this, this.constructor);
52
+ this.name = "Deprecation";
53
+ }
54
+ };
55
+ exports.Deprecation = Deprecation;
56
+ }));
57
+
58
+ //#endregion
59
+ //#region ../../node_modules/.pnpm/wrappy@1.0.2/node_modules/wrappy/wrappy.js
60
+ var require_wrappy = /* @__PURE__ */ __commonJSMin(((exports, module) => {
61
+ module.exports = wrappy;
62
+ function wrappy(fn, cb) {
63
+ if (fn && cb) return wrappy(fn)(cb);
64
+ if (typeof fn !== "function") throw new TypeError("need wrapper function");
65
+ Object.keys(fn).forEach(function(k) {
66
+ wrapper[k] = fn[k];
67
+ });
68
+ return wrapper;
69
+ function wrapper() {
70
+ var args = new Array(arguments.length);
71
+ for (var i = 0; i < args.length; i++) args[i] = arguments[i];
72
+ var ret = fn.apply(this, args);
73
+ var cb = args[args.length - 1];
74
+ if (typeof ret === "function" && ret !== cb) Object.keys(cb).forEach(function(k) {
75
+ ret[k] = cb[k];
76
+ });
77
+ return ret;
78
+ }
79
+ }
80
+ }));
81
+
82
+ //#endregion
83
+ //#region ../../node_modules/.pnpm/once@1.4.0/node_modules/once/once.js
84
+ var require_once = /* @__PURE__ */ __commonJSMin(((exports, module) => {
85
+ var wrappy = require_wrappy();
86
+ module.exports = wrappy(once);
87
+ module.exports.strict = wrappy(onceStrict);
88
+ once.proto = once(function() {
89
+ Object.defineProperty(Function.prototype, "once", {
90
+ value: function() {
91
+ return once(this);
92
+ },
93
+ configurable: true
94
+ });
95
+ Object.defineProperty(Function.prototype, "onceStrict", {
96
+ value: function() {
97
+ return onceStrict(this);
98
+ },
99
+ configurable: true
100
+ });
101
+ });
102
+ function once(fn) {
103
+ var f = function() {
104
+ if (f.called) return f.value;
105
+ f.called = true;
106
+ return f.value = fn.apply(this, arguments);
107
+ };
108
+ f.called = false;
109
+ return f;
110
+ }
111
+ function onceStrict(fn) {
112
+ var f = function() {
113
+ if (f.called) throw new Error(f.onceError);
114
+ f.called = true;
115
+ return f.value = fn.apply(this, arguments);
116
+ };
117
+ f.onceError = (fn.name || "Function wrapped with `once`") + " shouldn't be called more than once";
118
+ f.called = false;
119
+ return f;
120
+ }
121
+ }));
122
+
123
+ //#endregion
124
+ //#region ../../node_modules/.pnpm/@octokit+request-error@5.1.1/node_modules/@octokit/request-error/dist-node/index.js
125
+ var require_dist_node = /* @__PURE__ */ __commonJSMin(((exports, module) => {
126
+ var __create = Object.create;
127
+ var __defProp = Object.defineProperty;
128
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
129
+ var __getOwnPropNames = Object.getOwnPropertyNames;
130
+ var __getProtoOf = Object.getPrototypeOf;
131
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
132
+ var __export = (target, all) => {
133
+ for (var name in all) __defProp(target, name, {
134
+ get: all[name],
135
+ enumerable: true
136
+ });
137
+ };
138
+ var __copyProps = (to, from, except, desc) => {
139
+ if (from && typeof from === "object" || typeof from === "function") {
140
+ for (let key of __getOwnPropNames(from)) if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, {
141
+ get: () => from[key],
142
+ enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
143
+ });
144
+ }
145
+ return to;
146
+ };
147
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", {
148
+ value: mod,
149
+ enumerable: true
150
+ }) : target, mod));
151
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
152
+ var dist_src_exports = {};
153
+ __export(dist_src_exports, { RequestError: () => RequestError });
154
+ module.exports = __toCommonJS(dist_src_exports);
155
+ var import_deprecation = require_dist_node$1();
156
+ var import_once = __toESM(require_once());
157
+ var logOnceCode = (0, import_once.default)((deprecation) => console.warn(deprecation));
158
+ var logOnceHeaders = (0, import_once.default)((deprecation) => console.warn(deprecation));
159
+ var RequestError = class extends Error {
160
+ constructor(message, statusCode, options) {
161
+ super(message);
162
+ if (Error.captureStackTrace) Error.captureStackTrace(this, this.constructor);
163
+ this.name = "HttpError";
164
+ this.status = statusCode;
165
+ let headers;
166
+ if ("headers" in options && typeof options.headers !== "undefined") headers = options.headers;
167
+ if ("response" in options) {
168
+ this.response = options.response;
169
+ headers = options.response.headers;
170
+ }
171
+ const requestCopy = Object.assign({}, options.request);
172
+ if (options.request.headers.authorization) requestCopy.headers = Object.assign({}, options.request.headers, { authorization: options.request.headers.authorization.replace(/(?<! ) .*$/, " [REDACTED]") });
173
+ requestCopy.url = requestCopy.url.replace(/\bclient_secret=\w+/g, "client_secret=[REDACTED]").replace(/\baccess_token=\w+/g, "access_token=[REDACTED]");
174
+ this.request = requestCopy;
175
+ Object.defineProperty(this, "code", { get() {
176
+ logOnceCode(new import_deprecation.Deprecation("[@octokit/request-error] `error.code` is deprecated, use `error.status`."));
177
+ return statusCode;
178
+ } });
179
+ Object.defineProperty(this, "headers", { get() {
180
+ logOnceHeaders(new import_deprecation.Deprecation("[@octokit/request-error] `error.headers` is deprecated, use `error.response.headers`."));
181
+ return headers || {};
182
+ } });
183
+ }
184
+ };
185
+ }));
186
+
187
+ //#endregion
188
+ //#region src/github/errors.ts
189
+ var import_dist_node = require_dist_node();
190
+ /**
191
+ * 将 Octokit 请求错误映射为 `AppError`。
192
+ *
193
+ * @param error - 原始错误(`RequestError` 或网络异常)
194
+ * @param context - 调用上下文描述(如 `'fetch repo info for foo/bar'`),会附加到错误消息中
195
+ */
196
+ function mapGitHubError(error, context) {
197
+ if (error instanceof import_dist_node.RequestError) {
198
+ const code = resolveErrorCode(error);
199
+ const details = collectErrorDetails(error);
200
+ return new AppError(code, `${context}: ${error.message}`, {
201
+ cause: error,
202
+ details
203
+ });
204
+ }
205
+ if (error instanceof Error) return new AppError("NETWORK_ERROR", `${context}: ${error.message}`, { cause: error });
206
+ return new AppError("NETWORK_ERROR", `${context}: unknown error`, { details: { raw: toErrorMessage(error) } });
207
+ }
208
+ function resolveErrorCode(error) {
209
+ switch (error.status) {
210
+ case 401: return "AUTHENTICATION_FAILED";
211
+ case 403: {
212
+ const remaining = error.response?.headers["x-ratelimit-remaining"];
213
+ if (remaining !== void 0 && remaining === "0") return "RATE_LIMITED";
214
+ return "PERMISSION_DENIED";
215
+ }
216
+ case 404: return "REPO_NOT_FOUND";
217
+ default: return "GITHUB_API_ERROR";
218
+ }
219
+ }
220
+ function collectErrorDetails(error) {
221
+ const details = {
222
+ status: error.status,
223
+ requestUrl: error.request?.url,
224
+ requestMethod: error.request?.method
225
+ };
226
+ const resetAt = error.response?.headers["x-ratelimit-reset"];
227
+ if (resetAt) details.rateLimitReset = resetAt;
228
+ return details;
229
+ }
230
+
231
+ //#endregion
232
+ //#region src/github/dependabot-fetcher.ts
233
+ /**
234
+ * 拉取指定仓库的 Dependabot 告警并映射为标准化模型。
235
+ *
236
+ * - 自动分页(`octokit.paginate()`),返回所有匹配的告警
237
+ * - 仅处理 `state=open` 的告警(可通过 `params.state` 覆盖)
238
+ * - 异常通过 `mapGitHubError` 转为 `AppError`
239
+ * - 空仓库返回 `[]`,不抛异常
240
+ *
241
+ * @param client - 已认证的 Octokit 实例(来自 `createGitHubClient`)
242
+ * @param params - 仓库标识与过滤参数
243
+ * @returns 标准化告警列表
244
+ *
245
+ * @example
246
+ * ```typescript
247
+ * const octokit = createGitHubClient({ token: 'ghp_xxxx' })
248
+ * const alerts = await fetchDependabotAlerts(octokit, { owner: 'foo', repo: 'bar' })
249
+ * ```
250
+ */
251
+ async function fetchDependabotAlerts(client, params) {
252
+ const { owner, repo, state = "open", perPage = 100 } = params;
253
+ try {
254
+ return (await client.paginate(client.rest.dependabot.listAlertsForRepo, {
255
+ owner,
256
+ repo,
257
+ state,
258
+ per_page: perPage
259
+ })).map((alert) => normalizeAlert(alert, owner, repo));
260
+ } catch (error) {
261
+ throw mapGitHubError(error, `fetch dependabot alerts for ${owner}/${repo}`);
262
+ }
263
+ }
264
+ /**
265
+ * 将单条 Dependabot API 原始告警转换为标准化模型。
266
+ *
267
+ * 映射逻辑:
268
+ * - severity 直接透传(Dependabot 的 `low|medium|high|critical` 与 `AlertSeverity` 对齐)
269
+ * - fixable 判定:`security_vulnerability.first_patched_version !== null`
270
+ * - fixStrategy:有修复版本 → `'upgrade'`,否则 `null`
271
+ * - recommendedVersion 取自 `first_patched_version.identifier`
272
+ * - 可选字段兜底:`packageName`/`packageEcosystem` → `'unknown'`,`manifestPath` → `''`
273
+ * - `defaultBranch` 不在此层填充(由上层调用方获取后注入)
274
+ */
275
+ function normalizeAlert(alert, owner, repo) {
276
+ const firstPatched = alert.security_vulnerability.first_patched_version;
277
+ const fixable = firstPatched !== null;
278
+ return {
279
+ id: alert.number,
280
+ source: "dependabot",
281
+ repository: `${owner}/${repo}`,
282
+ defaultBranch: "",
283
+ severity: alert.security_advisory.severity,
284
+ packageEcosystem: alert.dependency.package?.ecosystem ?? "unknown",
285
+ packageName: alert.dependency.package?.name ?? "unknown",
286
+ manifestPath: alert.dependency.manifest_path ?? "",
287
+ ruleId: alert.security_advisory.ghsa_id,
288
+ summary: alert.security_advisory.summary,
289
+ htmlUrl: alert.html_url,
290
+ fixable,
291
+ fixStrategy: fixable ? "upgrade" : null,
292
+ recommendedVersion: firstPatched?.identifier ?? "",
293
+ dependencyType: normalizeDependencyRelationship(alert.dependency.relationship)
294
+ };
295
+ }
296
+ /**
297
+ * 将 Dependabot API 的 `dependency.relationship` 映射为 `DependencyType`。
298
+ *
299
+ * GitHub API 返回的合法值:
300
+ * - `'direct'` → 直接依赖
301
+ * - `'indirect'` → 间接依赖(transitive)
302
+ * - `null`/`undefined` → 未知
303
+ */
304
+ function normalizeDependencyRelationship(relationship) {
305
+ if (relationship === "direct") return "direct";
306
+ if (relationship === "indirect") return "transitive";
307
+ }
308
+
309
+ //#endregion
310
+ //#region src/github/pr-creator.ts
311
+ const BOT_NAME = "dependfix[bot]";
312
+ const BOT_EMAIL = "dependfix[bot]@users.noreply.github.com";
313
+ /** 自动修复分支统一前缀(T210 起分支名 = 前缀 + 内容指纹 8 位) */
314
+ const BRANCH_PREFIX = "dependfix/auto-fix-";
315
+ /**
316
+ * 计算修复内容指纹(sha256 前 8 位)。
317
+ *
318
+ * 指纹基于**结构化修复结果**而非 git diff:
319
+ * - 成功升级集:`pkg@toVersion`(排序拼接)
320
+ * - 修复失败集:失败包名(排序拼接)
321
+ * - lockfile 修复状态(成功/失败)
322
+ *
323
+ * 同告警集 → 同修复结果 → 同指纹 → 幂等跳过;
324
+ * 内容变化(升级集/失败集/锁文件修复变化)→ 指纹变化 → 关旧开新。
325
+ * 不用 diff hash 是因为 pnpm 版本等非确定性因素会使锁文件内容漂移,
326
+ * 导致"告警没变但指纹变"。
327
+ */
328
+ function computeFixFingerprint(actions) {
329
+ const isUpgrade = (a) => a.type === "dependency-upgrade" && !!a.target && !a.target.startsWith("PR #");
330
+ const upgrades = actions.filter((a) => isUpgrade(a) && a.success && a.toVersion).map((a) => `${a.target}@${a.toVersion}`).sort();
331
+ const failures = actions.filter((a) => isUpgrade(a) && !a.success).map((a) => a.target).sort();
332
+ const repairs = actions.filter((a) => a.type === "lockfile-repair").map((a) => `${a.target}:${a.success}`).sort();
333
+ const payload = JSON.stringify([
334
+ upgrades,
335
+ failures,
336
+ repairs
337
+ ]);
338
+ return createHash("sha256").update(payload).digest("hex").slice(0, 8);
339
+ }
340
+ /**
341
+ * 从分支名提取内容指纹(`dependfix/auto-fix-{fp8}` → `{fp8}`)。
342
+ * 指纹格式为 8 位 hex;分支名不含 `-` 后的内容、前缀不匹配或格式不合法时返回 null
343
+ * (如旧版 runId 分支,会被 supersede)。
344
+ */
345
+ function extractFingerprintFromBranch(branchName) {
346
+ if (!branchName.startsWith(BRANCH_PREFIX)) return null;
347
+ const fingerprint = branchName.slice(19);
348
+ return /^[0-9a-f]{8}$/.test(fingerprint) ? fingerprint : null;
349
+ }
350
+ /**
351
+ * 计算 fix-and-pr 去重决策:
352
+ * - 存在同指纹 open PR → skip(仅需关闭其余异指纹 PR)
353
+ * - 否则 → create(关闭全部旧 PR,先建新后关旧由调用方保证)
354
+ */
355
+ function computeFixAndPrPlan(existingPRs, fingerprint) {
356
+ const sameContentPR = existingPRs.find((pr) => extractFingerprintFromBranch(pr.headRef) === fingerprint);
357
+ if (sameContentPR) return {
358
+ action: "skip",
359
+ sameContentPR,
360
+ supersedePRs: existingPRs.filter((pr) => pr !== sameContentPR)
361
+ };
362
+ return {
363
+ action: "create",
364
+ supersedePRs: existingPRs
365
+ };
366
+ }
367
+ /**
368
+ * 在工作目录创建修复分支。
369
+ *
370
+ * - 分支名由调用方传入(T210 起为 `dependfix/auto-fix-{内容指纹}`,不再依赖 runId)
371
+ * - 如果分支已存在(如重跑),切换到该分支
372
+ *
373
+ * @returns 分支名和是否为新创建
374
+ */
375
+ function createFixBranch(branchName, workDir) {
376
+ if (branchExists(branchName, workDir)) {
377
+ execSync(`git checkout ${branchName}`, {
378
+ cwd: workDir,
379
+ stdio: "pipe"
380
+ });
381
+ return {
382
+ branchName,
383
+ created: false
384
+ };
385
+ }
386
+ execSync(`git checkout -b ${branchName}`, {
387
+ cwd: workDir,
388
+ stdio: "pipe"
389
+ });
390
+ return {
391
+ branchName,
392
+ created: true
393
+ };
394
+ }
395
+ /**
396
+ * 暂存所有变更并提交。
397
+ * 自动设置 git user.name / user.email(如未设置)。
398
+ */
399
+ function stageAndCommit(message, workDir) {
400
+ ensureGitConfig(workDir);
401
+ execSync("git add .", {
402
+ cwd: workDir,
403
+ stdio: "pipe"
404
+ });
405
+ execSync(`git commit -m "${escapeShell(message)}"`, {
406
+ cwd: workDir,
407
+ stdio: "pipe"
408
+ });
409
+ }
410
+ /**
411
+ * 推送分支到远程 origin。
412
+ */
413
+ function pushBranch(branchName, workDir) {
414
+ execSync(`git push origin ${branchName}`, {
415
+ cwd: workDir,
416
+ stdio: "pipe"
417
+ });
418
+ }
419
+ /**
420
+ * 查找目标仓库中所有 open 状态的 dependfix 自动修复 PR。
421
+ *
422
+ * GitHub 的 `pulls.list` 的 `head` 参数不支持通配符,因此拉取 open PR 列表后
423
+ * 按 head 分支前缀 `dependfix/auto-fix-` 过滤。假设 PR 数量不多(单页 100 足够),
424
+ * 未来 PR 数量增大时可引入 label 索引(见 backlog B1)。
425
+ */
426
+ async function findDependfixOpenPR(octokit, owner, repo) {
427
+ const { data } = await octokit.rest.pulls.list({
428
+ owner,
429
+ repo,
430
+ state: "open",
431
+ per_page: 100
432
+ });
433
+ return data.filter((pr) => pr.head.ref.startsWith(BRANCH_PREFIX)).map((pr) => ({
434
+ number: pr.number,
435
+ htmlUrl: pr.html_url,
436
+ headRef: pr.head.ref
437
+ }));
438
+ }
439
+ /**
440
+ * 关闭 Pull Request(用于 supersede 旧 PR)。
441
+ */
442
+ async function closePullRequest(octokit, owner, repo, pullNumber) {
443
+ await octokit.rest.pulls.update({
444
+ owner,
445
+ repo,
446
+ pull_number: pullNumber,
447
+ state: "closed"
448
+ });
449
+ }
450
+ /**
451
+ * 通过 GitHub API 创建 Pull Request。
452
+ */
453
+ async function createPullRequest(params) {
454
+ const { octokit, owner, repo, headBranch, baseBranch, title, body } = params;
455
+ const { data } = await octokit.rest.pulls.create({
456
+ owner,
457
+ repo,
458
+ title,
459
+ body,
460
+ head: headBranch,
461
+ base: baseBranch
462
+ });
463
+ return {
464
+ number: data.number,
465
+ htmlUrl: data.html_url
466
+ };
467
+ }
468
+ /**
469
+ * 列出远端所有 `dependfix/` 前缀分支(git.listMatchingRefs 精确前缀匹配)。
470
+ */
471
+ async function listDependfixBranches(octokit, owner, repo) {
472
+ const { data } = await octokit.rest.git.listMatchingRefs({
473
+ owner,
474
+ repo,
475
+ ref: "heads/dependfix",
476
+ per_page: 100
477
+ });
478
+ return data.map((ref) => ref.ref.replace(/^refs\/heads\//, "")).filter((name) => name.startsWith("dependfix/"));
479
+ }
480
+ /**
481
+ * 查询分支对应的最近 PR 状态(pulls.list head 精确匹配,按最近更新取 1 条)。
482
+ */
483
+ async function getBranchPrStatus(octokit, owner, repo, branch) {
484
+ const { data } = await octokit.rest.pulls.list({
485
+ owner,
486
+ repo,
487
+ head: `${owner}:${branch}`,
488
+ state: "all",
489
+ sort: "updated",
490
+ direction: "desc",
491
+ per_page: 1
492
+ });
493
+ const pr = data[0];
494
+ if (!pr) return {
495
+ branch,
496
+ prNumber: null,
497
+ merged: false,
498
+ closed: false
499
+ };
500
+ return {
501
+ branch,
502
+ prNumber: pr.number,
503
+ merged: pr.state === "closed" && pr.merged_at !== null,
504
+ closed: pr.state === "closed"
505
+ };
506
+ }
507
+ /**
508
+ * 删除远端分支(git ref delete)。失败(分支保护、不存在)由调用方捕获处理。
509
+ */
510
+ async function deleteRemoteBranch(octokit, owner, repo, branch) {
511
+ await octokit.rest.git.deleteRef({
512
+ owner,
513
+ repo,
514
+ ref: `heads/${branch}`
515
+ });
516
+ }
517
+ /**
518
+ * 判定交互确认回答是否为"是"(y/yes,大小写不敏感;空输入默认拒绝)。
519
+ */
520
+ function isConfirmAnswer(answer) {
521
+ return /^y(es)?$/i.test(answer.trim());
522
+ }
523
+ /**
524
+ * 从 RunResult 生成 PR body(Markdown)。
525
+ *
526
+ * @param supersededNumbers 被本 PR 取代并已关闭的旧 PR 编号列表(用于 Supersedes 声明)
527
+ */
528
+ function generatePRBody(result, supersededNumbers) {
529
+ const { summary, actions, errors } = result;
530
+ const lines = [
531
+ "## 🔒 Dependfix Auto Fix",
532
+ "",
533
+ `**Run ID**: \`${result.runId}\``,
534
+ `**Mode**: ${result.config.mode}`,
535
+ `**Time**: ${result.startedAt}`,
536
+ "",
537
+ "### 📊 Summary",
538
+ "",
539
+ "| Metric | Value |",
540
+ "|--------|-------|",
541
+ `| Repositories scanned | ${summary.repositoriesScanned} |`,
542
+ `| Alerts found | ${summary.alertsFound} |`,
543
+ `| Alerts fixable | ${summary.alertsFixable} |`,
544
+ `| Dependencies upgraded | ${summary.alertsFixed} |`,
545
+ `| Upgrades failed | ${summary.alertsFailed} |`,
546
+ `| Lockfile repairs | ${summary.lockfileRepairs} |`,
547
+ `| Verifications passed | ${summary.verificationsPassed} |`,
548
+ `| Verifications failed | ${summary.verificationsFailed} |`,
549
+ ""
550
+ ];
551
+ const upgrades = actions.filter((a) => a.type === "dependency-upgrade" && a.success);
552
+ if (upgrades.length > 0) {
553
+ lines.push("### 📦 Upgraded Dependencies", "");
554
+ lines.push("| Package | From | To | Major |");
555
+ lines.push("|---------|------|----|-------|");
556
+ for (const a of upgrades) {
557
+ const major = a.isMajor ? "⚠️ Yes" : "No";
558
+ lines.push(`| \`${a.target}\` | ${a.fromVersion ?? "-"} | ${a.toVersion ?? "-"} | ${major} |`);
559
+ }
560
+ lines.push("");
561
+ }
562
+ const failures = actions.filter((a) => a.type === "dependency-upgrade" && !a.success);
563
+ if (failures.length > 0) {
564
+ lines.push("### ⚠️ Failed Upgrades", "");
565
+ for (const a of failures) lines.push(`- **\`${a.target}\`**: ${a.error ?? "unknown error"}`);
566
+ lines.push("");
567
+ }
568
+ const verifications = actions.filter((a) => a.type === "verification");
569
+ if (verifications.length > 0) {
570
+ lines.push("### ✅ Verification", "");
571
+ for (const v of verifications) {
572
+ const icon = v.success ? "✅" : "❌";
573
+ lines.push(`- ${icon} \`${v.target}\` ${v.error ? `(${v.error})` : ""}`);
574
+ }
575
+ lines.push("");
576
+ }
577
+ if (errors.length > 0) {
578
+ lines.push("### 🚨 Errors", "");
579
+ for (const e of errors) lines.push(`- **${e.stage}**: ${e.message}`);
580
+ lines.push("");
581
+ }
582
+ lines.push("---", "", "*This PR was automatically created by [dependfix](https://github.com/dependfix/dependfix).*");
583
+ if (supersededNumbers && supersededNumbers.length > 0) lines.push("", `> **Supersedes**: ${supersededNumbers.map((n) => `#${n}`).join(", ")}(内容已更新,将取代旧 PR)`);
584
+ return lines.join("\n");
585
+ }
586
+ function branchExists(branchName, workDir) {
587
+ try {
588
+ execSync(`git rev-parse --verify ${branchName}`, {
589
+ cwd: workDir,
590
+ stdio: "pipe"
591
+ });
592
+ return true;
593
+ } catch {
594
+ return false;
595
+ }
596
+ }
597
+ function ensureGitConfig(workDir) {
598
+ const hasName = gitConfigExists("user.name", workDir);
599
+ const hasEmail = gitConfigExists("user.email", workDir);
600
+ if (!hasName) execSync(`git config user.name "${BOT_NAME}"`, {
601
+ cwd: workDir,
602
+ stdio: "pipe"
603
+ });
604
+ if (!hasEmail) execSync(`git config user.email "${BOT_EMAIL}"`, {
605
+ cwd: workDir,
606
+ stdio: "pipe"
607
+ });
608
+ }
609
+ function gitConfigExists(key, workDir) {
610
+ try {
611
+ execSync(`git config ${key}`, {
612
+ cwd: workDir,
613
+ stdio: "pipe"
614
+ });
615
+ return true;
616
+ } catch {
617
+ return false;
618
+ }
619
+ }
620
+ function escapeShell(str) {
621
+ return str.replace(/"/g, "\\\"");
622
+ }
623
+
624
+ //#endregion
625
+ //#region src/fixers/dependency/index.ts
626
+ /**
627
+ * 升级单个依赖到指定版本。
628
+ *
629
+ * - 在 `workDir` 中查找 `package.json` 并修改目标包的版本声明(保留原始前缀)
630
+ * - 执行 `pnpm install --no-frozen-lockfile` 更新 lockfile
631
+ * - 失败时自动回滚 `package.json` 和 `pnpm-lock.yaml`
632
+ * - 不执行验证(由 T107 负责)
633
+ *
634
+ * @param params - 包名、目标版本、工作目录
635
+ * @returns 修复结果
636
+ */
637
+ async function upgradeDependency(params) {
638
+ const { packageName, targetVersion, workDir } = params;
639
+ const pkgPath = join(workDir, "package.json");
640
+ const lockfilePath = join(workDir, "pnpm-lock.yaml");
641
+ if (!existsSync(pkgPath)) return failResult(packageName, targetVersion, "", `${pkgPath}: file not found`);
642
+ let pkg;
643
+ try {
644
+ pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
645
+ } catch {
646
+ return failResult(packageName, targetVersion, "", `${pkgPath}: invalid JSON`);
647
+ }
648
+ const dep = findDependencyVersion(pkg, packageName);
649
+ if (!dep) return failResult(packageName, targetVersion, "", `package "${packageName}" not found in dependencies / devDependencies / optionalDependencies`);
650
+ const fromVersion = dep.version;
651
+ const toVersion = `${extractPrefix(fromVersion)}${targetVersion}`;
652
+ const isMajor = parseMajorVersion(fromVersion) !== parseMajorVersion(toVersion) && parseMajorVersion(fromVersion) !== -1 && parseMajorVersion(toVersion) !== -1;
653
+ const pkgBackup = `${pkgPath}.bak`;
654
+ const lockBackup = `${lockfilePath}.bak`;
655
+ try {
656
+ copyFileSync(pkgPath, pkgBackup);
657
+ if (existsSync(lockfilePath)) copyFileSync(lockfilePath, lockBackup);
658
+ } catch {
659
+ return failResult(packageName, targetVersion, fromVersion, "failed to create backup files");
660
+ }
661
+ const deps = pkg[dep.group];
662
+ if (!deps) return failResult(packageName, targetVersion, fromVersion, `dependency group "${dep.group}" is missing`);
663
+ deps[packageName] = toVersion;
664
+ writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`, "utf-8");
665
+ try {
666
+ await execPnpmInstall(workDir);
667
+ } catch (installErr) {
668
+ rollback$1(pkgPath, pkgBackup, lockfilePath, lockBackup);
669
+ cleanupBackups({
670
+ pkgBackup,
671
+ lockBackup
672
+ });
673
+ return {
674
+ packageName,
675
+ fromVersion,
676
+ toVersion,
677
+ isMajor,
678
+ success: false,
679
+ error: `pnpm install failed: ${getStderr(installErr)}`
680
+ };
681
+ }
682
+ cleanupBackups({
683
+ pkgBackup,
684
+ lockBackup
685
+ });
686
+ return {
687
+ packageName,
688
+ fromVersion,
689
+ toVersion,
690
+ isMajor,
691
+ success: true
692
+ };
693
+ }
694
+ /**
695
+ * 通过 pnpm `overrides` 升级间接(transitive)依赖。
696
+ *
697
+ * 间接依赖不在 `package.json` 的 `dependencies` / `devDependencies` 中列出,
698
+ * 因此无法通过常规 `upgradeDependency` 处理。本函数根据项目类型选择覆盖写入位置:
699
+ *
700
+ * - **有 `pnpm-workspace.yaml`**(monorepo / workspace)→ 写入其中的 `overrides` 字段(pnpm v10+ 推荐)
701
+ * - **无 `pnpm-workspace.yaml`**(单包项目)→ 写入 `package.json` 的 `pnpm.overrides`
702
+ *
703
+ * 失败时自动回滚被修改的文件和 `pnpm-lock.yaml`。不执行验证(由 T107 负责)。
704
+ *
705
+ * @param params - 包名、目标版本、工作目录
706
+ * @returns 修复结果
707
+ */
708
+ async function overrideTransitiveDependency(params) {
709
+ const { packageName, targetVersion, workDir } = params;
710
+ const pkgPath = join(workDir, "package.json");
711
+ const workspaceYamlPath = join(workDir, "pnpm-workspace.yaml");
712
+ const lockfilePath = join(workDir, "pnpm-lock.yaml");
713
+ const usesWorkspaceYaml = existsSync(workspaceYamlPath);
714
+ if (!existsSync(pkgPath)) return failResult(packageName, targetVersion, "", `${pkgPath}: file not found`);
715
+ let pkg;
716
+ try {
717
+ pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
718
+ } catch {
719
+ return failResult(packageName, targetVersion, "", `${pkgPath}: invalid JSON`);
720
+ }
721
+ const directDep = findDependencyVersion(pkg, packageName);
722
+ if (directDep) return failResult(packageName, targetVersion, directDep.version, `package "${packageName}" is a direct dependency (${directDep.group}), use upgradeDependency instead`);
723
+ const fromVersion = readLockfileVersion(lockfilePath, packageName) ?? "unknown";
724
+ const toVersion = `${extractPrefix(fromVersion)}${targetVersion}`;
725
+ const isMajor = parseMajorVersion(fromVersion) !== parseMajorVersion(toVersion) && parseMajorVersion(fromVersion) !== -1 && parseMajorVersion(toVersion) !== -1;
726
+ const pkgBackup = `${pkgPath}.bak`;
727
+ const workspaceBackup = usesWorkspaceYaml ? `${workspaceYamlPath}.bak` : null;
728
+ const lockBackup = `${lockfilePath}.bak`;
729
+ try {
730
+ copyFileSync(pkgPath, pkgBackup);
731
+ if (usesWorkspaceYaml) copyFileSync(workspaceYamlPath, workspaceBackup);
732
+ if (existsSync(lockfilePath)) copyFileSync(lockfilePath, lockBackup);
733
+ } catch {
734
+ return failResult(packageName, targetVersion, fromVersion, "failed to create backup files");
735
+ }
736
+ let oldOverride;
737
+ if (usesWorkspaceYaml) oldOverride = writeWorkspaceOverride(workspaceYamlPath, packageName, toVersion);
738
+ else {
739
+ const overrides = ensurePnpmOverrides(pkg);
740
+ oldOverride = overrides[packageName];
741
+ overrides[packageName] = toVersion;
742
+ writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`, "utf-8");
743
+ }
744
+ try {
745
+ await execPnpmInstall(workDir);
746
+ } catch (installErr) {
747
+ rollbackOverrides({
748
+ usesWorkspaceYaml,
749
+ workspaceYamlPath,
750
+ packageName,
751
+ oldOverride,
752
+ pkg,
753
+ pkgPath
754
+ });
755
+ rollback$1(pkgPath, pkgBackup, lockfilePath, lockBackup);
756
+ if (usesWorkspaceYaml) rollback$1(workspaceYamlPath, workspaceBackup, lockfilePath, null);
757
+ cleanupBackups({
758
+ pkgBackup,
759
+ lockBackup,
760
+ workspaceBackup
761
+ });
762
+ return {
763
+ packageName,
764
+ fromVersion,
765
+ toVersion,
766
+ isMajor,
767
+ success: false,
768
+ error: `pnpm install failed: ${getStderr(installErr)}`
769
+ };
770
+ }
771
+ cleanupBackups({
772
+ pkgBackup,
773
+ lockBackup,
774
+ workspaceBackup
775
+ });
776
+ return {
777
+ packageName,
778
+ fromVersion,
779
+ toVersion,
780
+ isMajor,
781
+ success: true
782
+ };
783
+ }
784
+ /**
785
+ * 从版本声明中提取前缀(^ / ~ / 空)。
786
+ *
787
+ * @example
788
+ * extractPrefix('^4.17.20') // '^'
789
+ * extractPrefix('~4.17.0') // '~'
790
+ * extractPrefix('4.17.20') // ''
791
+ * extractPrefix('*') // '^' (默认)
792
+ */
793
+ function extractPrefix(version) {
794
+ const trimmed = version.trim();
795
+ if (/^\d/.test(trimmed)) return "";
796
+ if (trimmed.startsWith("^")) return "^";
797
+ if (trimmed.startsWith("~")) return "~";
798
+ return "^";
799
+ }
800
+ /**
801
+ * 从版本声明中提取主版本号(major)。
802
+ * 去除前缀后匹配第一个 semver 段。
803
+ *
804
+ * @example
805
+ * parseMajorVersion('^4.17.20') // 4
806
+ * parseMajorVersion('~2.0.0') // 2
807
+ * parseMajorVersion('1.2.3') // 1
808
+ * parseMajorVersion('*') // -1
809
+ */
810
+ function parseMajorVersion(version) {
811
+ const cleaned = version.replace(/^\s*[\^~>=<]*\s*/, "");
812
+ const match = /^(\d+)\.\d+\.\d+/.exec(cleaned);
813
+ return match ? Number.parseInt(match[1], 10) : -1;
814
+ }
815
+ /**
816
+ * 在 `package.json` 的 `dependencies` / `devDependencies` / `optionalDependencies` 中按顺序查找包。
817
+ */
818
+ function findDependencyVersion(pkg, packageName) {
819
+ for (const group of [
820
+ "dependencies",
821
+ "devDependencies",
822
+ "optionalDependencies"
823
+ ]) {
824
+ const deps = pkg[group];
825
+ if (deps && typeof deps === "object" && packageName in deps) return {
826
+ group,
827
+ version: deps[packageName]
828
+ };
829
+ }
830
+ return null;
831
+ }
832
+ /**
833
+ * 确保 `package.json` 中存在 `pnpm.overrides` 对象,若不存在则创建。
834
+ * 返回 `overrides` 对象的引用,调用方可直接写入。
835
+ */
836
+ function ensurePnpmOverrides(pkg) {
837
+ if (!pkg.pnpm) pkg.pnpm = {};
838
+ if (!pkg.pnpm.overrides) pkg.pnpm.overrides = {};
839
+ return pkg.pnpm.overrides;
840
+ }
841
+ /**
842
+ * 从 `pnpm-lock.yaml` 中读取指定包的当前锁定版本。
843
+ * 通过简单的正则匹配查找,不解析完整 YAML(性能优先)。
844
+ *
845
+ * pnpm v9+ lockfile 格式(v9.0 起):
846
+ * /package-name/version:
847
+ * /@scope/package-name/version:
848
+ *
849
+ * @returns 锁定版本字符串(如 `'5.0.0'`),未找到返回 `null`
850
+ */
851
+ function readLockfileVersion(lockfilePath, packageName) {
852
+ if (!existsSync(lockfilePath)) return null;
853
+ try {
854
+ const content = readFileSync(lockfilePath, "utf-8");
855
+ const escapedName = escapeRegExp(packageName);
856
+ const match = new RegExp(`^/${escapedName}/(\\d+(?:\\.\\d+(?:\\.\\d+)?(?:-[a-zA-Z0-9.]+)?)):`, "m").exec(content);
857
+ return match ? match[1] : null;
858
+ } catch {
859
+ return null;
860
+ }
861
+ }
862
+ /**
863
+ * 转义正则表达式特殊字符。
864
+ */
865
+ function escapeRegExp(str) {
866
+ return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
867
+ }
868
+ /**
869
+ * 执行 `pnpm install --no-frozen-lockfile`。
870
+ * 将同步 `execSync` 包装为 Promise,使上层 `upgradeDependency` 保持 async 语义。
871
+ */
872
+ function execPnpmInstall(workDir) {
873
+ return new Promise((resolve, reject) => {
874
+ try {
875
+ execSync("pnpm install --no-frozen-lockfile", {
876
+ cwd: workDir,
877
+ encoding: "utf-8",
878
+ stdio: "pipe",
879
+ timeout: 12e4
880
+ });
881
+ resolve();
882
+ } catch (err) {
883
+ reject(err instanceof Error ? err : new Error(String(err)));
884
+ }
885
+ });
886
+ }
887
+ function failResult(packageName, targetVersion, fromVersion, error) {
888
+ return {
889
+ packageName,
890
+ fromVersion,
891
+ toVersion: targetVersion,
892
+ isMajor: false,
893
+ success: false,
894
+ error
895
+ };
896
+ }
897
+ function rollback$1(pkgPath, pkgBackup, lockfilePath, lockBackup) {
898
+ try {
899
+ if (existsSync(pkgBackup)) copyFileSync(pkgBackup, pkgPath);
900
+ if (lockBackup !== null && existsSync(lockBackup)) copyFileSync(lockBackup, lockfilePath);
901
+ } catch {}
902
+ }
903
+ /**
904
+ * 安全删除备份文件,失败静默忽略。
905
+ */
906
+ function safeUnlink(filePath) {
907
+ try {
908
+ if (existsSync(filePath)) unlinkSync(filePath);
909
+ } catch {}
910
+ }
911
+ /**
912
+ * 清理所有备份文件。
913
+ */
914
+ function cleanupBackups(paths) {
915
+ safeUnlink(paths.pkgBackup);
916
+ if (paths.lockBackup !== null) safeUnlink(paths.lockBackup);
917
+ if (paths.workspaceBackup) safeUnlink(paths.workspaceBackup);
918
+ }
919
+ /**
920
+ * 在 `pnpm-workspace.yaml` 中写入一条 override。
921
+ *
922
+ * 读取现有 YAML → 设置 `overrides[packageName] = version` → 原样写回。
923
+ *
924
+ * @returns 写入前该包的旧值(`undefined` 表示原先不存在该 override)
925
+ */
926
+ function writeWorkspaceOverride(workspaceYamlPath, packageName, version) {
927
+ let doc;
928
+ try {
929
+ const raw = readFileSync(workspaceYamlPath, "utf-8");
930
+ doc = YAML.parse(raw) ?? {};
931
+ } catch {
932
+ doc = {};
933
+ }
934
+ const overrides = doc.overrides ?? {};
935
+ const old = overrides[packageName];
936
+ overrides[packageName] = version;
937
+ doc.overrides = overrides;
938
+ writeFileSync(workspaceYamlPath, YAML.stringify(doc), "utf-8");
939
+ return old;
940
+ }
941
+ /**
942
+ * 回滚 override 写入操作。
943
+ *
944
+ * - `pnpm-workspace.yaml` 模式:删除刚写入的条目,或恢复旧值
945
+ * - `package.json` 模式:同理操作 `pkg.pnpm.overrides`
946
+ */
947
+ function rollbackOverrides(params) {
948
+ const { usesWorkspaceYaml, workspaceYamlPath, packageName, oldOverride, pkg, pkgPath } = params;
949
+ if (usesWorkspaceYaml) try {
950
+ const raw = readFileSync(workspaceYamlPath, "utf-8");
951
+ const doc = YAML.parse(raw) ?? {};
952
+ const overrides = doc.overrides ?? {};
953
+ if (oldOverride === void 0) {
954
+ const next = Object.fromEntries(Object.entries(overrides).filter(([key]) => key !== packageName));
955
+ if (Object.keys(next).length === 0) delete doc.overrides;
956
+ else doc.overrides = next;
957
+ } else overrides[packageName] = oldOverride;
958
+ writeFileSync(workspaceYamlPath, YAML.stringify(doc), "utf-8");
959
+ } catch {}
960
+ else {
961
+ if (pkg.pnpm?.overrides) {
962
+ const overrides = pkg.pnpm.overrides;
963
+ if (oldOverride === void 0) {
964
+ const next = Object.fromEntries(Object.entries(overrides).filter(([key]) => key !== packageName));
965
+ if (Object.keys(next).length === 0) {
966
+ delete pkg.pnpm.overrides;
967
+ if (Object.keys(pkg.pnpm).length === 0) delete pkg.pnpm;
968
+ } else pkg.pnpm.overrides = next;
969
+ } else overrides[packageName] = oldOverride;
970
+ }
971
+ writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`, "utf-8");
972
+ }
973
+ }
974
+ function getStderr(err) {
975
+ if (err instanceof Error && "stderr" in err) {
976
+ const stderr = err.stderr;
977
+ if (typeof stderr === "string") return stderr.trim();
978
+ if (Buffer.isBuffer(stderr)) return stderr.toString("utf-8").trim();
979
+ return typeof stderr === "object" && stderr !== null ? JSON.stringify(stderr) : String(stderr);
980
+ }
981
+ if (err instanceof Error) return err.message;
982
+ return typeof err === "object" && err !== null ? JSON.stringify(err) : String(err);
983
+ }
984
+
985
+ //#endregion
986
+ //#region src/fixers/pnpm/index.ts
987
+ /**
988
+ * 执行 `pnpm i --frozen-lockfile` 并解析失败分类。
989
+ * 成功时返回 { ok: true }。
990
+ */
991
+ function classifyLockfileFailure(workDir) {
992
+ try {
993
+ execSync("pnpm install --frozen-lockfile", {
994
+ cwd: workDir,
995
+ stdio: "pipe",
996
+ timeout: 12e4
997
+ });
998
+ return { ok: true };
999
+ } catch (err) {
1000
+ const stderr = getStderrText(err);
1001
+ if (!stderr) return {
1002
+ ok: false,
1003
+ category: "UNKNOWN",
1004
+ stderr: ""
1005
+ };
1006
+ return {
1007
+ ok: false,
1008
+ category: parseFailureCategory(stderr),
1009
+ stderr
1010
+ };
1011
+ }
1012
+ }
1013
+ const FAILURE_PATTERNS = [
1014
+ ["LOCKFILE_NOT_FOUND", /no lockfile|Cannot find.*pnpm-lock\.yaml|ENOENT.*lockfile/i],
1015
+ ["MANIFEST_MISMATCH", /ERR_PNPM_OUTDATED_LOCKFILE|out of sync|needs update/i],
1016
+ ["CREDENTIAL_ERROR", /E401|ERR_PNPM_FETCH_401|ERR_PNPM_FETCH_403|authentication failed|Authorization/i],
1017
+ ["CORRUPTED_LOCKFILE", /\b(broken|corrupted|Cannot read).*lockfile|ERR_PNPM_BROKEN_LOCKFILE/i],
1018
+ ["LOCKFILE_VERSION_MISMATCH", /lockfileVersion.*incompatible|unsupported lockfile version|lockfile had been generated with/i],
1019
+ ["RESOLVE_ERROR", /ERR_PNPM_NO_MATCHING_VERSION|resolution|resolve.*failed|ERR_PNPM_PEER_DEP_ISSUES/i]
1020
+ ];
1021
+ /**
1022
+ * 按优先级匹配 stderr,返回首个命中的 FailureCategory。
1023
+ * 不匹配任何特征时返回 'UNKNOWN'。
1024
+ */
1025
+ function parseFailureCategory(stderr) {
1026
+ for (const [category, pattern] of FAILURE_PATTERNS) if (pattern.test(stderr)) return category;
1027
+ return "UNKNOWN";
1028
+ }
1029
+ function getStderrText(err) {
1030
+ if (!err.stderr) return err.message ?? "";
1031
+ if (typeof err.stderr === "string") return err.stderr;
1032
+ return err.stderr.toString("utf-8");
1033
+ }
1034
+ function execRepair(command, workDir, timeoutMs = 18e4) {
1035
+ execSync(command, {
1036
+ cwd: workDir,
1037
+ stdio: "pipe",
1038
+ timeout: timeoutMs
1039
+ });
1040
+ }
1041
+ /**
1042
+ * 确定某个分类下适用的修复策略链(从首到末)。
1043
+ * CREDENTIAL_ERROR 返回空链(不可修复)。
1044
+ */
1045
+ function getStrategyChain(category) {
1046
+ switch (category) {
1047
+ case "LOCKFILE_NOT_FOUND":
1048
+ case "MANIFEST_MISMATCH": return ["REGENERATE", "REINSTALL"];
1049
+ case "CORRUPTED_LOCKFILE": return [
1050
+ "FIX_ENTRIES",
1051
+ "REGENERATE",
1052
+ "REINSTALL"
1053
+ ];
1054
+ case "LOCKFILE_VERSION_MISMATCH": return [
1055
+ "PIN_TOOLCHAIN",
1056
+ "REGENERATE",
1057
+ "REINSTALL"
1058
+ ];
1059
+ case "RESOLVE_ERROR": return [
1060
+ "REGENERATE",
1061
+ "FIX_ENTRIES",
1062
+ "REINSTALL"
1063
+ ];
1064
+ case "UNKNOWN": return ["REGENERATE", "REINSTALL"];
1065
+ case "CREDENTIAL_ERROR": return [];
1066
+ }
1067
+ }
1068
+ function getStrategyCommand(strategy) {
1069
+ switch (strategy) {
1070
+ case "REGENERATE": return "pnpm install --lockfile-only";
1071
+ case "FIX_ENTRIES": return "pnpm install --fix-lockfile --lockfile-only";
1072
+ case "REINSTALL": return "pnpm install --no-frozen-lockfile";
1073
+ case "PIN_TOOLCHAIN": return "pnpm install --lockfile-only";
1074
+ default: return "";
1075
+ }
1076
+ }
1077
+ /**
1078
+ * 比较修复前后 lockfile,返回行数与包数量变化摘要。
1079
+ */
1080
+ function computeLockfileDiff(beforePath, afterPath) {
1081
+ const beforeLines = countLockfileLines(beforePath);
1082
+ const afterLines = countLockfileLines(afterPath);
1083
+ const beforePackages = countLockfilePackages(beforePath);
1084
+ const afterPackages = countLockfilePackages(afterPath);
1085
+ const linesChanged = afterLines - beforeLines;
1086
+ const packagesChanged = afterPackages - beforePackages;
1087
+ const parts = [];
1088
+ if (linesChanged !== 0) parts.push(`${linesChanged > 0 ? "+" : ""}${linesChanged} lines`);
1089
+ if (packagesChanged !== 0) parts.push(`${packagesChanged > 0 ? "+" : ""}${packagesChanged} packages`);
1090
+ return {
1091
+ linesChanged,
1092
+ packagesChanged,
1093
+ summary: parts.length > 0 ? `lockfile updated: ${parts.join(", ")}` : "lockfile unchanged"
1094
+ };
1095
+ }
1096
+ function countLockfileLines(path) {
1097
+ if (!existsSync(path)) return 0;
1098
+ return readFileSync(path, "utf-8").split("\n").length;
1099
+ }
1100
+ /**
1101
+ * 统计 pnpm-lock.yaml 中 `packages:` 块下的条目数。
1102
+ * packages 条目以缩进 + `/` 开头(如 ` /lodash@4.17.21:`)。
1103
+ */
1104
+ function countLockfilePackages(path) {
1105
+ if (!existsSync(path)) return 0;
1106
+ const lines = readFileSync(path, "utf-8").split("\n");
1107
+ let inPackages = false;
1108
+ let count = 0;
1109
+ for (const line of lines) {
1110
+ if (!inPackages) {
1111
+ if (/^packages:\s*$/.test(line)) inPackages = true;
1112
+ continue;
1113
+ }
1114
+ if (/^[a-zA-Z]/.test(line)) break;
1115
+ if (/^\s+\//.test(line)) count++;
1116
+ }
1117
+ return count;
1118
+ }
1119
+ /**
1120
+ * 从 package.json 的 `packageManager` 字段解析 pnpm 版本。
1121
+ * 优先级: toolchain.pnpmVersion > packageManager > null
1122
+ */
1123
+ function resolvePnpmVersion(workDir, toolchain) {
1124
+ if (toolchain?.pnpmVersion) return toolchain.pnpmVersion;
1125
+ const pkgPath = join(workDir, "package.json");
1126
+ if (!existsSync(pkgPath)) return null;
1127
+ try {
1128
+ const pm = JSON.parse(readFileSync(pkgPath, "utf-8")).packageManager;
1129
+ if (typeof pm === "string") {
1130
+ const match = /pnpm@(.+)$/.exec(pm);
1131
+ if (match?.[1]) return match[1];
1132
+ }
1133
+ } catch {}
1134
+ return null;
1135
+ }
1136
+ const LOCKFILE_NAME = "pnpm-lock.yaml";
1137
+ /**
1138
+ * 在 workDir 中使用 `pnpm install --frozen-lockfile` 验证 lockfile 合规性。
1139
+ * 验证通过 → 返回 ret:0;失败 → 返回 ret:1 + stderr。
1140
+ * 不抛异常——调用方通过 ret 判断结果。
1141
+ */
1142
+ function verifyFrozenLockfile(workDir) {
1143
+ try {
1144
+ execSync("pnpm install --frozen-lockfile", {
1145
+ cwd: workDir,
1146
+ stdio: "pipe",
1147
+ timeout: 12e4
1148
+ });
1149
+ return {
1150
+ ret: 0,
1151
+ stderr: ""
1152
+ };
1153
+ } catch (err) {
1154
+ return {
1155
+ ret: 1,
1156
+ stderr: getStderrText(err)
1157
+ };
1158
+ }
1159
+ }
1160
+ /**
1161
+ * 备份 pnpm-lock.yaml → `<name>.bak`,返回备份路径。
1162
+ * lockfile 不存在时会创建空 .bak(回滚时删除即可)。
1163
+ */
1164
+ function backupLockfilePath(workDir) {
1165
+ const src = join(workDir, LOCKFILE_NAME);
1166
+ const bak = `${src}.bak`;
1167
+ if (existsSync(src)) copyFileSync(src, bak);
1168
+ return bak;
1169
+ }
1170
+ /** 从 .bak 还原 lockfile,并删除 .bak。 */
1171
+ function rollback(bakPath, workDir) {
1172
+ const target = join(workDir, LOCKFILE_NAME);
1173
+ try {
1174
+ if (existsSync(bakPath)) copyFileSync(bakPath, target);
1175
+ } finally {
1176
+ try {
1177
+ rmSync(bakPath);
1178
+ } catch {}
1179
+ }
1180
+ }
1181
+ /**
1182
+ * 检测并修复 pnpm frozen-lockfile 漂移。
1183
+ *
1184
+ * 流程:
1185
+ * 1. 先跑 `pnpm i --frozen-lockfile` 诊断
1186
+ * 2. 若已合规 → 直接返回 success
1187
+ * 3. 若为 CREDENTIAL_ERROR → SKIP
1188
+ * 4. 按分类选取策略链,逐级尝试修复:
1189
+ * - 每个策略执行后立即用 `pnpm i --frozen-lockfile` 验证
1190
+ * - 验证通过 → 记录 diff、返回 success
1191
+ * - 验证失败 → 继续下一个策略
1192
+ * 5. 所有策略失败 → 回滚 lockfile、返回失败结果
1193
+ */
1194
+ function repairLockfile(params) {
1195
+ const { workDir } = params;
1196
+ const workDir_ = workDir;
1197
+ const attempts = [];
1198
+ const bakPath = backupLockfilePath(workDir_);
1199
+ const classification = classifyLockfileFailure(workDir_);
1200
+ if (classification.ok) {
1201
+ try {
1202
+ rmSync(bakPath);
1203
+ } catch {}
1204
+ return {
1205
+ success: true,
1206
+ diff: null,
1207
+ attemptHistory: attempts
1208
+ };
1209
+ }
1210
+ const category = classification.category ?? "UNKNOWN";
1211
+ const stderr = classification.stderr ?? "";
1212
+ if (category === "CREDENTIAL_ERROR") {
1213
+ try {
1214
+ rmSync(bakPath);
1215
+ } catch {}
1216
+ return {
1217
+ success: false,
1218
+ failureCategory: "CREDENTIAL_ERROR",
1219
+ failureDetail: `registry authentication error: ${truncate(stderr, 500)}`,
1220
+ strategy: "SKIP",
1221
+ attemptHistory: attempts
1222
+ };
1223
+ }
1224
+ const strategyChain = getStrategyChain(category);
1225
+ let lastError = stderr;
1226
+ const startTimestamp = Date.now();
1227
+ for (const strategy of strategyChain) {
1228
+ const command = getStrategyCommand(strategy);
1229
+ const t0 = Date.now();
1230
+ let strategySuccess = false;
1231
+ let strategyError;
1232
+ try {
1233
+ execRepair(command, workDir_);
1234
+ strategySuccess = true;
1235
+ } catch (err) {
1236
+ strategyError = getStderrText(err) || err.message;
1237
+ }
1238
+ const durationMs = Date.now() - t0;
1239
+ attempts.push({
1240
+ strategy,
1241
+ command,
1242
+ success: strategySuccess,
1243
+ error: strategyError,
1244
+ durationMs
1245
+ });
1246
+ if (!strategySuccess) {
1247
+ lastError = strategyError ?? lastError;
1248
+ continue;
1249
+ }
1250
+ const verify = verifyFrozenLockfile(workDir_);
1251
+ if (verify.ret === 0) {
1252
+ const diff = computeLockfileDiff(bakPath, join(workDir_, LOCKFILE_NAME));
1253
+ try {
1254
+ rmSync(bakPath);
1255
+ } catch {}
1256
+ return {
1257
+ success: true,
1258
+ strategy,
1259
+ diff,
1260
+ attemptHistory: attempts
1261
+ };
1262
+ }
1263
+ lastError = verify.stderr;
1264
+ }
1265
+ rollback(bakPath, workDir_);
1266
+ const durationMs = Date.now() - startTimestamp;
1267
+ attempts.push({
1268
+ strategy: "SKIP",
1269
+ command: "rollback",
1270
+ success: true,
1271
+ durationMs
1272
+ });
1273
+ return {
1274
+ success: false,
1275
+ failureCategory: category,
1276
+ failureDetail: truncate(lastError, 500),
1277
+ attemptHistory: attempts
1278
+ };
1279
+ }
1280
+ function truncate(text, maxLen) {
1281
+ if (text.length <= maxLen) return text;
1282
+ return `${text.slice(0, maxLen)}…`;
1283
+ }
1284
+
1285
+ //#endregion
1286
+ //#region src/runners/verification-runner.ts
1287
+ const DEFAULT_COMMANDS = [
1288
+ "pnpm install --frozen-lockfile",
1289
+ "pnpm lint",
1290
+ "pnpm build"
1291
+ ];
1292
+ const MAX_OUTPUT_LINES = 200;
1293
+ /**
1294
+ * 按顺序在工作目录执行命令序列,任一命令失败则停止。
1295
+ *
1296
+ * @example
1297
+ * ```ts
1298
+ * const result = await runVerification({ workDir: '/repo' })
1299
+ * if (!result.success) {
1300
+ * console.error(result.failure)
1301
+ * }
1302
+ * ```
1303
+ */
1304
+ async function runVerification(params) {
1305
+ const commands = params.commands ?? DEFAULT_COMMANDS;
1306
+ const commandResults = [];
1307
+ for (const command of commands) {
1308
+ const result = await execCommand(command, params.workDir);
1309
+ commandResults.push(result);
1310
+ if (result.exitCode !== 0) return {
1311
+ success: false,
1312
+ commandResults,
1313
+ failedCommand: command,
1314
+ failure: `command "${command}" exited with code ${result.exitCode}`
1315
+ };
1316
+ }
1317
+ return {
1318
+ success: true,
1319
+ commandResults
1320
+ };
1321
+ }
1322
+ /**
1323
+ * 执行单条 shell 命令,捕获 stdout/stderr(截断到 200 行)并脱敏。
1324
+ */
1325
+ function execCommand(command, workDir) {
1326
+ return new Promise((resolve) => {
1327
+ const startTime = Date.now();
1328
+ const cp = spawn(command, {
1329
+ cwd: workDir,
1330
+ shell: true,
1331
+ stdio: "pipe"
1332
+ });
1333
+ const stdoutChunks = [];
1334
+ const stderrChunks = [];
1335
+ cp.stdout.on("data", (data) => {
1336
+ stdoutChunks.push(data.toString("utf-8"));
1337
+ });
1338
+ cp.stderr.on("data", (data) => {
1339
+ stderrChunks.push(data.toString("utf-8"));
1340
+ });
1341
+ cp.on("error", (err) => {
1342
+ resolve({
1343
+ command,
1344
+ exitCode: -1,
1345
+ durationMs: Date.now() - startTime,
1346
+ stdout: "",
1347
+ stderr: sanitizeOutput(err.message)
1348
+ });
1349
+ });
1350
+ cp.on("close", (code) => {
1351
+ const stdout = truncateLines(stdoutChunks.join(""), MAX_OUTPUT_LINES);
1352
+ const stderr = truncateLines(stderrChunks.join(""), MAX_OUTPUT_LINES);
1353
+ resolve({
1354
+ command,
1355
+ exitCode: code ?? -1,
1356
+ durationMs: Date.now() - startTime,
1357
+ stdout: sanitizeOutput(stdout),
1358
+ stderr: sanitizeOutput(stderr)
1359
+ });
1360
+ });
1361
+ });
1362
+ }
1363
+ /** 截断文本到指定行数 */
1364
+ function truncateLines(text, maxLines) {
1365
+ const lines = text.split("\n");
1366
+ if (lines.length <= maxLines) return text;
1367
+ return `${lines.slice(0, maxLines).join("\n")}\n... (truncated, ${lines.length - maxLines} more lines)`;
1368
+ }
1369
+ /**
1370
+ * 脱敏处理:移除命令输出中可能包含的敏感信息。
1371
+ *
1372
+ * 过滤规则:
1373
+ * - 包含 `token=` / `secret=` / `password=` 的行
1374
+ * - `npm_config_` / `GITHUB_TOKEN` / `NPM_TOKEN` 等环境变量暴露
1375
+ * - URL 中的 user:password@ 模式
1376
+ */
1377
+ const SECRET_PATTERNS = [
1378
+ [/^(GITHUB_TOKEN|NPM_TOKEN|NODE_AUTH_TOKEN|DOCKER_PASSWORD)=.+/gim, "$1=***"],
1379
+ [/\b(token|secret|password|api[_-]?key)\s*[=:]\s*\S+/gi, "$1=***"],
1380
+ [/\/\/[^@\n]+@/g, "//***@"],
1381
+ [/npm_config_[a-z_]+=\S+/g, "npm_config_***=***"]
1382
+ ];
1383
+ /**
1384
+ * 对外暴露以支持单独测试
1385
+ */
1386
+ function sanitizeOutput(output) {
1387
+ let result = output;
1388
+ for (const [pattern, replacement] of SECRET_PATTERNS) result = result.replace(pattern, replacement);
1389
+ return result;
1390
+ }
1391
+
1392
+ //#endregion
1393
+ //#region src/app-helpers.ts
1394
+ const DEFAULT_VERIFY_COMMANDS = [
1395
+ "pnpm install --frozen-lockfile",
1396
+ "pnpm lint",
1397
+ "pnpm build"
1398
+ ];
1399
+ /** 匹配 `pnpm <singleWord>` 模式的命令(可能是 package.json script 引用) */
1400
+ const PNPM_SCRIPT_RE = /^pnpm\s+([a-zA-Z][a-zA-Z0-9:_-]*)$/;
1401
+ /** 自动修复提交的标准消息(本地 --commit 与 fix-and-pr 共用) */
1402
+ const FIX_COMMIT_MESSAGE = "fix(deps): automated dependfix security repair";
1403
+ /**
1404
+ * 升级单个告警对应的依赖(dry-run 仅记录)。
1405
+ * 优先直接升级,失败且为间接依赖时回退到 pnpm overrides。
1406
+ */
1407
+ async function upgradeAlert(ctx, alert) {
1408
+ const { config, logger, workDir } = ctx;
1409
+ const startMs = Date.now();
1410
+ if (config.dryRun) {
1411
+ logger.info(`[dry-run] Would upgrade ${alert.packageName} to ${alert.recommendedVersion}`);
1412
+ return {
1413
+ type: "dependency-upgrade",
1414
+ repository: alert.repository,
1415
+ target: alert.packageName,
1416
+ fromVersion: alert.recommendedVersion ? `< ${alert.recommendedVersion}` : void 0,
1417
+ toVersion: alert.recommendedVersion,
1418
+ isMajor: false,
1419
+ success: true,
1420
+ durationMs: 0
1421
+ };
1422
+ }
1423
+ try {
1424
+ let result = await upgradeDependency({
1425
+ packageName: alert.packageName,
1426
+ targetVersion: alert.recommendedVersion,
1427
+ workDir
1428
+ });
1429
+ if (!result.success && result.error?.includes("not found in dependencies")) {
1430
+ result = await overrideTransitiveDependency({
1431
+ packageName: alert.packageName,
1432
+ targetVersion: alert.recommendedVersion,
1433
+ workDir
1434
+ });
1435
+ logger.info(result.success ? `Upgraded ${result.packageName}: ${result.fromVersion} → ${result.toVersion} (pnpm overrides)` : `Failed to upgrade ${result.packageName}: ${result.error}`);
1436
+ } else logger.info(result.success ? `Upgraded ${result.packageName}: ${result.fromVersion} → ${result.toVersion}` : `Failed to upgrade ${result.packageName}: ${result.error}`);
1437
+ return {
1438
+ type: "dependency-upgrade",
1439
+ repository: alert.repository,
1440
+ target: alert.packageName,
1441
+ fromVersion: result.fromVersion,
1442
+ toVersion: result.toVersion,
1443
+ isMajor: result.isMajor,
1444
+ success: result.success,
1445
+ error: result.error,
1446
+ durationMs: Date.now() - startMs
1447
+ };
1448
+ } catch (error) {
1449
+ const message = toErrorMessage(error);
1450
+ logger.error(`Upgrade error for ${alert.packageName}: ${message}`);
1451
+ return {
1452
+ type: "dependency-upgrade",
1453
+ repository: alert.repository,
1454
+ target: alert.packageName,
1455
+ toVersion: alert.recommendedVersion,
1456
+ success: false,
1457
+ error: message,
1458
+ durationMs: Date.now() - startMs
1459
+ };
1460
+ }
1461
+ }
1462
+ /** 尝试修复 pnpm-lock.yaml(dry-run 仅记录)。 */
1463
+ function tryLockfileRepair(ctx, repo) {
1464
+ const { config, logger, workDir } = ctx;
1465
+ const startMs = Date.now();
1466
+ if (config.dryRun) {
1467
+ logger.info(`[dry-run] Would attempt lockfile repair for ${repo}`);
1468
+ return {
1469
+ type: "lockfile-repair",
1470
+ repository: repo,
1471
+ target: "pnpm-lock.yaml",
1472
+ success: true,
1473
+ durationMs: 0
1474
+ };
1475
+ }
1476
+ try {
1477
+ const result = repairLockfile({ workDir });
1478
+ logger.info(result.success ? `Lockfile repaired for ${repo} (strategy: ${result.strategy ?? "N/A"})` : `Lockfile repair failed for ${repo}: ${result.failureDetail ?? "unknown"}`);
1479
+ return {
1480
+ type: "lockfile-repair",
1481
+ repository: repo,
1482
+ target: "pnpm-lock.yaml",
1483
+ success: result.success,
1484
+ error: result.success ? void 0 : result.failureDetail ?? "Lockfile repair failed",
1485
+ strategy: result.strategy,
1486
+ durationMs: Date.now() - startMs,
1487
+ diff: result.diff?.summary
1488
+ };
1489
+ } catch (error) {
1490
+ const message = toErrorMessage(error);
1491
+ logger.error(`Lockfile repair error for ${repo}: ${message}`);
1492
+ return {
1493
+ type: "lockfile-repair",
1494
+ repository: repo,
1495
+ target: "pnpm-lock.yaml",
1496
+ success: false,
1497
+ error: message,
1498
+ durationMs: Date.now() - startMs
1499
+ };
1500
+ }
1501
+ }
1502
+ /** 执行验证命令链,返回逐命令结果。 */
1503
+ async function verifyProject(ctx, repo) {
1504
+ const { customCommands, logger, workDir, allErrors } = ctx;
1505
+ const rawCommands = customCommands ?? DEFAULT_VERIFY_COMMANDS;
1506
+ const { valid, skipped } = !customCommands ? validateVerifyCommands(rawCommands, workDir) : {
1507
+ valid: rawCommands,
1508
+ skipped: []
1509
+ };
1510
+ for (const cmd of skipped) {
1511
+ logger.info(`Skipping command "${cmd}": script not found in package.json`);
1512
+ allErrors.push({
1513
+ repository: repo,
1514
+ target: cmd,
1515
+ stage: "verify",
1516
+ category: "SCRIPT_NOT_FOUND",
1517
+ message: `Skipped: no matching script in package.json for "${cmd}"`
1518
+ });
1519
+ }
1520
+ if (valid.length === 0) {
1521
+ logger.info(`No verification commands to run for ${repo}`);
1522
+ return [];
1523
+ }
1524
+ try {
1525
+ return (await runVerification({
1526
+ workDir,
1527
+ commands: valid
1528
+ })).commandResults.map((cr) => ({
1529
+ type: "verification",
1530
+ repository: repo,
1531
+ target: cr.command,
1532
+ success: cr.exitCode === 0,
1533
+ error: cr.exitCode !== 0 ? `exit code ${cr.exitCode}` : void 0,
1534
+ durationMs: cr.durationMs
1535
+ }));
1536
+ } catch (error) {
1537
+ const message = toErrorMessage(error);
1538
+ logger.error(`Verification error for ${repo}: ${message}`);
1539
+ return [{
1540
+ type: "verification",
1541
+ repository: repo,
1542
+ target: "verification",
1543
+ success: false,
1544
+ error: message
1545
+ }];
1546
+ }
1547
+ }
1548
+ /**
1549
+ * 校验默认命令链中的脚本引用是否存在。
1550
+ *
1551
+ * - `pnpm install --frozen-lockfile` 等非脚本命令 → 直接保留
1552
+ * - `pnpm lint` 等脚本命令 → 检查 `package.json#scripts` 是否存在对应键
1553
+ * - 用户自定义命令(`--commands`)不经过此校验
1554
+ */
1555
+ function validateVerifyCommands(commands, workDir) {
1556
+ const pkgJsonPath = join(workDir, "package.json");
1557
+ let pkgScripts = {};
1558
+ if (existsSync(pkgJsonPath)) try {
1559
+ pkgScripts = JSON.parse(readFileSync(pkgJsonPath, "utf-8")).scripts ?? {};
1560
+ } catch {
1561
+ return {
1562
+ valid: commands,
1563
+ skipped: []
1564
+ };
1565
+ }
1566
+ const valid = [];
1567
+ const skipped = [];
1568
+ for (const cmd of commands) {
1569
+ const match = PNPM_SCRIPT_RE.exec(cmd);
1570
+ if (match) {
1571
+ const scriptName = match[1];
1572
+ if (pkgScripts[scriptName]) valid.push(cmd);
1573
+ else skipped.push(cmd);
1574
+ } else valid.push(cmd);
1575
+ }
1576
+ return {
1577
+ valid,
1578
+ skipped
1579
+ };
1580
+ }
1581
+ /**
1582
+ * 将修复产生的变更提交到本地当前分支。
1583
+ *
1584
+ * - 无任何变更(含已暂存变更)时跳过
1585
+ * - config 校验已保证 `commit` 与 `dryRun` / `createPullRequest` 互斥,
1586
+ * 因此这里不需要再检查这两个开关
1587
+ */
1588
+ function commitLocalChanges(ctx) {
1589
+ const { logger, workDir } = ctx;
1590
+ if (!hasGitChanges(workDir)) {
1591
+ logger.info("No changes to commit — skipping local commit");
1592
+ return;
1593
+ }
1594
+ ensureGitignore(workDir);
1595
+ stageAndCommit(FIX_COMMIT_MESSAGE, workDir);
1596
+ logger.info(`Committed fix changes to current branch: ${FIX_COMMIT_MESSAGE}`);
1597
+ }
1598
+ /**
1599
+ * 检查工作目录是否有未提交的变更(含未暂存与已暂存)。
1600
+ */
1601
+ function hasGitChanges(workDir) {
1602
+ try {
1603
+ execSync("git diff --quiet", {
1604
+ cwd: workDir,
1605
+ stdio: "pipe"
1606
+ });
1607
+ execSync("git diff --cached --quiet", {
1608
+ cwd: workDir,
1609
+ stdio: "pipe"
1610
+ });
1611
+ return false;
1612
+ } catch {
1613
+ return true;
1614
+ }
1615
+ }
1616
+ /**
1617
+ * 确保目标仓库的 `.gitignore` 中包含 `dependfix-reports/`。
1618
+ *
1619
+ * - 仅在 workDir 是 git 仓库时执行
1620
+ * - 已存在该条目时幂等跳过
1621
+ * - 失败(权限、磁盘满等)静默降级
1622
+ */
1623
+ function ensureGitignore(workDir) {
1624
+ try {
1625
+ if (!existsSync(join(workDir, ".git"))) return;
1626
+ const gitignorePath = join(workDir, ".gitignore");
1627
+ const entry = "dependfix-reports/";
1628
+ let content = "";
1629
+ if (existsSync(gitignorePath)) content = readFileSync(gitignorePath, "utf-8");
1630
+ if (content.split("\n").some((l) => l.trim() === entry)) return;
1631
+ const block = `${content.endsWith("\n") || content.length === 0 ? "" : "\n"}# dependfix\n${entry}\n`;
1632
+ writeFileSync(gitignorePath, content + block, "utf-8");
1633
+ } catch {}
1634
+ }
1635
+ /**
1636
+ * 关闭被取代的旧 PR。单条关闭失败不中断其余 PR 的关闭,
1637
+ * 失败记录为 `PR_CLOSE_FAILED`(与 PR 创建失败区分)。
1638
+ */
1639
+ async function closeSupersededPRs(ctx, client, owner, repo, supersedePRs) {
1640
+ const { logger, allErrors } = ctx;
1641
+ for (const old of supersedePRs) try {
1642
+ await closePullRequest(client, owner, repo, old.number);
1643
+ logger.info(`Closed superseded PR #${old.number}`);
1644
+ } catch (error) {
1645
+ const message = toErrorMessage(error);
1646
+ logger.error(`Failed to close superseded PR #${old.number}: ${message}`);
1647
+ allErrors.push({
1648
+ repository: `${owner}/${repo}`,
1649
+ stage: "report",
1650
+ category: "PR_CLOSE_FAILED",
1651
+ message: `Failed to close PR #${old.number}: ${message}`
1652
+ });
1653
+ }
1654
+ }
1655
+ /**
1656
+ * (fix-and-pr + --cleanup-branches)将已合并的 dependfix 分支列为待清理清单,
1657
+ * 记录到报告与日志,不执行删除。
1658
+ */
1659
+ async function reportCleanupCandidates(ctx, client) {
1660
+ const { config, logger, allActions, allErrors } = ctx;
1661
+ for (const repo of config.repositories) {
1662
+ const [owner, name] = repo.split("/");
1663
+ try {
1664
+ const branches = await listDependfixBranches(client, owner, name);
1665
+ for (const branch of branches) if ((await getBranchPrStatus(client, owner, name, branch)).merged) {
1666
+ logger.info(`[cleanup] merged branch awaiting manual cleanup: ${branch}`);
1667
+ allActions.push({
1668
+ type: "branch-cleanup",
1669
+ repository: repo,
1670
+ target: branch,
1671
+ success: true,
1672
+ diff: "merged; run `dependfix cleanup-branches` to delete",
1673
+ durationMs: 0
1674
+ });
1675
+ }
1676
+ } catch (error) {
1677
+ const message = toErrorMessage(error);
1678
+ logger.error(`[cleanup] detection failed for ${repo}: ${message}`);
1679
+ allErrors.push({
1680
+ repository: repo,
1681
+ stage: "report",
1682
+ category: "CLEANUP_DETECT_FAILED",
1683
+ message
1684
+ });
1685
+ }
1686
+ }
1687
+ }
1688
+ /**
1689
+ * 交互式确认删除。非 TTY(CI/管道)时直接拒绝。
1690
+ */
1691
+ function confirmCleanup(ctx, repo, candidates) {
1692
+ const { logger } = ctx;
1693
+ if (!process.stdin.isTTY) {
1694
+ logger.warn("[cleanup] non-TTY environment — deletion requires interactive confirmation, aborting");
1695
+ return Promise.resolve(false);
1696
+ }
1697
+ const rl = createInterface({
1698
+ input: process.stdin,
1699
+ output: process.stdout
1700
+ });
1701
+ return new Promise((resolve) => {
1702
+ rl.question(`Delete ${candidates.length} branch(es) from ${repo}? [y/N] `, (answer) => {
1703
+ rl.close();
1704
+ resolve(isConfirmAnswer(answer));
1705
+ });
1706
+ });
1707
+ }
1708
+ /** 汇总所有动作到 summary(alertsSkipped 已在 processRepoForFix 中累加)。 */
1709
+ function computeSummary(ctx) {
1710
+ const { config, allActions, allAlerts, summary } = ctx;
1711
+ let fixed = 0;
1712
+ let failed = 0;
1713
+ let lockfileRepairs = 0;
1714
+ let verificationsPassed = 0;
1715
+ let verificationsFailed = 0;
1716
+ for (const action of allActions) {
1717
+ if (action.type === "dependency-upgrade") if (action.success) fixed++;
1718
+ else failed++;
1719
+ if (action.type === "lockfile-repair" && action.success) lockfileRepairs++;
1720
+ if (action.type === "verification") if (action.success) verificationsPassed++;
1721
+ else verificationsFailed++;
1722
+ }
1723
+ const fixable = allAlerts.filter((a) => a.fixable).length;
1724
+ summary.repositoriesScanned = config.repositories.length;
1725
+ summary.alertsFound = allAlerts.length;
1726
+ summary.alertsFixable = fixable;
1727
+ summary.alertsFixed = fixed;
1728
+ summary.alertsFailed = failed;
1729
+ summary.lockfileRepairs = lockfileRepairs;
1730
+ summary.verificationsPassed = verificationsPassed;
1731
+ summary.verificationsFailed = verificationsFailed;
1732
+ }
1733
+ /** 组装最终运行结果。 */
1734
+ function buildRunResult(ctx) {
1735
+ const reportConfig = {
1736
+ mode: ctx.config.mode,
1737
+ severityThreshold: ctx.config.severityThreshold,
1738
+ repositories: ctx.config.repositories,
1739
+ dryRun: ctx.config.dryRun,
1740
+ createPullRequest: ctx.config.createPullRequest,
1741
+ maxAlertsPerRepository: ctx.config.maxAlertsPerRepository
1742
+ };
1743
+ return {
1744
+ runId: ctx.runId,
1745
+ startedAt: ctx.startedAt,
1746
+ finishedAt: ctx.finishedAt,
1747
+ config: reportConfig,
1748
+ summary: ctx.summary,
1749
+ repositories: ctx.repoResults,
1750
+ alerts: ctx.allAlerts,
1751
+ actions: ctx.allActions,
1752
+ errors: ctx.allErrors
1753
+ };
1754
+ }
1755
+ /**
1756
+ * 计算退出码:
1757
+ * - 0: 全部仓库处理成功(无 failed actions、无 errors)
1758
+ * - 1: 部分仓库失败
1759
+ * - 2: 全部仓库失败(或无仓库被成功处理)
1760
+ */
1761
+ function computeExitCode(ctx) {
1762
+ const { config, allErrors, allActions, repoResults } = ctx;
1763
+ const hasErrors = allErrors.length > 0;
1764
+ const hasFailures = allActions.some((a) => !a.success);
1765
+ const hasRepoSuccess = repoResults.length > 0 && repoResults.some((r) => r.alertsCount > 0 || r.fixed > 0 || r.verificationPassed === true);
1766
+ const hasCleanupSuccess = config.mode === "cleanup-branches" && allActions.some((a) => a.success && a.type === "branch-cleanup");
1767
+ const hasSuccess = hasRepoSuccess || hasCleanupSuccess;
1768
+ if (config.mode === "fix-and-pr") return 0;
1769
+ if (!hasErrors && !hasFailures) return 0;
1770
+ if (hasSuccess) return 1;
1771
+ return 2;
1772
+ }
1773
+
1774
+ //#endregion
1775
+ //#region src/app.ts
1776
+ var DependfixApp = class {
1777
+ config;
1778
+ workDir;
1779
+ logger;
1780
+ verbose;
1781
+ customCommands;
1782
+ runId;
1783
+ allAlerts = [];
1784
+ allActions = [];
1785
+ allErrors = [];
1786
+ repoResults = [];
1787
+ summary = createEmptyRunSummary();
1788
+ startedAt = "";
1789
+ finishedAt = "";
1790
+ constructor(options) {
1791
+ this.config = options.config;
1792
+ this.workDir = options.workDir ?? process.cwd();
1793
+ this.verbose = options.verbose ?? false;
1794
+ this.customCommands = options.commands;
1795
+ this.runId = `dependfix-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
1796
+ this.logger = createLogger({
1797
+ name: "dependfix",
1798
+ minLevel: this.verbose ? "debug" : "info"
1799
+ });
1800
+ }
1801
+ /** 供辅助方法使用的状态切片。 */
1802
+ get ctx() {
1803
+ return {
1804
+ config: this.config,
1805
+ workDir: this.workDir,
1806
+ logger: this.logger,
1807
+ customCommands: this.customCommands,
1808
+ runId: this.runId,
1809
+ allAlerts: this.allAlerts,
1810
+ allActions: this.allActions,
1811
+ allErrors: this.allErrors,
1812
+ repoResults: this.repoResults,
1813
+ summary: this.summary,
1814
+ startedAt: this.startedAt,
1815
+ finishedAt: this.finishedAt
1816
+ };
1817
+ }
1818
+ /** 执行完整的编排流程,返回结构化结果和退出码。 */
1819
+ async run() {
1820
+ this.startedAt = (/* @__PURE__ */ new Date()).toISOString();
1821
+ this.logger.info(`Starting dependfix run ${this.runId}`, {
1822
+ mode: this.config.mode,
1823
+ repositories: this.config.repositories
1824
+ });
1825
+ try {
1826
+ await this.executeMode();
1827
+ } catch (error) {
1828
+ const message = toErrorMessage(error);
1829
+ this.logger.error(`Unexpected error: ${message}`);
1830
+ this.allErrors.push({
1831
+ repository: "*",
1832
+ stage: "report",
1833
+ category: "FATAL",
1834
+ message
1835
+ });
1836
+ }
1837
+ this.finishedAt = (/* @__PURE__ */ new Date()).toISOString();
1838
+ computeSummary(this.ctx);
1839
+ const runResult = buildRunResult(this.ctx);
1840
+ const exitCode = computeExitCode(this.ctx);
1841
+ try {
1842
+ writeReport(generateMarkdownReport(runResult), generateJsonReport(runResult), this.startedAt, this.runId);
1843
+ this.logger.info(`Reports written to ./dependfix-reports/`);
1844
+ } catch (reportError) {
1845
+ const message = toErrorMessage(reportError);
1846
+ this.logger.error(`Failed to write reports: ${message}`);
1847
+ }
1848
+ ensureGitignore(this.workDir);
1849
+ this.logger.info(`Run ${this.runId} completed`, { exitCode });
1850
+ return {
1851
+ result: runResult,
1852
+ exitCode
1853
+ };
1854
+ }
1855
+ async executeMode() {
1856
+ switch (this.config.mode) {
1857
+ case "report-only":
1858
+ await this.executeReportMode();
1859
+ break;
1860
+ case "fix":
1861
+ await this.executeFixMode();
1862
+ break;
1863
+ case "fix-and-pr":
1864
+ await this.executeFixAndPrMode();
1865
+ break;
1866
+ case "cleanup-branches":
1867
+ await this.executeCleanupBranchesMode();
1868
+ break;
1869
+ }
1870
+ }
1871
+ async executeReportMode() {
1872
+ const client = this.createClient();
1873
+ for (const repo of this.config.repositories) await this.processRepoForReport(client, repo);
1874
+ }
1875
+ async processRepoForReport(client, repo) {
1876
+ const startTime = Date.now();
1877
+ const [owner, name] = repo.split("/");
1878
+ try {
1879
+ const { filtered } = filterAlerts(await fetchDependabotAlerts(client, {
1880
+ owner,
1881
+ repo: name
1882
+ }), { severityThreshold: this.config.severityThreshold });
1883
+ const { limited } = limitAlerts(prioritizeAlerts(filtered), this.config.maxAlertsPerRepository);
1884
+ const defaultBranch = await this.fetchDefaultBranch(client, owner, name);
1885
+ this.allAlerts.push(...limited);
1886
+ this.repoResults.push({
1887
+ repository: repo,
1888
+ defaultBranch,
1889
+ alertsCount: limited.length,
1890
+ fixable: limited.filter((a) => a.fixable).length,
1891
+ fixed: 0,
1892
+ failed: 0,
1893
+ lockfileRepaired: false,
1894
+ durationMs: Date.now() - startTime
1895
+ });
1896
+ this.logger.info(`Fetched ${limited.length} alerts for ${repo}`);
1897
+ } catch (error) {
1898
+ const message = toErrorMessage(error);
1899
+ this.logger.error(`Failed to fetch alerts for ${repo}: ${message}`);
1900
+ this.allErrors.push({
1901
+ repository: repo,
1902
+ stage: "fetch",
1903
+ category: "FETCH_FAILED",
1904
+ message
1905
+ });
1906
+ this.repoResults.push({
1907
+ repository: repo,
1908
+ defaultBranch: "",
1909
+ alertsCount: 0,
1910
+ fixable: 0,
1911
+ fixed: 0,
1912
+ failed: 0,
1913
+ lockfileRepaired: false,
1914
+ durationMs: Date.now() - startTime
1915
+ });
1916
+ }
1917
+ }
1918
+ async executeFixMode() {
1919
+ const client = this.createClient();
1920
+ for (const repo of this.config.repositories) await this.processRepoForFix(client, repo);
1921
+ if (this.config.commit) try {
1922
+ commitLocalChanges(this.ctx);
1923
+ } catch (error) {
1924
+ const message = toErrorMessage(error);
1925
+ this.logger.error(`Local commit failed: ${message}`);
1926
+ this.allErrors.push({
1927
+ repository: "*",
1928
+ stage: "fix",
1929
+ category: "COMMIT_FAILED",
1930
+ message
1931
+ });
1932
+ }
1933
+ }
1934
+ async processRepoForFix(client, repo) {
1935
+ const startTime = Date.now();
1936
+ const [owner, name] = repo.split("/");
1937
+ let alertsCount = 0;
1938
+ let fixable = 0;
1939
+ let fixed = 0;
1940
+ let failed = 0;
1941
+ let lockfileRepaired = false;
1942
+ let verificationPassed;
1943
+ let defaultBranch = "";
1944
+ try {
1945
+ const { filtered } = filterAlerts(await fetchDependabotAlerts(client, {
1946
+ owner,
1947
+ repo: name
1948
+ }), { severityThreshold: this.config.severityThreshold });
1949
+ const { limited } = limitAlerts(prioritizeAlerts(filtered), this.config.maxAlertsPerRepository);
1950
+ alertsCount = limited.length;
1951
+ fixable = limited.filter((a) => a.fixable).length;
1952
+ defaultBranch = await this.fetchDefaultBranch(client, owner, name);
1953
+ this.allAlerts.push(...limited);
1954
+ const fixableAlerts = limited.filter((a) => a.fixable && a.recommendedVersion);
1955
+ for (const alert of fixableAlerts) {
1956
+ const action = await upgradeAlert(this.ctx, alert);
1957
+ this.allActions.push(action);
1958
+ if (action.success) fixed++;
1959
+ else failed++;
1960
+ }
1961
+ const skippedCount = limited.length - fixableAlerts.length;
1962
+ this.summary.alertsSkipped += skippedCount;
1963
+ const repairAction = tryLockfileRepair(this.ctx, repo);
1964
+ this.allActions.push(repairAction);
1965
+ if (repairAction.success) lockfileRepaired = true;
1966
+ if (!this.config.dryRun) {
1967
+ const verifyActions = await verifyProject(this.ctx, repo);
1968
+ this.allActions.push(...verifyActions);
1969
+ verificationPassed = verifyActions.every((a) => a.success);
1970
+ } else {
1971
+ this.logger.info(`[dry-run] Skipping verification for ${repo}`);
1972
+ verificationPassed = void 0;
1973
+ }
1974
+ } catch (error) {
1975
+ const message = toErrorMessage(error);
1976
+ this.logger.error(`Failed to process ${repo}: ${message}`);
1977
+ this.allErrors.push({
1978
+ repository: repo,
1979
+ stage: "fix",
1980
+ category: "PROCESS_FAILED",
1981
+ message
1982
+ });
1983
+ }
1984
+ this.repoResults.push({
1985
+ repository: repo,
1986
+ defaultBranch,
1987
+ alertsCount,
1988
+ fixable,
1989
+ fixed,
1990
+ failed,
1991
+ lockfileRepaired,
1992
+ verificationPassed,
1993
+ durationMs: Date.now() - startTime
1994
+ });
1995
+ }
1996
+ /**
1997
+ * 修复 → 计算内容指纹 → 查重 →(同指纹跳过 / 异指纹关旧开新)。
1998
+ *
1999
+ * 去重语义(T210):
2000
+ * - 同告警集 → 同修复结果 → 同指纹 → 跳过,不重复提交相同 PR
2001
+ * - 内容变化 → 先创建新 PR(body 注明 Supersedes),成功后关闭旧 PR;
2002
+ * 新 PR 创建失败时保留旧 PR,避免出现"无 PR 窗口"
2003
+ */
2004
+ async executeFixAndPrMode() {
2005
+ const client = this.createClient();
2006
+ for (const repo of this.config.repositories) await this.processRepoForFix(client, repo);
2007
+ if (this.config.cleanupBranches) await reportCleanupCandidates(this.ctx, client);
2008
+ if (this.config.dryRun) {
2009
+ this.logger.info("[dry-run] Would create branch, commit, push, and PR");
2010
+ return;
2011
+ }
2012
+ if (!hasGitChanges(this.workDir)) {
2013
+ this.logger.info("No changes to commit — skipping PR creation");
2014
+ return;
2015
+ }
2016
+ if (this.config.repositories.length === 0) return;
2017
+ const firstRepo = this.config.repositories[0];
2018
+ const [owner, repo] = firstRepo.split("/");
2019
+ try {
2020
+ const fingerprint = computeFixFingerprint(this.allActions);
2021
+ this.logger.info(`Fix fingerprint: ${fingerprint}`);
2022
+ const plan = computeFixAndPrPlan(await findDependfixOpenPR(client, owner, repo), fingerprint);
2023
+ if (plan.action === "skip" && plan.sameContentPR) {
2024
+ this.logger.info(`Open PR #${plan.sameContentPR.number} already contains identical fixes — skipping PR creation`);
2025
+ this.allActions.push({
2026
+ type: "dependency-upgrade",
2027
+ repository: firstRepo,
2028
+ target: `PR #${plan.sameContentPR.number} (existing)`,
2029
+ fromVersion: void 0,
2030
+ toVersion: plan.sameContentPR.htmlUrl,
2031
+ isMajor: false,
2032
+ success: true,
2033
+ durationMs: 0
2034
+ });
2035
+ await closeSupersededPRs(this.ctx, client, owner, repo, plan.supersedePRs);
2036
+ return;
2037
+ }
2038
+ if (plan.supersedePRs.length > 0) this.logger.info(`Existing PR(s) with different content will be superseded: ${plan.supersedePRs.map((pr) => `#${pr.number}`).join(", ")}`);
2039
+ const branchName = `dependfix/auto-fix-${fingerprint}`;
2040
+ createFixBranch(branchName, this.workDir);
2041
+ this.logger.info(`Creating fix branch: ${branchName}`);
2042
+ this.logger.info("Staging and committing changes");
2043
+ stageAndCommit(FIX_COMMIT_MESSAGE, this.workDir);
2044
+ this.logger.info(`Pushing branch: ${branchName}`);
2045
+ pushBranch(branchName, this.workDir);
2046
+ const defaultBranch = await this.fetchDefaultBranch(client, owner, repo);
2047
+ computeSummary(this.ctx);
2048
+ const prBody = generatePRBody(buildRunResult(this.ctx), plan.supersedePRs.map((pr) => pr.number));
2049
+ const pr = await createPullRequest({
2050
+ octokit: client,
2051
+ owner,
2052
+ repo,
2053
+ headBranch: branchName,
2054
+ baseBranch: defaultBranch,
2055
+ title: `fix(deps): automated security fix — ${this.summary.alertsFixed} upgrades`,
2056
+ body: prBody
2057
+ });
2058
+ this.logger.info(`PR created: ${pr.htmlUrl}`);
2059
+ this.allActions.push({
2060
+ type: "dependency-upgrade",
2061
+ repository: firstRepo,
2062
+ target: `PR #${pr.number}`,
2063
+ fromVersion: void 0,
2064
+ toVersion: pr.htmlUrl,
2065
+ isMajor: false,
2066
+ success: true,
2067
+ durationMs: 0
2068
+ });
2069
+ await closeSupersededPRs(this.ctx, client, owner, repo, plan.supersedePRs);
2070
+ } catch (error) {
2071
+ const message = toErrorMessage(error);
2072
+ this.logger.error(`PR creation failed: ${message}`);
2073
+ this.allErrors.push({
2074
+ repository: this.config.repositories[0] ?? "*",
2075
+ stage: "report",
2076
+ category: "PR_CREATION_FAILED",
2077
+ message
2078
+ });
2079
+ }
2080
+ }
2081
+ /**
2082
+ * 清理已合并(或已关闭)的 dependfix 分支。
2083
+ *
2084
+ * - 分类:已合并(安全清理)/ 已关闭未合并(supersede 孤儿)/ open(跳过)
2085
+ * - 删除前必须交互式确认(y/N);非 TTY 环境(CI)默认拒绝
2086
+ * - 只删 `dependfix/` 前缀分支,绝不触碰 open PR 对应分支
2087
+ * - dry-run 模式仅列清单并打印 "would delete",不执行删除
2088
+ */
2089
+ async executeCleanupBranchesMode() {
2090
+ const client = this.createClient();
2091
+ for (const repo of this.config.repositories) {
2092
+ const [owner, name] = repo.split("/");
2093
+ try {
2094
+ const branches = await listDependfixBranches(client, owner, name);
2095
+ if (branches.length === 0) {
2096
+ this.logger.info(`[cleanup] ${repo}: no dependfix branches found`);
2097
+ continue;
2098
+ }
2099
+ const statuses = [];
2100
+ for (const branch of branches) statuses.push(await getBranchPrStatus(client, owner, name, branch));
2101
+ const merged = statuses.filter((s) => s.merged);
2102
+ const orphaned = statuses.filter((s) => s.closed && !s.merged);
2103
+ const open = statuses.filter((s) => !s.closed);
2104
+ this.logger.info(`[cleanup] ${repo}: ${merged.length} merged, ${orphaned.length} closed, ${open.length} kept`);
2105
+ for (const s of merged) this.logger.info(` [merged] ${s.branch}${s.prNumber ? ` (PR #${s.prNumber})` : ""}`);
2106
+ for (const s of orphaned) this.logger.info(` [closed] ${s.branch}${s.prNumber ? ` (PR #${s.prNumber})` : ""}`);
2107
+ for (const s of open) {
2108
+ const label = s.prNumber ? `[open — kept]` : `[no PR — kept]`;
2109
+ this.logger.info(` ${label} ${s.branch}${s.prNumber ? ` (PR #${s.prNumber})` : ""}`);
2110
+ }
2111
+ const candidates = [...merged, ...orphaned];
2112
+ if (candidates.length === 0) {
2113
+ this.logger.info("[cleanup] nothing to delete");
2114
+ continue;
2115
+ }
2116
+ if (this.config.dryRun) {
2117
+ this.logger.info(`[dry-run] Would delete ${candidates.length} branch(es): ${candidates.map((s) => s.branch).join(", ")}`);
2118
+ continue;
2119
+ }
2120
+ if (!await confirmCleanup(this.ctx, repo, candidates)) {
2121
+ this.logger.info("[cleanup] cancelled by user");
2122
+ continue;
2123
+ }
2124
+ for (const s of candidates) try {
2125
+ await deleteRemoteBranch(client, owner, name, s.branch);
2126
+ this.logger.info(`Deleted branch: ${s.branch}`);
2127
+ this.allActions.push({
2128
+ type: "branch-cleanup",
2129
+ repository: repo,
2130
+ target: s.branch,
2131
+ success: true,
2132
+ diff: s.merged ? "merged" : "closed",
2133
+ durationMs: 0
2134
+ });
2135
+ } catch (error) {
2136
+ const message = toErrorMessage(error);
2137
+ this.logger.error(`Failed to delete branch ${s.branch}: ${message}`);
2138
+ this.allErrors.push({
2139
+ repository: repo,
2140
+ stage: "report",
2141
+ category: "BRANCH_DELETE_FAILED",
2142
+ message: `Failed to delete ${s.branch}: ${message}`
2143
+ });
2144
+ }
2145
+ } catch (error) {
2146
+ const message = toErrorMessage(error);
2147
+ this.logger.error(`[cleanup] failed for ${repo}: ${message}`);
2148
+ this.allErrors.push({
2149
+ repository: repo,
2150
+ stage: "report",
2151
+ category: "CLEANUP_FAILED",
2152
+ message
2153
+ });
2154
+ }
2155
+ }
2156
+ }
2157
+ createClient() {
2158
+ return createGitHubClient({ token: this.config.githubToken });
2159
+ }
2160
+ /**
2161
+ * 获取仓库的默认分支。
2162
+ * 失败时返回 `'unknown'`(不阻塞主流程)。
2163
+ */
2164
+ async fetchDefaultBranch(client, owner, repo) {
2165
+ try {
2166
+ const { data } = await client.rest.repos.get({
2167
+ owner,
2168
+ repo
2169
+ });
2170
+ return data.default_branch;
2171
+ } catch {
2172
+ return "unknown";
2173
+ }
2174
+ }
2175
+ };
2176
+
2177
+ //#endregion
2178
+ //#region src/github/repo-selector.ts
2179
+ /**
2180
+ * 从文件读取仓库列表(每行一个 `owner/repo`)。
2181
+ * 空行和 `#` 开头的注释行会被跳过。
2182
+ */
2183
+ function readReposFile(filePath) {
2184
+ if (!existsSync(filePath)) throw new AppError("REPO_FILE_NOT_FOUND", `Repositories file not found: ${filePath}`);
2185
+ const lines = readFileSync(filePath, "utf-8").split("\n");
2186
+ const repos = [];
2187
+ for (let i = 0; i < lines.length; i += 1) {
2188
+ const line = lines[i].trim();
2189
+ if (!line || line.startsWith("#")) continue;
2190
+ if (!isValidRepoIdentifier(line)) throw new AppError("CONFIG_VALIDATION_ERROR", `Invalid repository identifier at ${filePath}:${i + 1}: "${line}". Expected format: owner/repo`);
2191
+ repos.push(line);
2192
+ }
2193
+ if (repos.length === 0) throw new AppError("CONFIG_VALIDATION_ERROR", `No valid repository identifiers found in ${filePath}`);
2194
+ return repos;
2195
+ }
2196
+ /**
2197
+ * 合并 CLI 传入仓库列表与文件中的仓库列表,去重并校验。
2198
+ */
2199
+ function resolveRepoList(cliRepos, reposFilePath) {
2200
+ const repos = [...cliRepos];
2201
+ if (reposFilePath) {
2202
+ const fileRepos = readReposFile(reposFilePath);
2203
+ repos.push(...fileRepos);
2204
+ }
2205
+ const deduped = [...new Set(repos)];
2206
+ for (const repo of deduped) if (!isValidRepoIdentifier(repo)) throw new AppError("CONFIG_VALIDATION_ERROR", `Invalid repository identifier: "${repo}". Expected format: owner/repo`);
2207
+ return deduped;
2208
+ }
2209
+
2210
+ //#endregion
2211
+ //#region src/config/index.ts
2212
+ const RUNTIME_MODES = [
2213
+ "report-only",
2214
+ "fix",
2215
+ "fix-and-pr",
2216
+ "cleanup-branches"
2217
+ ];
2218
+ const SEVERITY_THRESHOLDS = [
2219
+ "critical",
2220
+ "high",
2221
+ "medium",
2222
+ "all"
2223
+ ];
2224
+ const DEFAULT_RUNTIME_CONFIG = {
2225
+ mode: "report-only",
2226
+ severityThreshold: "high",
2227
+ maxAlertsPerRepository: 10
2228
+ };
2229
+ function isRuntimeMode$1(value) {
2230
+ return RUNTIME_MODES.includes(value);
2231
+ }
2232
+ function isSeverityThreshold$1(value) {
2233
+ return SEVERITY_THRESHOLDS.includes(value);
2234
+ }
2235
+ function normalizeBoolean(value, fieldName) {
2236
+ if (value === void 0 || value.trim() === "") return;
2237
+ const normalized = value.trim().toLowerCase();
2238
+ if ([
2239
+ "true",
2240
+ "1",
2241
+ "yes",
2242
+ "on"
2243
+ ].includes(normalized)) return true;
2244
+ if ([
2245
+ "false",
2246
+ "0",
2247
+ "no",
2248
+ "off"
2249
+ ].includes(normalized)) return false;
2250
+ throw new AppError("CONFIG_VALIDATION_ERROR", `Invalid boolean value for ${fieldName}: ${value}`);
2251
+ }
2252
+ function normalizeInteger(value, fieldName) {
2253
+ if (value === void 0 || value.trim() === "") return;
2254
+ const parsed = Number.parseInt(value, 10);
2255
+ if (!Number.isInteger(parsed) || parsed <= 0) throw new AppError("CONFIG_VALIDATION_ERROR", `${fieldName} must be a positive integer`);
2256
+ return parsed;
2257
+ }
2258
+ function normalizeList(value) {
2259
+ if (value === void 0 || value.trim() === "") return;
2260
+ const items = value.split(",").map((item) => item.trim()).filter(Boolean);
2261
+ return items.length > 0 ? items : void 0;
2262
+ }
2263
+ function readRuntimeMode(value, fieldName) {
2264
+ if (value === void 0 || value.trim() === "") return;
2265
+ if (!isRuntimeMode$1(value)) throw new AppError("CONFIG_VALIDATION_ERROR", `${fieldName} must be one of: ${RUNTIME_MODES.join(", ")}`);
2266
+ return value;
2267
+ }
2268
+ function readSeverityThreshold(value, fieldName) {
2269
+ if (value === void 0 || value.trim() === "") return;
2270
+ if (!isSeverityThreshold$1(value)) throw new AppError("CONFIG_VALIDATION_ERROR", `${fieldName} must be one of: ${SEVERITY_THRESHOLDS.join(", ")}`);
2271
+ return value;
2272
+ }
2273
+ function readEnvConfig(env = process.env) {
2274
+ return {
2275
+ mode: readRuntimeMode(env.AUTO_FIX_GITHUB_SECURITY_MODE, "AUTO_FIX_GITHUB_SECURITY_MODE"),
2276
+ severityThreshold: readSeverityThreshold(env.AUTO_FIX_GITHUB_SECURITY_SEVERITY_THRESHOLD, "AUTO_FIX_GITHUB_SECURITY_SEVERITY_THRESHOLD"),
2277
+ repositories: normalizeList(env.AUTO_FIX_GITHUB_SECURITY_REPOSITORIES),
2278
+ dryRun: normalizeBoolean(env.AUTO_FIX_GITHUB_SECURITY_DRY_RUN, "AUTO_FIX_GITHUB_SECURITY_DRY_RUN"),
2279
+ createPullRequest: normalizeBoolean(env.AUTO_FIX_GITHUB_SECURITY_CREATE_PR, "AUTO_FIX_GITHUB_SECURITY_CREATE_PR"),
2280
+ commit: normalizeBoolean(env.AUTO_FIX_GITHUB_SECURITY_COMMIT, "AUTO_FIX_GITHUB_SECURITY_COMMIT"),
2281
+ cleanupBranches: normalizeBoolean(env.AUTO_FIX_GITHUB_SECURITY_CLEANUP_BRANCHES, "AUTO_FIX_GITHUB_SECURITY_CLEANUP_BRANCHES"),
2282
+ githubToken: env.AUTO_FIX_GITHUB_SECURITY_GITHUB_TOKEN?.trim() || env.GITHUB_TOKEN?.trim() || void 0,
2283
+ maxAlertsPerRepository: normalizeInteger(env.AUTO_FIX_GITHUB_SECURITY_MAX_ALERTS_PER_REPOSITORY, "AUTO_FIX_GITHUB_SECURITY_MAX_ALERTS_PER_REPOSITORY")
2284
+ };
2285
+ }
2286
+ function resolveDryRun(mode, cliOverrides, envConfig) {
2287
+ if (cliOverrides.dryRun !== void 0) return cliOverrides.dryRun;
2288
+ if (envConfig.dryRun !== void 0) return envConfig.dryRun;
2289
+ return mode === "report-only";
2290
+ }
2291
+ function resolveCreatePullRequest(mode, cliOverrides, envConfig) {
2292
+ if (cliOverrides.createPullRequest !== void 0) return cliOverrides.createPullRequest;
2293
+ if (envConfig.createPullRequest !== void 0) return envConfig.createPullRequest;
2294
+ return mode === "fix-and-pr";
2295
+ }
2296
+ function resolveCommit(cliOverrides, envConfig) {
2297
+ if (cliOverrides.commit !== void 0) return cliOverrides.commit;
2298
+ if (envConfig.commit !== void 0) return envConfig.commit;
2299
+ return false;
2300
+ }
2301
+ function resolveCleanupBranches(cliOverrides, envConfig) {
2302
+ if (cliOverrides.cleanupBranches !== void 0) return cliOverrides.cleanupBranches;
2303
+ if (envConfig.cleanupBranches !== void 0) return envConfig.cleanupBranches;
2304
+ return false;
2305
+ }
2306
+ function validateRuntimeConfig(config) {
2307
+ if (!config.githubToken) throw new AppError("CONFIG_VALIDATION_ERROR", "Missing GitHub token. Provide GITHUB_TOKEN or AUTO_FIX_GITHUB_SECURITY_GITHUB_TOKEN.");
2308
+ if (config.repositories.length === 0) throw new AppError("CONFIG_VALIDATION_ERROR", "Missing target repositories. Provide --repo, --repository, --repos-file or AUTO_FIX_GITHUB_SECURITY_REPOSITORIES.");
2309
+ for (const repo of config.repositories) if (!isValidRepoIdentifier(repo)) throw new AppError("CONFIG_VALIDATION_ERROR", `Invalid repository identifier: "${repo}". Expected format: owner/repo`);
2310
+ if (config.mode === "report-only" && config.createPullRequest) throw new AppError("CONFIG_VALIDATION_ERROR", "createPullRequest cannot be enabled when mode is report-only.");
2311
+ if (config.dryRun && config.createPullRequest) throw new AppError("CONFIG_VALIDATION_ERROR", "createPullRequest cannot be enabled while dryRun is true.");
2312
+ if (config.commit && config.mode !== "fix") throw new AppError("CONFIG_VALIDATION_ERROR", "commit is only supported in fix mode.");
2313
+ if (config.commit && config.dryRun) throw new AppError("CONFIG_VALIDATION_ERROR", "commit cannot be enabled while dryRun is true.");
2314
+ if (config.commit && config.createPullRequest) throw new AppError("CONFIG_VALIDATION_ERROR", "commit cannot be enabled together with createPullRequest. Use fix-and-pr mode instead.");
2315
+ return config;
2316
+ }
2317
+ function resolveRuntimeConfig(options = {}) {
2318
+ const envConfig = readEnvConfig(options.env);
2319
+ const cliOverrides = options.cliOverrides ?? {};
2320
+ const mode = cliOverrides.mode ?? envConfig.mode ?? DEFAULT_RUNTIME_CONFIG.mode;
2321
+ let repositories = resolveRepoList([...envConfig.repositories ?? [], ...cliOverrides.repositories ?? []], cliOverrides.reposFilePath);
2322
+ if (repositories.length === 0) {
2323
+ const inferred = inferRepoFromGitRemote(options.workDir ?? process.cwd());
2324
+ if (inferred) repositories = [inferred];
2325
+ }
2326
+ return validateRuntimeConfig({
2327
+ mode,
2328
+ severityThreshold: cliOverrides.severityThreshold ?? envConfig.severityThreshold ?? DEFAULT_RUNTIME_CONFIG.severityThreshold,
2329
+ repositories,
2330
+ dryRun: resolveDryRun(mode, cliOverrides, envConfig),
2331
+ createPullRequest: resolveCreatePullRequest(mode, cliOverrides, envConfig),
2332
+ commit: resolveCommit(cliOverrides, envConfig),
2333
+ cleanupBranches: resolveCleanupBranches(cliOverrides, envConfig),
2334
+ githubToken: cliOverrides.githubToken ?? envConfig.githubToken ?? "",
2335
+ maxAlertsPerRepository: cliOverrides.maxAlertsPerRepository ?? envConfig.maxAlertsPerRepository ?? DEFAULT_RUNTIME_CONFIG.maxAlertsPerRepository
2336
+ });
2337
+ }
2338
+ /** 匹配 GitHub remote URL 的正则(HTTPS / SSH / git@ 格式) */
2339
+ const GITHUB_REMOTE_RE = /github\.com[/:]([^/]+)\/([^/\s.]+?)(?:\.git)?\s*$/i;
2340
+ /**
2341
+ * 从 git remote origin 推断 owner/repo。
2342
+ *
2343
+ * 支持格式:
2344
+ * - `https://github.com/owner/repo.git`
2345
+ * - `git@github.com:owner/repo.git`
2346
+ * - `ssh://git@github.com/owner/repo.git`
2347
+ *
2348
+ * @returns `owner/repo` 或 `null`(非 GitHub / 无 origin)
2349
+ */
2350
+ function inferRepoFromGitRemote(workDir) {
2351
+ try {
2352
+ const url = execSync("git remote get-url origin", {
2353
+ cwd: workDir,
2354
+ encoding: "utf-8",
2355
+ stdio: "pipe"
2356
+ }).trim();
2357
+ const match = GITHUB_REMOTE_RE.exec(url);
2358
+ return match ? `${match[1]}/${match[2]}` : null;
2359
+ } catch {
2360
+ return null;
2361
+ }
2362
+ }
2363
+
2364
+ //#endregion
2365
+ //#region src/cli/index.ts
2366
+ const argsDef = {
2367
+ mode: {
2368
+ type: "positional",
2369
+ description: "运行模式:report-only, fix, fix-and-pr, cleanup-branches",
2370
+ required: false,
2371
+ default: "report-only"
2372
+ },
2373
+ repo: {
2374
+ type: "string",
2375
+ description: "目标仓库 (owner/repo),逗号分隔多个",
2376
+ alias: [
2377
+ "r",
2378
+ "repository",
2379
+ "repositories"
2380
+ ]
2381
+ },
2382
+ "repos-file": {
2383
+ type: "string",
2384
+ description: "从文件读取仓库列表(每行一个 owner/repo)"
2385
+ },
2386
+ "severity-threshold": {
2387
+ type: "string",
2388
+ description: "严重级别阈值:critical, high, medium, all",
2389
+ default: "high"
2390
+ },
2391
+ "dry-run": {
2392
+ type: "boolean",
2393
+ description: "试运行模式,不实际写入文件",
2394
+ negativeDescription: "关闭试运行模式"
2395
+ },
2396
+ "create-pr": {
2397
+ type: "boolean",
2398
+ description: "创建 Pull Request",
2399
+ negativeDescription: "不创建 Pull Request"
2400
+ },
2401
+ commit: {
2402
+ type: "boolean",
2403
+ description: "修复完成后在本地当前分支直接提交(不推送、不创建 PR)",
2404
+ negativeDescription: "不自动提交(默认)"
2405
+ },
2406
+ "cleanup-branches": {
2407
+ type: "boolean",
2408
+ description: "(fix-and-pr 模式)结束后列出已合并的 dependfix 分支到报告,不自动删除",
2409
+ negativeDescription: "不执行分支清理检查"
2410
+ },
2411
+ "github-token": {
2412
+ type: "string",
2413
+ description: "GitHub Personal Access Token"
2414
+ },
2415
+ "max-alerts-per-repository": {
2416
+ type: "string",
2417
+ description: "每个仓库最多处理的告警数",
2418
+ default: "10"
2419
+ },
2420
+ commands: {
2421
+ type: "string",
2422
+ description: "自定义验证命令(逗号分隔),覆盖默认的 install/lint/build"
2423
+ },
2424
+ verbose: {
2425
+ type: "boolean",
2426
+ description: "输出详细日志"
2427
+ }
2428
+ };
2429
+ function isRuntimeMode(value) {
2430
+ return RUNTIME_MODES.includes(value);
2431
+ }
2432
+ function isSeverityThreshold(value) {
2433
+ return SEVERITY_THRESHOLDS.includes(value);
2434
+ }
2435
+ function appendRepositories(target, value) {
2436
+ for (const repository of value.split(",")) {
2437
+ const trimmed = repository.trim();
2438
+ if (!trimmed) continue;
2439
+ if (!isValidRepoIdentifier(trimmed)) throw new AppError("ARGUMENT_PARSE_ERROR", `Invalid repository identifier: "${trimmed}". Expected format: owner/repo`);
2440
+ target.push(trimmed);
2441
+ }
2442
+ }
2443
+ function parseCommandsFlag(value) {
2444
+ return value.split(",").map((cmd) => cmd.trim()).filter(Boolean);
2445
+ }
2446
+ function parsedArgsToCliOverrides(parsed) {
2447
+ const overrides = {};
2448
+ const rawMode = parsed.mode;
2449
+ if (isRuntimeMode(rawMode)) overrides.mode = rawMode;
2450
+ const repoValue = parsed.repo;
2451
+ if (repoValue) {
2452
+ const repos = [];
2453
+ appendRepositories(repos, repoValue);
2454
+ if (repos.length > 0) overrides.repositories = repos;
2455
+ }
2456
+ const reposFile = parsed["repos-file"];
2457
+ if (reposFile) overrides.reposFilePath = reposFile;
2458
+ const severityThreshold = parsed["severity-threshold"];
2459
+ if (isSeverityThreshold(severityThreshold)) overrides.severityThreshold = severityThreshold;
2460
+ if (parsed["dry-run"] !== void 0) overrides.dryRun = parsed["dry-run"];
2461
+ if (parsed["create-pr"] !== void 0) overrides.createPullRequest = parsed["create-pr"];
2462
+ if (parsed.commit !== void 0) overrides.commit = parsed.commit;
2463
+ if (parsed["cleanup-branches"] !== void 0) overrides.cleanupBranches = parsed["cleanup-branches"];
2464
+ const githubToken = parsed["github-token"];
2465
+ if (githubToken) overrides.githubToken = githubToken;
2466
+ const maxAlerts = parsed["max-alerts-per-repository"];
2467
+ if (maxAlerts) {
2468
+ const num = Number.parseInt(maxAlerts, 10);
2469
+ if (Number.isNaN(num)) throw new AppError("ARGUMENT_PARSE_ERROR", `Invalid --max-alerts-per-repository value: "${maxAlerts}". Expected a positive integer.`);
2470
+ overrides.maxAlertsPerRepository = num;
2471
+ }
2472
+ if (parsed.verbose !== void 0) overrides.verbose = parsed.verbose;
2473
+ const commandsValue = parsed.commands;
2474
+ if (commandsValue) overrides.commands = parseCommandsFlag(commandsValue);
2475
+ return overrides;
2476
+ }
2477
+ function parseCliArgs(rawArgs) {
2478
+ const configOverrides = parsedArgsToCliOverrides(parseArgs(rawArgs, argsDef));
2479
+ return {
2480
+ mode: configOverrides.mode,
2481
+ configOverrides,
2482
+ rawArgs: [...rawArgs]
2483
+ };
2484
+ }
2485
+ function runCli(rawArgs) {
2486
+ const invocation = parseCliArgs(rawArgs);
2487
+ return {
2488
+ ok: true,
2489
+ invocation,
2490
+ config: resolveRuntimeConfig({
2491
+ env: process.env,
2492
+ cliOverrides: invocation.configOverrides
2493
+ })
2494
+ };
2495
+ }
2496
+ async function runApp(rawArgs) {
2497
+ const invocation = parseCliArgs(rawArgs);
2498
+ const { exitCode } = await new DependfixApp({
2499
+ config: resolveRuntimeConfig({
2500
+ env: process.env,
2501
+ cliOverrides: invocation.configOverrides
2502
+ }),
2503
+ verbose: invocation.configOverrides.verbose,
2504
+ commands: invocation.configOverrides.commands
2505
+ }).run();
2506
+ return exitCode;
2507
+ }
2508
+ const dependfixCommand = defineCommand({
2509
+ meta: {
2510
+ name: "dependfix",
2511
+ version: "0.1.0",
2512
+ description: "自动化处理 Dependabot / Code Scanning 安全告警的修复工具"
2513
+ },
2514
+ args: argsDef,
2515
+ async run({ rawArgs }) {
2516
+ let exitCode = 1;
2517
+ try {
2518
+ exitCode = await runApp(rawArgs);
2519
+ } catch (error) {
2520
+ const appError = toAppError(error, "CLI_EXECUTION_FAILED");
2521
+ console.error(`Error: ${appError.message}`);
2522
+ exitCode = 1;
2523
+ }
2524
+ process.exitCode = exitCode;
2525
+ }
2526
+ });
2527
+
2528
+ //#endregion
2529
+ export { createPullRequest as A, fetchDependabotAlerts as B, parseMajorVersion as C, computeFixAndPrPlan as D, closePullRequest as E, getBranchPrStatus as F, createGitHubClient as H, isConfirmAnswer as I, listDependfixBranches as L, extractFingerprintFromBranch as M, findDependfixOpenPR as N, computeFixFingerprint as O, generatePRBody as P, pushBranch as R, overrideTransitiveDependency as S, upgradeDependency as T, mapGitHubError as V, repairLockfile as _, RUNTIME_MODES as a, extractPrefix as b, readEnvConfig as c, resolveRepoList as d, DependfixApp as f, computeLockfileDiff as g, classifyLockfileFailure as h, DEFAULT_RUNTIME_CONFIG as i, deleteRemoteBranch as j, createFixBranch as k, resolveRuntimeConfig as l, sanitizeOutput as m, parseCliArgs as n, SEVERITY_THRESHOLDS as o, runVerification as p, runCli as r, inferRepoFromGitRemote as s, dependfixCommand as t, readReposFile as u, resolvePnpmVersion as v, readLockfileVersion as w, findDependencyVersion as x, ensurePnpmOverrides as y, stageAndCommit as z };
2530
+ //# sourceMappingURL=cli.mjs.map