@1e0zj/dsh-plugin-mall 0.3.4 → 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/src/guard.js CHANGED
@@ -15,7 +15,9 @@ import {
15
15
  mkdtempSync,
16
16
  readFileSync,
17
17
  readdirSync,
18
+ realpathSync,
18
19
  rmSync,
20
+ symlinkSync,
19
21
  writeFileSync,
20
22
  } from "node:fs";
21
23
  import { homedir, tmpdir } from "node:os";
@@ -25,17 +27,47 @@ import { JSON_SCHEMA, Type, load } from "js-yaml";
25
27
  import { satisfies, validRange } from "semver";
26
28
 
27
29
  // Official patches (e.g. @deepseek-ai/dsh-base, dsh-web-app) mark raw JS
28
- // expressions with the scalar tag `!!js`. Construct it as the inert source
29
- // text never evaluate it on top of a safe schema, so every other unknown
30
- // tag is still rejected as invalid YAML.
30
+ // expressions with the scalar tag `!!js`. Construct the loader's own marker
31
+ // `{ __jsExpr: source }`, recognised by its `isJsExpr` and never evaluate
32
+ // it, on top of a safe schema so every other unknown tag is still rejected as
33
+ // invalid YAML. Keeping the tag's identity matters: `!!js process.env.KEY` and
34
+ // the plain string "process.env.KEY" are the same characters but not the same
35
+ // value, and a candidate swapping one for the other changes what runs.
31
36
  const JS_SCALAR_TYPE = new Type("tag:yaml.org,2002:js", {
32
37
  kind: "scalar",
33
- construct: (data) => String(data),
38
+ construct: (data) => ({ __jsExpr: String(data) }),
34
39
  });
40
+
41
+ /** The loader's own test for an expression node (cordis-plugin-loader). */
42
+ function isJsExpr(value) {
43
+ return value instanceof Object && "__jsExpr" in value;
44
+ }
35
45
  const PATCH_SCHEMA = JSON_SCHEMA.extend([JS_SCALAR_TYPE]);
36
46
 
37
47
  const PROFILE_FILES = ["package.json", "pnpm-lock.yaml", "pnpm-workspace.yaml", "cordis.patch.yml"];
38
48
  const HOST_PACKAGE_RE = /^@deepseek-ai\//;
49
+ // Loader rows whose id or package names a protection boundary. Switching one
50
+ // off, or replacing its config wholesale, drops a guarantee every other plugin
51
+ // relies on, so those rows get their own line in the report instead of being
52
+ // counted in with the rest.
53
+ const SECURITY_ROW_RE = /sandbox|approval|permission|policy|credential|landlock/i;
54
+ // Identity the scan gives an id-less row. The loader mints a random one at
55
+ // load time (`ensureId`); this one only has to be unique inside one scan and
56
+ // impossible to confuse with an id somebody actually wrote.
57
+ const SCAN_ID_PREFIX = "\u0000auto:";
58
+ // Past this many of someone else's rows changed, a candidate is not extending
59
+ // the profile any more, it is replacing its composition. Measured against the
60
+ // real thing: dropped on a headless profile, dsh-TUI switches off 23 rows and
61
+ // replaces the config of 6 more, while a plugin that tunes what it needs
62
+ // changes one or two rows.
63
+ const SURFACE_TAKEOVER_MIN = 10;
64
+ // Host packages only another front door peer-depends on. The terminal stack is
65
+ // the load-bearing half: a package built on it, shipping its own `bin` and no
66
+ // browser half, IS a surface — it exists to replace dsh-web-app over dsh-base,
67
+ // not to run beside it. The host runner alone is weaker evidence (host-side
68
+ // tooling legitimately uses it), so it only feeds the advisory warning.
69
+ const TERMINAL_PEER_PACKAGES = ["@deepseek-ai/dsh-terminal", "@deepseek-ai/dsh-terminal-bash"];
70
+ const SURFACE_PEER_PACKAGES = [...TERMINAL_PEER_PACKAGES, "@deepseek-ai/dsh-cordis-host-runner"];
39
71
  const NPM_PACKAGE_NAME_RE = /^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/i;
40
72
  // Snapshot/pending schema version. v2 makes the original dependency list and
41
73
  // the candidate identity MANDATORY: a rollback that cannot name the candidate
@@ -132,41 +164,157 @@ function hostPackageInfo(packageName, anchorDir) {
132
164
  }
133
165
  }
134
166
 
