@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/README.md +23 -7
- package/package.json +1 -1
- package/src/cli.js +160 -10
- package/src/guard.js +1576 -76
- package/src/index.js +75 -4
- package/src/installer.js +779 -35
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
|
|
29
|
-
//
|
|
30
|
-
// tag is still rejected as
|
|
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
|
|
230
|
+
const ops = [];
|
|
231
|
+
const notes = { generated: [] };
|
|
142
232
|
for (const entry of document) {
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
595
|
+
const infos = new Map();
|
|
226
596
|
for (const name of new Set([...dependencies, ...bundles])) {
|
|
227
|
-
|
|
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
|
|
230
|
-
//
|
|
231
|
-
//
|
|
232
|
-
//
|
|
233
|
-
//
|
|
234
|
-
//
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
-
|
|
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
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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
|
|
375
|
-
issues.push(issue("block", "loader-id-collision", "加载 ID 被重复占用",
|
|
376
|
-
|
|
377
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
491
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
559
|
-
|
|
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.
|
|
2120
|
-
//
|
|
2121
|
-
//
|
|
2122
|
-
//
|
|
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
|
-
|
|
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
|
|
2325
|
-
//
|
|
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
|
-
|
|
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
|