dsh-m 0.4.3 → 0.5.1

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.
@@ -8,10 +8,11 @@
8
8
  */
9
9
  import { BOOT_ID, publicInstallStatus } from './dsh-cli.js';
10
10
  import { resolveDshVersion } from './dsh-version.js';
11
- import { listInstalledWithMeta, listMarket, installFromRegistry, uninstallPlugin, upgradePlugin, } from './market.js';
12
- import { runProfileTransaction, TransactionError, makeNpmWarmPackument } from './profile-transaction.js';
11
+ import { listInstalledWithMeta, listMarket, installFromRegistry, selfUpgrade, uninstallPlugin, upgradePlugin, InstallGuardError, } from './market.js';
12
+ import { runProfileTransaction, TransactionError } from './profile-transaction.js';
13
13
  import { readInstalledPluginReadme } from './installed.js';
14
14
  import { isNewerVersion, npmLatest } from './versions.js';
15
+ import { getCommunitySummary } from './community.js';
15
16
  import { RegistryConfigError } from './registry-controller.js';
16
17
  import { checkRegistryEntries } from './registry-check.js';
17
18
  import { CATEGORIES } from './registry.js';
@@ -32,6 +33,8 @@ class ApiProtocolError extends Error {
32
33
  }
33
34
  }
34
35
  const BODY_MAX_BYTES = 1 << 20;