167
+ /** The shape every patch parser returns, so callers can always spread it. */
168
+ function emptyPatchOps() {
169
+ return { ops: [], rows: [], overrides: [] };
170
+ }
171
+
172
+ /**
173
+ * Parse one patch list into the two entry kinds the loader distinguishes
174
+ * (`applyEntryPatches` in @deepseek-ai/cordis-plugin-include):
175
+ *
176
+ * - `insert:` appends rows — to the profile root, or into the group named by
177
+ * the sibling `id`;
178
+ * - anything else is id-targeted, and every sibling key REPLACES that key on
179
+ * the row already carrying that id: `config` is swapped wholesale (never
180
+ * deep merged), `disabled: true` unmounts the row. A sibling `name` is
181
+ * only a guard — the loader skips the whole entry when it does not match
182
+ * the target row's name, and skips an id-less non-insert entry outright.
183
+ *
184
+ * Both kinds have to be parsed. Reading only `insert` was how a candidate that
185
+ * switches off two dozen of the profile's existing rows still scanned as safe:
186
+ * overriding rows is the patch layer's main documented use, and it was the one
187
+ * thing the scan could not see. `ops` keeps document order because the loader
188
+ * applies entries in order — a patch can target a row an earlier entry in the
189
+ * same file inserted.
190
+ */
191
+ /**
192
+ * Validate one `insert:` list and return the rows the loader would push, plus
193
+ * a flat descriptor of every row in it (a group's children included, the way
194
+ * `buildMap` walks them). The rows stay RAW: a later patch replaces whole
195
+ * fields on them, including a group's `config`, so the subtree a row owns is
196
+ * only known once every layer has been applied.
197
+ */
198
+ function insertRowsOf(list, issues, owner, flat = []) {
199
+ const raw = [];
200
+ for (const row of list) {
201
+ if (row === null || row === undefined) {
202
+ // buildMap reads `entry.id` on every inserted row, so a null row is a
203
+ // TypeError at boot, not a row the loader ignores.
204
+ issues.push(issue("block", "patch-entry-invalid", "补丁 insert 里有空条目", `${owner} 的补丁 insert 列表里有一个 null 条目;loader 索引每一行时会直接抛错,dsh 起不来。`, { package: owner }));
205
+ continue;
206
+ }
207
+ if (typeof row !== "object" || Array.isArray(row)) {
208
+ issues.push(issue("block", "patch-entry-invalid", "补丁 insert 里有非法条目", `${owner} 的补丁 insert 列表里有一个不是映射的条目(${JSON.stringify(row)});它会被原样插进组装树,成为一条挂不起来的行。`, { package: owner }));
209
+ continue;
210
+ }
211
+ raw.push(row);
212
+ // Exactly as written. The loader compares ids and names with `===` and
213
+ // only mints an id when the written one is FALSY (`ensureId`), so a
214
+ // numeric `id: 7` is a real id — trimming it, or demoting it to "no id",
215
+ // would make the scan resolve targets dsh misses and miss the duplicate
216
+ // ids dsh refuses to boot with.
217
+ flat.push({ id: row.id ? row.id : undefined, name: row.name ? row.name : undefined, source: undefined, owner });
218
+ if (row.group && Array.isArray(row.config)) insertRowsOf(row.config, issues, owner, flat);
219
+ }
220
+ return { raw, flat };
221
+ }
222
+
135
223
  function parsePatchDocument(document, source, issues, owner) {
136
- if (document === null || document === undefined) return [];
224
+ if (document === null || document === undefined) return emptyPatchOps();
225
+ if (rejectCyclicDocument(document, issues, owner)) return emptyPatchOps();
137
226
  if (!Array.isArray(document)) {
138
227
  issues.push(issue("block", "patch-shape", "插件补丁结构错误", `${owner} 的补丁顶层必须是数组。`, { package: owner }));
139
- return [];
228
+ return emptyPatchOps();
140
229
  }
141
- const rows = [];
230
+ const ops = [];
231
+ const notes = { generated: [] };
142
232
  for (const entry of document) {
143
- if (!Array.isArray(entry?.insert)) continue;
144
- for (const row of entry.insert) {
145
- if (row === null || typeof row !== "object") continue;
146
- const id = typeof row.id === "string" ? row.id.trim() : "";
147
- const name = typeof row.name === "string" ? row.name.trim() : "";
148
- if (id.length === 0 || name.length === 0) continue;
149
- rows.push({ id, name, source, owner });
233
+ // The loader destructures every entry (`const { id, insert, name,
234
+ // ...overrides } = patch`), so a null entry throws at boot. Any other
235
+ // non-mapping entry destructures to an id-less patch, which the loader
236
+ // warns about and skips inert, but never what the author meant.
237
+ if (entry === null || entry === undefined) {
238
+ issues.push(issue("block", "patch-entry-invalid", "补丁里有空条目", `${owner} 的补丁里有一个 null 条目;loader 会对每个条目解构,遇到它直接抛错,dsh 起不来。`, { package: owner }));
239
+ continue;
150
240
  }
241
+ if (typeof entry !== "object" || Array.isArray(entry)) {
242
+ issues.push(issue("warn", "patch-entry-ignored", "补丁里有无效条目", `${owner} 的补丁里有一个不是映射的条目(${JSON.stringify(entry)});它没有 id,loader 只会打一条警告然后跳过——写在那里不起任何作用。`, { package: owner }));
243
+ continue;
244
+ }
245
+ if (entry.insert) { // the loader's own test: `if (insert)`, so 0 and "" fall through
246
+ if (!Array.isArray(entry.insert)) {
247
+ issues.push(issue("block", "patch-entry-invalid", "补丁条目结构错误", `${owner} 的补丁里有一条 insert 不是数组;loader 会展开它插进组装树,非可迭代值直接抛错,可迭代值则插入一堆挂不起来的行。`, { package: owner }));
248
+ continue;
249
+ }
250
+ // `insert` with an `id` targets that group; without one it appends to
251
+ // the profile root.
252
+ const into = entry.id ? entry.id : undefined;
253
+ const inserted = insertRowsOf(entry.insert, issues, owner);
254
+ for (const row of inserted.flat) {
255
+ row.source = source;
256
+ if (row.id === undefined) notes.generated.push(row.name ?? "(无 name)");
257
+ }
258
+ ops.push({ kind: "insert", into, raw: inserted.raw, rows: inserted.flat });
259
+ continue;
260
+ }
261
+ const { id, name, insert, ...values } = entry;
262
+ if (!id) {
263
+ // "patch: id is required for non-insert patches" — one warning, skipped.
264
+ issues.push(issue("warn", "patch-entry-ignored", "补丁里有无 id 的条目", `${owner} 的补丁里有一个既没有 insert 也没有 id 的条目;loader 只会打一条警告然后跳过——写在那里不起任何作用。`, { package: owner }));
265
+ continue;
266
+ }
267
+ ops.push({
268
+ kind: "override",
269
+ id,
270
+ // The guard is `if (name && name !== target.name)`: a falsy name — 0,
271
+ // false, "" — does not arm it at all, and the patch applies to whatever
272
+ // row carries that id. Treating those as "a name that does not match"
273
+ // silently drops patches the loader applies.
274
+ name: name ? name : undefined,
275
+ keys: Object.keys(values),
276
+ values,
277
+ source,
278
+ owner,
279
+ });
151
280
  }
152
- return rows;
281
+ if (notes.generated.length > 0) {
282
+ issues.push(issue("warn", "patch-row-generated-id", `补丁有 ${notes.generated.length} 条行没写 id`, `${owner} 的 ${notes.generated.join("、")} 没有 id。loader 会当场生成一个随机 id 并照常挂载,但每次读取都换一个——即使文本没变,也会被当成先删后加、重新挂载一遍。`, { package: owner }));
283
+ }
284
+ return {
285
+ ops,
286
+ rows: ops.filter((op) => op.kind === "insert").flatMap((op) => op.rows),
287
+ overrides: ops.filter((op) => op.kind === "override"),
288
+ };
153
289
  }
154
290
 
155
291
  function parsePatch(filePath, source, issues, owner) {
156
292
  if (!existsSync(filePath)) {
157
293
  issues.push(issue("block", "patch-missing", "插件补丁文件不存在", `${owner} 声明了 ${filePath},但文件不存在。`, { package: owner }));
158
- return [];
294
+ return emptyPatchOps();
159
295
  }
160
296
  let document;
161
297
  try {
162
298
  document = load(readFileSync(filePath, "utf8"), { schema: PATCH_SCHEMA });
163
299
  } catch (error) {
164
300
  issues.push(issue("block", "patch-invalid", "插件补丁无法解析", `${owner} 的 ${basename(filePath)} 不是有效 YAML:${error.message}`, { package: owner }));
165
- return [];
301
+ return emptyPatchOps();
166
302
  }
167
303
  return parsePatchDocument(document, source, issues, owner);
168
304
  }
169
305
 
306
+ /**
307
+ * Reject a document whose YAML anchors point back into themselves. This runs
308
+ * BEFORE anything walks the parsed value: a self-referencing `insert` row
309
+ * would send the row collector into infinite recursion, so the check cannot
310
+ * live at the end of parsing.
311
+ */
312
+ function rejectCyclicDocument(document, issues, owner) {
313
+ if (!hasCycle(document)) return false;
314
+ issues.push(issue("block", "patch-cyclic-value", "补丁里有自引用的值", `${owner} 的补丁里有一个 YAML 锚点指回自己所在的容器(例如 \`config: &loop\n self: *loop\`);这样的配置无法被序列化,也无法写回组装树。`, { package: owner }));
315
+ return true;
316
+ }
317
+
170
318
  /**
171
319
  * Parse a bundle patch fetched as text (the browsing-time remote scan never
172
320
  * writes the candidate to disk). Shape/parse failures are blockers, exactly as
@@ -178,7 +326,7 @@ function parsePatchText(text, source, issues, owner) {
178
326
  document = load(String(text), { schema: PATCH_SCHEMA });
179
327
  } catch (error) {
180
328
  issues.push(issue("block", "patch-invalid", "插件补丁无法解析", `${owner} 的补丁不是有效 YAML:${error.message}`, { package: owner }));
181
- return [];
329
+ return emptyPatchOps();
182
330
  }
183
331
  return parsePatchDocument(document, source, issues, owner);
184
332
  }
@@ -203,10 +351,10 @@ function bundlePatchPath(info, issues, owner) {
203
351
  return target;
204
352
  }
205
353
 
206
- function rowsForPackage(info, issues, source = "bundle") {
354
+ function patchOpsForPackage(info, issues, source = "bundle") {
207
355
  const owner = info.manifest.name ?? basename(info.dir);
208
356
  const patchPath = bundlePatchPath(info, issues, owner);
209
- if (patchPath === undefined) return [];
357
+ if (patchPath === undefined) return emptyPatchOps();
210
358
  return parsePatch(patchPath, source, issues, owner);
211
359
  }
212
360
 
@@ -216,33 +364,329 @@ function clientRowId(packageName) {
216
364
  return trimmed.length > 0 ? trimmed : last;
217
365
  }
218
366
 
367
+ /**
368
+ * Compose an ordered patch stack into the row map it produces, entry by entry,
369
+ * the way the loader's own `applyEntryPatches` does: a later entry can target
370
+ * a row an earlier one inserted, repeated writes to one row collapse, and the
371
+ * last layer to write a key wins. Composing (rather than matching ops against
372
+ * a static snapshot) is what makes an ordered patch readable at all — a patch
373
+ * that disables a row and then re-enables it leaves it enabled, and ten writes
374
+ * to one row are one changed row, not ten.
375
+ *
376
+ * @param layers - `{owner, patch}` in application order.
377
+ * @param trace - owner whose entries are recorded, for the report.
378
+ * @returns the composed rows, plus which of `trace`'s entries were skipped and
379
+ * which rows it wrote (a write a later layer takes back leaves no
380
+ * trace in the result, so the caller needs both to explain itself).
381
+ */
382
+ /** Layer ownership, kept off the row's own keys so it never enters a diff. */
383
+ const ROW_OWNER = Symbol("dsh-plugin-mall.owner");
384
+
385
+ function stampOwner(rows, owner) {
386
+ for (const row of rows) {
387
+ if (row === null || typeof row !== "object" || Array.isArray(row)) continue;
388
+ Object.defineProperty(row, ROW_OWNER, { value: owner, configurable: true, enumerable: false, writable: true });
389
+ if (row.group && Array.isArray(row.config)) stampOwner(row.config, owner);
390
+ }
391
+ }
392
+
393
+ /** The stricter of two mount states (disabled beats unknown beats enabled). */
394
+ function worstState(left, right) {
395
+ if (left === "disabled" || right === "disabled") return "disabled";
396
+ if (left === "unknown" || right === "unknown") return "unknown";
397
+ return "enabled";
398
+ }
399
+
400
+ function composeEntries(layers, trace) {
401
+ // Two structures, because the loader keeps two. `data` is the entry list it
402
+ // ends up booting — rows nested inside groups included, and a later patch
403
+ // that replaces a group's `config` replaces that whole subtree. `entryMap`
404
+ // is what id-targeted patches resolve against: `buildMap` fills it while
405
+ // rows are INSERTED, so rows that arrive later through a `config` override
406
+ // are in the tree but not addressable. Projecting mounts off a single flat
407
+ // map cannot express both.
408
+ const data = [];
409
+ const entryMap = new Map();
410
+ const skipped = [];
411
+ const touched = [];
412
+
413
+ const indexRows = (rows) => {
414
+ for (const row of rows) {
415
+ if (row === null || typeof row !== "object" || Array.isArray(row)) continue;
416
+ if (row.id) entryMap.set(row.id, row);
417
+ if (row.group && Array.isArray(row.config)) indexRows(row.config);
418
+ }
419
+ };
420
+
421
+ // The launcher does NOT apply layers one call at a time: it flattens every
422
+ // layer into a single `applyEntryPatches([], layers.flat())`
423
+ // (dsh-app-boot's own composeEntries). One call means one lookup map, built
424
+ // from the rows as they are inserted — so a row that arrives through a
425
+ // `config` override is in the tree but addressable to nobody, in any layer.
426
+ for (const { owner, patch } of layers) {
427
+ for (const op of patch.ops) {
428
+ if (op.kind === "insert") {
429
+ // Detached, exactly like the loader's own structuredClone: layers must
430
+ // not alias each other's values, or a later override would reach back
431
+ // into the parsed patch of an earlier one.
432
+ const rows = structuredClone(op.raw);
433
+ stampOwner(rows, owner);
434
+ if (op.into !== undefined) {
435
+ // Inserting into a group: the loader warns and drops the whole list
436
+ // when the target is missing or is not a group, so those rows never
437
+ // mount and the patch quietly does nothing.
438
+ const target = entryMap.get(op.into);
439
+ if (target === undefined || !target.group) {
440
+ if (owner === trace) skipped.push({ id: op.into, why: target === undefined ? "profile 里没有这个 id,插不进去" : `id=${op.into} 不是 group,插不进去` });
441
+ continue;
442
+ }
443
+ if (!Array.isArray(target.config)) target.config = [];
444
+ target.config.push(...rows);
445
+ } else {
446
+ data.push(...rows);
447
+ }
448
+ indexRows(rows);
449
+ continue;
450
+ }
451
+ const target = entryMap.get(op.id);
452
+ if (target === undefined) {
453
+ // The loader prints one stderr warning and boots without the entry.
454
+ if (owner === trace) skipped.push({ id: op.id, why: "profile 里没有这个 id" });
455
+ continue;
456
+ }
457
+ if (op.name !== undefined && op.name !== target.name) {
458
+ if (owner === trace) skipped.push({ id: op.id, why: `name 对不上(该行现在是 ${target.name})` });
459
+ continue;
460
+ }
461
+ // Every sibling key replaces that key on the target — `config` and
462
+ // `disabled`, but equally `inject`, `intercept`, `isolate`, `group` and
463
+ // anything a later dsh adds. Applying only the two we grade would leave
464
+ // the rest invisible, which is the same blind spot in a new place.
465
+ for (const key of op.keys) {
466
+ target[key] = structuredClone(op.values[key]);
467
+ // Rows that arrive this way belong to the layer that wrote them — and
468
+ // deliberately do NOT enter entryMap, matching the loader.
469
+ if (key === "config" && Array.isArray(target[key])) stampOwner(target[key], owner);
470
+ }
471
+ if (owner === trace && !touched.includes(op.id)) touched.push(op.id);
472
+ }
473
+ }
474
+
475
+ // The mount projection is read off the FINAL tree, so a group whose config
476
+ // was replaced contributes its new children and not its old ones.
477
+ const mounted = [];
478
+ let generated = 0;
479
+ const walk = (rows, inherited) => {
480
+ for (const row of rows) {
481
+ if (row === null || typeof row !== "object" || Array.isArray(row)) continue;
482
+ const declaredId = row.id ? row.id : undefined;
483
+ const name = row.name ? row.name : undefined;
484
+ const inheritedNext = worstState(inherited, disabledState(row.disabled));
485
+ mounted.push({
486
+ // `ensureId` mints one at load time for a row whose id is falsy.
487
+ id: declaredId === undefined ? `${SCAN_ID_PREFIX}${generated++}` : declaredId,
488
+ generatedId: declaredId === undefined,
489
+ name: typeof name === "string" ? name : undefined,
490
+ // A truthy non-string name is not a module specifier: the loader calls
491
+ // `name.startsWith(...)` on it and the entry throws at import.
492
+ unusableName: name !== undefined && typeof name !== "string" ? name : undefined,
493
+ owner: row[ROW_OWNER],
494
+ // `_disabled` short-circuits for a group: the group entry itself is
495
+ // never disabled, only what sits under it is.
496
+ state: row.group ? "enabled" : inheritedNext,
497
+ // The row as it ends up in the tree, for the field diff.
498
+ options: row,
499
+ });
500
+ if (row.group && Array.isArray(row.config)) walk(row.config, inheritedNext);
501
+ }
502
+ };
503
+ walk(data, "enabled");
504
+
505
+ // Two views, for two questions. `entries` answers "would a patch targeting
506
+ // this id hit anything?" — that is the lookup map, and nothing else.
507
+ // `rows` answers "what does the profile end up with?" — read off the final
508
+ // tree, so a group whose `config` was replaced reports its new children and
509
+ // its old ones as gone. Diffing the lookup map instead would miss both.
510
+ const entries = new Map();
511
+ for (const [id, row] of entryMap) entries.set(id, { id, name: row.name, options: row, insertedBy: row[ROW_OWNER] });
512
+ const rows = new Map();
513
+ for (const row of mounted) rows.set(row.id, { ...row, insertedBy: row.owner });
514
+
515
+ return { data, entries, rows, skipped, touched, mounted };
516
+ }
517
+
518
+ /**
519
+ * Resolve one `dsh.profile.bundles` entry the way the launcher's own
520
+ * `resolveBundleDir` does: the dsh installation first, the profile directory
521
+ * second. That order is the contract that in-box bundles always come from the
522
+ * same installation as the running dsh, never from a profile-local copy — a
523
+ * scan that reverses it composes a tree the profile will not boot. The
524
+ * installation anchor is approached through the shared profiles/node_modules
525
+ * link farm dsh maintains, which is where a profile can see it from.
526
+ * Resolution never asks the package to export `./package.json`.
527
+ */
528
+ /**
529
+ * Recover the launcher's REAL installation anchor from the shared fallback.
530
+ * `healProfilesModuleFallback` always links the dsh app package itself before
531
+ * walking the rest of its dependency closure, so that link is the one piece
532
+ * of the farm that can prove where the running installation lives. Following
533
+ * it also makes a half-built farm harmless: resolution runs from the actual
534
+ * app package, not from whichever sibling links happened to be created first.
535
+ *
536
+ * A plain directory under the farm is not evidence. It can be an empty/partial
537
+ * scaffold left by an interrupted heal (or a test/profile copy), and treating
538
+ * its mere existence as a complete installation used to turn "cannot verify"
539
+ * into a blocker capable of rolling back a healthy profile.
540
+ */
541
+ function dshInstallAnchor(profileDir) {
542
+ const modulesDir = join(dirname(profileDir), "node_modules");
543
+ const visibleAnchor = join(modulesDir, "@deepseek-ai", "dsh", "package.json");
544
+ try {
545
+ const realAnchor = realpathSync(visibleAnchor);
546
+ const rel = relative(modulesDir, realAnchor);
547
+ if (rel === "" || (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel))) return undefined;
548
+ if (readJson(realAnchor)?.name !== "@deepseek-ai/dsh") return undefined;
549
+ return realAnchor;
550
+ } catch {
551
+ return undefined;
552
+ }
553
+ }
554
+
555
+ function bundleInfo(packageName, profileDir, installAnchor) {
556
+ if (typeof packageName !== "string" || !NPM_PACKAGE_NAME_RE.test(packageName)) return undefined;
557
+ const parts = packageName.split("/");
558
+ const anchors = installAnchor === undefined
559
+ ? [join(profileDir, "package.json")]
560
+ : [installAnchor, join(profileDir, "package.json")];
561
+ for (const anchor of anchors) {
562
+ let searchPaths;
563
+ try {
564
+ searchPaths = createRequire(anchor).resolve.paths(packageName) ?? [];
565
+ } catch {
566
+ continue;
567
+ }
568
+ for (const searchPath of searchPaths) {
569
+ const manifestPath = join(searchPath, ...parts, "package.json");
570
+ if (!existsSync(manifestPath)) continue;
571
+ try {
572
+ const manifest = readJson(manifestPath);
573
+ if (manifest?.name !== packageName) continue;
574
+ return { manifestPath, dir: dirname(manifestPath), manifest };
575
+ } catch {
576
+ continue;
577
+ }
578
+ }
579
+ }
580
+ return undefined;
581
+ }
582
+
583
+ /** The home-level patch layer (`<home>/cordis.patch.yml`) for one profile. */
584
+ function homePatchPath(profileDir) {
585
+ return join(dirname(dirname(profileDir)), "cordis.patch.yml");
586
+ }
587
+
219
588
  function installedProfile(profileDir, issues) {
220
589
  const manifestPath = join(profileDir, "package.json");
221
590
  const manifest = readJson(manifestPath);
222
591
  const dependencies = Object.keys(manifest.dependencies ?? {});
223
592
  const bundles = manifest.dsh?.profile?.bundles ?? [];
593
+ const installAnchor = dshInstallAnchor(profileDir);
224
594
  const packages = new Map();
225
- const rows = [];
595
+ const infos = new Map();
226
596
  for (const name of new Set([...dependencies, ...bundles])) {
227
- const info = packageInfo(name, profileDir);
597
+ // Bundles resolve through the launcher's two anchors; a plain dependency
598
+ // stays on the strict profile-only lookup, where ancestor-only resolution
599
+ // is the fingerprint of a crashed install rather than a normal in-box
600
+ // package. In-box bundles (@deepseek-ai/dsh-base, dsh-web-app, …) live in
601
+ // neither the profile's node_modules nor its dependency list, and without
602
+ // resolving them every check here compared a candidate against
603
+ // third-party rows only — colliding with an official row read as safe.
604
+ const info = bundles.includes(name) ? bundleInfo(name, profileDir, installAnchor) : packageInfo(name, profileDir);
228
605
  if (info === undefined) {
229
- // A dependency the manifest declares but node_modules cannot resolve (or
230
- // whose package.json cannot be read) is the fingerprint of a crash
231
- // mid-install: pnpm updated package.json before materializing the package.
232
- // Skipping it would let recoverProfile commit a profile dsh cannot load.
233
- // Template bundles appear only in `bundles`, never in `dependencies`, so
234
- // they stay silent here (they are in-box, not expected under node_modules).
606
+ // A profile layer that resolves from neither anchor stops dsh at
607
+ // startup ("cannot resolve profile bundle X"), so it is a blocker even
608
+ // though in-box bundles are deliberately absent from `dependencies`.
609
+ // For a plain dependency, the same failure is the fingerprint of a crash
610
+ // mid-install: pnpm updated package.json before materializing the
611
+ // package. Either way, skipping it would let recoverProfile commit a
612
+ // profile dsh cannot load.
613
+ //
614
+ // Unless the first anchor is the thing that is missing. The bundle check
615
+ // sits on the rollback path, so it must never turn "the scan cannot see
616
+ // the dsh installation from here" into a verdict that discards a healthy
617
+ // profile: without the link farm the launcher would still resolve the
618
+ // bundle through its real installation anchor, which we have no way to
619
+ // reach. Say what is unverified and let dsh be the judge.
620
+ //
621
+ // The dependency check comes FIRST and is never softened: a name can be
622
+ // both a layer and a dependency, and for that one no anchor is in doubt
623
+ // — pnpm owes it a directory in the profile's own node_modules, and its
624
+ // absence is the crash-mid-install fingerprint whatever the link farm
625
+ // looks like. `bundleInfo` already searched everywhere `packageInfo`
626
+ // would, so reaching here means that lookup failed too.
235
627
  if (dependencies.includes(name)) {
236
628
  issues.push(issue("block", "package-unresolved", "依赖无法解析", `package.json 声明了依赖 ${name},但 node_modules 中无法解析或读取其 package.json(安装可能未完成)。`, { package: name }));
629
+ } else if (bundles.includes(name)) {
630
+ issues.push(installAnchor !== undefined
631
+ ? issue("block", "bundle-unresolved", "profile 层无法解析", `dsh.profile.bundles 里列了 ${name},但从 dsh 安装目录和 ${profileDir} 都解析不到它;dsh 启动时会直接报错退出。`, { package: name })
632
+ : issue("warn", "bundle-unverified", "无法确认 profile 层能否解析", `dsh.profile.bundles 里列了 ${name},本次扫描解析不到它——但 ${join(dirname(profileDir), "node_modules")} 里也没有一个能指回真实安装的 @deepseek-ai/dsh 软链,扫描够不到 dsh 的安装锚点。dsh 启动时会重建共享软链并按真正的安装锚点解析,所以这里不作判断。`, { package: name }));
237
633
  }
238
634
  continue;
239
635
  }
636
+ infos.set(name, info);
240
637
  packages.set(name, info.manifest);
241
- if (bundles.includes(name)) rows.push(...rowsForPackage(info, issues, "bundle"));
242
638
  }
243
- const profilePatch = join(profileDir, "cordis.patch.yml");
244
- if (existsSync(profilePatch)) rows.push(...parsePatch(profilePatch, "profile", issues, "profile cordis.patch.yml"));
245
- return { manifest, dependencies, bundles, packages, rows };
639
+ // Layer order decides who wins a row: bundles in `dsh.profile.bundles`
640
+ // order, then the profile's own patch, then the home-level patch — machine
641
+ // local preferences that apply to every profile, so they outrank the
642
+ // per-profile layer (dsh's own composeProfile, in that order).
643
+ const rows = [];
644
+ const bundleLayers = [];
645
+ for (const name of bundles) {
646
+ const info = infos.get(name);
647
+ if (info === undefined) continue; // already reported as unresolved
648
+ if (typeof info.manifest?.dsh?.bundle?.patch !== "string") {
649
+ // Naming a bundle-less package as a layer is a misconfiguration, not
650
+ // "no patches": dsh throws "declares no dsh.bundle" and never starts.
651
+ issues.push(issue("block", "bundle-manifest-missing", "profile 层没有 dsh.bundle 声明", `${name} 被列为 profile 层,但它的 package.json 没有 dsh.bundle.patch;dsh 启动时会直接报错退出。`, { package: name }));
652
+ continue;
653
+ }
654
+ const patch = patchOpsForPackage(info, issues, "bundle");
655
+ rows.push(...patch.rows);
656
+ bundleLayers.push({ owner: name, patch });
657
+ }
658
+ const userLayers = [];
659
+ for (const [file, owner] of [[join(profileDir, "cordis.patch.yml"), "profile cordis.patch.yml"], [homePatchPath(profileDir), "home cordis.patch.yml"]]) {
660
+ if (!existsSync(file)) continue;
661
+ const patch = parsePatch(file, "profile", issues, owner);
662
+ rows.push(...patch.rows);
663
+ userLayers.push({ owner, patch });
664
+ }
665
+ // Composed lazily: the rollback validation that runs at every startup needs
666
+ // the packages and the raw rows, not always the composed tree.
667
+ let composed;
668
+ const compose = () => (composed ??= composeEntries([...bundleLayers, ...userLayers]));
669
+ return {
670
+ manifest,
671
+ dependencies,
672
+ bundles,
673
+ packages,
674
+ rows,
675
+ bundleLayers,
676
+ userLayers,
677
+ get entries() {
678
+ return compose().entries;
679
+ },
680
+ // The rows that survive composition: a row an existing bundle inserts into
681
+ // a group nobody provides is dropped by the loader, so it can neither
682
+ // collide with a candidate nor be mounted twice.
683
+ get composedRows() {
684
+ return compose().mounted;
685
+ },
686
+ get composedRowsById() {
687
+ return compose().rows;
688
+ },
689
+ };
246
690
  }
