@dependfix/engine 0.3.0 → 0.4.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/index.mjs CHANGED
@@ -1,6 +1,6 @@
1
- import { Octokit } from "@octokit/rest";
1
+ import { a as fromPat, c as computeRetryDelayMs, l as createGitHubClient } from "./auth2.mjs";
2
2
  import { RequestError } from "@octokit/request-error";
3
- import { AppError, collectCodeScanningSuggestions, createEmptyRunSummary, createLogger, filterAlerts, generateJsonReport, generateMarkdownReport, isAlertFixedByActions, isValidRepoIdentifier, limitAlerts, mapCodeScanningSeverity, normalizeAuditSeverity, prioritizeAlerts, toErrorMessage, writeReport } from "@dependfix/core";
3
+ import { AppError, collectCodeScanningSuggestions, createEmptyRunSummary, createLogger, filterAlerts, generateJsonReport, generateMarkdownReport, isAlertFixedByActions, isValidRepoIdentifier, limitAlerts, mapCodeScanningSeverity, normalizeAuditSeverity, normalizeUpstreamId, prioritizeAlerts, toErrorMessage, writeReport } from "@dependfix/core";
4
4
  import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
5
5
  import { execFileSync, execSync, spawn, spawnSync } from "node:child_process";
6
6
  import { createHash } from "node:crypto";
@@ -12,116 +12,6 @@ import { connect } from "node:net";
12
12
  import { z } from "zod";
13
13
  import { createInterface } from "node:readline";
14
14
 
15
- //#region src/github/client.ts
16
- /**
17
- * 创建已认证的 Octokit 实例。
18
- *
19
- * 调用方直接使用 `octokit.rest.*` 访问所有已类型化的 GitHub REST API。
20
- * 分页使用 `octokit.paginate()` 自动合并多页结果。
21
- *
22
- * 配置 `retry` 时通过 `hook.wrap('request')` 统一包装所有请求
23
- * (含 paginate 内部请求),对限流响应做指数退避重试。
24
- *
25
- * @example
26
- * ```typescript
27
- * const octokit = createGitHubClient({ token: 'ghp_xxxx' })
28
- *
29
- * // 仓库信息
30
- * const { data: repo } = await octokit.rest.repos.get({ owner: 'foo', repo: 'bar' })
31
- *
32
- * // Dependabot 告警(自动分页)
33
- * const alerts = await octokit.paginate(
34
- * octokit.rest.dependabot.listAlertsForRepo,
35
- * { owner: 'foo', repo: 'bar', state: 'open', per_page: 100 },
36
- * )
37
- * ```
38
- */
39
- function createGitHubClient(options) {
40
- const client = new Octokit({
41
- auth: options.token,
42
- baseUrl: options.baseUrl ?? "https://api.github.com"
43
- });
44
- const maxRetries = options.retry?.maxRetries ?? 3;
45
- if (maxRetries > 0) applyRetryPolicy(client, {
46
- maxRetries,
47
- baseDelayMs: options.retry?.baseDelayMs ?? 1e3,
48
- maxBackoffMs: options.retry?.maxBackoffMs ?? MAX_BACKOFF_MS_DEFAULT
49
- });
50
- return client;
51
- }
52
- const SECONDARY_RATE_LIMIT_RE = /secondary rate limit|abuse|retry later|retry-after/i;
53
- /** 指数退避单次等待默认上限(30s),防止长时间空转(可经 retry.maxBackoffMs 覆盖) */
54
- const MAX_BACKOFF_MS_DEFAULT = 3e4;
55
- function applyRetryPolicy(client, retry) {
56
- const maxRetries = retry.maxRetries ?? 3;
57
- const baseDelayMs = retry.baseDelayMs ?? 1e3;
58
- const maxBackoffMs = retry.maxBackoffMs ?? MAX_BACKOFF_MS_DEFAULT;
59
- client.hook.wrap("request", async (request, options) => {
60
- const method = (options.method ?? "GET").toUpperCase();
61
- if (method !== "GET" && method !== "HEAD") return request(options);
62
- let attempt = 0;
63
- for (;;) try {
64
- return await request(options);
65
- } catch (error) {
66
- const delayMs = computeRetryDelayMs(error, attempt, baseDelayMs, maxBackoffMs);
67
- if (delayMs === null || attempt >= maxRetries) throw error;
68
- attempt += 1;
69
- await sleep(delayMs);
70
- }
71
- });
72
- }
73
- /**
74
- * 计算限流重试等待时间(纯函数,可单测)。
75
- *
76
- * 可重试(返回等待毫秒):
77
- * - 429(Too Many Requests / secondary rate limit)
78
- * - 403 + `x-ratelimit-remaining: 0`(primary rate limit)
79
- * - 403/429 + message 含 secondary rate limit / abuse / retry 特征
80
- *
81
- * 等待策略(Retry-After 优先):
82
- * 1. `retry-after` 头(秒)→ 等待其秒数(受 maxBackoffMs 上限约束)
83
- * 2. `x-ratelimit-reset`(unix 秒)→ 等待到 reset + 1s 缓冲
84
- * 3. 否则 → 指数退避 `baseDelayMs * 2^attempt`
85
- * 所有等待均受 maxBackoffMs 上限约束(默认 30s)。
86
- *
87
- * 不重试(返回 null):
88
- * - 非 RequestError(网络错误等由上层 AppError 语义处理)
89
- * - 权限类 403(无限流特征)
90
- * - 其他状态码
91
- */
92
- function computeRetryDelayMs(error, attempt, baseDelayMs = 1e3, maxBackoffMs = MAX_BACKOFF_MS_DEFAULT) {
93
- if (!(error instanceof RequestError)) return null;
94
- const status = error.status;
95
- const headers = error.response?.headers ?? {};
96
- const remaining = headers["x-ratelimit-remaining"];
97
- const message = error.message;
98
- let retryable = false;
99
- if (status === 429) retryable = true;
100
- else if (status === 403) {
101
- if (remaining !== void 0 && remaining === "0") retryable = true;
102
- else if (SECONDARY_RATE_LIMIT_RE.test(message)) retryable = true;
103
- }
104
- if (!retryable) return null;
105
- const retryAfter = headers["retry-after"];
106
- if (retryAfter !== void 0) {
107
- const seconds = Number(retryAfter);
108
- if (Number.isFinite(seconds) && seconds > 0) return Math.min(seconds * 1e3, maxBackoffMs);
109
- }
110
- const reset = headers["x-ratelimit-reset"];
111
- if (reset !== void 0) {
112
- const resetMs = Number(reset) * 1e3;
113
- if (Number.isFinite(resetMs) && resetMs > 0) {
114
- const waitMs = resetMs - Date.now() + 1e3;
115
- if (waitMs > 0) return Math.min(waitMs, maxBackoffMs);
116
- }
117
- }
118
- return Math.min(baseDelayMs * 2 ** attempt, maxBackoffMs);
119
- }
120
- function sleep(ms) {
121
- return new Promise((resolve) => setTimeout(resolve, ms));
122
- }
123
-
124
- //#endregion
125
15
  //#region src/github/errors.ts
126
16
  /**
127
17
  * 将 Octokit 请求错误映射为 `AppError`。
@@ -249,7 +139,8 @@ function normalizeFinding(finding, owner, repo) {
249
139
  alertClass: "report-only",
250
140
  startLine: finding.location?.start_line ?? void 0,
251
141
  endLine: finding.location?.end_line ?? void 0,
252
- suggestion: description || "人工审查该 Code Quality finding"
142
+ suggestion: description || "人工审查该 Code Quality finding",
143
+ upstreamId: normalizeUpstreamId("code-quality", { alertNumber: finding.number })
253
144
  };
254
145
  }
255
146
  /**
@@ -553,7 +444,8 @@ function normalizeAlert$1(alert, owner, repo) {
553
444
  alertClass: classifyRule(ruleId),
554
445
  startLine: instance?.location?.start_line ?? void 0,
555
446
  endLine: instance?.location?.end_line ?? void 0,
556
- suggestion: suggestionFor(ruleId)
447
+ suggestion: suggestionFor(ruleId),
448
+ upstreamId: normalizeUpstreamId("code-scanning", { alertNumber: alert.number })
557
449
  };
558
450
  }
559
451
  /**
@@ -585,8 +477,13 @@ function mapSeverity(alert) {
585
477
  *
586
478
  * @example
587
479
  * ```typescript
588
- * const octokit = createGitHubClient({ token: 'ghp_xxxx' })
480
+ * // 推荐:使用 auth 抽象层(M18.1 实施后)
481
+ * import { fromPat } from '@dependfix/engine/auth'
482
+ * const octokit = createGitHubClient({ auth: fromPat('ghp_xxxx') })
589
483
  * const alerts = await fetchDependabotAlerts(octokit, { owner: 'foo', repo: 'bar' })
484
+ *
485
+ * // 向后兼容:使用 token 字段(deprecated)
486
+ * const octokit = createGitHubClient({ token: 'ghp_xxxx' })
590
487
  * ```
591
488
  */