36
+ /** 社区开放分类安全 slug(计划 Task 6;与适配层 COMMUNITY_CATEGORY_RE 同语义) */
37
+ const COMMUNITY_SLUG_RE = /^[a-z0-9-]{1,32}$/;
35
38
  function readBody(req, maxBytes = BODY_MAX_BYTES) {
36
39
  return new Promise((resolve, reject) => {
37
40
  const chunks = [];
@@ -100,7 +103,7 @@ function strArg(body, key) {
100
103
  function boolArg(v) {
101
104
  return v === true || v === 'true' || v === 1 || v === '1';
102
105
  }
103
- function snapshotPayload(snap) {
106
+ function snapshotPayload(snap, config) {
104
107
  return {
105
108
  registryUrl: snap.configuredAddress,
106
109
  configuredAddress: snap.configuredAddress,
@@ -110,6 +113,9 @@ function snapshotPayload(snap) {
110
113
  configErrors: snap.configErrors,
111
114
  warnings: snap.warnings,
112
115
  registryState: snap.loaded,
116
+ // 社区两键(M1 Task 6):设置页回显数据源
117
+ communityCatalog: config?.communityCatalog,
118
+ communityCatalogPin: config?.communityCatalogPin,
113
119
  };
114
120
  }
115
121
  function sendJson(res, status, value) {
@@ -137,6 +143,22 @@ function errorStatus(err) {
137
143
  const status = err.code === 'protected' ? 403 : err.code === 'not-installed' ? 404 : 409;
138
144
  return { status, payload: { ok: false, error: err.message, code: err.code } };
139
145
  }
146
+ if (err instanceof InstallGuardError) {
147
+ // 装后守卫拦截(M2 Task 3):结构化投影含 needsRestart/restartSafe/kind——GUI 一键重启只读 restartSafe
148
+ return {
149
+ status: 409,
150
+ payload: {
151
+ ok: false,
152
+ error: err.message,
153
+ kind: err.kind,
154
+ violations: err.violations,
155
+ compensation: err.compensation,
156
+ needsRestart: err.needsRestart,
157
+ restartSafe: err.restartSafe,
158
+ ...(err.repairBasis ? { repairBasis: err.repairBasis } : {}),
159
+ },
160
+ };
161
+ }
140
162
  if (err instanceof IncompatibleError) {
141
163
  // 结构化 issue:GUI 据此弹「仍要安装」确认(forceIncompatible 重试)
142
164
  return { status: 409, payload: { ok: false, error: err.message, issue: err.issue } };
@@ -169,8 +191,10 @@ export function createApiDispatcher(ctx) {
169
191
  installFromRegistry: ctx.deps?.installFromRegistry ?? installFromRegistry,
170
192
  uninstallPlugin: ctx.deps?.uninstallPlugin ?? uninstallPlugin,
171
193
  upgradePlugin: ctx.deps?.upgradePlugin ?? upgradePlugin,
194
+ selfUpgrade: ctx.deps?.selfUpgrade ?? selfUpgrade,
172
195
  checkRegistryEntries: ctx.deps?.checkRegistryEntries ?? checkRegistryEntries,
173
196
  npmLatest: ctx.deps?.npmLatest ?? npmLatest,
197
+ getCommunitySummary: ctx.deps?.getCommunitySummary ?? getCommunitySummary,
174
198
  runTransaction: ctx.deps?.runTransaction ?? runProfileTransaction,
175
199
  scheduleRestart: ctx.deps?.scheduleRestart ?? scheduleRestart,
176
200
  resolveDshVersion: ctx.deps?.resolveDshVersion ?? resolveDshVersion,
@@ -211,17 +235,11 @@ export function createApiDispatcher(ctx) {
211
235
  break;
212
236
  }
213
237
  case 'self-upgrade': {
214
- const latest = await d.npmLatest(ctx.pkg.name, cfg().timeoutMs ?? 20_000);
215
- // 缺 integrity 一律 fail closed,不进事务
216
- if (!latest.integrity) {
217
- throw new Error(`npm metadata 缺少 dist integrity:${ctx.pkg.name}@${latest.version},拒绝升级`);
218
- }
219
- const result = await d.runTransaction({ kind: 'install-npm', pkg: ctx.pkg.name, version: latest.version, integrity: latest.integrity, signal }, { warmPackument: makeNpmWarmPackument(cfg().timeoutMs ?? 20_000) });
220
- if (!result.ok)
221
- throw new TransactionError(result);
238
+ // M2 Task 3:self-upgrade 收编 market.selfUpgrade(统一 mutation session + 守卫,直调事务旁路封死)
239
+ const result = await d.selfUpgrade(ctx.pkg.name, ctx.pkg.version, cfg(), { signal });
222
240
  payload = {
223
- pkg: ctx.pkg.name,
224
- version: latest.version,
241
+ pkg: result.pkg,
242
+ version: result.version,
225
243
  buildApprovals: result.buildApprovals ?? [],
226
244
  fallbackAllBuilds: result.fallbackAllBuilds === true,
227
245
  needsRestart: true,
@@ -231,7 +249,14 @@ export function createApiDispatcher(ctx) {
231
249
  case 'registry': {
232
250
  await ctx.controller.ensureReady();
233
251
  const snap = await ctx.controller.snapshot({ force: boolArg(body.force), signal });
234
- payload = { plugins: snap.loaded.registry.plugins, registryState: snap.loaded };
252
+ // 社区 summary:primaryEntries 直接取本次 snapshot(displaced 与本次响应同代,不重拉主清单);
253
+ // 3s 是本 waiter 的等待上限——到点返回 unavailable summary,主清单响应按契约照常返回
254
+ const community = await d.getCommunitySummary(snap.loaded.registry.plugins, cfg(), {
255
+ force: boolArg(body.force),
256
+ signal,
257
+ deadlineAt: Date.now() + 3_000,
258
+ });
259
+ payload = { plugins: snap.loaded.registry.plugins, registryState: snap.loaded, community };
235
260
  break;
236
261
  }
237
262
  case 'market': {
@@ -241,14 +266,23 @@ export function createApiDispatcher(ctx) {
241
266
  const limit = Number.isFinite(limitRaw) && limitRaw > 0 ? Math.min(50, Math.max(1, Math.floor(limitRaw))) : 50;
242
267
  const offsetRaw = Number(body.offset);
243
268
  const offset = Number.isFinite(offsetRaw) && offsetRaw > 0 ? Math.floor(offsetRaw) : 0;
244
- const category = typeof body.category === 'string' && CATEGORIES.includes(body.category)
245
- ? body.category
246
- : null;
269
+ // category:精选 5 + 社区开放 slug(非法 slug → 400,不静默吞)
270
+ const categoryRaw = typeof body.category === 'string' ? body.category.trim() : '';
271
+ let category = null;
272
+ if (categoryRaw !== '') {
273
+ if (CATEGORIES.includes(categoryRaw) || COMMUNITY_SLUG_RE.test(categoryRaw)) {
274
+ category = categoryRaw;
275
+ }
276
+ else {
277
+ throw new ApiProtocolError(400, `非法分类: ${categoryRaw}(需精选分类或 [a-z0-9-]{1,32} slug)`);
278
+ }
279
+ }
247
280
  const result = await d.listMarket(cfg(), {
248
281
  query: strArg(body, 'query'),
249
282
  category,
250
283
  offset,
251
284
  limit,
285
+ primaryOnly: boolArg(body.primaryOnly),
252
286
  force: boolArg(body.force),
253
287
  withLatest: true,
254
288
  namespace: 'host',
@@ -325,14 +359,14 @@ export function createApiDispatcher(ctx) {
325
359
  }
326
360
  case 'registry-config': {
327
361
  const snap = await ctx.controller.snapshot();
328
- payload = { ...snapshotPayload(snap) };
362
+ payload = { applied: false, ...snapshotPayload(snap, cfg()) };
329
363
  break;
330
364
  }
331
365
  case 'registry-config-apply': {
332
366
  if (typeof body.registryUrl !== 'string')
333
367
  throw new ApiProtocolError(400, '缺少 registryUrl');
334
368
  const snap = await ctx.controller.apply(body.registryUrl, { signal });
335
- payload = { applied: true, ...snapshotPayload(snap) };
369
+ payload = { applied: true, ...snapshotPayload(snap, cfg()) };
336
370
  break;
337
371
  }
338
372
  case 'registry-default-download': {
package/lib/core/httpx.js CHANGED
@@ -71,6 +71,7 @@ export async function fetchLimited(url, opts = {}) {
71
71
  let current = assertSafeUrl(url);
72
72
  const seen = new Set([current.toString()]);
73
73
  for (let hop = 0;; hop++) {
74
+ opts.onRequest?.(current.toString());
74
75
  const res = await fetch(current, {
75
76
  redirect: 'manual',
76
77
  signal,
@@ -149,3 +150,40 @@ export async function isReachable(url, timeoutMs = 8000, signal) {
149
150
  return false;
150
151
  }
151
152
  }
153
+ /**
154
+ * 拉取失败三要素统一(M2 Task 4):发生了什么(label + 根因)/ 代价(尝试次数 + 耗时)/ 现在怎么办。
155
+ * 根因分类:AbortError=「请求被取消」;TimeoutError/超时=「请求超时」;message 形如 `HTTP <n>`
156
+ * 才映射状态码;其余保留安全化根因(不吞协议错误细节)。
157
+ */
158
+ export function describeFetchFailure({ label, err, attempts, elapsedMs }) {
159
+ let reason;
160
+ if (err instanceof Error && err.name === 'AbortError') {
161
+ reason = '请求被取消';
162
+ }
163
+ else if (err instanceof Error && (err.name === 'TimeoutError' || /timed?\s?out|超时/i.test(err.message))) {
164
+ reason = '请求超时';
165
+ }
166
+ else if (err instanceof HttpError || (err instanceof Error && /^HTTP \d{3}/.test(err.message))) {
167
+ reason = err instanceof Error ? err.message : String(err);
168
+ }
169
+ else if (err instanceof Error) {
170
+ reason = err.message;
171
+ }
172
+ else {
173
+ reason = String(err);
174
+ }
175
+ const cost = [];
176
+ if (typeof attempts === 'number' && attempts > 0)
177
+ cost.push(`${attempts} 次尝试`);
178
+ if (typeof elapsedMs === 'number' && elapsedMs > 0)
179
+ cost.push(`耗时 ${(elapsedMs / 1000).toFixed(1)}s`);
180
+ const costText = cost.length > 0 ? `(${cost.join(',')})` : '';
181
+ return `${label} 失败:${reason}${costText};可稍后重试或检查网络后重试`;
182
+ }
183
+ /** 字节下载(社区目录正文用):非 2xx 抛 HttpError;bytes = 完整响应体。 */
184
+ export async function fetchBytesLimited(url, opts = {}) {
185
+ const res = await fetchLimited(url, opts);
186
+ if (!res.ok)
187
+ throw new HttpError(res.status, `HTTP ${res.status}`, res.headers);
188
+ return { bytes: res.buffer, finalUrl: res.finalUrl };
189
+ }
@@ -0,0 +1,308 @@
1
+ /**
2
+ * 装后假成功守卫三项检查(M2 Task 2 / DESIGN.md §3「装后假成功守卫」):
3
+ * ① NO_DSH_MARKER——包内无 dsh 插件标记(package.json 的 dsh.bundle/dsh.client 键或包内 cordis.patch.yml);
4
+ * ② ENTRY_UNRESOLVABLE——入口不可解析(exports/main 指向文件在包目录内不存在,或 realpath 越界);
5
+ * ③ LOADER_ID_CONFLICT——新增包的 loader insert id 与现存 id 冲突(两个同 id entry 会让下次开机
6
+ * 整个 profile 起不来)。
7
+ *
8
+ * 读错误分类(io taxonomy):ENOENT 归属语义结论(marker 不存在/入口不存在/无 patch = 空集);
9
+ * EACCES/EIO/坏 JSON/坏 YAML → `unavailable`(结论不可定,由调用方 fail-open + guardWarning)。
10
+ * 自撞排除:现存 id 集合排除 `addedPkgs ∪ priorPkgs` 的贡献——升级/重复安装不与自己的旧 id 冲突。
11
+ * bundles 数组不产生 loader id(bundle 是包级启停选择,不是 cordis insert 合成条目),不参与冲突集合。
12
+ */
13
+ import { readFile, realpath, stat } from 'node:fs/promises';
14
+ import { isAbsolute, join, relative } from 'node:path';
15
+ import { readBundlePatchRows } from './patch-yaml.js';
16
+ /** 包名 → node_modules 内目录(@scope/name → node_modules/@scope/name)。 */
17
+ function pkgDirOf(pkgsDir, name) {
18
+ return join(pkgsDir, ...name.split('/'));
19
+ }
20
+ function contained(dir, p) {
21
+ const rel = relative(dir, p);
22
+ return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel);
23
+ }
24
+ /** 读 JSON:ENOENT → null(语义结论用);坏 JSON/其他 → 'unreadable'(unavailable 用)。 */
25
+ async function readJsonOrNull(file) {
26
+ let text;
27
+ try {
28
+ text = await readFile(file, 'utf8');
29
+ }
30
+ catch (err) {
31
+ const code = err.code;
32
+ if (code === 'ENOENT')
33
+ return null;
34
+ return 'unreadable';
35
+ }
36
+ try {
37
+ const parsed = JSON.parse(text);
38
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
39
+ return 'unreadable';
40
+ return parsed;
41
+ }
42
+ catch {
43
+ return 'unreadable';
44
+ }
45
+ }
46
+ /** cordis.patch.yml 存在即 marker(内容可读性由 LOADER_ID_CONFLICT 检查另行分类)。 */
47
+ async function hasPatchFile(pkgDir) {
48
+ try {
49
+ await stat(join(pkgDir, 'cordis.patch.yml'));
50
+ return true;
51
+ }
52
+ catch (err) {
53
+ const code = err.code;
54
+ if (code === 'ENOENT')
55
+ return null;
56
+ return 'unreadable';
57
+ }
58
+ }
59
+ /**
60
+ * 入口目标解析(保守口径):
61
+ * - exports 为 string(root sugar)→ 可检查;
62
+ * - exports 为 object:仅 `.` 键(值为 string 或 {default})或仅 `default` 键 → 可检查;
63
+ * 含其他条件键(./client 等多键导出)→ conservative(调用方 unavailable——多入口解析歧义);
64
+ * - 无 exports → main;main 也缺失 → null(NO ENTRY 声明)。
65
+ */
66
+ function entryTargetOf(pkgJson) {
67
+ const exportsField = pkgJson.exports;
68
+ if (typeof exportsField === 'string') {
69
+ return { target: exportsField };
70
+ }
71
+ if (exportsField && typeof exportsField === 'object' && !Array.isArray(exportsField)) {
72
+ const keys = Object.keys(exportsField);
73
+ if (keys.includes('.') && keys.length === 1) {
74
+ const dot = exportsField['.'];
75
+ if (typeof dot === 'string')
76
+ return { target: dot };
77
+ if (dot && typeof dot === 'object' && !Array.isArray(dot)) {
78
+ const dotKeys = Object.keys(dot);
79
+ if (dotKeys.length === 1 && dotKeys[0] === 'default' && typeof dot.default === 'string') {
80
+ return { target: dot.default };
81
+ }
82
+ }
83
+ return { conservative: true };
84
+ }
85
+ if (keys.length === 1 && keys[0] === 'default' && typeof exportsField.default === 'string') {
86
+ return { target: exportsField.default };
87
+ }
88
+ return { conservative: true };
89
+ }
90
+ const main = pkgJson.main;
91
+ if (typeof main === 'string' && main.trim() !== '')
92
+ return { target: main };
93
+ return { none: true };
94
+ }
95
+ async function checkEntry(pkg, pkgDir, pkgJson) {
96
+ const resolved = entryTargetOf(pkgJson);
97
+ if ('none' in resolved) {
98
+ return { pkg, code: 'ENTRY_UNRESOLVABLE', detail: 'package.json 缺少 exports/main 入口声明' };
99
+ }
100
+ if ('conservative' in resolved) {
101
+ return { pkg, reason: `入口解析保守跳过(exports 含多条件键,不猜入口): ${pkgDir}` };
102
+ }
103
+ const entryAbs = join(pkgDir, resolved.target);
104
+ let st;
105
+ try {
106
+ st = await stat(entryAbs);
107
+ }
108
+ catch (err) {
109
+ const code = err.code;
110
+ if (code === 'ENOENT') {
111
+ return { pkg, code: 'ENTRY_UNRESOLVABLE', detail: `入口文件不存在: ${resolved.target}` };
112
+ }
113
+ return { pkg: null, reason: `入口 stat 失败(${code ?? '未知错误'}): ${entryAbs}` };
114
+ }
115
+ if (!st.isFile()) {
116
+ return { pkg, code: 'ENTRY_UNRESOLVABLE', detail: `入口不是常规文件: ${resolved.target}` };
117
+ }
118
+ // realpath containment:入口真实路径必须仍在包目录内(防 symlink 逃逸)
119
+ try {
120
+ const [entryReal, pkgReal] = await Promise.all([realpath(entryAbs), realpath(pkgDir)]);
121
+ if (!contained(pkgReal, entryReal)) {
122
+ return { pkg, code: 'ENTRY_UNRESOLVABLE', detail: '入口 realpath 越出包目录(symlink 逃逸)' };
123
+ }
124
+ }
125
+ catch (err) {
126
+ return { pkg: null, reason: `入口 realpath 失败(${err.code ?? '未知错误'}): ${entryAbs}` };
127
+ }
128
+ return null;
129
+ }
130
+ /** 读取一个包的 insert ids:无 patch 文件 → [];坏 YAML → 'unreadable'。 */
131
+ async function insertIdsOf(pkgDir) {
132
+ let text;
133
+ try {
134
+ text = await readFile(join(pkgDir, 'cordis.patch.yml'), 'utf8');
135
+ }
136
+ catch (err) {
137
+ const code = err.code;
138
+ if (code === 'ENOENT')
139
+ return [];
140
+ return 'unreadable';
141
+ }
142
+ const rows = readBundlePatchRows(text);
143
+ if (!rows.readable)
144
+ return 'unreadable';
145
+ return rows.inserts.map((row) => row.id);
146
+ }
147
+ /** 读 profile 根 cordis.patch.yml 的 insert ids:ENOENT = 空集;坏 YAML/EACCES = 'unreadable'。 */
148
+ async function rootInsertIds(profileDir) {
149
+ let text;
150
+ try {
151
+ text = await readFile(join(profileDir, 'cordis.patch.yml'), 'utf8');
152
+ }
153
+ catch (err) {
154
+ const code = err.code;
155
+ if (code === 'ENOENT')
156
+ return [];
157
+ return 'unreadable';
158
+ }
159
+ const rows = readBundlePatchRows(text);
160
+ if (!rows.readable)
161
+ return 'unreadable';
162
+ return rows.inserts.map((row) => row.id);
163
+ }
164
+ /** dependencies 完整快照缺省实现:`<profileDir>/package.json` 的 dependencies。文件缺失 = 空集(合法新 profile);其他读取/解析失败显式抛出 → 全局 unavailable。 */
165
+ async function defaultReadDeps(profileDir) {
166
+ let text;
167
+ try {
168
+ text = await readFile(join(profileDir, 'package.json'), 'utf8');
169
+ }
170
+ catch (err) {
171
+ if (err.code === 'ENOENT')
172
+ return {};
173
+ throw err;
174
+ }
175
+ let raw;
176
+ try {
177
+ raw = JSON.parse(text);
178
+ }
179
+ catch {
180
+ throw new Error('profile package.json 不是合法 JSON');
181
+ }
182
+ const deps = raw?.dependencies;
183
+ if (!deps || typeof deps !== 'object' || Array.isArray(deps))
184
+ return {};
185
+ const out = {};
186
+ for (const [name, spec] of Object.entries(deps)) {
187
+ if (typeof spec === 'string')
188
+ out[name] = spec;
189
+ }
190
+ return out;
191
+ }
192
+ /**
193
+ * 三项检查入口:对 `addedPkgs` 逐包验证 marker / 入口 / loader id 冲突。
194
+ * `ok` = 无 violation 且无 unavailable;`unavailable` 非空 → 结论不可定(调用方 fail-open + guardWarning)。
195
+ */
196
+ export async function verifyInstalledAdditions(input, deps) {
197
+ const violations = [];
198
+ const unavailable = [];
199
+ const pkgsDir = input.pkgsDir ?? join(input.profileDir, 'node_modules');
200
+ // 依赖全量快照:显式失败 → 全局 unavailable(结论不可定)
201
+ let installedDeps;
202
+ try {
203
+ installedDeps = deps?.readDeps ? await deps.readDeps(input.profileDir) : await defaultReadDeps(input.profileDir);
204
+ }
205
+ catch (err) {
206
+ return {
207
+ ok: false,
208
+ violations: [],
209
+ unavailable: [{ pkg: null, reason: `profile dependencies 全量读取失败:${err instanceof Error ? err.message : err}` }],
210
+ };
211
+ }
212
+ const added = input.addedPkgs.filter((p) => typeof p === 'string' && p.trim() !== '');
213
+ const excluded = new Set([...added, ...(input.priorPkgs ?? [])]);
214
+ // ---------- ① NO_DSH_MARKER + ② ENTRY_UNRESOLVABLE(逐新增包) ----------
215
+ for (const pkg of added) {
216
+ const pkgDir = pkgDirOf(pkgsDir, pkg);
217
+ const pkgJson = await readJsonOrNull(join(pkgDir, 'package.json'));
218
+ if (pkgJson === 'unreadable') {
219
+ unavailable.push({ pkg, reason: `package.json 不可读(EACCES/坏 JSON): ${pkgDir}` });
220
+ continue;
221
+ }
222
+ if (pkgJson === null) {
223
+ violations.push({ pkg, code: 'NO_DSH_MARKER', detail: `包目录或 package.json 不存在: ${pkgDir}` });
224
+ continue;
225
+ }
226
+ const hasKey = 'dsh' in pkgJson && pkgJson.dsh !== null && typeof pkgJson.dsh === 'object';
227
+ let marker = hasKey;
228
+ if (!marker) {
229
+ const patch = await hasPatchFile(pkgDir);
230
+ if (patch === 'unreadable') {
231
+ unavailable.push({ pkg, reason: `cordis.patch.yml stat 失败(EACCES 等): ${pkgDir}` });
232
+ continue;
233
+ }
234
+ marker = patch === true;
235
+ }
236
+ if (!marker) {
237
+ violations.push({ pkg, code: 'NO_DSH_MARKER', detail: '包内无 dsh.bundle/dsh.client 标记且无 cordis.patch.yml' });
238
+ continue;
239
+ }
240
+ // ② 入口(marker 成立才检查;marker 缺失时安装本来就要补偿)
241
+ const entry = await checkEntry(pkg, pkgDir, pkgJson);
242
+ if (entry) {
243
+ if ('code' in entry)
244
+ violations.push(entry);
245
+ else
246
+ unavailable.push(entry);
247
+ }
248
+ }
249
+ // ---------- ③ LOADER_ID_CONFLICT(新增包 pairwise + 现存集合,排除自撞) ----------
250
+ const rootIds = await rootInsertIds(input.profileDir);
251
+ if (rootIds === 'unreadable') {
252
+ unavailable.push({ pkg: null, reason: 'profile 根 cordis.patch.yml 不可读(EACCES/坏 YAML),loader id 集合不可定' });
253
+ }
254
+ // 已装依赖包的 insert ids(排除 addedPkgs ∪ priorPkgs 的贡献——自撞排除)
255
+ const existing = rootIds === 'unreadable' ? [] : rootIds.map((id) => ({ id, source: 'profile 根 patch' }));
256
+ for (const depName of Object.keys(installedDeps)) {
257
+ if (excluded.has(depName))
258
+ continue;
259
+ const ids = await insertIdsOf(pkgDirOf(pkgsDir, depName));
260
+ if (ids === 'unreadable') {
261
+ unavailable.push({ pkg: null, reason: `已装包 cordis.patch.yml 不可读,loader id 集合不可定: ${depName}` });
262
+ continue;
263
+ }
264
+ for (const id of ids)
265
+ existing.push({ id, source: depName });
266
+ }
267
+ // 新增包的 ids(自撞排除在 pairwise 内同样适用:A 的新 id 不会与 A 自己比较)
268
+ const addedIds = new Map();
269
+ for (const pkg of added) {
270
+ addedIds.set(pkg, await insertIdsOf(pkgDirOf(pkgsDir, pkg)));
271
+ }
272
+ for (const [pkg, ids] of addedIds) {
273
+ if (ids === 'unreadable') {
274
+ unavailable.push({ pkg, reason: `新增包 cordis.patch.yml 不可读(EACCES/坏 YAML): ${pkg}` });
275
+ continue;
276
+ }
277
+ if (ids.length === 0)
278
+ continue;
279
+ // pairwise:与其他新增包
280
+ const conflicting = [];
281
+ for (const [other, otherIds] of addedIds) {
282
+ if (other === pkg || otherIds === 'unreadable')
283
+ continue;
284
+ for (const id of ids) {
285
+ if (otherIds.includes(id))
286
+ conflicting.push({ id, source: other });
287
+ }
288
+ }
289
+ // 与现存集合(根 patch + 其他已装包)
290
+ for (const id of ids) {
291
+ for (const ex of existing) {
292
+ if (ex.id === id)
293
+ conflicting.push({ id, source: ex.source });
294
+ }
295
+ }
296
+ if (conflicting.length > 0) {
297
+ const seen = new Set();
298
+ const unique = conflicting.filter((c) => (seen.has(`${c.id}|${c.source}`) ? false : (seen.add(`${c.id}|${c.source}`), true)));
299
+ violations.push({
300
+ pkg,
301
+ code: 'LOADER_ID_CONFLICT',
302
+ detail: `insert id 与现存 loader 条目冲突:${unique.map((c) => `${c.id}(来自 ${c.source})`).join('、')}`,
303
+ conflictingIds: unique,
304
+ });
305
+ }
306
+ }
307
+ return { ok: violations.length === 0 && unavailable.length === 0, violations, unavailable };
308
+ }