247
691
 
248
692
  function compatibilityIssues(candidate, current, profileDir) {
@@ -276,6 +720,22 @@ function compatibilityIssues(candidate, current, profileDir) {
276
720
  }
277
721
  }
278
722
 
723
+ // A package that ships its own command line, peer-depends on the packages
724
+ // only a front door needs, and brings no `dsh.client` browser half is a
725
+ // rival surface: it is built to REPLACE dsh-web-app over dsh-base, not to
726
+ // run inside this profile. Advisory on its own — the rows such a bundle
727
+ // rewrites are what actually blocks it.
728
+ const surfacePeers = surfacePeersOf(candidate.manifest);
729
+ if (surfacePeers.length > 0) {
730
+ issues.push(issue(
731
+ "warn",
732
+ "rival-host-surface",
733
+ "这更像另一套宿主前端",
734
+ `${candidateName} 自带命令行入口(bin)、依赖 ${surfacePeers.join("、")},且没有 dsh.client 浏览器半边;它多半是替代当前前端的另一套门面,装进这个 profile 不会出现在界面上。`,
735
+ { package: candidateName },
736
+ ));
737
+ }
738
+
279
739
  const engineRange = candidate.manifest.engines?.node;
280
740
  if (typeof engineRange === "string") {
281
741
  try {
@@ -337,29 +797,370 @@ function compatibilityIssues(candidate, current, profileDir) {
337
797
  return issues;
338
798
  }
339
799
 
340
- function rowConflictIssues(candidateName, candidateRows, existingRows) {
800
+ /**
801
+ * Whether a row id ends up mounted in the composed tree. A row that is not
802
+ * there at all (its insert was dropped) or that ends up disabled cannot be
803
+ * half of a double mount; without this a candidate that switches the old row
804
+ * off and remounts the same module under a new id reads as mounting it twice.
805
+ */
806
+ /** One row's own `disabled` value, read the way the loader reads it. */
807
+ function disabledState(value) {
808
+ // `disabledOf`: an expression is evaluated at load time against the loader
809
+ // context, everything else goes through Boolean().
810
+ if (isJsExpr(value)) return "unknown";
811
+ return value ? "disabled" : "enabled";
812
+ }
813
+
814
+ /** How a pair of projected rows would mount together. */
815
+ function bothMount(left, right) {
816
+ const states = [left.state, right.state];
817
+ if (states.includes("disabled")) return "not-both";
818
+ return states.includes("unknown") ? "unknown" : "both";
819
+ }
820
+
821
+ /**
822
+ * Rows the loader would try to mount without a module specifier. `import(undefined)`
823
+ * fails the whole startup, so an enabled one is a blocker; a disabled one is
824
+ * dead weight the author probably did not intend.
825
+ */
826
+ function namelessRowIssues(rows, subject) {
341
827
  const issues = [];
342
- for (let index = 0; index < candidateRows.length; index++) {
343
- const row = candidateRows[index];
344
- for (let otherIndex = index + 1; otherIndex < candidateRows.length; otherIndex++) {
345
- const other = candidateRows[otherIndex];
346
- if (row.id === other.id && row.name !== other.name) {
347
- issues.push(issue("block", "candidate-duplicate-id", "插件内部存在重复加载 ID", `${candidateName} 在同一补丁中用 id=${row.id} 加载 ${row.name} ${other.name}。`, { package: candidateName }));
348
- }
349
- if (!HOST_PACKAGE_RE.test(candidateName) && row.name === other.name && row.id !== other.id) {
350
- issues.push(issue("block", "candidate-double-mount", "插件内部会重复挂载模块", `${row.name} 同时使用 id=${row.id} 和 id=${other.id}。`, { package: candidateName }));
351
- }
828
+ const ids = (list) => list.map((row) => (row.generatedId ? "(无 id 的行)" : String(row.id))).join("、");
829
+ const report = (list, code, what) => {
830
+ if (list.length === 0) return;
831
+ const live = list.filter((row) => row.state !== "disabled");
832
+ if (live.length > 0) {
833
+ issues.push(issue("block", code, `有 ${live.length} 条启用的行${what.title}`, `${subject} ${ids(live)} ${what.detail}这些行是启用状态,dsh 起不来。`, { package: subject }));
834
+ return;
352
835
  }
353
- for (const existing of existingRows) {
354
- // Updating a package naturally compares against its currently mounted
355
- // row. The same id+name is an update, not a collision.
356
- if (existing.owner === candidateName && existing.id === row.id && existing.name === row.name) continue;
357
- if (existing.id === row.id && existing.name !== row.name) {
358
- issues.push(issue("block", "loader-id-collision", "加载 ID 已被其他插件占用", `候选插件要用 id=${row.id} 加载 ${row.name},但 ${existing.owner} 已用它加载 ${existing.name}。`, { package: candidateName, conflictsWith: [existing.owner] }));
359
- } else if (existing.name === row.name && existing.id !== row.id) {
360
- issues.push(issue("block", "double-mount", "同一模块会被挂载两次", `${row.name} 已由 ${existing.owner} 以 id=${existing.id} 挂载,候选插件还会以 id=${row.id} 再挂一次。`, { package: candidateName, conflictsWith: [existing.owner] }));
361
- }
836
+ issues.push(issue("warn", code, `有 ${list.length} 条行${what.title}`, `${subject} 的 ${ids(list)} ${what.detail}它们当前是停用状态,所以还不会让 dsh 起不来——一旦被启用就会。`, { package: subject }));
837
+ };
838
+ report(rows.filter((row) => row.name === undefined && row.unusableName === undefined), "patch-row-no-name", {
839
+ title: "没有 name",
840
+ detail: "没有 name,loader 会拿 undefined import;",
841
+ });
842
+ report(rows.filter((row) => row.unusableName !== undefined), "patch-row-name-invalid", {
843
+ title: " name 不是字符串",
844
+ detail: "的 name 不是字符串(模块名必须是字符串,loader 会对它调用 name.startsWith);",
845
+ });
846
+ return issues;
847
+ }
848
+
849
+ /**
850
+ * The conflicts the candidate ADDS. Both trees are scanned whole and the
851
+ * before-set is subtracted, because a conflict is a property of the composed
852
+ * tree, not of who owns which row: flipping one existing row back on can put
853
+ * two rows nobody in this install owns into conflict, and an update that
854
+ * leaves a pre-existing conflict untouched should not be blamed for it.
855
+ */
856
+ function newConflictIssues(candidateName, beforeRows, afterRows) {
857
+ const identity = (entry) => `${entry.code}|${entry.title}|${entry.detail}`;
858
+ const existing = new Set(detectRowConflicts(beforeRows).map(identity));
859
+ return detectRowConflicts(afterRows)
860
+ .filter((entry) => !existing.has(identity(entry)))
861
+ .map((entry) => ({ ...entry, package: candidateName }));
862
+ }
863
+
864
+ /** Keys the current config carries that the replacement does not restate. */
865
+ function droppedConfigKeys(current, next) {
866
+ if (current === null || typeof current !== "object" || Array.isArray(current)) return [];
867
+ if (next === null || typeof next !== "object" || Array.isArray(next)) return Object.keys(current);
868
+ return Object.keys(current).filter((key) => !(key in next));
869
+ }
870
+
871
+ /** `id (package)` list for a report line, clipped so a 23-row patch stays readable. */
872
+ function listRows(rows, limit = 6) {
873
+ const shown = rows.slice(0, limit).map((row) => `${row.id}(${row.name})`).join("、");
874
+ return rows.length > limit ? `${shown} 等 ${rows.length} 条` : shown;
875
+ }
876
+
877
+ /** Front-door peers a candidate declares, empty unless it looks like a surface. */
878
+ function surfacePeersOf(manifest) {
879
+ if (manifest?.bin === undefined || manifest?.dsh?.client !== undefined) return [];
880
+ return Object.keys(manifest?.peerDependencies ?? {}).filter((name) => SURFACE_PEER_PACKAGES.includes(name));
881
+ }
882
+
883
+ /** A surface built on the terminal stack: it replaces the front door, never joins it. */
884
+ function isRivalFrontDoor(manifest) {
885
+ return surfacePeersOf(manifest).some((name) => TERMINAL_PEER_PACKAGES.includes(name));
886
+ }
887
+
888
+ /**
889
+ * Whether two `disabled` values leave the row in the same state. Literals are
890
+ * compared the way the loader reads them (`Boolean(options.disabled)`, so
891
+ * absent and `false` are one state); an expression on either side is compared
892
+ * by source, because what it evaluates to is only known at load time.
893
+ */
894
+ function sameDisabledState(left, right) {
895
+ if (isJsExpr(left) || isJsExpr(right)) return stableJson(left) === stableJson(right);
896
+ return Boolean(left) === Boolean(right);
897
+ }
898
+
899
+ /** The row option keys whose value differs between two composed states. */
900
+ function changedRowKeys(prev, next) {
901
+ const left = prev?.options ?? {};
902
+ const right = next?.options ?? {};
903
+ // `id` and `name` are the row's identity, not fields a patch can rewrite:
904
+ // the loader destructures both out before applying the rest, and a row that
905
+ // arrived under an id somebody else owns is an id collision, reported as
906
+ // one — not as "this plugin rewrote a field".
907
+ return [...new Set([...Object.keys(left), ...Object.keys(right)])].filter((key) => key !== "id" && key !== "name").filter((key) => (key === "disabled"
908
+ ? !sameDisabledState(left.disabled, right.disabled)
909
+ : stableJson(left[key]) !== stableJson(right[key])));
910
+ }
911
+
912
+ /**
913
+ * Key-order-independent value identity, for "did this row's field change?".
914
+ * `undefined` (the key is absent) and `null` (the key is set to null) are
915
+ * different values to the loader, so they must not share a rendering.
916
+ */
917
+ function stableJson(value, seen = new Set()) {
918
+ if (value === undefined) return "\u0000absent";
919
+ if (value === null || typeof value !== "object") return JSON.stringify(value);
920
+ // A YAML anchor can point at its own container. The parse-time check below
921
+ // rejects those, but this stays cycle-safe so no caller can be crashed by a
922
+ // value that reached it another way.
923
+ if (seen.has(value)) return "\u0000cycle";
924
+ seen.add(value);
925
+ const rendered = Array.isArray(value)
926
+ ? `[${value.map((item) => stableJson(item, seen)).join(",")}]`
927
+ : `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJson(value[key], seen)}`).join(",")}}`;
928
+ seen.delete(value);
929
+ return rendered;
930
+ }
931
+
932
+ /** Whether a parsed value contains a cycle (`config: &loop { self: *loop }`). */
933
+ function hasCycle(value, seen = new Set()) {
934
+ if (value === null || typeof value !== "object") return false;
935
+ if (seen.has(value)) return true;
936
+ seen.add(value);
937
+ const found = Object.values(value).some((item) => hasCycle(item, seen));
938
+ seen.delete(value);
939
+ return found;
940
+ }
941
+
942
+ /**
943
+ * Grade what a candidate's patch does to the rows the profile already
944
+ * composes, by composing the profile twice — with and without the candidate's
945
+ * layer in the position dsh would apply it (an update keeps its place in
946
+ * `dsh.profile.bundles`, a fresh install lands after every other bundle) — and
947
+ * diffing the two row maps. Simulating rather than reading the patch entry by
948
+ * entry is what keeps the verdict honest: an entry that targets a row the same
949
+ * patch inserted is not "missing", ten writes to one row are one changed row,
950
+ * a disable followed by an enable is not a disable, and a write the profile's
951
+ * own patch layer takes back afterwards changes nothing at all. It also means
952
+ * an update is judged on what it would newly do, not waved through because the
953
+ * installed version already owns those rows.
954
+ *
955
+ * Overriding an existing row is a documented, legitimate bundle technique —
956
+ * dsh-web-app configures dsh-base's rows exactly that way, and the docs tell
957
+ * bundle authors to — so severity comes from scale and from what changes,
958
+ * never from the act itself:
959
+ *
960
+ * - disabling, re-enabling or reconfiguring someone else's rows is a warning
961
+ * that names them, so the user confirms a change they can actually see;
962
+ * - past SURFACE_TAKEOVER_MIN rows it blocks: a patch that size is a rival
963
+ * composition rather than an addition, and installing it leaves the
964
+ * current surface without rows it needs;
965
+ * - an entry that ends up doing nothing — unknown id, a `name` guard that
966
+ * does not match, or a write a later layer takes back — is reported as
967
+ * inert, because that plugin's customization silently will not apply here.
968
+ */
969
+ /**
970
+ * Compose the profile as it would stand with the candidate's layer applied.
971
+ * The layer replaces the installed one in place when this is an update (a
972
+ * bundle keeps its position in `dsh.profile.bundles`) and lands after every
973
+ * other bundle when it is a fresh install. The baseline is the profile as it
974
+ * stands today, so an update is judged on what it would newly change — not on
975
+ * the footprint its own installed version already has.
976
+ */
977
+ function simulateCandidateLayer(candidateName, patch, current) {
978
+ const candidateLayer = { owner: candidateName, patch };
979
+ const installedAt = current.bundleLayers.findIndex((layer) => layer.owner === candidateName);
980
+ const withCandidate = installedAt === -1
981
+ ? [...current.bundleLayers, candidateLayer]
982
+ : current.bundleLayers.map((layer, index) => (index === installedAt ? candidateLayer : layer));
983
+ const simulated = composeEntries([...withCandidate, ...current.userLayers], candidateName);
984
+ return { before: current.composedRowsById, withCandidate, isUpdate: installedAt !== -1, ...simulated };
985
+ }
986
+
987
+ function patchTargetIssues(candidateName, manifest, patch, current, simulated) {
988
+ // An update is diffed even with an empty patch: dropping the layer removes
989
+ // whatever it used to provide, which is exactly the change worth reporting.
990
+ if (patch.ops.length === 0 && !simulated.isUpdate) return [];
991
+ const issues = [];
992
+ const { before, withCandidate } = simulated;
993
+
994
+ // Walk the UNION of before and after, over the FINAL trees: a row can also
995
+ // disappear — when an update stops providing the group other layers were
996
+ // inserting into, or when a `config` override replaces a group's children.
997
+ const changes = [];
998
+ const changed = new Set();
999
+ for (const id of new Set([...before.keys(), ...simulated.rows.keys()])) {
1000
+ const prev = before.get(id);
1001
+ if (prev === undefined) continue; // a row the candidate inserts, not an override
1002
+ const row = { id, name: prev.name, owner: prev.insertedBy };
1003
+ const next = simulated.rows.get(id);
1004
+ if (next === undefined) {
1005
+ changed.add(id);
1006
+ changes.push({ ...row, keys: [], kind: "removed", structural: true });
1007
+ continue;
362
1008
  }
1009
+ const keys = changedRowKeys(prev, next);
1010
+ if (keys.length === 0) continue;
1011
+ changed.add(id);
1012
+ const from = prev.options.disabled;
1013
+ const to = next.options.disabled;
1014
+ // Structure is any field but a config value: whether the row runs, what it
1015
+ // waits for, where it runs. A row can change config AND structure, so the
1016
+ // bucket it gets reported in must not decide whether the structural half
1017
+ // counts — that is graded on its own below.
1018
+ const structural = keys.some((key) => key !== "config");
1019
+ let kind = "fields";
1020
+ if (keys.includes("disabled") && (isJsExpr(from) || isJsExpr(to))) kind = "rewired";
1021
+ else if (keys.includes("disabled") && Boolean(to)) kind = "disabled";
1022
+ else if (keys.includes("disabled") && Boolean(from)) kind = "enabled";
1023
+ else if (keys.includes("config")) kind = "replaced";
1024
+ changes.push(kind === "replaced"
1025
+ ? { ...row, keys, kind, structural, dropped: droppedConfigKeys(prev.options.config, next.options.config) }
1026
+ : { ...row, keys, kind, structural });
1027
+ }
1028
+ const ofKind = (kind) => changes.filter((row) => row.kind === kind);
1029
+ const disabled = ofKind("disabled");
1030
+ const enabled = ofKind("enabled");
1031
+ const rewired = ofKind("rewired");
1032
+ const replaced = ofKind("replaced");
1033
+ const fields = ofKind("fields");
1034
+ const removed = ofKind("removed");
1035
+ // Rows the candidate brings itself are its own business: configuring one it
1036
+ // just inserted is not a write that "did nothing to the profile".
1037
+ const ownIds = new Set(patch.rows.map((row) => row.id));
1038
+ const inert = [...simulated.skipped];
1039
+ const unchanged = simulated.touched.filter((id) => !changed.has(id) && !ownIds.has(id));
1040
+ if (unchanged.length > 0) {
1041
+ // A write that lands nowhere is only worth reporting when a LATER layer
1042
+ // takes it back. Compare the bundle stack on its own: a row that moves
1043
+ // there but not in the full composition is one the profile's or home's
1044
+ // patch layer outranks, while a row that does not move either way is the
1045
+ // candidate restating a value that already holds (every update does that).
1046
+ const bundlesBefore = composeEntries(current.bundleLayers).rows;
1047
+ const bundlesAfter = composeEntries(withCandidate).rows;
1048
+ for (const id of unchanged) {
1049
+ if (changedRowKeys(bundlesBefore.get(id), bundlesAfter.get(id)).length === 0) continue;
1050
+ inert.push({ id, why: "写了,但被 profile 或 home 的 patch 层盖住了——那两层排在所有 bundle 之后" });
1051
+ }
1052
+ }
1053
+
1054
+ const touched = changes;
1055
+ const owners = [...new Set(touched.map((row) => row.owner))];
1056
+ const security = touched.filter((row) => SECURITY_ROW_RE.test(`${row.id} ${row.name}`));
1057
+ const structural = touched.filter((row) => row.structural);
1058
+ // The front-door fingerprint is corroborating evidence, never the whole
1059
+ // case: it blocks only together with a structural change — switching rows on
1060
+ // or off, trading a load-time condition for a constant, rewiring
1061
+ // `inject`/`isolate`, or rewriting a protection row. Tuning one ordinary
1062
+ // config row is what a CLI helper legitimately does, and stays a warning
1063
+ // whatever the manifest looks like.
1064
+ const rivalSeverity = new Set([...structural, ...security].map((row) => row.id)).size;
1065
+ if (rivalSeverity > 0 && isRivalFrontDoor(manifest)) {
1066
+ // Two front doors over one profile is the exclusive-slot conflict in its
1067
+ // purest form: whichever surface the user actually boots, these rows now
1068
+ // carry the other one's values.
1069
+ issues.push(issue(
1070
+ "block",
1071
+ "rival-surface-rewrite",
1072
+ "另一套门面,却要改写当前组合的行",
1073
+ `${candidateName} 是建立在终端栈上的另一套门面(自带 bin、无 dsh.client),它的补丁会改写当前 profile 的 ${touched.length} 条行(${listRows(touched)})。这个 profile 已经有自己的前端,装它不会多出一个界面,只会让这些行改用另一套门面的取值。`,
1074
+ { package: candidateName, conflictsWith: owners },
1075
+ ));
1076
+ } else if (touched.length >= SURFACE_TAKEOVER_MIN) {
1077
+ issues.push(issue(
1078
+ "block",
1079
+ "surface-takeover",
1080
+ "插件会重写这个 profile 的整套组合",
1081
+ `${candidateName} 的补丁会改写 ${touched.length} 条已有加载行:停用 ${disabled.length} 条、启用 ${enabled.length} 条、改 disabled 条件 ${rewired.length} 条、换整块 config ${replaced.length} 条、改其他字段 ${fields.length} 条(${listRows(touched)})。这是另一套完整组合,不是叠加在当前 profile 上的插件——装进来会让当前界面失去它依赖的行。`,
1082
+ { package: candidateName, conflictsWith: owners },
1083
+ ));
1084
+ } else {
1085
+ if (disabled.length > 0) {
1086
+ issues.push(issue(
1087
+ "warn",
1088
+ "patch-disables-rows",
1089
+ `插件会停用 ${disabled.length} 条已加载的行`,
1090
+ `${candidateName} 的补丁把 ${listRows(disabled)} 设为 disabled: true,这些插件会被卸载(配置项保留,改回来即可恢复)。`,
1091
+ { package: candidateName, conflictsWith: [...new Set(disabled.map((row) => row.owner))] },
1092
+ ));
1093
+ }
1094
+ if (enabled.length > 0) {
1095
+ issues.push(issue(
1096
+ "warn",
1097
+ "patch-enables-rows",
1098
+ `插件会启用 ${enabled.length} 条当前被停用的行`,
1099
+ `${candidateName} 的补丁把 ${listRows(enabled)} 从 disabled 改回启用——这些行是当前组合里被有意关掉的(例如 web 关掉了 hmr),重新打开等于替它做了决定。`,
1100
+ { package: candidateName, conflictsWith: [...new Set(enabled.map((row) => row.owner))] },
1101
+ ));
1102
+ }
1103
+ if (removed.length > 0) {
1104
+ const others = removed.filter((row) => row.owner !== candidateName);
1105
+ issues.push(issue(
1106
+ "warn",
1107
+ "patch-removes-rows",
1108
+ `装上之后有 ${removed.length} 条行不再存在`,
1109
+ `${listRows(removed)} 会从组装树里消失。`
1110
+ + (others.length > 0
1111
+ ? `其中 ${listRows(others)} 是别人插入的行——多半是它们要插进的 group 没了,loader 会连整段 insert 一起丢掉。`
1112
+ : "这些是候选包自己此前提供的行;依赖它们的插件会跟着失效。"),
1113
+ { package: candidateName, conflictsWith: [...new Set(removed.map((row) => row.owner))] },
1114
+ ));
1115
+ }
1116
+ if (rewired.length > 0) {
1117
+ issues.push(issue(
1118
+ "warn",
1119
+ "patch-replaces-condition",
1120
+ `插件会改掉 ${rewired.length} 条行的 disabled 条件`,
1121
+ `${candidateName} 的补丁把 ${listRows(rewired)} 的 disabled 在表达式和定值之间对换。\`!!js\` 表达式是加载时求值的条件(例如按平台开关),换成定值等于把那个条件永久压成一个结果,而且不会有任何提示。`,
1122
+ { package: candidateName, conflictsWith: [...new Set(rewired.map((row) => row.owner))] },
1123
+ ));
1124
+ }
1125
+ if (fields.length > 0) {
1126
+ issues.push(issue(
1127
+ "warn",
1128
+ "patch-rewrites-fields",
1129
+ `插件会改写 ${fields.length} 条已有行的其他字段`,
1130
+ `${candidateName} 的补丁改的是 ${fields.map((row) => `${row.id}(${row.keys.join("、")})`).join("、")}。inject / isolate / group 这些字段决定这行注入什么服务、落在哪个隔离域,改动同样是整键替换。`,
1131
+ { package: candidateName, conflictsWith: [...new Set(fields.map((row) => row.owner))] },
1132
+ ));
1133
+ }
1134
+ if (replaced.length > 0) {
1135
+ const lossy = replaced.filter((row) => row.dropped.length > 0);
1136
+ issues.push(issue(
1137
+ "warn",
1138
+ "patch-replaces-config",
1139
+ `插件会替换 ${replaced.length} 条已有行的整块 config`,
1140
+ `${candidateName} 的补丁按 id 覆盖 ${listRows(replaced)};patch 替换整块 config、不做深度合并。`
1141
+ + (lossy.length > 0 ? `其中 ${lossy.map((row) => `${row.id} 会丢掉 ${row.dropped.join("、")}`).join(";")}。` : ""),
1142
+ { package: candidateName, conflictsWith: [...new Set(replaced.map((row) => row.owner))] },
1143
+ ));
1144
+ }
1145
+ }
1146
+
1147
+ if (security.length > 0) {
1148
+ issues.push(issue(
1149
+ "warn",
1150
+ "patch-touches-security-row",
1151
+ "插件会改动安全相关的加载行",
1152
+ `${candidateName} 的补丁动了 ${listRows(security)}——沙箱、审批、权限这类行一旦被停用或换掉配置,影响的是所有插件和所有工具调用,不只是它自己。`,
1153
+ { package: candidateName },
1154
+ ));
1155
+ }
1156
+ if (inert.length > 0) {
1157
+ issues.push(issue(
1158
+ "warn",
1159
+ "patch-target-missing",
1160
+ `插件有 ${inert.length} 条补丁在这个 profile 上不生效`,
1161
+ `${inert.map((row) => `${row.id}:${row.why}`).join(";")}。dsh 对打不中的 patch 只打一条 stderr 警告然后照常启动,所以这些定制会静默失效——通常说明这个插件是给另一种 profile 写的。`,
1162
+ { package: candidateName },
1163
+ ));
363
1164
  }