592
489
  async function fetchDependabotAlerts(client, params) {
@@ -632,10 +529,22 @@ function normalizeAlert(alert, owner, repo) {
632
529
  fixable,
633
530
  fixStrategy: fixable ? "upgrade" : null,
634
531
  recommendedVersion: firstPatched?.identifier ?? "",
635
- dependencyType: normalizeDependencyRelationship(alert.dependency.relationship)
532
+ dependencyType: normalizeDependencyRelationship(alert.dependency.relationship),
533
+ upstreamId: normalizeUpstreamId("dependabot", { alertNumber: alert.number }),
534
+ ghsaId: alert.security_advisory.ghsa_id,
535
+ cveIds: extractCveIds(alert.security_advisory.identifiers)
636
536
  };
637
537
  }
638
538
  /**
539
+ ** 从 dependabot alert.security_advisory.identifiers[] 提取 CVE 列表(type === 'CVE')。
540
+ ** 兼容 identifiers 缺省为 undefined / 空数组场景。
541
+ */
542
+ function extractCveIds(identifiers) {
543
+ if (!Array.isArray(identifiers) || identifiers.length === 0) return;
544
+ const cves = identifiers.filter((id) => id?.type === "CVE" && typeof id.value === "string" && Boolean(id.value)).map((id) => id.value);
545
+ return cves.length > 0 ? cves : void 0;
546
+ }
547
+ /**
639
548
  * 将 Dependabot API 的 `dependency.relationship` 映射为 `DependencyType`。
640
549
  *
641
550
  * GitHub API 返回的合法值:
@@ -650,8 +559,19 @@ function normalizeDependencyRelationship(relationship) {
650
559
 
651
560
  //#endregion
652
561
  //#region src/github/pr-creator.ts
653
- const BOT_NAME = "dependfix[bot]";
654
- const BOT_EMAIL = "dependfix[bot]@users.noreply.github.com";
562
+ /**
563
+ * PAT 路径默认 commit author(保持现有 PAT 路径用户行为零变化)。
564
+ *
565
+ * 注:此常量仅作为 PAT 路径的默认值;GitHub App 路径(M18.2 之后接入)走动态 commit author。
566
+ * 现有 PAT 路径仍硬编码 `dependfix[bot]@users.noreply.github.com`,虽非真实 bot 身份(字符串约定),
567
+ * 但保持行为不变以确保 PAT 用户无感升级。已知缺陷由 C22 范围之外的后续阶段修复。
568
+ *
569
+ * @see [C22 PAT 无感升级评估 §5.1 兼容性](../../../../docs/design/governance/c22-pat-backward-compat.md)
570
+ */
571
+ const PAT_DEFAULT_COMMIT_AUTHOR = {
572
+ name: "dependfix[bot]",
573
+ email: "dependfix[bot]@users.noreply.github.com"
574
+ };
655
575
  /** 自动修复分支统一前缀(分支名 = 前缀 + 内容指纹 8 位) */
656
576
  const BRANCH_PREFIX = "dependfix/auto-fix-";
657
577
  /**
@@ -747,14 +667,28 @@ function createFixBranch(branchName, workDir) {
747
667
  * 自动设置 git user.name / user.email(如未设置)。
748
668
  * 使用 `execFileSync` 参数数组形式(不经 shell),保证多行 commit message
749
669
  * 与 UTF-8 字符(如 →)在 Windows/Linux 双平台传递一致。
670
+ *
671
+ * W3 修复(M18.4 audit round 2):显式传 `-c user.name=X -c user.email=Y` 强制 commit author
672
+ * 使用传入值,不受 host 全局 `user.name`(如 CaoMeiYouRen)污染——`git commit` 走 lookup order
673
+ * (env → `-c` config → local → global → system),显式 `-c` 在 lookup order 中最高优先。
674
+ *
675
+ * @param author - 可选 commit author 信息;不传时使用 PAT 默认值(保持现有 PAT 路径行为零变化)。
676
+ * M18.2 之后 GitHub App 路径会传入动态生成的 `{app_id}+{bot_login}[bot]` author。
677
+ *
678
+ * @see [C22 PAT 无感升级评估 §5.1 兼容性](../../../../docs/design/governance/c22-pat-backward-compat.md)
750
679
  */