364
1165
  return issues;
365
1166
  }
@@ -371,10 +1172,21 @@ function detectRowConflicts(rows) {
371
1172
  const row = rows[index];
372
1173
  for (let otherIndex = index + 1; otherIndex < rows.length; otherIndex++) {
373
1174
  const other = rows[otherIndex];
374
- if (row.id === other.id && row.name !== other.name) {
375
- issues.push(issue("block", "loader-id-collision", "加载 ID 被重复占用", `${row.owner} 用 id=${row.id} 加载 ${row.name},而 ${other.owner} 也用它加载 ${other.name}。`, { conflictsWith: [row.owner, other.owner] }));
376
- } else if (row.owner !== other.owner && row.name === other.name && row.id !== other.id) {
377
- issues.push(issue("block", "double-mount", "同一模块被挂载两次", `${row.name} 同时被 ${row.owner}(id=${row.id})和 ${other.owner}(id=${other.id})挂载。`, { conflictsWith: [row.owner, other.owner] }));
1175
+ if (row.id === other.id) {
1176
+ issues.push(issue("block", "loader-id-collision", "加载 ID 被重复占用", row.name === other.name
1177
+ ? `${row.owner} ${other.owner} 都用 id=${row.id} 加载 ${row.name};loader 见到重复 id 直接抛错。`
1178
+ : `${row.owner} id=${row.id} 加载 ${row.name},而 ${other.owner} 也用它加载 ${other.name}。`, { conflictsWith: [row.owner, other.owner] }));
1179
+ } else if (row.name !== undefined && row.name === other.name && row.id !== other.id
1180
+ // Mounting one module twice on purpose is something the in-box
1181
+ // bundles do (dsh-base mounts dsh-tool-subagent under two ids); from
1182
+ // anyone else, in one layer or across two, it is a defect.
1183
+ && (row.owner !== other.owner || !HOST_PACKAGE_RE.test(String(row.owner ?? "")))) {
1184
+ const together = bothMount(row, other);
1185
+ if (together === "both") {
1186
+ issues.push(issue("block", "double-mount", "同一模块被挂载两次", `${row.name} 同时被 ${row.owner}(id=${row.id})和 ${other.owner}(id=${other.id})挂载。`, { conflictsWith: [row.owner, other.owner] }));
1187
+ } else if (together === "unknown") {
1188
+ issues.push(issue("warn", "double-mount-conditional", "是否重复挂载取决于加载时的条件", `${row.name} 会被 ${row.owner}(id=${row.id})和 ${other.owner}(id=${other.id})各挂一次,其中至少一条的 disabled 是 \`!!js\` 表达式——真正挂几份要到加载时求值才知道。`, { conflictsWith: [row.owner, other.owner] }));
1189
+ }
378
1190
  }
379
1191
  }
380
1192
  }
@@ -411,7 +1223,8 @@ export function validateInstalledProfile(profileDir) {
411
1223
  issues.push(issue("block", "host-module-shadow", "插件复制了 DSH 宿主模块", `${name} 把 ${hostDeps.join(", ")} 放在 dependencies 中,会产生双模块实例并破坏工具调度。`, { package: name, conflictsWith: hostDeps }));
412
1224
  }
413
1225
  }
414
- issues.push(...detectRowConflicts(current.rows));
1226
+ issues.push(...detectRowConflicts(current.composedRows));
1227
+ issues.push(...namelessRowIssues(current.composedRows, "profile"));
415
1228
  const blockers = issues.filter((entry) => entry.severity === "block");
416
1229
  const warnings = issues.filter((entry) => entry.severity === "warn");
417
1230
  const verdict = blockers.length > 0 ? "blocked" : warnings.length > 0 ? "warning" : "safe";
@@ -477,18 +1290,26 @@ export function inspectCandidate({ profileDir, candidateManifestPath, spec }) {
477
1290
  issues.push(...currentIssues.map((entry) => ({ ...entry, severity: "warn", code: `existing-${entry.code}` })));
478
1291
  issues.push(...compatibilityIssues(candidate, current, profileDir));
479
1292
 
480
- let rows = rowsForPackage(candidate, issues, "candidate");
1293
+ const patch = patchOpsForPackage(candidate, issues, "candidate");
1294
+ let rows = patch.rows;
1295
+ let clientPatch;
481
1296
  const kind = typeof candidate.manifest.dsh?.bundle?.patch === "string"
482
1297
  ? "bundle"
483
1298
  : candidate.manifest.dsh?.client !== undefined ? "client" : "plain";
484
1299
  if (kind === "client" && candidateName.length > 0) {
485
1300
  rows = [{ id: clientRowId(candidateName), name: candidateName, source: "candidate-client", owner: candidateName }];
1301
+ clientPatch = { ops: [{ kind: "insert", into: undefined, raw: [{ id: clientRowId(candidateName), name: candidateName }], rows }], rows, overrides: [] };
486
1302
  }
487
1303
  if (kind === "plain") {
488
1304
  issues.push(issue("warn", "not-a-plugin", "该包没有声明 DSH 插件入口", `${candidateName || spec} 没有 dsh.bundle.patch 或 dsh.client,安装后只是普通依赖。`, { package: candidateName || undefined }));
489
1305
  }
490
- const existingRows = current.rows.filter((row) => row.owner !== candidateName);
491
- issues.push(...rowConflictIssues(candidateName, rows, existingRows));
1306
+ // One simulation feeds both checks: what mounts after the candidate's layer
1307
+ // decides whether a "double mount" is really two live rows, and the same
1308
+ // composition is what the row-change grading diffs against.
1309
+ const simulated = simulateCandidateLayer(candidateName, clientPatch ?? patch, current);
1310
+ issues.push(...namelessRowIssues(simulated.mounted.filter((row) => row.owner === candidateName), candidateName || spec));
1311
+ issues.push(...newConflictIssues(candidateName, current.composedRows, simulated.mounted));
1312
+ issues.push(...patchTargetIssues(candidateName, candidate.manifest, patch, current, simulated));
492
1313
 
493
1314
  // UI replacements historically predate exclusiveGroups. Keep the
494
1315
  // heuristic advisory-only to avoid blocking legitimate sidebar extensions.
@@ -541,22 +1362,37 @@ export function inspectRemoteCandidate({ profileDir, manifest, patchText, spec }
541
1362
  const kind = typeof manifest?.dsh?.bundle?.patch === "string"
542
1363
  ? "bundle"
543
1364
  : manifest?.dsh?.client !== undefined ? "client" : "plain";
544
- let rows = [];
1365
+ let patch = emptyPatchOps();
1366
+ // "Not fetched" is not "empty": treating an unfetched patch as `[]` would
1367
+ // simulate an update that withdraws every row the installed version
1368
+ // provides, and report that invented removal as a takeover.
1369
+ let patchKnown = kind !== "bundle";
545
1370
  if (kind === "bundle") {
546
1371
  if (patchText === undefined) {
547
1372
  issues.push(issue("warn", "patch-unverified", "补丁未获取,加载冲突未检查", `${candidateName || spec} 声明了 ${manifest.dsh.bundle.patch},但浏览时未能获取该文件;加载 ID 冲突要在安装预检时才会验证。`, { package: candidateName || undefined }));
548
1373
  } else {
549
- rows = parsePatchText(patchText, "candidate", issues, candidateName || spec);
1374
+ patch = parsePatchText(patchText, "candidate", issues, candidateName || spec);
1375
+ patchKnown = true;
550
1376
  }
551
1377
  }
1378
+ let rows = patch.rows;
1379
+ let clientPatch;
552
1380
  if (kind === "client" && candidateName.length > 0) {
1381
+ // A browser-half package has no bundle patch; the client module system
1382
+ // mounts it. Model that as the one row it effectively contributes, so it
1383
+ // goes through the same composition as everything else.
553
1384
  rows = [{ id: clientRowId(candidateName), name: candidateName, source: "candidate-client", owner: candidateName }];
1385
+ clientPatch = { ops: [{ kind: "insert", into: undefined, raw: [{ id: clientRowId(candidateName), name: candidateName }], rows }], rows, overrides: [] };
554
1386
  }
555
1387
  if (kind === "plain") {
556
1388
  issues.push(issue("warn", "not-a-plugin", "该包没有声明 DSH 插件入口", `${candidateName || spec} 没有 dsh.bundle.patch 或 dsh.client,安装后只是普通依赖。`, { package: candidateName || undefined }));
557
1389
  }
558
- const existingRows = current.rows.filter((row) => row.owner !== candidateName);
559
- issues.push(...rowConflictIssues(candidateName, rows, existingRows));
1390
+ const simulated = patchKnown ? simulateCandidateLayer(candidateName, clientPatch ?? patch, current) : undefined;
1391
+ if (simulated !== undefined) {
1392
+ issues.push(...namelessRowIssues(simulated.mounted.filter((row) => row.owner === candidateName), candidateName || spec));
1393
+ issues.push(...newConflictIssues(candidateName, current.composedRows, simulated.mounted));
1394
+ issues.push(...patchTargetIssues(candidateName, manifest, patch, current, simulated));
1395
+ }
560
1396
 
561
1397
  if (/sidebar/i.test(candidateName) && current.dependencies.some((name) => name !== candidateName && /sidebar/i.test(name))) {
562
1398
  const other = current.dependencies.find((name) => name !== candidateName && /sidebar/i.test(name));
@@ -1532,7 +2368,7 @@ export function validateRemoveCompletion(profileDir, candidateName) {
1532
2368
  const profilePatch = join(profileDir, "cordis.patch.yml");
1533
2369
  if (!existsSync(profilePatch)) return { ok: issues.length === 0, issues };
1534
2370
  const patchIssues = [];
1535
- const rows = parsePatch(profilePatch, "profile", patchIssues, "profile cordis.patch.yml");
2371
+ const { rows } = parsePatch(profilePatch, "profile", patchIssues, "profile cordis.patch.yml");
1536
2372
  issues.push(...patchIssues.filter((entry) => entry.severity === "block"));
1537
2373
  if (rows.some((row) => row.name === candidateName)) {
1538
2374
  issues.push(issue("block", "remove-incomplete", "Plugin removal is incomplete", `${candidateName} is still mounted by a profile cordis.patch.yml row.`));
@@ -1804,6 +2640,548 @@ async function selfTest() {
1804
2640
  if (malformed.verdict !== "blocked" || !malformed.issues.some((entry) => entry.code === "patch-invalid")) throw new Error("remote malformed patch fixture failed");
1805
2641
  }
1806
2642
 
2643
+ // ── differential: our composition vs the loader's own applyEntryPatches ──
2644
+ //
2645
+ // Every check in this file rests on one claim — that the tree we compose
2646
+ // is the tree dsh boots. Example fixtures can only show that the cases we
2647
+ // thought of agree. This feeds the SAME layers to the official
2648
+ // `applyEntryPatches` and to `composeEntries`, on hand-written shapes and
2649
+ // on randomly generated ones, and compares the resulting trees.
2650
+ //
2651
+ // It is a HARD failure when the include package will not import. This is
2652
+ // the one test the rest of the file leans on, and it used to skip itself
2653
+ // with a console line — so the day the fixture tree stopped carrying
2654
+ // @deepseek-ai/cordis-plugin-include (it arrives as a transitive
2655
+ // dependency of dsh-app-boot, and nothing pinned it), the load-bearing
2656
+ // check would quietly stop running while the suite still said PASS. The
2657
+ // package is a declared fixture dependency now; skipping is opt-in and has
2658
+ // to be typed out.
2659
+ {
2660
+ let applyEntryPatches;
2661
+ try {
2662
+ ({ applyEntryPatches } = await import("@deepseek-ai/cordis-plugin-include"));
2663
+ } catch (error) {
2664
+ if (process.env.DSH_GUARD_SKIP_DIFFERENTIAL !== "1") {
2665
+ throw new Error(`differential vs applyEntryPatches cannot run: @deepseek-ai/cordis-plugin-include failed to import (${error.message}). `
2666
+ + "Install the locked fixture dependencies (npm ci --prefix .github/fixtures/guard-tests) — or set DSH_GUARD_SKIP_DIFFERENTIAL=1 to run the rest of the suite without the one check that proves our composition matches the loader's.");
2667
+ }
2668
+ console.log("SKIP differential vs applyEntryPatches (DSH_GUARD_SKIP_DIFFERENTIAL=1)");
2669
+ }
2670
+ if (applyEntryPatches !== undefined) {
2671
+ const composeOurs = (documents) => composeEntries(documents.map((document, index) => ({
2672
+ owner: `L${index}`,
2673
+ patch: parsePatchDocument(structuredClone(document), "diff", [], `L${index}`),
2674
+ }))).data;
2675
+ // The launcher's own call, verbatim: every layer flattened, applied
2676
+ // once over an empty root.
2677
+ const composeTheirs = (documents) => applyEntryPatches([], structuredClone(documents.flat()), () => {});
2678
+ const compare = (documents, label) => {
2679
+ shapes += 1;
2680
+ const ours = JSON.stringify(composeOurs(documents));
2681
+ const theirs = JSON.stringify(composeTheirs(documents));
2682
+ if (ours !== theirs) {
2683
+ throw new Error(`differential mismatch (${label})\n patches: ${JSON.stringify(documents)}\n ours: ${ours}\n loader: ${theirs}`);
2684
+ }
2685
+ };
2686
+
2687
+ // Hand-written shapes, each one a semantic an earlier model got wrong.
2688
+ let shapes = 0;
2689
+ compare([[{ insert: [{ id: "a", name: "mod-a" }] }], [{ id: "a", config: { x: 1 } }]], "override after insert");
2690
+ compare([[{ insert: [{ id: "g", name: "grp", group: true, config: [{ id: "c", name: "mod-c" }] }] }], [{ id: "g", config: [{ id: "d", name: "mod-d" }] }]], "group config replaced");
2691
+ compare([[{ insert: [{ id: "g", name: "grp", group: true, config: [] }] }], [{ id: "g", insert: [{ id: "c", name: "mod-c" }] }]], "insert into group");
2692
+ compare([[{ insert: [{ id: "g", name: "grp", group: true, config: [] }] }], [{ id: "nope", insert: [{ id: "c", name: "mod-c" }] }]], "insert into missing group");
2693
+ compare([[{ insert: [{ id: "a", name: "mod-a" }] }], [{ id: "a", name: "other", config: { x: 1 } }]], "name guard mismatch");
2694
+ compare([[{ insert: [{ id: "a", name: "mod-a" }] }], [{ config: { x: 1 } }]], "id-less non-insert entry");
2695
+ compare([[{ insert: [{ name: "mod-a" }] }], [{ id: "a", config: { x: 1 } }]], "id-less inserted row");
2696
+ compare([[{ insert: [{ id: "g", name: "grp", group: true, config: [{ id: "c", name: "mod-c" }] }] }], [{ id: "c", disabled: true }]], "target a nested row");
2697
+ // Non-string values: `ensureId` only mints an id when the written one
2698
+ // is falsy, and every comparison is `===`.
2699
+ compare([[{ insert: [{ id: 7, name: "mod-a" }] }], [{ id: 7, config: { x: 1 } }]], "numeric id targeted by a numeric patch");
2700
+ compare([[{ insert: [{ id: 7, name: "mod-a" }] }], [{ id: "7", config: { x: 1 } }]], "numeric id is not its string spelling");
2701
+ compare([[{ insert: [{ id: "a", name: "mod-a" }] }], [{ id: "a", name: 7, config: { x: 1 } }]], "numeric name guard");
2702
+ // `if (name && ...)`: a falsy name arms no guard at all.
2703
+ for (const falsy of [0, false, ""]) {
2704
+ compare([[{ insert: [{ id: "a", name: "mod-a" }] }], [{ id: "a", name: falsy, config: { x: 1 } }]], `falsy name guard ${JSON.stringify(falsy)}`);
2705
+ }
2706
+ compare([[{ insert: [{ id: 0, name: "mod-a" }, { id: false, name: "mod-b" }] }], [{ id: "a", config: { x: 1 } }]], "falsy ids get minted, not matched");
2707
+ compare(
2708
+ [[{ insert: [{ id: "g", name: "grp", group: true, config: [] }] }], [{ id: "g", config: [{ id: "late", name: "mod-late" }] }], [{ id: "late", config: { y: 2 } }]],
2709
+ "a row added by a config override is addressable to nobody",
2710
+ );
2711
+
2712
+ const fixedShapes = shapes;
2713
+ // Generated shapes: same alphabet, random order. A seeded PRNG so a
2714
+ // failure names the seed that reproduces it.
2715
+ const ids = ["a", "b", "g", "h", "c", 7, 0];
2716
+ const names = ["mod-a", "mod-b", "grp"];
2717
+ for (let seed = 1; seed <= 300; seed++) {
2718
+ let state = seed * 2654435761 % 4294967296;
2719
+ const next = () => {
2720
+ state = (state * 1664525 + 1013904223) % 4294967296;
2721
+ return state / 4294967296;
2722
+ };
2723
+ const pick = (list) => list[Math.floor(next() * list.length)];
2724
+ const row = (depth) => {
2725
+ const isGroup = depth < 2 && next() < 0.3;
2726
+ const entry = {};
2727
+ // 7 and 0 are in the alphabet on purpose: one is a truthy
2728
+ // non-string id, the other is falsy and gets minted.
2729
+ if (next() < 0.85) entry.id = pick(ids);
2730
+ entry.name = isGroup ? "grp" : pick(names);
2731
+ if (isGroup) {
2732
+ entry.group = true;
2733
+ entry.config = Array.from({ length: Math.floor(next() * 3) }, () => row(depth + 1));
2734
+ } else if (next() < 0.5) {
2735
+ entry.config = { value: Math.floor(next() * 5) };
2736
+ }
2737
+ if (next() < 0.3) entry.disabled = next() < 0.5 ? true : { __jsExpr: "process.platform === 'win32'" };
2738
+ return entry;
2739
+ };
2740
+ const documents = Array.from({ length: 1 + Math.floor(next() * 3) }, () => (
2741
+ Array.from({ length: 1 + Math.floor(next() * 4) }, () => {
2742
+ const roll = next();
2743
+ if (roll < 0.45) return { insert: Array.from({ length: 1 + Math.floor(next() * 2) }, () => row(0)) };
2744
+ if (roll < 0.6) return { id: pick(ids), insert: [row(1)] };
2745
+ const patch = { id: pick(ids) };
2746
+ if (next() < 0.3) patch.name = next() < 0.25 ? pick([0, false, ""]) : pick(names);
2747
+ if (next() < 0.5) patch.config = { value: Math.floor(next() * 5) };
2748
+ if (next() < 0.4) patch.disabled = next() < 0.5;
2749
+ if (next() < 0.3) patch.inject = ["loader"];
2750
+ return patch;
2751
+ })
2752
+ ));
2753
+ compare(documents, `seed ${seed}`);
2754
+ }
2755
+ console.log(`PASS differential vs applyEntryPatches (${fixedShapes} shapes + ${shapes - fixedShapes} generated)`);
2756
+ }
2757
+ }
2758
+
2759
+ // In-box bundles live in the shared profiles/node_modules, one level above
2760
+ // the profile, so they must resolve the way Node does rather than through
2761
+ // the strict profile-only lookup. Without that every row dsh-base
2762
+ // contributes is invisible and a candidate colliding with one reads safe.
2763
+ {
2764
+ const p = join(root, "profiles", "inbox");
2765
+ mkdirSync(join(p, "node_modules"), { recursive: true });
2766
+ writeFileSync(join(p, "package.json"), JSON.stringify({
2767
+ dependencies: {},
2768
+ dsh: { profile: { bundles: ["@deepseek-ai/fake-base-fixture"] } },
2769
+ }));
2770
+ writeFileSync(join(p, "cordis.patch.yml"), "[]\n");
2771
+ // The real fallback contains links, not copied directories. Recovering
2772
+ // the dsh link's target must take us to the same install anchor the
2773
+ // launcher uses, even when the rest of the farm is only half-built.
2774
+ const installScope = join(root, "fake-dsh-install", "node_modules", "@deepseek-ai");
2775
+ const appDir = join(installScope, "dsh");
2776
+ mkdirSync(appDir, { recursive: true });
2777
+ writeFileSync(join(appDir, "package.json"), JSON.stringify({ name: "@deepseek-ai/dsh", version: "1.0.0" }));
2778
+ const farmAppDir = join(root, "profiles", "node_modules", "@deepseek-ai", "dsh");
2779
+ mkdirSync(dirname(farmAppDir), { recursive: true });
2780
+ symlinkSync(appDir, farmAppDir, process.platform === "win32" ? "junction" : "dir");
2781
+ const baseDir = join(installScope, "fake-base-fixture");
2782
+ mkdirSync(baseDir, { recursive: true });
2783
+ writeFileSync(join(baseDir, "package.json"), JSON.stringify({
2784
+ name: "@deepseek-ai/fake-base-fixture", version: "1.0.0", dsh: { bundle: { patch: "./cordis.patch.yml" } },
2785
+ }));
2786
+ writeFileSync(join(baseDir, "cordis.patch.yml"), "- insert:\n - id: tools\n name: '@deepseek-ai/fake-tools'\n");
2787
+ // …and a profile-local copy of the same bundle mounting something else.
2788
+ // The launcher resolves bundles installation-anchor first, profile
2789
+ // second, so this copy is NOT what boots; a scan that picked it up would
2790
+ // compose a tree the profile never loads.
2791
+ const shadowDir = join(p, "node_modules", "@deepseek-ai", "fake-base-fixture");
2792
+ mkdirSync(shadowDir, { recursive: true });
2793
+ writeFileSync(join(shadowDir, "package.json"), JSON.stringify({
2794
+ name: "@deepseek-ai/fake-base-fixture", version: "9.9.9", dsh: { bundle: { patch: "./cordis.patch.yml" } },
2795
+ }));
2796
+ writeFileSync(join(shadowDir, "cordis.patch.yml"), "- insert:\n - id: shadow-only\n name: '@deepseek-ai/fake-shadow'\n");
2797
+ const takes = (id) => inspectRemoteCandidate({
2798
+ profileDir: p,
2799
+ manifest: { name: "takes-tools", version: "1.0.0", dsh: { bundle: { patch: "./cordis.patch.yml" } } },
2800
+ patchText: `- insert:\n - id: ${id}\n name: takes-tools\n`,
2801
+ spec: "github:owner/takes-tools",
2802
+ });
2803
+ const collision = takes("tools");
2804
+ if (collision.verdict !== "blocked" || !collision.issues.some((entry) => entry.code === "loader-id-collision")) throw new Error("in-box bundle rows must take part in the scan");
2805
+ // Same id AND same module is a duplicate id all the same: the loader
2806
+ // refuses the whole tree ("duplicate loader entry id"), so this is a
2807
+ // profile that will not start, not a harmless restatement.
2808
+ const sameModule = inspectRemoteCandidate({
2809
+ profileDir: p,
2810
+ manifest: { name: "takes-tools", version: "1.0.0", dsh: { bundle: { patch: "./cordis.patch.yml" } } },
2811
+ patchText: "- insert:\n - id: tools\n name: '@deepseek-ai/fake-tools'\n",
2812
+ spec: "github:owner/takes-tools",
2813
+ });
2814
+ if (sameModule.verdict !== "blocked" || !sameModule.issues.some((entry) => entry.code === "loader-id-collision")) throw new Error("re-inserting an existing id with the same module must block too");
2815
+ if (takes("shadow-only").verdict !== "safe") throw new Error("a profile-local shadow of an in-box bundle must not be the composed one");
2816
+ }
2817
+
2818
+ // Patch entries that are NOT inserts: the candidate reaches into rows the
2819
+ // profile already composes. This is the hole the browsing badge had — a
2820
+ // bundle rewriting two dozen existing rows scanned as "safe, zero issues".
2821
+ {
2822
+ const p = join(root, "profiles", "override");
2823
+ mkdirSync(join(p, "node_modules", "host-bundle"), { recursive: true });
2824
+ writeFileSync(join(p, "package.json"), JSON.stringify({
2825
+ dependencies: { "host-bundle": "1.0.0" },
2826
+ dsh: { profile: { bundles: ["host-bundle"] } },
2827
+ }));
2828
+ writeFileSync(join(p, "cordis.patch.yml"), "[]\n");
2829
+ writeFileSync(join(p, "node_modules", "host-bundle", "package.json"), JSON.stringify({
2830
+ name: "host-bundle", version: "1.0.0", dsh: { bundle: { patch: "./cordis.patch.yml" } },
2831
+ }));
2832
+ // Twelve rows: past the takeover threshold, one of them a security row.
2833
+ const ids = ["approval", "tool-a", "tool-b", "tool-c", "tool-d", "tool-e", "tool-f", "tool-g", "tool-h", "tool-i", "tool-j", "tool-k"];
2834
+ writeFileSync(
2835
+ join(p, "node_modules", "host-bundle", "cordis.patch.yml"),
2836
+ `- insert:\n${ids.map((id) => ` - id: ${id}\n name: host-${id}\n config:\n keep: 1\n also: 2\n`).join("")}`,
2837
+ );
2838
+ const scan = (patchText, name = "candidate-bundle") => inspectRemoteCandidate({
2839
+ profileDir: p,
2840
+ manifest: { name, version: "1.0.0", dsh: { bundle: { patch: "./cordis.patch.yml" } } },
2841
+ patchText,
2842
+ spec: `github:owner/${name}`,
2843
+ });
2844
+
2845
+ const replaces = scan("- id: tool-a\n config:\n keep: 9\n");
2846
+ if (replaces.verdict !== "warning") throw new Error("overriding an existing row must not scan as safe");
2847
+ const replaced = replaces.issues.find((entry) => entry.code === "patch-replaces-config");
2848
+ if (replaced === undefined) throw new Error("config replacement fixture failed");
2849
+ if (!replaced.detail.includes("also")) throw new Error("config replacement must name the keys it drops");
2850
+
2851
+ if (!scan("- id: tool-a\n disabled: true\n").issues.some((entry) => entry.code === "patch-disables-rows")) throw new Error("row disable fixture failed");
2852
+ if (!scan("- id: approval\n disabled: true\n").issues.some((entry) => entry.code === "patch-touches-security-row")) throw new Error("security row fixture failed");
2853
+
2854
+ const takeover = scan(ids.map((id) => `- id: ${id}\n disabled: true\n`).join(""));
2855
+ if (takeover.verdict !== "blocked" || !takeover.issues.some((entry) => entry.code === "surface-takeover")) throw new Error("surface takeover fixture failed");
2856
+
2857
+ // Entries that hit nothing: an unknown id, and a `name` guard the loader
2858
+ // would reject. Neither changes the profile, both are reported as inert.
2859
+ const inert = scan("- id: not-here\n config:\n x: 1\n- id: tool-b\n name: someone-else\n disabled: true\n");
2860
+ if (!inert.issues.some((entry) => entry.code === "patch-target-missing")) throw new Error("inert patch fixture failed");
2861
+ if (inert.issues.some((entry) => entry.code === "patch-disables-rows")) throw new Error("a name-guard mismatch must not count as a disable");
2862
+
2863
+ // The candidate's layer is composed, not read entry by entry, so the
2864
+ // loader's own ordering holds: a row this patch inserted is a valid
2865
+ // target, repeated writes to one row are ONE changed row, and a disable
2866
+ // the same patch takes back is not a disable.
2867
+ const ownRow = scan("- insert:\n - id: mine\n name: candidate-bundle\n- id: mine\n config:\n a: 1\n");
2868
+ if (ownRow.verdict !== "safe") throw new Error(`overriding a row the same patch inserted must stay safe: ${ownRow.summary}`);
2869
+ const repeated = scan(Array.from({ length: SURFACE_TAKEOVER_MIN }, (_, index) => `- id: tool-a\n config:\n keep: ${index}\n`).join(""));
2870
+ if (repeated.issues.some((entry) => entry.code === "surface-takeover")) throw new Error("repeated writes to one row must count as one row");
2871
+ const rollback = scan("- id: tool-a\n disabled: true\n- id: tool-a\n disabled: false\n");
2872
+ if (rollback.issues.some((entry) => entry.code === "patch-disables-rows")) throw new Error("a disable the same patch reverts must not be reported");
2873
+
2874
+ // The profile's own patch layer, and the home-level layer above it, are
2875
+ // applied after every bundle: a candidate writing the same key loses.
2876
+ for (const [file, label] of [[join(p, "cordis.patch.yml"), "profile"], [join(root, "cordis.patch.yml"), "home"]]) {
2877
+ writeFileSync(file, "- id: tool-c\n config:\n keep: 5\n");
2878
+ const outranked = scan("- id: tool-c\n config:\n keep: 9\n");
2879
+ if (outranked.issues.some((entry) => entry.code === "patch-replaces-config")) throw new Error(`the ${label} patch layer must outrank a candidate bundle`);
2880
+ if (!outranked.issues.some((entry) => entry.code === "patch-target-missing")) throw new Error(`a key the ${label} layer sets afterwards is inert`);
2881
+ rmSync(file, { force: true });
2882
+ }
2883
+ writeFileSync(join(p, "cordis.patch.yml"), "[]\n");
2884
+
2885
+ // A rival front door: own command line, terminal peer, no browser half.
2886
+ const rival = inspectRemoteCandidate({
2887
+ profileDir: p,
2888
+ manifest: {
2889
+ name: "rival-tui",
2890
+ version: "1.0.0",
2891
+ bin: { "rival-tui": "./bin.js" },
2892
+ peerDependencies: { "@deepseek-ai/dsh-terminal": "*" },
2893
+ dsh: { bundle: { patch: "./cordis.patch.yml" } },
2894
+ },
2895
+ patchText: "- insert:\n - id: rival-row\n name: rival-tui\n",
2896
+ spec: "github:owner/rival-tui",
2897
+ });
2898
+ if (!rival.issues.some((entry) => entry.code === "rival-host-surface")) throw new Error("rival surface fingerprint fixture failed");
2899
+ if (rival.verdict !== "warning") throw new Error("a rival surface that only inserts its own rows stays advisory");
2900
+
2901
+ // …and the fingerprint alone never escalates an ordinary config tweak:
2902
+ // shipping a CLI beside a plugin is legitimate, so only the behaviour
2903
+ // that makes two surfaces incompatible — switching rows off, or
2904
+ // rewriting a protection row — turns it into a conflict.
2905
+ const asRival = (patchText) => inspectRemoteCandidate({
2906
+ profileDir: p,
2907
+ manifest: {
2908
+ name: "rival-tui",
2909
+ version: "1.0.0",
2910
+ bin: { "rival-tui": "./bin.js" },
2911
+ peerDependencies: { "@deepseek-ai/dsh-terminal": "*" },
2912
+ dsh: { bundle: { patch: "./cordis.patch.yml" } },
2913
+ },
2914
+ patchText,
2915
+ spec: "github:owner/rival-tui",
2916
+ });
2917
+ const tweak = asRival("- insert:\n - id: rival-row\n name: rival-tui\n- id: tool-a\n config:\n keep: 9\n");
2918
+ if (tweak.verdict !== "warning" || tweak.issues.some((entry) => entry.code === "rival-surface-rewrite")) throw new Error("a CLI-shipping bundle tuning one ordinary row must not be blocked by the fingerprint");
2919
+ // …but a row that changes config AND structure counts as structural: the
2920
+ // bucket it gets reported in must not decide the severity.
2921
+ const both = asRival("- id: tool-a\n config:\n keep: 9\n inject:\n loader: true\n");
2922
+ if (both.verdict !== "blocked" || !both.issues.some((entry) => entry.code === "rival-surface-rewrite")) throw new Error("a config change alongside an inject rewrite is still structural");
2923
+ for (const patchText of ["- id: tool-a\n disabled: true\n", "- id: approval\n config:\n strict: false\n"]) {
2924
+ const takeover = asRival(patchText);
2925
+ if (takeover.verdict !== "blocked" || !takeover.issues.some((entry) => entry.code === "rival-surface-rewrite")) throw new Error(`rival surface must block on ${patchText.trim()}`);
2926
+ }
2927
+
2928
+ // A candidate that switches the old row off and remounts the same module
2929
+ // under a new id mounts it ONCE. Comparing raw insert rows called that a
2930
+ // double mount; the composed state is what decides.
2931
+ const remount = scan("- id: tool-a\n disabled: true\n- insert:\n - id: tool-a-next\n name: host-tool-a\n");
2932
+ if (remount.issues.some((entry) => entry.code === "double-mount")) throw new Error("a disabled row cannot be half of a double mount");
2933
+ if (!remount.issues.some((entry) => entry.code === "patch-disables-rows")) throw new Error("the disable itself is still reported");
2934
+
2935
+ // What the loader THROWS on must never pass as installable: it
2936
+ // destructures every entry (so a null entry is a TypeError) and spreads
2937
+ // every `insert` (so a non-list one is too, or fills the tree with
2938
+ // junk). A null row inside an `insert` list is the same crash one level
2939
+ // down — buildMap reads `.id` on it.
2940
+ for (const broken of ["- null\n", "- insert: 5\n", "- insert:\n - null\n"]) {
2941
+ const malformed = scan(broken);
2942
+ if (malformed.verdict !== "blocked" || !malformed.issues.some((entry) => entry.code === "patch-entry-invalid")) throw new Error(`malformed patch entry must block: ${JSON.stringify(broken)}`);
2943
+ }
2944
+ // What the loader merely WARNS about is inert, not fatal: a scalar or
2945
+ // list entry destructures to an id-less patch, which it skips.
2946
+ for (const ignored of ["- 42\n", "- - nested\n"]) {
2947
+ const inertEntry = scan(ignored);
2948
+ if (inertEntry.verdict !== "warning" || !inertEntry.issues.some((entry) => entry.code === "patch-entry-ignored")) throw new Error(`an id-less patch entry is inert, not fatal: ${JSON.stringify(ignored)}`);
2949
+ }
2950
+
2951
+ // Inserting into a group that does not exist (or is not a group) drops
2952
+ // the whole list, so nothing mounts and a later entry targeting those
2953
+ // rows finds nothing either.
2954
+ const orphan = scan("- id: no-such-group\n insert:\n - id: child\n name: cand-child\n- id: child\n config:\n x: 1\n");
2955
+ if (orphan.verdict !== "warning" || !orphan.issues.some((entry) => entry.code === "patch-target-missing")) throw new Error("insert into a missing group must be reported as inert");
2956
+ // …and a row that never lands cannot collide with anything either.
2957
+ const orphanCollision = scan("- id: no-such-group\n insert:\n - id: tool-a\n name: someone-else\n");
2958
+ if (orphanCollision.issues.some((entry) => entry.code === "loader-id-collision")) throw new Error("a dropped insert must not be reported as a loader-id collision");
2959
+
2960
+ // `insert` and `group` are read as plain truthiness by the loader, so a
2961
+ // falsy one takes the other branch — an `insert: 0` entry is an id-less
2962
+ // patch it warns about, not an insert to validate.
2963
+ const falsyInsert = scan("- insert: 0\n");
2964
+ if (falsyInsert.verdict !== "warning" || !falsyInsert.issues.some((entry) => entry.code === "patch-entry-ignored")) throw new Error("a falsy insert is an id-less patch entry, not a broken insert");
2965
+
2966
+ // A row whose `disabled` is truthy-but-not-`true` is still off: the
2967
+ // loader reads it through Boolean(), so `yes` disables the row.
2968
+ writeFileSync(join(p, "node_modules", "host-bundle", "cordis.patch.yml"),
2969
+ `- insert:\n${ids.map((id) => ` - id: ${id}\n name: host-${id}\n config:\n keep: 1\n also: 2\n`).join("")} - id: truthy-off\n name: shared-module\n disabled: yes\n`);
2970
+ const remountTruthy = scan("- insert:\n - id: truthy-on\n name: shared-module\n");
2971
+ if (remountTruthy.issues.some((entry) => entry.code === "double-mount")) throw new Error("a row disabled by a truthy value is not mounted");
2972
+
2973
+ // Every top-level field of a row is replaced, not just config/disabled:
2974
+ // `inject` decides what services the row waits for.
2975
+ const rewire = scan("- id: tool-a\n inject:\n loader: true\n");
2976
+ if (rewire.verdict !== "warning" || !rewire.issues.some((entry) => entry.code === "patch-rewrites-fields")) throw new Error("rewriting inject/isolate/group must be reported");
2977
+
2978
+ // A `!!js` condition traded for a constant is neither an enable nor a
2979
+ // disable — the row simply stops being conditional.
2980
+ writeFileSync(join(p, "node_modules", "host-bundle", "cordis.patch.yml"),
2981
+ `- insert:\n${ids.map((id) => ` - id: ${id}\n name: host-${id}\n config:\n keep: 1\n also: 2\n`).join("")} - id: conditional\n name: host-conditional\n disabled: !!js process.platform === 'win32'\n`);
2982
+ const constant = scan("- id: conditional\n disabled: false\n");
2983
+ if (!constant.issues.some((entry) => entry.code === "patch-replaces-condition")) throw new Error("replacing a !!js condition with a constant must be reported");
2984
+ if (constant.issues.some((entry) => entry.code === "patch-enables-rows")) throw new Error("a condition swap is not an enable");
2985
+ }
2986
+
2987
+ // Mount state is read the way the loader reads it — through the parent
2988
+ // chain, with `!!js` as a third state — and a row the loader would give a
2989
+ // generated id to is still a row.
2990
+ {
2991
+ const p = join(root, "profiles", "mount-state");
2992
+ const bundle = (pkg, patchText) => {
2993
+ mkdirSync(join(p, "node_modules", pkg), { recursive: true });
2994
+ writeFileSync(join(p, "node_modules", pkg, "package.json"), JSON.stringify({
2995
+ name: pkg, version: "1.0.0", dsh: { bundle: { patch: "./cordis.patch.yml" } },
2996
+ }));
2997
+ writeFileSync(join(p, "node_modules", pkg, "cordis.patch.yml"), patchText);
2998
+ };
2999
+ mkdirSync(join(p, "node_modules"), { recursive: true });
3000
+ writeFileSync(join(p, "cordis.patch.yml"), "[]\n");
3001
+ const scanWith = (bundles, patchText) => {
3002
+ writeFileSync(join(p, "package.json"), JSON.stringify({ dependencies: {}, dsh: { profile: { bundles } } }));
3003
+ return inspectRemoteCandidate({
3004
+ profileDir: p,
3005
+ manifest: { name: "cand", version: "1.0.0", dsh: { bundle: { patch: "./cordis.patch.yml" } } },
3006
+ patchText,
3007
+ spec: "cand",
3008
+ });
3009
+ };
3010
+
3011
+ // `ensureId` mints an id for an id-less row and mounts it anyway.
3012
+ bundle("plain-host", "- insert:\n - id: old\n name: shared-module\n");
3013
+ const idless = scanWith(["plain-host"], "- insert:\n - name: shared-module\n");
3014
+ if (idless.candidate.rows.length !== 1) throw new Error("an id-less row is still a row");
3015
+ if (!idless.issues.some((entry) => entry.code === "double-mount")) throw new Error("an id-less row can still double-mount an existing module");
3016
+ if (!idless.issues.some((entry) => entry.code === "patch-row-generated-id")) throw new Error("an id-less row deserves its own diagnosis");
3017
+ if (!scanWith(["plain-host"], "- insert:\n - id: nameless\n config: {}\n").issues.some((entry) => entry.code === "patch-row-no-name")) throw new Error("a row without a name deserves its own diagnosis");
3018
+
3019
+ // An expression decides at load time: neither "mounts" nor "does not".
3020
+ bundle("expr-host", "- insert:\n - id: old\n name: shared-module\n disabled: !!js false\n");
3021
+ const conditional = scanWith(["expr-host"], "- insert:\n - id: new\n name: shared-module\n");
3022
+ if (conditional.issues.some((entry) => entry.code === "double-mount")) throw new Error("an expression-gated row must not be treated as a certain mount");
3023
+ if (!conditional.issues.some((entry) => entry.code === "double-mount-conditional")) throw new Error("an expression-gated row must not pass as silently safe either");
3024
+
3025
+ // A nested row inherits its parent group's disabled (loader `_disabled`).
3026
+ bundle("group-host", "- insert:\n - id: shelf\n name: host-shelf\n group: true\n disabled: true\n config:\n - id: child\n name: shared-module\n");
3027
+ if (scanWith(["group-host"], "- insert:\n - id: new\n name: shared-module\n").verdict !== "safe") throw new Error("a row under a disabled group is not mounted");
3028
+
3029
+ // A self-referencing anchor is rejected, not walked into a stack
3030
+ // overflow — including one nested inside an `insert` subtree, which the
3031
+ // row collector would recurse into before any later check could run.
3032
+ for (const cyclicPatch of [
3033
+ "- id: old\n config: &loop\n self: *loop\n",
3034
+ "- insert:\n - &loop\n id: shelf\n name: group-host\n group: true\n config:\n - *loop\n",
3035
+ ]) {
3036
+ const cyclic = scanWith(["plain-host"], cyclicPatch);
3037
+ if (cyclic.verdict !== "blocked" || !cyclic.issues.some((entry) => entry.code === "patch-cyclic-value")) throw new Error("a cyclic patch value must be rejected");
3038
+ }
3039
+
3040
+ // A row with no module specifier stays in the tree; whether it stops dsh
3041
+ // from starting depends on its effective mount state.
3042
+ const blank = scanWith(["plain-host"], "- insert:\n - id: blank\n config: {}\n");
3043
+ if (blank.verdict !== "blocked" || !blank.issues.some((entry) => entry.code === "patch-row-no-name")) throw new Error("an enabled row without a name stops dsh from starting");
3044
+ const blankOff = scanWith(["plain-host"], "- insert:\n - id: blank\n disabled: true\n config: {}\n");
3045
+ if (blankOff.verdict !== "warning" || !blankOff.issues.some((entry) => entry.code === "patch-row-no-name")) throw new Error("a disabled row without a name is a warning, not a blocker");
3046
+
3047
+ // Overriding a group's `config` replaces its whole subtree, so the mount
3048
+ // projection has to be recomputed from the final tree — in both
3049
+ // directions: rows leaving, and rows arriving.
3050
+ bundle("shelf-host", "- insert:\n - id: shelf\n name: host-shelf\n group: true\n config:\n - id: child\n name: shared-module\n");
3051
+ const emptied = scanWith(["shelf-host"], "- id: shelf\n config: []\n- insert:\n - id: mine\n name: shared-module\n");
3052
+ if (emptied.issues.some((entry) => entry.code === "double-mount")) throw new Error("a child removed by a config override is not mounted any more");
3053
+ bundle("shelf-empty", "- insert:\n - id: shelf\n name: host-shelf\n group: true\n config: []\n - id: root-row\n name: shared-module\n");
3054
+ const filled = scanWith(["shelf-empty"], "- id: shelf\n config:\n - id: added\n name: shared-module\n");
3055
+ if (!filled.issues.some((entry) => entry.code === "double-mount")) throw new Error("a child added by a config override is mounted");
3056
+
3057
+ // Emptying a group removes its children from the final tree — the
3058
+ // lookup map still holds them, which is why the change diff has to read
3059
+ // the tree instead.
3060
+ const children = Array.from({ length: SURFACE_TAKEOVER_MIN + 1 }, (_, index) => ` - id: c${index}\n name: mod-${index}\n`).join("");
3061
+ bundle("shelf-full", `- insert:\n - id: shelf\n name: host-shelf\n group: true\n config:\n${children}`);
3062
+ const emptiedGroup = scanWith(["shelf-full"], "- id: shelf\n config: []\n");
3063
+ if (emptiedGroup.verdict !== "blocked" || !emptiedGroup.issues.some((entry) => entry.code === "surface-takeover")) throw new Error("emptying a group withdraws every row under it");
3064
+
3065
+ // A conflict is a property of the composed tree, not of who owns a row:
3066
+ // re-enabling one of two same-module rows creates a double mount even
3067
+ // though the candidate owns neither of them.
3068
+ bundle("twins", "- insert:\n - id: on\n name: shared-module\n - id: off\n name: shared-module\n disabled: true\n");
3069
+ const reEnabled = scanWith(["twins"], "- id: off\n disabled: false\n");
3070
+ if (reEnabled.verdict !== "blocked" || !reEnabled.issues.some((entry) => entry.code === "double-mount")) throw new Error("a conflict the candidate creates between someone else's rows is still its doing");
3071
+ // …while a conflict that was already there is not the candidate's fault.
3072
+ bundle("already", "- insert:\n - id: one\n name: shared-module\n - id: two\n name: shared-module\n");
3073
+ const preExisting = scanWith(["already"], "- insert:\n - id: mine\n name: unrelated-module\n");
3074
+ if (preExisting.issues.some((entry) => entry.code === "double-mount")) throw new Error("a pre-existing conflict must not be charged to the candidate");
3075
+
3076
+ // A falsy `name` does not arm the guard, so these overrides land — and
3077
+ // ten landed disables are a takeover, not a "patch that hits nothing".
3078
+ const tenIds = Array.from({ length: SURFACE_TAKEOVER_MIN }, (_, index) => `row-${index}`);
3079
+ bundle("guard-host", `- insert:\n${tenIds.map((id) => ` - id: ${id}\n name: host-${id}\n`).join("")}`);
3080
+ for (const falsy of ["0", "false", "\"\""]) {
3081
+ const landed = scanWith(["guard-host"], tenIds.map((id) => `- id: ${id}\n name: ${falsy}\n disabled: true\n`).join(""));
3082
+ if (landed.verdict !== "blocked" || !landed.issues.some((entry) => entry.code === "surface-takeover")) throw new Error(`a falsy name guard must not disarm the patch: ${falsy}`);
3083
+ }
3084
+ // …while a truthy one that does not match still skips the entry.
3085
+ const guarded = scanWith(["guard-host"], "- id: row-0\n name: someone-else\n disabled: true\n");
3086
+ if (!guarded.issues.some((entry) => entry.code === "patch-target-missing")) throw new Error("a truthy name guard that misses is inert");
3087
+
3088
+ // A truthy non-string id is a real id — not "no id". Two rows carrying
3089
+ // the same one is the duplicate the loader refuses to boot with, so it
3090
+ // cannot be reported as a mintable-id warning.
3091
+ bundle("numeric-host", "- insert:\n - id: 7\n name: host-seven\n");
3092
+ const numericDuplicate = scanWith(["numeric-host"], "- insert:\n - id: 7\n name: cand-seven\n");
3093
+ if (numericDuplicate.verdict !== "blocked" || !numericDuplicate.issues.some((entry) => entry.code === "loader-id-collision")) throw new Error("a duplicate numeric id must block");
3094
+ if (numericDuplicate.issues.some((entry) => entry.code === "patch-row-generated-id")) throw new Error("a numeric id is not a generated id");
3095
+ // …and a numeric target resolves to it, while its string spelling does not.
3096
+ if (scanWith(["numeric-host"], "- id: 7\n config:\n x: 1\n").issues.some((entry) => entry.code === "patch-target-missing")) throw new Error("a numeric target resolves");
3097
+ if (!scanWith(["numeric-host"], "- id: \"7\"\n config:\n x: 1\n").issues.some((entry) => entry.code === "patch-target-missing")) throw new Error("a numeric id is not its string spelling");
3098
+ // A truthy non-string NAME is not a module specifier at all.
3099
+ const badName = scanWith(["numeric-host"], "- insert:\n - id: bad\n name: 42\n");
3100
+ if (badName.verdict !== "blocked" || !badName.issues.some((entry) => entry.code === "patch-row-name-invalid")) throw new Error("a non-string name stops dsh from starting");
3101
+
3102
+ // Ids are compared exactly: a padded id is a different id, and an
3103
+ // override carrying one hits nothing.
3104
+ bundle("exact-host", "- insert:\n - id: target\n name: host-target\n");
3105
+ if (scanWith(["exact-host"], "- insert:\n - id: \" target \"\n name: cand-target\n").verdict !== "safe") throw new Error("a padded id is a different id, not a collision");
3106
+ if (!scanWith(["exact-host"], "- id: \" target \"\n config:\n x: 1\n").issues.some((entry) => entry.code === "patch-target-missing")) throw new Error("an override with a padded id hits nothing");
3107
+ }
3108
+
3109
+ // Updating an installed plugin is judged on what its NEW layer would do,
3110
+ // with the installed layer taken out of the stack first. Matching by
3111
+ // package name instead let an update disable every row its own previous
3112
+ // version had merely configured, and still report zero issues.
3113
+ {
3114
+ const p = join(root, "profiles", "update");
3115
+ const ids = Array.from({ length: SURFACE_TAKEOVER_MIN }, (_, index) => `host-row-${index}`);
3116
+ mkdirSync(join(p, "node_modules", "host-bundle"), { recursive: true });
3117
+ mkdirSync(join(p, "node_modules", "plug"), { recursive: true });
3118
+ writeFileSync(join(p, "package.json"), JSON.stringify({
3119
+ dependencies: { "host-bundle": "1.0.0", plug: "1.0.0" },
3120
+ dsh: { profile: { bundles: ["host-bundle", "plug"] } },
3121
+ }));
3122
+ writeFileSync(join(p, "cordis.patch.yml"), "[]\n");
3123
+ for (const [pkg, patchText] of [
3124
+ ["host-bundle", `- insert:\n${ids.map((id) => ` - id: ${id}\n name: host-${id}\n config:\n keep: 1\n`).join("")}`],
3125
+ ["plug", ids.map((id) => `- id: ${id}\n config:\n keep: 2\n`).join("")],
3126
+ ]) {
3127
+ writeFileSync(join(p, "node_modules", pkg, "package.json"), JSON.stringify({
3128
+ name: pkg, version: "1.0.0", dsh: { bundle: { patch: "./cordis.patch.yml" } },
3129
+ }));
3130
+ writeFileSync(join(p, "node_modules", pkg, "cordis.patch.yml"), patchText);
3131
+ }
3132
+ const update = (patchText) => inspectRemoteCandidate({
3133
+ profileDir: p,
3134
+ manifest: { name: "plug", version: "2.0.0", dsh: { bundle: { patch: "./cordis.patch.yml" } } },
3135
+ patchText,
3136
+ spec: "plug",
3137
+ });
3138
+ if (update(readFileSync(join(p, "node_modules", "plug", "cordis.patch.yml"), "utf8")).verdict !== "safe") throw new Error("an update that changes nothing must stay quiet");
3139
+ const regressed = update(ids.map((id) => `- id: ${id}\n disabled: true\n`).join(""));
3140
+ if (regressed.verdict !== "blocked" || !regressed.issues.some((entry) => entry.code === "surface-takeover")) throw new Error("an update that switches off rows it used to configure must not be waved through");
3141
+
3142
+ // An update can also take rows AWAY — including rows another bundle
3143
+ // inserted into a group this one used to provide. Diffing only the rows
3144
+ // that still exist after the update cannot see that at all, and an
3145
+ // update whose patch is empty would not even be diffed.
3146
+ const shelf = join(root, "profiles", "shelf");
3147
+ mkdirSync(join(shelf, "node_modules", "shelf-owner"), { recursive: true });
3148
+ mkdirSync(join(shelf, "node_modules", "shelf-user"), { recursive: true });
3149
+ writeFileSync(join(shelf, "package.json"), JSON.stringify({
3150
+ dependencies: {}, dsh: { profile: { bundles: ["shelf-owner", "shelf-user"] } },
3151
+ }));
3152
+ writeFileSync(join(shelf, "cordis.patch.yml"), "[]\n");
3153
+ for (const [pkg, patchText] of [
3154
+ ["shelf-owner", "- insert:\n - id: shelf\n name: owner-shelf\n group: true\n config: []\n"],
3155
+ ["shelf-user", "- id: shelf\n insert:\n - id: on-shelf\n name: user-row\n"],
3156
+ ]) {
3157
+ writeFileSync(join(shelf, "node_modules", pkg, "package.json"), JSON.stringify({
3158
+ name: pkg, version: "1.0.0", dsh: { bundle: { patch: "./cordis.patch.yml" } },
3159
+ }));
3160
+ writeFileSync(join(shelf, "node_modules", pkg, "cordis.patch.yml"), patchText);
3161
+ }
3162
+ for (const patchText of ["- insert:\n - id: other\n name: owner-other\n", "[]\n"]) {
3163
+ const dropped = inspectRemoteCandidate({
3164
+ profileDir: shelf,
3165
+ manifest: { name: "shelf-owner", version: "2.0.0", dsh: { bundle: { patch: "./cordis.patch.yml" } } },
3166
+ patchText,
3167
+ spec: "shelf-owner",
3168
+ });
3169
+ const removal = dropped.issues.find((entry) => entry.code === "patch-removes-rows");
3170
+ if (removal === undefined) throw new Error("an update that removes rows must be reported");
3171
+ if (!removal.detail.includes("on-shelf")) throw new Error("the downstream row that disappears with the group must be named");
3172
+ }
3173
+ // …but a patch that could not be FETCHED is unknown, not empty: browsing
3174
+ // must not invent a withdrawal of everything the installed layer holds.
3175
+ const unfetched = inspectRemoteCandidate({
3176
+ profileDir: shelf,
3177
+ manifest: { name: "shelf-owner", version: "2.0.0", dsh: { bundle: { patch: "./cordis.patch.yml" } } },
3178
+ patchText: undefined,
3179
+ spec: "shelf-owner",
3180
+ });
3181
+ if (unfetched.verdict !== "warning" || !unfetched.issues.some((entry) => entry.code === "patch-unverified")) throw new Error("an unfetched patch is reported as unverified");
3182
+ if (unfetched.issues.some((entry) => entry.code === "patch-removes-rows" || entry.code === "surface-takeover")) throw new Error("an unfetched patch must not be simulated as an empty one");
3183
+ }
3184
+
1807
3185
  // Peer version checks resolve host packages through Node's upward lookup:
1808
3186
  // the host lives in the shared profiles/node_modules (the profile's own
1809
3187
  // node_modules has no @deepseek-ai/*), and missing that used to emit a
@@ -1900,12 +3278,36 @@ async function selfTest() {
1900
3278
  writeFileSync(join(p, "package.json"), JSON.stringify({ dependencies: {} }));
1901
3279
  writeFileSync(join(p, "cordis.patch.yml"), "- insert:\n - id: js-scalar\n name: js-scalar\n value: !!js globalThis.__dshGuardJsTagExecuted = true\n");
1902
3280
  const issues = [];
1903
- const rows = parsePatch(join(p, "cordis.patch.yml"), "profile", issues, "js-tag fixture");
3281
+ const { rows } = parsePatch(join(p, "cordis.patch.yml"), "profile", issues, "js-tag fixture");
1904
3282
  if (issues.length !== 0) throw new Error("!!js scalar fixture should parse without patch warnings");
1905
3283
  if (rows.length !== 1 || rows[0].id !== "js-scalar" || rows[0].name !== "js-scalar") throw new Error("!!js scalar fixture should still yield insert rows");
1906
3284
  if (globalThis.__dshGuardJsTagExecuted !== undefined) throw new Error("!!js scalar must never be executed");
1907
3285
  const v = validateInstalledProfile(p);
1908
3286
  if (v.ok !== true) throw new Error("profile with a !!js scalar patch should validate clean");
3287
+
3288
+ // The tag's identity survives parsing: an expression and a plain string
3289
+ // of the same characters are different values at runtime, so swapping
3290
+ // one for the other is a config change like any other.
3291
+ mkdirSync(join(p, "node_modules", "js-host"), { recursive: true });
3292
+ writeFileSync(join(p, "node_modules", "js-host", "package.json"), JSON.stringify({
3293
+ name: "js-host", version: "1.0.0", dsh: { bundle: { patch: "./cordis.patch.yml" } },
3294
+ }));
3295
+ writeFileSync(join(p, "node_modules", "js-host", "cordis.patch.yml"), "- insert:\n - id: js-row\n name: js-host\n config:\n value: !!js process.env.SECRET\n");
3296
+ writeFileSync(join(p, "package.json"), JSON.stringify({ dependencies: { "js-host": "1.0.0" }, dsh: { profile: { bundles: ["js-host"] } } }));
3297
+ const literalSwap = inspectRemoteCandidate({
3298
+ profileDir: p,
3299
+ manifest: { name: "js-cand", version: "1.0.0", dsh: { bundle: { patch: "./cordis.patch.yml" } } },
3300
+ patchText: "- id: js-row\n config:\n value: process.env.SECRET\n",
3301
+ spec: "js-cand",
3302
+ });
3303
+ if (!literalSwap.issues.some((entry) => entry.code === "patch-replaces-config")) throw new Error("replacing a !!js expression with a literal string of the same text is a change");
3304
+ const sameExpression = inspectRemoteCandidate({
3305
+ profileDir: p,
3306
+ manifest: { name: "js-cand", version: "1.0.0", dsh: { bundle: { patch: "./cordis.patch.yml" } } },
3307
+ patchText: "- id: js-row\n config:\n value: !!js process.env.SECRET\n",
3308
+ spec: "js-cand",
3309
+ });
3310
+ if (sameExpression.issues.some((entry) => entry.code === "patch-replaces-config")) throw new Error("restating the same !!js expression changes nothing");
1909
3311
  writeFileSync(join(p, "cordis.patch.yml"), "- insert:\n - id: evil\n name: evil\n value: !unknown-tag still-rejected\n");
1910
3312
  const rejected = [];
1911
3313
  parsePatch(join(p, "cordis.patch.yml"), "profile", rejected, "js-tag fixture");
@@ -2116,10 +3518,11 @@ async function selfTest() {
2116
3518
  }
2117
3519
 
2118
3520
  // Interrupted remove recovery: pnpm deleted dependencies/node_modules but
2119
- // dsh crashed before removing the bundle/profile row. Generic validation
2120
- // considers this loadable, so the remove-specific completion check must
2121
- // force rollback. A temp PATH stub models the real offline lockfile
2122
- // reconcile and restores the deleted direct package.
3521
+ // dsh crashed before removing the bundle/profile row. Both signals must
3522
+ // force rollback the leftover bundle entry no longer resolves, which is
3523
+ // itself a startup failure, and the remove-specific completion check is
3524
+ // what names the real cause. A temp PATH stub models the real offline
3525
+ // lockfile reconcile and restores the deleted direct package.
2123
3526
  {
2124
3527
  const p = join(root, "profiles", "remove-partial");
2125
3528
  mkdirSync(join(p, "node_modules", "victim"), { recursive: true });
@@ -2138,7 +3541,8 @@ async function selfTest() {
2138
3541
  dsh: { profile: { bundles: ["victim"] } },
2139
3542
  }));
2140
3543
  rmSync(join(p, "node_modules", "victim"), { recursive: true, force: true });
2141
- if (!validateInstalledProfile(p).ok) throw new Error("partial remove fixture must reproduce the generic-validation false safe");
3544
+ const partial = validateInstalledProfile(p);
3545
+ if (partial.ok || !partial.issues.some((entry) => entry.code === "bundle-unresolved")) throw new Error("a bundle left listed after its package is gone must fail validation");
2142
3546
 
2143
3547
  const binDir = join(root, "remove-partial-bin");
2144
3548
  mkdirSync(binDir);
@@ -2321,8 +3725,9 @@ async function selfTest() {
2321
3725
 
2322
3726
  // validateInstalledProfile blocks a dependency the manifest declares but
2323
3727
  // node_modules cannot resolve — the crash-mid-install fingerprint that must
2324
- // never be committed as healthy. A template bundle (bundle-only, never a
2325
- // dependency) is in-box and stays silent.
3728
+ // never be committed as healthy and equally a profile layer that resolves
3729
+ // from neither anchor, which stops dsh at startup with "cannot resolve
3730
+ // profile bundle". Only a layer that really is in-box stays silent.
2326
3731
  {
2327
3732
  const p = join(root, "profiles", "unresolved");
2328
3733
  mkdirSync(p, { recursive: true });
@@ -2333,10 +3738,105 @@ async function selfTest() {
2333
3738
  throw new Error("validateInstalledProfile should block a declared-but-unresolved dependency");
2334
3739
  }
2335
3740
  const q = join(root, "profiles", "template-bundle");
2336
- mkdirSync(q, { recursive: true });
2337
- writeFileSync(join(q, "package.json"), JSON.stringify({ dependencies: {}, dsh: { profile: { bundles: ["inbox-ui"] } } }));
3741
+ mkdirSync(join(q, "node_modules", "bundleless"), { recursive: true });
2338
3742
  writeFileSync(join(q, "cordis.patch.yml"), "[]\n");
2339
- if (validateInstalledProfile(q).ok !== true) throw new Error("a template bundle is in-box and must not be flagged as unresolved");
3743
+ writeFileSync(join(q, "package.json"), JSON.stringify({ dependencies: {}, dsh: { profile: { bundles: ["inbox-ui"] } } }));
3744
+ const missingBundle = validateInstalledProfile(q);
3745
+ if (missingBundle.ok !== false || !missingBundle.issues.some((entry) => entry.code === "bundle-unresolved")) {
3746
+ throw new Error("a listed bundle that resolves from neither anchor must block");
3747
+ }
3748
+ // A package that resolves but declares no dsh.bundle is the other half
3749
+ // of the same startup failure ("declares no dsh.bundle").
3750
+ writeFileSync(join(q, "node_modules", "bundleless", "package.json"), JSON.stringify({ name: "bundleless", version: "1.0.0" }));
3751
+ writeFileSync(join(q, "package.json"), JSON.stringify({ dependencies: {}, dsh: { profile: { bundles: ["bundleless"] } } }));
3752
+ const bundleless = validateInstalledProfile(q);
3753
+ if (bundleless.ok !== false || !bundleless.issues.some((entry) => entry.code === "bundle-manifest-missing")) {
3754
+ throw new Error("a profile layer without dsh.bundle must block");
3755
+ }
3756
+ // …while a layer that really is in-box (resolved through the shared
3757
+ // installation link farm, see the two-anchor fixture above) stays silent.
3758
+ writeFileSync(join(q, "package.json"), JSON.stringify({ dependencies: {}, dsh: { profile: { bundles: ["@deepseek-ai/fake-base-fixture"] } } }));
3759
+ if (validateInstalledProfile(q).ok !== true) throw new Error("an in-box bundle resolved from the installation anchor must stay silent");
3760
+ }
3761
+
3762
+ // …but "unresolved" is only a verdict when this scan can see what the
3763
+ // launcher sees. The installation anchor is reached through the shared
3764
+ // profiles/node_modules link farm; where that farm does not exist, an
3765
+ // unresolvable layer says nothing about the profile — dsh rebuilds the
3766
+ // farm on every start and then resolves through its real anchor. Blocking
3767
+ // here would roll back a healthy profile, so it reports as unverified.
3768
+ {
3769
+ const bare = join(root, "bare-home", "profiles", "no-farm");
3770
+ mkdirSync(bare, { recursive: true });
3771
+ writeFileSync(join(bare, "cordis.patch.yml"), "[]\n");
3772
+ writeFileSync(join(bare, "package.json"), JSON.stringify({ dependencies: {}, dsh: { profile: { bundles: ["@deepseek-ai/dsh-base"] } } }));
3773
+ const unverified = validateInstalledProfile(bare);
3774
+ if (unverified.ok !== true || !unverified.issues.some((entry) => entry.code === "bundle-unverified")) {
3775
+ throw new Error("without the link farm an unresolvable layer is unverified, not a blocker");
3776
+ }
3777
+ if (unverified.issues.some((entry) => entry.code === "bundle-unresolved")) throw new Error("an unreachable installation anchor must not read as a missing bundle");
3778
+ // An empty scope, one unrelated package, and even a copied dsh manifest
3779
+ // are all incomplete scaffolds — none proves that the real installation
3780
+ // was searched, so all three must stay unverified instead of blocking.
3781
+ const bareScope = join(root, "bare-home", "profiles", "node_modules", "@deepseek-ai");
3782
+ mkdirSync(bareScope, { recursive: true });
3783
+ for (const [label, prepare] of [
3784
+ ["empty scope", () => {}],
3785
+ ["partial farm", () => {
3786
+ const partial = join(bareScope, "some-other-package");
3787
+ mkdirSync(partial, { recursive: true });
3788
+ writeFileSync(join(partial, "package.json"), JSON.stringify({ name: "@deepseek-ai/some-other-package" }));
3789
+ }],
3790
+ ["copied app directory", () => {
3791
+ const copied = join(bareScope, "dsh");
3792
+ mkdirSync(copied, { recursive: true });
3793
+ writeFileSync(join(copied, "package.json"), JSON.stringify({ name: "@deepseek-ai/dsh" }));
3794
+ }],
3795
+ // The one that actually happens: dsh is reinstalled somewhere else and
3796
+ // the old link is left pointing at a directory that no longer exists.
3797
+ // This is also the only case that reaches the anchor lookup's throw
3798
+ // path rather than one of its value checks.
3799
+ ["dangling app link", () => {
3800
+ rmSync(join(bareScope, "dsh"), { recursive: true, force: true });
3801
+ const removed = join(root, "bare-install-gone", "node_modules", "@deepseek-ai", "dsh");
3802
+ mkdirSync(removed, { recursive: true });
3803
+ writeFileSync(join(removed, "package.json"), JSON.stringify({ name: "@deepseek-ai/dsh" }));
3804
+ symlinkSync(removed, join(bareScope, "dsh"), process.platform === "win32" ? "junction" : "dir");
3805
+ rmSync(join(root, "bare-install-gone"), { recursive: true, force: true });
3806
+ }],
3807
+ ]) {
3808
+ prepare();
3809
+ const incomplete = validateInstalledProfile(bare);
3810
+ if (incomplete.ok !== true || !incomplete.issues.some((entry) => entry.code === "bundle-unverified")
3811
+ || incomplete.issues.some((entry) => entry.code === "bundle-unresolved")) {
3812
+ throw new Error(`${label} must not masquerade as a complete installation anchor`);
3813
+ }
3814
+ }
3815
+
3816
+ // Once the fallback contains a genuine dsh link, follow it to the real
3817
+ // install. A bundle absent from BOTH that anchor and the profile is now a
3818
+ // trustworthy launcher failure and blocks as before.
3819
+ rmSync(join(bareScope, "dsh"), { recursive: true, force: true });
3820
+ const bareInstallApp = join(root, "bare-install", "node_modules", "@deepseek-ai", "dsh");
3821
+ mkdirSync(bareInstallApp, { recursive: true });
3822
+ writeFileSync(join(bareInstallApp, "package.json"), JSON.stringify({ name: "@deepseek-ai/dsh" }));
3823
+ symlinkSync(bareInstallApp, join(bareScope, "dsh"), process.platform === "win32" ? "junction" : "dir");
3824
+ const withAnchor = validateInstalledProfile(bare);
3825
+ if (withAnchor.ok !== false || !withAnchor.issues.some((entry) => entry.code === "bundle-unresolved")) {
3826
+ throw new Error("a real installation anchor must make an unresolvable layer block");
3827
+ }
3828
+
3829
+ // A name that is BOTH a layer and a dependency is judged as a
3830
+ // dependency: pnpm owes it a directory in the profile's own
3831
+ // node_modules, and no anchor is in doubt for that lookup. Softening it
3832
+ // with the bundle branch would let a crash-mid-install commit — an
3833
+ // exit-zero remove that leaves the package.json half-written is exactly
3834
+ // the post-state the rollback check exists to catch.
3835
+ writeFileSync(join(bare, "package.json"), JSON.stringify({ dependencies: { missing: "1.0.0" }, dsh: { profile: { bundles: ["missing"] } } }));
3836
+ const both = validateInstalledProfile(bare);
3837
+ if (both.ok !== false || !both.issues.some((entry) => entry.code === "package-unresolved")) {
3838
+ throw new Error("a dependency that is also a layer must still block as an unresolved dependency");
3839
+ }
2340
3840
  }
2341
3841
 
2342
3842
  // dsh.bundle.patch paths are clamped to the package directory: an escaping