751
- function stageAndCommit(message, workDir) {
752
- ensureGitConfig(workDir);
753
- execSync("git add .", {
680
+ function stageAndCommit(message, workDir, author) {
681
+ ensureGitConfig(workDir, author);
682
+ const effectiveAuthor = author ?? PAT_DEFAULT_COMMIT_AUTHOR;
683
+ execFileSync("git", ["add", "."], {
754
684
  cwd: workDir,
755
685
  stdio: "pipe"
756
686
  });
757
687
  execFileSync("git", [
688
+ "-c",
689
+ `user.name=${effectiveAuthor.name}`,
690
+ "-c",
691
+ `user.email=${effectiveAuthor.email}`,
758
692
  "commit",
759
693
  "-m",
760
694
  message
@@ -763,9 +697,6 @@ function stageAndCommit(message, workDir) {
763
697
  stdio: "pipe"
764
698
  });
765
699
  }
766
- /**
767
- * 推送分支到远程 origin。
768
- */
769
700
  function pushBranch(branchName, workDir) {
770
701
  execFileSync("git", [
771
702
  "push",
@@ -808,6 +739,38 @@ async function closePullRequest(octokit, owner, repo, pullNumber) {
808
739
  });
809
740
  }
810
741
  /**
742
+ * 在 Pull Request 上添加评论。
743
+ *
744
+ * 使用 `issues.createComment` 端点(GitHub PR 评论复用 issue comment API),
745
+ * 需要 token 具备 `issues: write` 权限(比 `pull-requests: write` 宽)。
746
+ *
747
+ * 用于在新 PR 上评论指向被取代的旧 PR,避免用户手动排查重复 PR。
748
+ */
749
+ async function commentOnPullRequest(octokit, owner, repo, pullNumber, body) {
750
+ await octokit.rest.issues.createComment({
751
+ owner,
752
+ repo,
753
+ issue_number: pullNumber,
754
+ body
755
+ });
756
+ }
757
+ /**
758
+ * 在 Pull Request 上添加 label。
759
+ *
760
+ * 使用 `issues.addLabels` 端点(GitHub PR label 复用 issue label API),
761
+ * 需要 token 具备 `issues: write` 权限。
762
+ *
763
+ * 用于标记重复 PR(如 `duplicate` label),便于用户过滤和管理。
764
+ */
765
+ async function addLabelToPullRequest(octokit, owner, repo, pullNumber, labels) {
766
+ await octokit.rest.issues.addLabels({
767
+ owner,
768
+ repo,
769
+ issue_number: pullNumber,
770
+ labels
771
+ });
772
+ }
773
+ /**
811
774
  * 通过 GitHub API 创建 Pull Request。
812
775
  */
813
776
  async function createPullRequest(params) {
@@ -1161,21 +1124,35 @@ function branchExists(branchName, workDir) {
1161
1124
  return false;
1162
1125
  }
1163
1126
  }
1164
- function ensureGitConfig(workDir) {
1127
+ function ensureGitConfig(workDir, author) {
1128
+ const effectiveAuthor = author ?? PAT_DEFAULT_COMMIT_AUTHOR;
1165
1129
  const hasName = gitConfigExists("user.name", workDir);
1166
1130
  const hasEmail = gitConfigExists("user.email", workDir);
1167
- if (!hasName) execSync(`git config user.name "${BOT_NAME}"`, {
1131
+ if (!hasName) execFileSync("git", [
1132
+ "config",
1133
+ "user.name",
1134
+ effectiveAuthor.name
1135
+ ], {
1168
1136
  cwd: workDir,
1169
1137
  stdio: "pipe"
1170
1138
  });
1171
- if (!hasEmail) execSync(`git config user.email "${BOT_EMAIL}"`, {
1139
+ if (!hasEmail) execFileSync("git", [
1140
+ "config",
1141
+ "user.email",
1142
+ effectiveAuthor.email
1143
+ ], {
1172
1144
  cwd: workDir,
1173
1145
  stdio: "pipe"
1174
1146
  });
1175
1147
  }
1176
1148
  function gitConfigExists(key, workDir) {
1177
1149
  try {
1178
- execSync(`git config ${key}`, {
1150
+ execFileSync("git", [
1151
+ "config",
1152
+ "--local",
1153
+ "--get",
1154
+ key
1155
+ ], {
1179
1156
  cwd: workDir,
1180
1157
  stdio: "pipe"
1181
1158
  });
@@ -1280,6 +1257,7 @@ const DEPENDABOT_CONFIG_PATH = ".github/dependabot.yml";
1280
1257
  * 2. topic 过滤(`--repo-topics`,AND 语义)
1281
1258
  * 3. 名单策略过滤(include / exclude / topicsExclude,探测前应用)
1282
1259
  * 4. dependabot.yml 探测(仅候选仓库触达 contents API;404 视为不支持,不剔除)
1260
+ * 5. 规模上限截断(`maxRepos`,按 fullName 字典序排序后截断)
1283
1261
  *
1284
1262
  * 结果按 `fullName` 字典序排序,保证同输入多次运行结果一致
1285
1263
  * (runId / 指纹稳定性前提)。
@@ -1287,7 +1265,7 @@ const DEPENDABOT_CONFIG_PATH = ".github/dependabot.yml";
1287
1265
  * @throws AppError(mapGitHubError 转换;token 权限、网络等全局性问题 fail-fast)
1288
1266
  */
1289
1267
  async function discoverRepositories(options) {
1290
- const { client, owners, topics = [], policy, probeDependabot = true, probeConcurrency = 5 } = options;
1268
+ const { client, owners, topics = [], policy, probeDependabot = true, probeConcurrency = 5, maxRepos } = options;
1291
1269
  const discovered = [];
1292
1270
  for (const owner of owners) {
1293
1271
  const repos = await fetchOwnerRepositories(client, owner);
@@ -1318,6 +1296,8 @@ async function discoverRepositories(options) {
1318
1296
  }
1319
1297
  if (probeDependabot) await probeDependabotConfigs(client, discovered, probeConcurrency);
1320
1298
  discovered.sort((a, b) => a.fullName.localeCompare(b.fullName));
1299
+ const effectiveMaxRepos = maxRepos ?? 100;
1300
+ if (effectiveMaxRepos > 0 && discovered.length > effectiveMaxRepos) return discovered.slice(0, effectiveMaxRepos);
1321
1301
  return discovered;
1322
1302
  }
1323
1303
  /**
@@ -2225,7 +2205,7 @@ function isCrossMajorFixRequired(lockfilePath, alert) {
2225
2205
  */
2226
2206
  function classifyLockfileFailure(workDir) {
2227
2207
  try {
2228
- execSync("pnpm install --frozen-lockfile", {
2208
+ execFileSync("pnpm", ["install", "--frozen-lockfile"], {
2229
2209
  cwd: workDir,
2230
2210
  stdio: "pipe",
2231
2211
  timeout: 12e4
@@ -2267,8 +2247,8 @@ function getStderrText(err) {
2267
2247
  if (typeof err.stderr === "string") return err.stderr;
2268
2248
  return err.stderr.toString("utf-8");
2269
2249
  }
2270
- function execRepair(command, workDir, timeoutMs = 18e4) {
2271
- execSync(command, {
2250
+ function execRepair(binary, args, workDir, timeoutMs = 18e4) {
2251
+ execFileSync(binary, args, {
2272
2252
  cwd: workDir,
2273
2253
  stdio: "pipe",
2274
2254
  timeout: timeoutMs
@@ -2308,14 +2288,43 @@ function getStrategyChain(category) {
2308
2288
  * (corepack 不可用/下载失败时命令本身失败 → 策略链 REGENERATE/REINSTALL 兜底,
2309
2289
  * 行为不劣于现状)
2310
2290
  * - `pnpmVersion` 缺省:回退裸 `pnpm install --lockfile-only`(保持旧 stub 语义)
2291
+ *
2292
+ * 返回 `{ binary, args }` 而非完整 shell 命令字符串,便于 `execFileSync` 参数化调用
2293
+ * 避免 shell 解释(`execSync` 模板拼接是 Code Scanning 命令注入告警源头)。所有
2294
+ * 参数均受 `isValidPnpmVersion` 白名单约束,无 shell metacharacter。
2311
2295
  */
2312
2296
  function getStrategyCommand(strategy, pnpmVersion) {
2313
2297
  switch (strategy) {
2314
- case "REGENERATE": return "pnpm install --lockfile-only";
2315
- case "FIX_ENTRIES": return "pnpm install --fix-lockfile --lockfile-only";
2316
- case "REINSTALL": return "pnpm install --no-frozen-lockfile";
2317
- case "PIN_TOOLCHAIN": return pnpmVersion ? `corepack pnpm@${pnpmVersion} install --lockfile-only` : "pnpm install --lockfile-only";
2318
- default: return "";
2298
+ case "REGENERATE": return {
2299
+ binary: "pnpm",
2300
+ args: ["install", "--lockfile-only"]
2301
+ };
2302
+ case "FIX_ENTRIES": return {
2303
+ binary: "pnpm",
2304
+ args: [
2305
+ "install",
2306
+ "--fix-lockfile",
2307
+ "--lockfile-only"
2308
+ ]
2309
+ };
2310
+ case "REINSTALL": return {
2311
+ binary: "pnpm",
2312
+ args: ["install", "--no-frozen-lockfile"]
2313
+ };
2314
+ case "PIN_TOOLCHAIN":
2315
+ if (!pnpmVersion) return {
2316
+ binary: "pnpm",
2317
+ args: ["install", "--lockfile-only"]
2318
+ };
2319
+ return {
2320
+ binary: "corepack",
2321
+ args: [
2322
+ `pnpm@${pnpmVersion}`,
2323
+ "install",
2324
+ "--lockfile-only"
2325
+ ]
2326
+ };
2327
+ default: return null;
2319
2328
  }
2320
2329
  }
2321
2330
  /** 从 lockfile 内容提取 `lockfileVersion` 字段值(如 `9.0`);缺失返回 null。 */
@@ -2366,7 +2375,7 @@ function countLockfilePackages(path) {
2366
2375
  }
2367
2376
  /**
2368
2377
  * pnpm 版本白名单格式(安全加固:拒绝不可信 packageManager/config 的任意字符串,
2369
- * 防命令注入——corepack 命令是唯一动态拼接的 execSync 命令)。
2378
+ * 防命令注入——corepack 命令参数 `pnpm@<version>` 受 PNPM_VERSION_RE 白名单约束)。
2370
2379
  * 兼容标准 semver 与 corepack 哈希后缀(pnpm@10.5.2+sha512.xxx)。
2371
2380
  */
2372
2381
  const PNPM_VERSION_RE = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/;
@@ -2400,7 +2409,7 @@ const LOCKFILE_NAME = "pnpm-lock.yaml";
2400
2409
  */
2401
2410
  function verifyFrozenLockfile(workDir) {
2402
2411
  try {
2403
- execSync("pnpm install --frozen-lockfile", {
2412
+ execFileSync("pnpm", ["install", "--frozen-lockfile"], {
2404
2413
  cwd: workDir,
2405
2414
  stdio: "pipe",
2406
2415
  timeout: 12e4
@@ -2486,12 +2495,14 @@ function repairLockfile(params) {
2486
2495
  let lastError = stderr;
2487
2496
  const startTimestamp = Date.now();
2488
2497
  for (const strategy of strategyChain) {
2489
- const command = getStrategyCommand(strategy, pnpmVersion);
2498
+ const cmd = getStrategyCommand(strategy, pnpmVersion);
2499
+ if (!cmd) continue;
2500
+ const command = [cmd.binary, ...cmd.args].join(" ");
2490
2501
  const t0 = Date.now();
2491
2502
  let strategySuccess = false;
2492
2503
  let strategyError;
2493
2504
  try {
2494
- execRepair(command, workDir_);
2505
+ execRepair(cmd.binary, cmd.args, workDir_);
2495
2506
  strategySuccess = true;
2496
2507
  } catch (err) {
2497
2508
  strategyError = getStderrText(err) || err.message;
@@ -2790,6 +2801,7 @@ const DEFAULT_RUNTIME_CONFIG = {
2790
2801
  maxConcurrency: 1,
2791
2802
  maxRetries: 3,
2792
2803
  maxBackoffMs: 3e4,
2804
+ maxRepos: 100,
2793
2805
  ai: {
2794
2806
  enabled: false,
2795
2807
  provider: "openai-compatible",
@@ -2915,6 +2927,7 @@ function readEnvConfig(env = process.env) {
2915
2927
  maxConcurrency: normalizeInteger(readEnv(env, "MAX_CONCURRENCY"), `${ENV_PREFIX}MAX_CONCURRENCY`),
2916
2928
  maxRetries: normalizeNonNegativeInteger(readEnv(env, "MAX_RETRIES"), `${ENV_PREFIX}MAX_RETRIES`),
2917
2929
  maxBackoffMs: normalizeInteger(readEnv(env, "MAX_BACKOFF_MS"), `${ENV_PREFIX}MAX_BACKOFF_MS`),
2930
+ maxRepos: normalizeNonNegativeInteger(readEnv(env, "MAX_REPOS"), `${ENV_PREFIX}MAX_REPOS`),
2918
2931
  upgradeGroups: normalizeUpgradeGroups(readEnv(env, "UPGRADE_GROUPS")),
2919
2932
  toolchainPnpmVersion: readEnv(env, "TOOLCHAIN_PNPM_VERSION")?.trim() || void 0,
2920
2933
  aiEnabled: normalizeBoolean(readEnv(env, "AI"), `${ENV_PREFIX}AI`),
@@ -2969,7 +2982,9 @@ function validateRuntimeConfig(config) {
2969
2982
  if (isAuditSource && config.owner && config.owner.length > 0) throw new AppError("CONFIG_VALIDATION_ERROR", "--owner / DEPENDFIX_OWNER requires the github-dependabot alert source (owner discovery uses the GitHub API).");
2970
2983
  if (config.mode === "cleanup-branches" && config.owner && config.owner.length > 0) throw new AppError("CONFIG_VALIDATION_ERROR", "--owner / DEPENDFIX_OWNER is not supported in cleanup-branches mode (branch cleanup requires explicit target repositories).");
2971
2984
  if (config.mode === "cleanup-branches" && config.maxConcurrency > 1) throw new AppError("CONFIG_VALIDATION_ERROR", "maxConcurrency > 1 is not supported in cleanup-branches mode (branch cleanup runs sequentially).");
2972
- if (config.repositories.length === 0 && !(config.owner && config.owner.length > 0)) if (isAuditSource) {} else throw new AppError("CONFIG_VALIDATION_ERROR", "Missing target repositories. Provide --repo, --repository, --repos-file, --owner or DEPENDFIX_REPOSITORIES / DEPENDFIX_OWNER.");
2985
+ if (config.repositories.length === 0 && !(config.owner && config.owner.length > 0)) {
2986
+ if (isAuditSource) {} else throw new AppError("CONFIG_VALIDATION_ERROR", "Missing target repositories. Provide --repo, --repository, --repos-file, --owner or DEPENDFIX_REPOSITORIES / DEPENDFIX_OWNER.");
2987
+ }
2973
2988
  for (const repo of config.repositories) if (!isValidRepoIdentifier(repo)) throw new AppError("CONFIG_VALIDATION_ERROR", `Invalid repository identifier: "${repo}". Expected format: owner/repo`);
2974
2989
  if (isAuditSource && config.repositories.length > 1) throw new AppError("CONFIG_VALIDATION_ERROR", "pnpm-audit alert source supports at most one repository (it scans the current workspace lockfile).");
2975
2990
  if (isAuditSource && config.codeScanningEnabled) throw new AppError("CONFIG_VALIDATION_ERROR", "code-scanning requires the github-dependabot alert source (Code Scanning alerts are fetched from the GitHub API).");
@@ -3022,6 +3037,7 @@ function resolveRuntimeConfig(options = {}) {
3022
3037
  maxConcurrency: cliOverrides.maxConcurrency ?? envConfig.maxConcurrency ?? DEFAULT_RUNTIME_CONFIG.maxConcurrency,
3023
3038
  maxRetries: cliOverrides.maxRetries ?? envConfig.maxRetries ?? DEFAULT_RUNTIME_CONFIG.maxRetries,
3024
3039
  maxBackoffMs: cliOverrides.maxBackoffMs ?? envConfig.maxBackoffMs ?? DEFAULT_RUNTIME_CONFIG.maxBackoffMs,
3040
+ maxRepos: cliOverrides.maxRepos ?? envConfig.maxRepos ?? DEFAULT_RUNTIME_CONFIG.maxRepos,
3025
3041
  upgradeGroups: cliOverrides.upgradeGroups ?? envConfig.upgradeGroups,
3026
3042
  toolchainPnpmVersion: cliOverrides.toolchainPnpmVersion ?? envConfig.toolchainPnpmVersion,
3027
3043
  ai: resolveAiOptions(cliOverrides, envConfig)
@@ -3810,15 +3826,19 @@ function restoreTrackedFiles(workDir, snapshot) {
3810
3826
  }
3811
3827
  }
3812
3828
  /**
3813
- * 快速验证(逐包):仅运行 `pnpm lint`(脚本存在时)。
3829
+ * 快速验证(逐包):运行验证命令序列。
3814
3830
  *
3815
- * 完整验证(install + lint + build)成本高,逐包执行不现实;
3816
- * lint 是依赖兼容性的快速信号(本次坏 PR 即因 lint 失败)。
3817
- * 无 lint 脚本时视为通过(与 verifyProject 的跳过语义一致)。
3831
+ * 有自定义命令时执行完整序列(含 install + setup + lint + build),
3832
+ * 否则仅运行 `pnpm lint`(脚本存在时)。
3833
+ *
3834
+ * 设计决策(run 33946113272 复盘):旧版仅跑 `pnpm lint`,但 Nuxt 等框架
3835
+ * 项目 lint 依赖 `nuxt prepare` 生成的 `.nuxt/tsconfig.json`——无 prepare
3836
+ * 步骤会导致 ESLint OOM。自定义命令已含 prepare,走完整序列可避免此问题;
3837
+ * `pnpm install` 在 node_modules 已就绪时近乎空操作,性能影响可忽略。
3818
3838
  */
3819
3839
  async function quickVerifyProject(ctx, repo) {
3820
- const { logger, workDir } = ctx;
3821
- const { valid, skipped } = validateVerifyCommands(["pnpm lint"], workDir);
3840
+ const { logger, workDir, customCommands } = ctx;
3841
+ const { valid, skipped } = validateVerifyCommands(customCommands ?? ["pnpm lint"], workDir);
3822
3842
  if (valid.length === 0) {
3823
3843
  if (skipped.length > 0) logger.info(`[quick-verify] ${repo}: no lint script, skipping per-package verification`);
3824
3844
  return true;
@@ -4359,6 +4379,12 @@ function parseLegacyAuditReport(report) {
4359
4379
  const advisories = report.advisories;
4360
4380
  if (!advisories || typeof advisories !== "object") return [];
4361
4381
  const actionMap = /* @__PURE__ */ new Map();
4382
+ const advisoryExtrasMap = /* @__PURE__ */ new Map();
4383
+ for (const [id, rawAdvisory] of Object.entries(advisories)) {
4384
+ if (!rawAdvisory || typeof rawAdvisory !== "object") continue;
4385
+ const extras = extractIdentifiers(rawAdvisory);
4386
+ if (extras.ghsaId || extras.cveIds && extras.cveIds.length > 0) advisoryExtrasMap.set(id, extras);
4387
+ }
4362
4388
  for (const action of toArray(report.actions)) {
4363
4389
  if (!action || typeof action !== "object") continue;
4364
4390
  const record = action;
@@ -4370,13 +4396,15 @@ function parseLegacyAuditReport(report) {
4370
4396
  if (!rawAdvisory || typeof rawAdvisory !== "object") continue;
4371
4397
  const advisory = rawAdvisory;
4372
4398
  const patched = normalizePatchedVersionValue(actionMap.get(id) ?? (typeof advisory.patched_versions === "string" ? advisory.patched_versions : void 0));
4399
+ const extras = advisoryExtrasMap.get(id);
4373
4400
  risks.push({
4374
4401
  advisoryId: resolveAdvisoryId(advisory),
4375
4402
  packageName: typeof advisory.module_name === "string" ? advisory.module_name : "unknown-package",
4376
4403
  severity: typeof advisory.severity === "string" ? advisory.severity : "medium",
4377
4404
  title: typeof advisory.title === "string" ? advisory.title : "Untitled advisory",
4378
4405
  htmlUrl: typeof advisory.url === "string" ? advisory.url : "",
4379
- patchedVersion: patched
4406
+ patchedVersion: patched,
4407
+ ...extras ?? {}
4380
4408
  });
4381
4409
  }
4382
4410
  return risks;
@@ -4401,26 +4429,30 @@ function parseModernAuditReport(report) {
4401
4429
  })();
4402
4430
  const viaItems = toArray(vulnerability.via).filter((item) => item !== null && typeof item === "object");
4403
4431
  if (viaItems.length === 0) {
4432
+ const extras = extractIdentifiers(vulnerability);
4404
4433
  risks.push({
4405
4434
  advisoryId: resolveAdvisoryId(vulnerability),
4406
4435
  packageName,
4407
4436
  severity: typeof vulnerability.severity === "string" ? vulnerability.severity : "medium",
4408
4437
  title: typeof vulnerability.title === "string" ? vulnerability.title : `${packageName} vulnerability`,
4409
4438
  htmlUrl: typeof vulnerability.url === "string" ? vulnerability.url : "",
4410
- patchedVersion: patchedFromFixAvailable
4439
+ patchedVersion: patchedFromFixAvailable,
4440
+ ...extras ?? {}
4411
4441
  });
4412
4442
  continue;
4413
4443
  }
4414
4444
  for (const advisory of viaItems) {
4415
4445
  let severity = typeof vulnerability.severity === "string" ? vulnerability.severity : "medium";
4416
4446
  if (typeof advisory.severity === "string" && advisory.severity) severity = advisory.severity;
4447
+ const extras = extractIdentifiers(advisory);
4417
4448
  risks.push({
4418
4449
  advisoryId: resolveAdvisoryId(advisory),
4419
4450
  packageName: typeof advisory.name === "string" && advisory.name ? advisory.name : packageName,
4420
4451
  severity,
4421
4452
  title: typeof advisory.title === "string" ? advisory.title : `${packageName} vulnerability`,
4422
4453
  htmlUrl: typeof advisory.url === "string" ? advisory.url : "",
4423
- patchedVersion: patchedFromFixAvailable
4454
+ patchedVersion: patchedFromFixAvailable,
4455
+ ...extras ?? {}
4424
4456
  });
4425
4457
  }
4426
4458
  }
@@ -4454,13 +4486,34 @@ function parseAuditReport(report) {
4454
4486
  return [...deduped.values()];
4455
4487
  }
4456
4488
  /**
4457
- * advisoryId 的稳定数字哈希(`NormalizedSecurityAlert.id` 为 number)。
4489
+ * advisoryId 的稳定数字哈希(M20 之前使用,M20 后保留以维持向后兼容 `NormalizedSecurityAlert.id: number`)。
4490
+ *
4458
4491
  * 取 sha256 前 4 字节完整 uint32(非设计稿的 `% 2^31`:uint32 语义更自然,
4459
4492
  * 碰撞概率 ~2^-32/对,12 条告警场景生日界 ~1.6e-8,可忽略)。
4493
+ *
4494
+ * M20 弃用:唯一去重键改为 `upstreamId`,详见 [`normalizeUpstreamId`](../../../core/src/alerts/upstream-id.ts)。
4495
+ * 本函数保留仅为兼容现有 `NormalizedSecurityAlert.id: number` 字段,**不是**平台去重键。
4460
4496
  */
4461
4497
  function hashAdvisoryId(packageName, advisoryId) {
4462
4498
  return createHash("sha256").update(`${packageName}:${advisoryId}`).digest().readUInt32BE(0);
4463
4499
  }
4500
+ /**
4501
+ ** M23.3 C66-A2:从 pnpm audit advisory 提取 GHSA ID + CVE 列表。
4502
+ ** 兼容 legacy `github_advisory_id` + modern `advisory.github_advisory_id` 与 `cves[]`。
4503
+ */
4504
+ function extractIdentifiers(candidate) {
4505
+ const ghsaId = typeof candidate.github_advisory_id === "string" && candidate.github_advisory_id ? candidate.github_advisory_id : void 0;
4506
+ const cves = candidate.cves;
4507
+ let cveIds;
4508
+ if (Array.isArray(cves)) {
4509
+ const valid = cves.filter((c) => typeof c === "string" && Boolean(c));
4510
+ if (valid.length > 0) cveIds = valid;
4511
+ }
4512
+ const result = {};
4513
+ if (ghsaId) result.ghsaId = ghsaId;
4514
+ if (cveIds) result.cveIds = cveIds;
4515
+ return result;
4516
+ }
4464
4517
  /** audit 风险 → 标准化告警(source='pnpm-audit',repository 由调用方注入) */
4465
4518
  function mapAuditRiskToAlert(risk, repository) {
4466
4519
  const fixable = risk.patchedVersion !== null;
@@ -4479,7 +4532,13 @@ function mapAuditRiskToAlert(risk, repository) {
4479
4532
  fixable,
4480
4533
  fixStrategy: fixable ? "upgrade" : null,
4481
4534
  recommendedVersion: risk.patchedVersion ?? "",
4482
- dependencyType: void 0
4535
+ dependencyType: void 0,
4536
+ upstreamId: normalizeUpstreamId("pnpm-audit", {
4537
+ packageName: risk.packageName,
4538
+ advisoryId: risk.advisoryId
4539
+ }),
4540
+ ghsaId: risk.ghsaId,
4541
+ cveIds: risk.cveIds
4483
4542
  };
4484
4543
  }
4485
4544
 
@@ -5407,11 +5466,15 @@ function computeSummary(ctx) {
5407
5466
  for (const action of allActions) {
5408
5467
  if (action.noOp) continue;
5409
5468
  if (action.strategy === "ai-patch" || action.strategy === "ai-suggestion") continue;
5410
- if (action.type === "dependency-upgrade" || action.type === "code-scanning-fix") if (action.success) fixed++;
5411
- else failed++;
5469
+ if (action.type === "dependency-upgrade" || action.type === "code-scanning-fix") {
5470
+ if (action.success) fixed++;
5471
+ else failed++;
5472
+ }
5412
5473
  if (action.type === "lockfile-repair" && action.success) lockfileRepairs++;
5413
- if (action.type === "verification") if (action.success) verificationsPassed++;
5414
- else verificationsFailed++;
5474
+ if (action.type === "verification") {
5475
+ if (action.success) verificationsPassed++;
5476
+ else verificationsFailed++;
5477
+ }
5415
5478
  }
5416
5479
  const fixable = allAlerts.filter((a) => a.fixable).length;
5417
5480
  summary.repositoriesScanned = repoResults.length;
@@ -5514,7 +5577,7 @@ function resolveAlertRepositories(ctx) {
5514
5577
  return ["local"];
5515
5578
  }
5516
5579
  /** 自动修复提交的兜底标题(无成功升级 / 包名超长无法生成动态标题时) */
5517
- const FIX_COMMIT_MESSAGE = "fix(deps): automated dependfix security repair";
5580
+ const FIX_COMMIT_MESSAGE = "chore(deps): automated dependfix security repair";
5518
5581
  /** commitlint header-max-length 上限(commitlint-config-cmyr 覆盖为 140) */
5519
5582
  const COMMIT_HEADER_MAX_LENGTH = 140;
5520
5583
  /**
@@ -5540,15 +5603,15 @@ function buildCommitTitle(upgrades) {
5540
5603
  const a = upgrades[0];
5541
5604
  const from = a.fromVersion && a.fromVersion !== "unknown" ? ` from ${a.fromVersion}` : "";
5542
5605
  const to = a.toVersion ? ` to ${a.toVersion}` : "";
5543
- const title = `fix(deps): bump ${a.target}${from}${to}`;
5606
+ const title = `chore(deps): bump ${a.target}${from}${to}`;
5544
5607
  return title.length <= COMMIT_HEADER_MAX_LENGTH ? title : FIX_COMMIT_MESSAGE;
5545
5608
  }
5546
5609
  const names = upgrades.map((a) => a.target);
5547
- const full = `fix(deps): bump ${names.join(", ")}`;
5610
+ const full = `chore(deps): bump ${names.join(", ")}`;
5548
5611
  if (full.length <= COMMIT_HEADER_MAX_LENGTH) return full;
5549
5612
  let count = names.length - 1;
5550
5613
  while (count > 0) {
5551
- const candidate = `fix(deps): bump ${names.slice(0, count).join(", ")} and ${names.length - count} more`;
5614
+ const candidate = `chore(deps): bump ${names.slice(0, count).join(", ")} and ${names.length - count} more`;
5552
5615
  if (candidate.length <= COMMIT_HEADER_MAX_LENGTH) return candidate;
5553
5616
  count--;
5554
5617
  }
@@ -5711,17 +5774,47 @@ async function upgradeAlert(ctx, alert) {
5711
5774
  }
5712
5775
  /**
5713
5776
  * 执行 Code Scanning 模板修复(2.0 节;从 app/index.ts 提取以控制文件行数)。
5714
- * 仅处理 A 类告警;逐告警:快照 → 应用模板 → quickVerify(lint)→ 失败回滚(不静默)。
5777
+ * 仅处理 A 类告警;按批处理(默认每 10 个告警跑一次 lint):快照 → 应用模板 → 累积到批 → 批满跑 lint → 失败回滚整批(不静默)。
5715
5778
  * - 快照失败 / 无模板 / 模板不适用 / 缺文件 → noOp 动作(回退建议模式,展示;
5716
5779
  * error 原因可审计,不计 failed 避免陈旧告警永久 exit 1/2)
5717
- * - 写盘失败 / lint 验证失败 → failed(回滚并记录;回滚失败时注明 file may be modified)
5780
+ * - 写盘失败 → 立即失败(不计 lint)+ 整批回滚
5781
+ * - lint 验证失败 → 整批回滚(粒度 = batchSize;vs 旧版每个告警回滚,回滚粒度变粗但 lint 提速 ~10x)
5782
+ *
5783
+ * 性能权衡(M28.2 实证):
5784
+ * - N 个 cs 告警(旧版)→ N 次 spawn `pnpm lint` 子进程:N=100 平均 2593 ms(基准 commit 395ee29)
5785
+ * - N 个 cs 告警(批处理)→ ceil(N/batchSize) 次 lint:N=100 平均 ~300 ms(提速 ~10x)
5786
+ * - 合并验证可提速 ~96x 但回滚粒度过粗(整次 fix 全失败);批处理折中
5787
+ *
5718
5788
  * @returns 本批次实际修复数(fixed)与失败数(failed),调用方累加到仓库统计
5719
5789
  */
5720
- async function runCodeScanningFixes(ctx, repo, alerts) {
5790
+ async function runCodeScanningFixes(ctx, repo, alerts, options = {}) {
5721
5791
  const { config, workDir, logger } = ctx;
5792
+ const batchSize = options.batchSize ?? DEFAULT_CS_BATCH_SIZE;
5722
5793
  const codeScanningAutoFixable = alerts.filter((a) => a.source === "code-scanning" && a.alertClass === "auto-fixable");
5723
5794
  let fixed = 0;
5724
5795
  let failed = 0;
5796
+ /** 待 lint 批:每条保留 sourceSnapshot + action 用于 lint 失败时整批回滚 */
5797
+ const pendingBatch = [];
5798
+ /**
5799
+ * 处理当前批:跑 lint + 按结果分发 fixed/failed + 回滚(lint 失败时)。
5800
+ */
5801
+ const flushBatch = async () => {
5802
+ if (pendingBatch.length === 0) return;
5803
+ if (await quickVerifyProject(ctx, repo)) for (const item of pendingBatch) {
5804
+ ctx.allActions.push(item.action);
5805
+ fixed++;
5806
+ logger.info(`[code-scanning] ${item.csAlert.ruleId}: ${item.action.diff}`);
5807
+ }
5808
+ else for (const item of pendingBatch) {
5809
+ const restored = restoreSourceFile(workDir, item.sourceSnapshot);
5810
+ item.action.success = false;
5811
+ item.action.error = restored ? "lint failed after code-scanning fix; changes rolled back" : "lint failed after code-scanning fix; rollback failed, file may be modified";
5812
+ ctx.allActions.push(item.action);
5813
+ failed++;
5814
+ logger.warn(`[code-scanning] ${item.csAlert.ruleId} fix rolled back: lint failed`);
5815
+ }
5816
+ pendingBatch.length = 0;
5817
+ };
5725
5818
  for (const csAlert of codeScanningAutoFixable) {
5726
5819
  const sourceSnapshot = snapshotSourceFile(workDir, csAlert.manifestPath);
5727
5820
  if (!sourceSnapshot) {
@@ -5762,24 +5855,30 @@ async function runCodeScanningFixes(ctx, repo, alerts) {
5762
5855
  ctx.allActions.push(action);
5763
5856
  continue;
5764
5857
  }
5765
- if (!await quickVerifyProject(ctx, repo)) {
5766
- const restored = restoreSourceFile(workDir, sourceSnapshot);
5767
- action.success = false;
5768
- action.error = restored ? "lint failed after code-scanning fix; changes rolled back" : "lint failed after code-scanning fix; rollback failed, file may be modified";
5769
- ctx.allActions.push(action);
5770
- failed++;
5771
- logger.warn(`[code-scanning] ${csAlert.ruleId} fix rolled back: lint failed`);
5772
- continue;
5773
- }
5774
- ctx.allActions.push(action);
5775
- fixed++;
5776
- logger.info(`[code-scanning] ${csAlert.ruleId}: ${action.diff}`);
5858
+ pendingBatch.push({
5859
+ csAlert,
5860
+ sourceSnapshot,
5861
+ action
5862
+ });
5863
+ if (pendingBatch.length >= batchSize) await flushBatch();
5777
5864
  }
5865
+ await flushBatch();
5778
5866
  return {
5779
5867
  fixed,
5780
5868
  failed
5781
5869
  };
5782
5870
  }
5871
+ /**
5872
+ * Code Scanning 修复批处理默认大小(M28.2 决策)。
5873
+ *
5874
+ * 决策依据(commit 395ee29 baseline):
5875
+ * - 旧版(每个告警 1 次 lint):N=10 平均 263 ms / N=50 平均 1294 ms / N=100 平均 2593 ms
5876
+ * - 批处理 batchSize=10:N=10 触发 1 次 lint(vs 旧版 10 次 → 提速 ~10x)
5877
+ * - 回滚粒度 = batchSize(vs 旧版每个告警独立回滚)
5878
+ *
5879
+ * 经验值:典型 cs 告警场景 5-50 个 / repo,batchSize=10 平衡 lint 提速 + 回滚粒度。
5880
+ */
5881
+ const DEFAULT_CS_BATCH_SIZE = 10;
5783
5882
  /** 尝试修复 pnpm-lock.yaml(dry-run 仅记录)。 */
5784
5883
  function tryLockfileRepair(ctx, repo) {
5785
5884
  const { config, logger, workDir } = ctx;
@@ -5958,7 +6057,7 @@ function buildPrTitle(summary, actions) {
5958
6057
  const parts = [];
5959
6058
  if (upgrades > 0) parts.push(`${upgrades} upgrade${upgrades > 1 ? "s" : ""}`);
5960
6059
  if (codeFixes > 0) parts.push(`${codeFixes} code fix${codeFixes > 1 ? "es" : ""}`);
5961
- return parts.length > 0 ? `fix(deps): automated security fix — ${parts.join(", ")}` : "fix(deps): automated security fix";
6060
+ return parts.length > 0 ? `chore(deps): automated security fix — ${parts.join(", ")}` : "chore(deps): automated security fix";
5962
6061
  }
5963
6062
 
5964
6063
  //#endregion
@@ -6004,19 +6103,23 @@ async function fetchRepoAlerts(deps, repo) {
6004
6103
  failedSources.push("dependabot");
6005
6104
  recordAlertSourceError(deps, repo, "dependabot", dependabotResult.reason);
6006
6105
  }
6007
- if (config.codeScanningEnabled) if (codeScanningResult.status === "fulfilled") {
6008
- alerts.push(...codeScanningResult.value);
6009
- deps.logger.info(`Fetched ${codeScanningResult.value.length} code scanning alerts for ${repo}`);
6010
- } else {
6011
- failedSources.push("code-scanning");
6012
- recordAlertSourceError(deps, repo, "code-scanning", codeScanningResult.reason);
6106
+ if (config.codeScanningEnabled) {
6107
+ if (codeScanningResult.status === "fulfilled") {
6108
+ alerts.push(...codeScanningResult.value);
6109
+ deps.logger.info(`Fetched ${codeScanningResult.value.length} code scanning alerts for ${repo}`);
6110
+ } else {
6111
+ failedSources.push("code-scanning");
6112
+ recordAlertSourceError(deps, repo, "code-scanning", codeScanningResult.reason);
6113
+ }
6013
6114
  }
6014
- if (config.codeQualityEnabled) if (codeQualityResult.status === "fulfilled") {
6015
- alerts.push(...codeQualityResult.value);
6016
- deps.logger.info(`Fetched ${codeQualityResult.value.length} code quality findings for ${repo}`);
6017
- } else {
6018
- failedSources.push("code-quality");
6019
- recordAlertSourceError(deps, repo, "code-quality", codeQualityResult.reason);
6115
+ if (config.codeQualityEnabled) {
6116
+ if (codeQualityResult.status === "fulfilled") {
6117
+ alerts.push(...codeQualityResult.value);
6118
+ deps.logger.info(`Fetched ${codeQualityResult.value.length} code quality findings for ${repo}`);
6119
+ } else {
6120
+ failedSources.push("code-quality");
6121
+ recordAlertSourceError(deps, repo, "code-quality", codeQualityResult.reason);
6122
+ }
6020
6123
  }
6021
6124
  let totalSources = 1;
6022
6125
  if (config.codeScanningEnabled) totalSources++;
@@ -6038,13 +6141,10 @@ async function fetchRepoAlerts(deps, repo) {
6038
6141
  * 背景详见 docs/plan/todo.md「已知缺口 G2」。
6039
6142
  */
6040
6143
  function createAlertsClientFromConfig(config) {
6041
- return createGitHubClient({
6042
- token: config.alertsToken || config.githubToken,
6043
- retry: {
6044
- maxRetries: config.maxRetries,
6045
- maxBackoffMs: config.maxBackoffMs
6046
- }
6047
- });
6144
+ return createGitHubClient({ auth: fromPat(config.alertsToken || config.githubToken, { retry: {
6145
+ maxRetries: config.maxRetries,
6146
+ maxBackoffMs: config.maxBackoffMs
6147
+ } }) });
6048
6148
  }
6049
6149
  /** 记录单个告警源的拉取失败(不中断另一源的处理)。 */
6050
6150
  function recordAlertSourceError(deps, repo, source, error) {
@@ -6055,6 +6155,7 @@ function recordAlertSourceError(deps, repo, source, error) {
6055
6155
  repository: repo,
6056
6156
  stage: "fetch",
6057
6157
  category: "FETCH_FAILED",
6158
+ source,
6058
6159
  message: hint ? `${message}(${hint})` : message
6059
6160
  });
6060
6161
  }
@@ -7154,6 +7255,35 @@ async function finalizeRepoFix(ctx, repo, progress) {
7154
7255
 
7155
7256
  //#endregion
7156
7257
  //#region src/app/index.ts
7258
+ /**
7259
+ * 输出"部分源拉取失败"汇总(todo.md §M19.5 C8 per-source 错误隔离)。
7260
+ *
7261
+ * 触发条件(必须全部满足,避免与"全部源失败"语义重叠):
7262
+ * 1. allErrors 至少包含一个 `stage='fetch' + category='FETCH_FAILED'` 错误(per-source 失败)
7263
+ * 2. **至少一个仓库成功拉取了部分告警**(isAnyRepoSuccessful = true)——
7264
+ * 否则就是"全部源失败"语义,已由 processRepoForReport catch + logger.error 单独处理,
7265
+ * 本函数聚焦"warn + 保留成功源"场景的汇总输出,避免重复提示。
7266
+ *
7267
+ * 汇总按 source 分组('dependabot' / 'code-scanning' / 'code-quality' / 'pnpm-audit'),
7268
+ * 列出每个源失败的仓库数 + 示例错误消息,便于用户快速定位是 token 权限还是网络问题。
7269
+ */
7270
+ function logPartialSourceFailureSummary(allErrors, logger, isAnyRepoSuccessful) {
7271
+ const fetchErrors = allErrors.filter((e) => e.stage === "fetch" && e.category === "FETCH_FAILED");
7272
+ if (fetchErrors.length === 0) return;
7273
+ if (!isAnyRepoSuccessful) return;
7274
+ const bySource = /* @__PURE__ */ new Map();
7275
+ for (const err of fetchErrors) {
7276
+ const source = err.source ?? "unknown";
7277
+ const existing = bySource.get(source);
7278
+ if (existing) existing.repos.add(err.repository);
7279
+ else bySource.set(source, {
7280
+ repos: /* @__PURE__ */ new Set([err.repository]),
7281
+ sampleMessage: err.message
7282
+ });
7283
+ }
7284
+ const summary = [...bySource.entries()].map(([source, { repos, sampleMessage }]) => `${source}(${repos.size} 个仓库: ${[...repos].slice(0, 3).join(", ")}${repos.size > 3 ? "..." : ""}): ${sampleMessage}`).join("\n - ");
7285
+ logger.warn(`[alerts] 部分源拉取失败(M19.5 per-source 错误隔离)— 成功源已保留继续处理,详细如下:\n - ${summary}`);
7286
+ }
7157
7287
  var DependfixApp = class {
7158
7288
  config;
7159
7289
  workDir;
@@ -7182,7 +7312,7 @@ var DependfixApp = class {
7182
7312
  this.customCommands = options.commands;
7183
7313
  this.executionEnvironment = options.executionEnvironment ?? "local";
7184
7314
  this.runId = `dependfix-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
7185
- this.logger = createLogger({
7315
+ this.logger = options.logger ?? createLogger({
7186
7316
  name: "dependfix",
7187
7317
  minLevel: this.verbose ? "debug" : "info"
7188
7318
  });
@@ -7257,6 +7387,8 @@ var DependfixApp = class {
7257
7387
  const costText = u.estimatedCostUsd !== void 0 ? `, 估算成本 $${u.estimatedCostUsd.toFixed(4)}` : "";
7258
7388
  this.logger.info(`[ai] run 总计: ${u.calls} 次调用, ${u.inputTokens} in / ${u.outputTokens} out tokens${costText}`);
7259
7389
  }
7390
+ const isAnyRepoSuccessful = this.repoResults.some((r) => r.alertsCount > 0 || r.fixed > 0 || r.verificationPassed === true);
7391
+ logPartialSourceFailureSummary(this.allErrors, this.logger, isAnyRepoSuccessful);
7260
7392
  return {
7261
7393
  result: runResult,
7262
7394
  exitCode
@@ -7454,6 +7586,7 @@ var DependfixApp = class {
7454
7586
  success: true,
7455
7587
  durationMs: 0
7456
7588
  });
7589
+ await this.handleDuplicatePRs(client, owner, repo, pr.number, plan.supersedePRs);
7457
7590
  await closeSupersededPRs(this.ctx, client, owner, repo, plan.supersedePRs);
7458
7591
  } catch (error) {
7459
7592
  const message = toErrorMessage(error);
@@ -7480,6 +7613,41 @@ var DependfixApp = class {
7480
7613
  for (const repo of this.config.repositories) await runBranchCleanupForRepo(this.ctx, client, repo);
7481
7614
  }
7482
7615
  /**
7616
+ * 重复 PR 处理:在新 PR 上添加评论指向被取代的旧 PR + 添加 `duplicate` label。
7617
+ *
7618
+ * 失败仅记录 warning(家务活 best-effort),不阻断主流程:
7619
+ * - 新 PR 已创建成功,用户已可访问
7620
+ * - 评论失败不影响 PR 本身的可用性
7621
+ * - 但需要 `issues: write` token scope(比 `pull-requests: write` 宽);
7622
+ * 若 token 权限不足,error 会被记录但不阻塞
7623
+ */
7624
+ async handleDuplicatePRs(client, owner, repo, newPRNumber, supersededPRs) {
7625
+ if (supersededPRs.length === 0) return;
7626
+ const commentBody = [
7627
+ "## ⚠️ Duplicate PR Notice",
7628
+ "",
7629
+ "This PR supersedes the following dependfix PR(s) with different content:",
7630
+ "",
7631
+ supersededPRs.map((pr) => `- [#${pr.number}](${pr.htmlUrl})`).join("\n"),
7632
+ "",
7633
+ "The superseded PR(s) have been closed. Please review this PR as the new canonical fix."
7634
+ ].join("\n");
7635
+ try {
7636
+ await commentOnPullRequest(client, owner, repo, newPRNumber, commentBody);
7637
+ this.logger.info(`Added duplicate notice comment to PR #${newPRNumber}`);
7638
+ } catch (error) {
7639
+ const message = toErrorMessage(error);
7640
+ this.logger.warn(`Failed to add duplicate notice comment to PR #${newPRNumber}: ${message}(token may lack \`issues: write\` scope)`);
7641
+ }
7642
+ try {
7643
+ await addLabelToPullRequest(client, owner, repo, newPRNumber, ["duplicate"]);
7644
+ this.logger.info(`Added 'duplicate' label to PR #${newPRNumber}`);
7645
+ } catch (error) {
7646
+ const message = toErrorMessage(error);
7647
+ this.logger.warn(`Failed to add 'duplicate' label to PR #${newPRNumber}: ${message}(token may lack \`issues: write\` scope)`);
7648
+ }
7649
+ }
7650
+ /**
7483
7651
  * 多仓库并发执行管线。
7484
7652
  *
7485
7653
  * - 并发窗口 = config.maxConcurrency(默认 1 保守串行,行为与现状一致)
@@ -7539,7 +7707,8 @@ var DependfixApp = class {
7539
7707
  client,
7540
7708
  owners: owner,
7541
7709
  topics: this.config.repoTopics,
7542
- policy
7710
+ policy,
7711
+ maxRepos: this.config.maxRepos
7543
7712
  });
7544
7713
  const explicitFiltered = filterExplicitRepositories(policy, this.config.repositories);
7545
7714
  const merged = mergeRepositories(explicitFiltered, discovered.map((r) => r.fullName));
@@ -7563,16 +7732,13 @@ var DependfixApp = class {
7563
7732
  return this.createClient();
7564
7733
  }
7565
7734
  createClient(token = this.config.githubToken) {
7566
- return createGitHubClient({
7567
- token,
7568
- retry: {
7569
- maxRetries: this.config.maxRetries,
7570
- maxBackoffMs: this.config.maxBackoffMs
7571
- }
7572
- });
7735
+ return createGitHubClient({ auth: fromPat(token, { retry: {
7736
+ maxRetries: this.config.maxRetries,
7737
+ maxBackoffMs: this.config.maxBackoffMs
7738
+ } }) });
7573
7739
  }
7574
7740
  };
7575
7741
 
7576
7742
  //#endregion
7577
- export { ALERT_SOURCES, AUTO_FIXABLE_RULES, AiUsageTracker, DEFAULT_CONCURRENCY, DEFAULT_RULES_CONFIG, DEFAULT_RUNTIME_CONFIG, DependfixApp, ENV_PREFIX, MAX_CONCURRENCY_LIMIT, MAX_GLOB_PATTERN_LENGTH, RULE_CONFIG_ENV, RUNTIME_MODES, SEVERITY_THRESHOLDS, SUGGESTED_RULES, analyzeTokenScope, applyCodeScanningFix, applyVersionedOverrides, assessBreakingChange, buildPrTitle, buildUpgradeGroups, checkTokenPermissions, classifyLockfileFailure, classifyRule, closePullRequest, compareSemver, computeFixAndPrPlan, computeFixFingerprint, computeLockfileDiff, computeRetryDelayMs, createFixBranch, createGitHubClient, createPullRequest, dedupeFixableAlerts, deleteRemoteBranch, detectPnpmMajor, discoverRepositories, ensurePnpmOverrides, estimateCostUsd, extractFingerprintFromBranch, extractLockfileVersion, extractMainPackage, extractPrefix, fetchCodeQualityFindings, fetchCodeScanningAlerts, fetchDefaultBranch, fetchDependabotAlerts, fetchPnpmAuditAlerts, filterExplicitRepositories, findDependencyVersion, findDependfixOpenPR, formatHistory, formatVerificationError, generatePRBody, getActiveRulesConfig, getBranchPrStatus, getCodeScanningFixTemplate, hashAdvisoryId, inferRepoFromGitRemote, isConfirmAnswer, isCrossMajorFixRequired, isMainPackagePresent, isNonSemverDeclaration, isRootDirectDependency, isValidConcurrency, isValidPnpmVersion, listDependfixBranches, loadRulesConfigFromEnv, loadRulesConfigFromFile, mapCodeQualitySeverity, mapGitHubError, maskSecrets, matchesPattern, matchesRepoExclude, matchesRepoGlob, matchesRepoInclude, matchesTopicsExclude, mergeRepositories, overrideTransitiveDependency, parseAuditReport, parseDependabotGroups, parseMajorVersion, parseRulesConfig, partitionSubmanifestAlerts, pushBranch, queryRepoHistory, quickVerifyProject, readArchiveIndex, readEnv, readEnvConfig, readExistingOverrides, readLockfileVersion, readLockfileVersions, readReposFile, repairLockfile, repoGlobToRegExp, resetActiveRulesConfig, resolvePnpmVersion, resolveRepoList, resolveRuntimeConfig, resolveWithinWorkDir, restoreSourceFile, restoreTrackedFiles, runVerification, runWithConcurrency, sanitizeOutput, setActiveRulesConfig, snapshotSourceFile, snapshotTrackedFiles, stageAndCommit, suggestionFor, summarizeVerificationOutput, upgradeDependency, writeArchive };
7743
+ export { ALERT_SOURCES, AUTO_FIXABLE_RULES, AiUsageTracker, DEFAULT_CONCURRENCY, DEFAULT_RULES_CONFIG, DEFAULT_RUNTIME_CONFIG, DependfixApp, ENV_PREFIX, MAX_CONCURRENCY_LIMIT, MAX_GLOB_PATTERN_LENGTH, RULE_CONFIG_ENV, RUNTIME_MODES, SEVERITY_THRESHOLDS, SUGGESTED_RULES, addLabelToPullRequest, analyzeTokenScope, applyCodeScanningFix, applyVersionedOverrides, assessBreakingChange, buildPrTitle, buildUpgradeGroups, checkTokenPermissions, classifyLockfileFailure, classifyRule, closePullRequest, closeSupersededPRs, commentOnPullRequest, compareSemver, computeFixAndPrPlan, computeFixFingerprint, computeLockfileDiff, computeRetryDelayMs, createFixBranch, createGitHubClient, createPullRequest, dedupeFixableAlerts, deleteRemoteBranch, detectPnpmMajor, discoverRepositories, ensurePnpmOverrides, estimateCostUsd, extractFingerprintFromBranch, extractLockfileVersion, extractMainPackage, extractPrefix, fetchCodeQualityFindings, fetchCodeScanningAlerts, fetchDefaultBranch, fetchDependabotAlerts, fetchPnpmAuditAlerts, filterExplicitRepositories, findDependencyVersion, findDependfixOpenPR, formatHistory, formatVerificationError, generatePRBody, getActiveRulesConfig, getBranchPrStatus, getCodeScanningFixTemplate, hashAdvisoryId, inferRepoFromGitRemote, isConfirmAnswer, isCrossMajorFixRequired, isMainPackagePresent, isNonSemverDeclaration, isRootDirectDependency, isValidConcurrency, isValidPnpmVersion, listDependfixBranches, loadRulesConfigFromEnv, loadRulesConfigFromFile, logPartialSourceFailureSummary, mapCodeQualitySeverity, mapGitHubError, maskSecrets, matchesPattern, matchesRepoExclude, matchesRepoGlob, matchesRepoInclude, matchesTopicsExclude, mergeRepositories, overrideTransitiveDependency, parseAuditReport, parseDependabotGroups, parseMajorVersion, parseRulesConfig, partitionSubmanifestAlerts, pushBranch, queryRepoHistory, quickVerifyProject, readArchiveIndex, readEnv, readEnvConfig, readExistingOverrides, readLockfileVersion, readLockfileVersions, readReposFile, repairLockfile, repoGlobToRegExp, resetActiveRulesConfig, resolvePnpmVersion, resolveRepoList, resolveRuntimeConfig, resolveWithinWorkDir, restoreSourceFile, restoreTrackedFiles, runVerification, runWithConcurrency, sanitizeOutput, setActiveRulesConfig, snapshotSourceFile, snapshotTrackedFiles, stageAndCommit, suggestionFor, summarizeVerificationOutput, upgradeDependency, writeArchive };
7578
7744
  //# sourceMappingURL=index.mjs.map