@bruc3van/dsh-doctor 0.1.5 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/doctor.mjs CHANGED
@@ -6,7 +6,12 @@ import yaml from 'js-yaml'
6
6
  import semver from 'semver'
7
7
  import { parseDocument } from 'yaml'
8
8
  import { languageName, localizedFinding } from './i18n.mjs'
9
+ import { buildConfigurationModel } from './config-model.mjs'
10
+ import { buildPluginDiagnoses } from './recovery.mjs'
9
11
 
12
+ // Mirrors packages/client/web/src/platform.ts in DeepSeek Harness. Installed
13
+ // DSH packages do not expose this source list, so update this baseline when
14
+ // Harness changes PLATFORM_MODULES.
10
15
  export const PLATFORM_MODULES = new Set([
11
16
  'react',
12
17
  'react/jsx-runtime',
@@ -45,6 +50,13 @@ function finding(severity, code, message, options = {}) {
45
50
  }
46
51
  }
47
52
 
53
+ function yamlErrorLocation(error) {
54
+ const mark = error?.mark
55
+ return Number.isInteger(mark?.line) && Number.isInteger(mark?.column)
56
+ ? `at line ${String(mark.line + 1)}, column ${String(mark.column + 1)}`
57
+ : 'parse error'
58
+ }
59
+
48
60
  function readJson(file, subject, findings) {
49
61
  let text
50
62
  try {
@@ -168,11 +180,12 @@ function manifestBin(packageDir) {
168
180
  }
169
181
  }
170
182
 
171
- function localDshPackage(start) {
183
+ function localDshBin(start) {
172
184
  let current = resolve(start)
173
185
  while (true) {
174
186
  const packageDir = join(current, 'node_modules', '@deepseek-ai', 'dsh')
175
- if (manifestBin(packageDir) !== undefined) return packageDir
187
+ const bin = manifestBin(packageDir)
188
+ if (bin !== undefined) return bin
176
189
  const parent = dirname(current)
177
190
  if (parent === current) return undefined
178
191
  current = parent
@@ -218,10 +231,9 @@ function resolveDshCli(options, harness, home) {
218
231
  if (bin !== undefined) return { command: [process.execPath, bin.bin], path: bin.bin, source: 'profile', version: bin.version }
219
232
  }
220
233
 
221
- const localPackage = localDshPackage(cwd)
222
- if (localPackage !== undefined) {
223
- const bin = manifestBin(localPackage)
224
- return { command: [process.execPath, bin.bin], path: bin.bin, source: 'project', version: bin.version }
234
+ const localBin = localDshBin(cwd)
235
+ if (localBin !== undefined) {
236
+ return { command: [process.execPath, localBin.bin], path: localBin.bin, source: 'project', version: localBin.version }
225
237
  }
226
238
 
227
239
  const pathCommand = executableOnPath('dsh', env)
@@ -422,6 +434,7 @@ function codePositions(source) {
422
434
  const code = new Uint8Array(source.length)
423
435
  let state = 'code'
424
436
  let regexClass = false
437
+ const templateExpressions = []
425
438
  for (let index = 0; index < source.length; index += 1) {
426
439
  const char = source[index]
427
440
  const next = source[index + 1]
@@ -432,11 +445,25 @@ function codePositions(source) {
432
445
  } else if (char === '/' && next === '*') {
433
446
  state = 'block-comment'
434
447
  index += 1
435
- } else if (char === "'" || char === '"' || char === '`') {
448
+ } else if (char === "'" || char === '"') {
436
449
  state = char
450
+ } else if (char === '`') {
451
+ state = 'template'
437
452
  } else if (char === '/' && regexCanStart(source, index)) {
438
453
  state = 'regex'
439
454
  regexClass = false
455
+ } else if (templateExpressions.length > 0 && char === '{') {
456
+ templateExpressions[templateExpressions.length - 1] += 1
457
+ code[index] = 1
458
+ } else if (templateExpressions.length > 0 && char === '}') {
459
+ const last = templateExpressions.length - 1
460
+ templateExpressions[last] -= 1
461
+ if (templateExpressions[last] === 0) {
462
+ templateExpressions.pop()
463
+ state = 'template'
464
+ } else {
465
+ code[index] = 1
466
+ }
440
467
  } else {
441
468
  code[index] = 1
442
469
  }
@@ -456,6 +483,14 @@ function codePositions(source) {
456
483
  else if (char === ']') regexClass = false
457
484
  else if (char === '/' && !regexClass) state = 'code'
458
485
  else if (char === '\n' || char === '\r') state = 'code'
486
+ } else if (state === 'template') {
487
+ if (char === '\\') index += 1
488
+ else if (char === '`') state = 'code'
489
+ else if (char === '$' && next === '{') {
490
+ templateExpressions.push(1)
491
+ state = 'code'
492
+ index += 1
493
+ }
459
494
  } else if (char === '\\') {
460
495
  index += 1
461
496
  } else if (char === state) {
@@ -484,6 +519,39 @@ function stringArray(value) {
484
519
  return Array.isArray(value) && value.every(item => typeof item === 'string') ? value : undefined
485
520
  }
486
521
 
522
+ function packageMainExport(manifest) {
523
+ const exported = manifest?.exports?.['.']
524
+ if (typeof exported === 'string') return exported
525
+ if (objectRecord(exported) !== undefined) {
526
+ for (const key of ['import', 'default', 'require']) {
527
+ if (typeof exported[key] === 'string') return exported[key]
528
+ }
529
+ }
530
+ return typeof manifest?.main === 'string' ? manifest.main : undefined
531
+ }
532
+
533
+ function inspectRuntimeServiceProvider(record) {
534
+ const candidates = [...new Set([packageMainExport(record.manifest), clientExport(record.manifest)].filter(value => typeof value === 'string'))]
535
+ const evidence = []
536
+ for (const exported of candidates) {
537
+ const file = safePackageFile(record.directory, exported)
538
+ if (file === undefined || !regularFile(file)) continue
539
+ try {
540
+ if (statSync(file).size > 16 * 1024 * 1024) continue
541
+ const source = readFileSync(file, 'utf8')
542
+ const code = codePositions(source)
543
+ const patterns = [
544
+ /\b(?:ctx|context)\s*\.\s*provide\s*\(/g,
545
+ /\bclass\s+[A-Za-z_$][\w$]*\s+extends\s+Service\b/g,
546
+ ]
547
+ if (patterns.some(pattern => [...source.matchAll(pattern)].some(match => code[match.index]))) evidence.push(file)
548
+ } catch {
549
+ // Existing package and client checks own unreadable-artifact findings.
550
+ }
551
+ }
552
+ return evidence.length === 0 ? { status: 'not-detected' } : { status: 'detected', evidence }
553
+ }
554
+
487
555
  function inspectClientPackage(record, context) {
488
556
  const {
489
557
  commandRepair, findings, harnessPackages, harnessPackagesAuthoritative, profile, resolvePackage,
@@ -663,7 +731,9 @@ function inspectPatchFile(file, subject, findings, packageName) {
663
731
  } catch (error) {
664
732
  findings.push(finding('error', 'INVALID_PATCH_YAML', `${subject} cannot be parsed.`, {
665
733
  package: packageName,
666
- evidence: `${file}: ${error instanceof Error ? error.message : String(error)}`,
734
+ // js-yaml messages include a source excerpt, which may contain plugin
735
+ // credentials. Preserve only the location needed to repair the syntax.
736
+ evidence: `${file}: ${yamlErrorLocation(error)}`,
667
737
  suggestion: 'Repair the YAML syntax before starting this profile.',
668
738
  }))
669
739
  return undefined
@@ -1084,7 +1154,8 @@ function inspectCompatibility(record, context) {
1084
1154
  }))
1085
1155
  continue
1086
1156
  }
1087
- const supplier = harnessPackages.get(name) ?? resolvePackage(name)
1157
+ const supplier = harnessPackages.get(name)
1158
+ ?? (harnessPackagesAuthoritative ? undefined : resolvePackage(name))
1088
1159
  if (supplier === undefined) continue
1089
1160
  const version = supplier.manifest?.version
1090
1161
  if (typeof version !== 'string' || semver.valid(version) === null) continue
@@ -1295,6 +1366,11 @@ export function diagnose(options = {}) {
1295
1366
  operation: { type: 'add-bundle', name },
1296
1367
  },
1297
1368
  }))
1369
+ findings.push(finding('warning', 'BUNDLE_PROFILE_DECLARATION_CONFLICT', `${name} declares a bundle layer but the profile does not activate it.`, {
1370
+ package: name,
1371
+ evidence: profileManifestFile,
1372
+ suggestion: 'Reconcile the direct dependency and dsh.profile.bundles declaration with the active DSH CLI.',
1373
+ }))
1298
1374
  }
1299
1375
  }
1300
1376
  const patchLayers = []
@@ -1302,7 +1378,7 @@ export function diagnose(options = {}) {
1302
1378
  for (const name of bundleNames) {
1303
1379
  const layer = inspectBundle(name, bundleRecords.get(name), findings)
1304
1380
  if (layer === undefined) patchCompositionValid = false
1305
- else patchLayers.push(layer)
1381
+ else patchLayers.push({ ...layer, kind: 'bundle' })
1306
1382
  }
1307
1383
 
1308
1384
  for (const [file, subject, label] of [
@@ -1310,10 +1386,87 @@ export function diagnose(options = {}) {
1310
1386
  [join(home, 'cordis.patch.yml'), 'home patch', 'home patch'],
1311
1387
  ]) {
1312
1388
  const patches = inspectPatchFileIfPresent(file, subject, findings)
1313
- if (patches !== undefined) patchLayers.push({ file, label, patches })
1389
+ if (patches !== undefined) patchLayers.push({
1390
+ file,
1391
+ label,
1392
+ patches,
1393
+ kind: label === 'profile patch' ? 'profile' : 'home',
1394
+ })
1314
1395
  else if (existsSync(file)) patchCompositionValid = false
1315
1396
  }
1397
+ for (const [index, value] of (options.patchFiles ?? []).entries()) {
1398
+ const file = resolve(expandUserPath(value))
1399
+ const patches = inspectPatchFile(file, `CLI overlay ${String(index + 1)}`, findings)
1400
+ if (patches !== undefined) patchLayers.push({ file, label: `CLI overlay ${String(index + 1)}`, patches, kind: 'overlay' })
1401
+ else patchCompositionValid = false
1402
+ }
1316
1403
  if (patchCompositionValid) inspectPatchComposition(patchLayers, findings)
1404
+ const configuration = buildConfigurationModel(patchLayers, issue => {
1405
+ const owner = issue.layer?.package ?? issue.details?.origin?.package ?? issue.entry?.name
1406
+ const evidence = issue.layer === undefined
1407
+ ? issue.details?.ids?.join(', ')
1408
+ : `${issue.layer.file}: entry ${String((issue.patchIndex ?? 0) + 1)}`
1409
+ const messages = {
1410
+ DUPLICATE_ENTRY_ID: `Entry id ${issue.details.id} is inserted more than once in the effective configuration.`,
1411
+ DUPLICATE_PLUGIN_MOUNT: `${issue.entry.name} is mounted by multiple active entries.`,
1412
+ CORE_ENTRY_DISABLED_BY_HIGHER_LAYER: `A higher configuration layer disables entry ${issue.entry.id}.`,
1413
+ ENTRY_STRUCTURE_OVERRIDDEN: `A higher configuration layer changes the structure of entry ${issue.entry.id}.`,
1414
+ INVALID_GROUP_CONFIG: `Group entry ${issue.entry.id} has a non-array config value.`,
1415
+ GROUP_CONTENT_REPLACED: `A higher configuration layer replaces the complete group content of entry ${issue.entry.id}.`,
1416
+ CONFIG_REPLACED_BY_HIGHER_LAYER: `A higher configuration layer replaces the complete config of entry ${issue.entry.id}.`,
1417
+ }
1418
+ findings.push(finding(['DUPLICATE_ENTRY_ID', 'INVALID_GROUP_CONFIG'].includes(issue.code) ? 'error' : 'warning', issue.code, messages[issue.code], {
1419
+ package: owner,
1420
+ evidence,
1421
+ details: issue.details,
1422
+ suggestion: 'Review the named configuration layer and keep one unambiguous source for this entry.',
1423
+ }))
1424
+ })
1425
+ configuration.profile = profile
1426
+ configuration.layerDetails = patchLayers.map(layer => {
1427
+ const insertedIds = new Set()
1428
+ const collect = entries => entries.forEach(entry => {
1429
+ if (typeof entry.id === 'string') insertedIds.add(entry.id)
1430
+ if (entry.group && Array.isArray(entry.config)) collect(entry.config)
1431
+ })
1432
+ layer.patches.filter(patch => Array.isArray(patch.insert)).forEach(patch => collect(patch.insert))
1433
+ return {
1434
+ kind: layer.kind,
1435
+ file: layer.file,
1436
+ package: layer.package,
1437
+ insertCount: layer.patches.filter(patch => Array.isArray(patch.insert)).length,
1438
+ overrideCount: layer.patches.filter(patch => !Array.isArray(patch.insert) && !insertedIds.has(patch.id)).length,
1439
+ }
1440
+ })
1441
+ configuration.patchReferences = patchLayers.flatMap(layer => layer.patches.flatMap((patch, patchIndex) =>
1442
+ !Array.isArray(patch.insert) && typeof patch.id === 'string'
1443
+ ? [{ id: patch.id, kind: layer.kind, file: layer.file, package: layer.package, patchIndex }]
1444
+ : []))
1445
+ if (patchCompositionValid) {
1446
+ const defaultConfiguration = buildConfigurationModel(patchLayers.filter(layer => layer.kind === 'bundle'))
1447
+ const knownIds = new Set(defaultConfiguration.entries.map(entry => entry.id).filter(id => typeof id === 'string' && id.length > 0))
1448
+ const knownGroups = new Set(defaultConfiguration.entries
1449
+ .filter(entry => entry.group === true && typeof entry.id === 'string' && entry.id.length > 0)
1450
+ .map(entry => entry.id))
1451
+ const indexInserted = entries => entries.forEach(entry => {
1452
+ if (typeof entry.id === 'string' && entry.id.length > 0) knownIds.add(entry.id)
1453
+ if (entry.group === true && typeof entry.id === 'string' && entry.id.length > 0) knownGroups.add(entry.id)
1454
+ if (entry.group === true && Array.isArray(entry.config)) indexInserted(entry.config)
1455
+ })
1456
+ for (const layer of patchLayers.filter(item => item.kind !== 'bundle')) {
1457
+ layer.patches.forEach((patch, patchIndex) => {
1458
+ if (Array.isArray(patch.insert)) {
1459
+ if (typeof patch.id !== 'string' || patch.id.length === 0 || knownGroups.has(patch.id)) indexInserted(patch.insert)
1460
+ return
1461
+ }
1462
+ if (typeof patch.id !== 'string' || knownIds.has(patch.id)) return
1463
+ findings.push(finding('warning', 'PATCH_INCOMPATIBLE_WITH_CURRENT_DSH', `${layer.label} still targets entry ${patch.id}, which is absent from the current default DSH tree.`, {
1464
+ evidence: `${layer.file}: entry ${String(patchIndex + 1)}`,
1465
+ suggestion: 'Adjust or remove this patch after comparing it with the current default configuration.',
1466
+ }))
1467
+ })
1468
+ }
1469
+ }
1317
1470
  inspectSettings(join(home, 'settings.yaml'), findings)
1318
1471
  inspectCredentials(join(home, '.credentials.yaml'), findings)
1319
1472
 
@@ -1346,32 +1499,65 @@ export function diagnose(options = {}) {
1346
1499
  )
1347
1500
  }
1348
1501
 
1502
+ const activeHarnessVersions = Object.fromEntries([...harness.packages].flatMap(([name, record]) =>
1503
+ typeof record.manifest?.version === 'string' ? [[name, record.manifest.version]] : []))
1504
+ if (typeof harness.version === 'string') activeHarnessVersions['@deepseek-ai/dsh'] ??= harness.version
1505
+ for (const record of thirdPartyRecords) {
1506
+ const peers = objectRecord(record.manifest.peerDependencies)
1507
+ if (peers === undefined) continue
1508
+ for (const name of Object.keys(peers)) {
1509
+ if (name !== 'cordis' && !name.startsWith('@deepseek-ai/')) continue
1510
+ const supplier = harness.packages.get(name)
1511
+ ?? (harness.authoritative ? undefined : resolvePackage(name))
1512
+ if (typeof supplier?.manifest?.version === 'string') activeHarnessVersions[name] = supplier.manifest.version
1513
+ }
1514
+ }
1515
+
1516
+ const packages = dependencyNames.map(name => {
1517
+ const record = records.get(name)
1518
+ if (record === undefined) return { name, installed: false, compatibility: 'incompatible' }
1519
+ return {
1520
+ name: record.requestedName ?? record.manifest.name,
1521
+ version: record.manifest.version,
1522
+ directory: record.directory,
1523
+ installed: true,
1524
+ client: record.manifest?.dsh?.client !== undefined,
1525
+ clientExternal: stringArray(record.manifest?.dsh?.client?.external) ?? [],
1526
+ clientInject: stringArray(record.manifest?.dsh?.client?.inject) ?? [],
1527
+ runtimeServiceProvider: inspectRuntimeServiceProvider(record),
1528
+ bundle: record.manifest?.dsh?.bundle !== undefined,
1529
+ compatibility: pluginCompatibility(
1530
+ record,
1531
+ findings,
1532
+ compatibilityChecks.get(record.requestedName ?? record.manifest.name),
1533
+ ),
1534
+ }
1535
+ })
1536
+ const pluginDiagnoses = buildPluginDiagnoses({
1537
+ packages,
1538
+ findings,
1539
+ configuration,
1540
+ bundleNames,
1541
+ profileManifest,
1542
+ lockfile,
1543
+ dshCli: { available: dshCli !== undefined },
1544
+ })
1349
1545
  return finish({
1350
1546
  home,
1351
1547
  profile,
1352
1548
  profileDir,
1353
- harness: { root: harness.root, version: harness.version },
1549
+ harness: {
1550
+ root: harness.root,
1551
+ version: harness.version,
1552
+ packages: activeHarnessVersions,
1553
+ },
1354
1554
  lockfile,
1355
1555
  dshCli: dshCli === undefined
1356
1556
  ? { available: false, commandRepairNeeded }
1357
1557
  : { available: true, commandRepairNeeded, ...dshCli },
1358
- packages: dependencyNames.map(name => {
1359
- const record = records.get(name)
1360
- if (record === undefined) return { name, installed: false, compatibility: 'incompatible' }
1361
- return {
1362
- name: record.requestedName ?? record.manifest.name,
1363
- version: record.manifest.version,
1364
- directory: record.directory,
1365
- installed: true,
1366
- client: record.manifest?.dsh?.client !== undefined,
1367
- bundle: record.manifest?.dsh?.bundle !== undefined,
1368
- compatibility: pluginCompatibility(
1369
- record,
1370
- findings,
1371
- compatibilityChecks.get(record.requestedName ?? record.manifest.name),
1372
- ),
1373
- }
1374
- }),
1558
+ packages,
1559
+ configuration,
1560
+ pluginDiagnoses,
1375
1561
  }, findings)
1376
1562
  }
1377
1563
 
@@ -1392,7 +1578,7 @@ function finish(context, findings) {
1392
1578
  compatible: context.packages.filter(item => item.compatibility === 'compatible').length,
1393
1579
  }
1394
1580
  context = { ...context, compatibility }
1395
- return { version: 1, ok: summary.errors === 0, context, summary, findings }
1581
+ return { version: 2, ok: summary.errors === 0, context, summary, findings }
1396
1582
  }
1397
1583
 
1398
1584
  function appendReportField(lines, label, value, indent = ' ') {
@@ -1425,7 +1611,7 @@ export function formatReport(report, options = {}) {
1425
1611
  : zh ? '未找到(命令型修复已禁用)' : 'not found (command-based repairs disabled)'
1426
1612
  const lines = [
1427
1613
  'DSH Doctor',
1428
- `${zh ? 'Profile' : 'Profile'}: ${report.context.profile}`,
1614
+ `Profile: ${report.context.profile}`,
1429
1615
  `${zh ? 'DSH 主目录' : 'Home'}: ${report.context.home}`,
1430
1616
  `${zh ? '当前使用的 DSH' : 'Active DSH'}: ${report.context.harness.version ?? 'unknown'}${report.context.harness.root ? ` (${report.context.harness.root})` : ''}`,
1431
1617
  `${zh ? 'DSH CLI' : 'DSH CLI'}: ${cliText}`,
@@ -1437,6 +1623,7 @@ export function formatReport(report, options = {}) {
1437
1623
  '',
1438
1624
  ]
1439
1625
  const packageNames = new Set(report.context.packages.map(item => item.name))
1626
+ const diagnoses = new Map((report.context.pluginDiagnoses ?? []).map(item => [item.name, item]))
1440
1627
  const pluginFindings = new Map()
1441
1628
  const environmentFindings = []
1442
1629
  for (const item of report.findings) {
@@ -1475,6 +1662,14 @@ export function formatReport(report, options = {}) {
1475
1662
  appendReportField(lines, zh ? '版本' : 'Version', plugin.installed === false
1476
1663
  ? zh ? '未安装' : 'not installed'
1477
1664
  : plugin.version ?? (zh ? '未知' : 'unknown'))
1665
+ const diagnosis = diagnoses.get(plugin.name)
1666
+ if (diagnosis !== undefined) {
1667
+ appendReportField(lines, zh ? '原因类型' : 'Cause', diagnosis.cause.type)
1668
+ appendReportField(lines, zh ? '配置来源' : 'Configuration origin', diagnosis.configuration.originLayer)
1669
+ appendReportField(lines, zh ? '兼容版本' : 'Compatible version', diagnosis.update.status === 'not-checked'
1670
+ ? zh ? '未检查 registry' : 'registry not checked'
1671
+ : diagnosis.update.status)
1672
+ }
1478
1673
  lines.push(zh
1479
1674
  ? ` 问题(${String(originals.length)}):`
1480
1675
  : ` Problems (${String(originals.length)}):`)
@@ -1506,6 +1701,15 @@ export function formatReport(report, options = {}) {
1506
1701
  lines.push(` ${zh ? '可执行命令' : 'Available commands'}:`)
1507
1702
  commands.forEach(command => lines.push(` $ ${command}`))
1508
1703
  }
1704
+ if (diagnosis !== undefined) {
1705
+ lines.push(` ${zh ? '恢复选择' : 'Recovery options'}:`)
1706
+ for (const option of diagnosis.recovery.options) {
1707
+ lines.push(` - ${option.kind}: ${option.availability}${option.recommended ? (zh ? '(推荐)' : ' (recommended)') : ''}`)
1708
+ }
1709
+ if (diagnosis.recovery.options.some(option => option.kind === 'remove')) {
1710
+ lines.push(` ${zh ? '动态 Service 依赖:未知,静态检查不能证明删除不影响其他功能。' : 'Dynamic Service dependencies: unknown; static checks cannot prove removal leaves every feature working.'}`)
1711
+ }
1712
+ }
1509
1713
  lines.push('')
1510
1714
  }
1511
1715
  }
package/src/i18n.mjs CHANGED
@@ -87,6 +87,15 @@ const ZH_MESSAGES = {
87
87
  PATCH_TARGET_NOT_FOUND: item => `Patch 指向了不存在的配置行:${captured(item.message, / row ([^.]+)\.$/) ?? '未知'}。`,
88
88
  PATCH_TARGET_NOT_GROUP: item => `Patch 尝试向非 group 配置行 ${captured(item.message, / row ([^,]+),/) ?? '未知'} 插入内容。`,
89
89
  PATCH_NAME_MISMATCH: () => 'Patch 的 name 断言与目标配置行不一致。',
90
+ PATCH_INCOMPATIBLE_WITH_CURRENT_DSH: item => `配置层仍指向当前 DSH 默认树中不存在的行:${captured(item.message, /targets entry ([^,]+),/) ?? '未知'}。`,
91
+ DUPLICATE_ENTRY_ID: () => '生效配置中存在重复的 entry id。',
92
+ DUPLICATE_PLUGIN_MOUNT: item => `${item.package ?? '插件'}由多个启用状态的 entry 重复挂载。`,
93
+ CORE_ENTRY_DISABLED_BY_HIGHER_LAYER: () => '较高优先级配置层禁用了较低层提供的 entry。',
94
+ ENTRY_STRUCTURE_OVERRIDDEN: () => '较高优先级配置层改变了 entry 的结构。',
95
+ INVALID_GROUP_CONFIG: () => 'group entry 的 config 必须是数组。',
96
+ GROUP_CONTENT_REPLACED: () => '较高优先级配置层替换了 group 的完整内容。',
97
+ CONFIG_REPLACED_BY_HIGHER_LAYER: () => '较高优先级配置层替换了 entry 的完整 config。',
98
+ BUNDLE_PROFILE_DECLARATION_CONFLICT: item => `${item.package} 声明了 bundle 层,但当前 profile 没有启用它。`,
90
99
  INVALID_SETTINGS_DOCUMENT: () => 'Harness 设置文件无法解析。',
91
100
  INVALID_SETTINGS_ROOT: () => 'Harness 设置文件顶层必须是命名空间映射。',
92
101
  INVALID_CREDENTIALS_DOCUMENT: () => 'Harness 凭据文件无法解析。',
@@ -152,6 +161,15 @@ const ZH_SUGGESTIONS = {
152
161
  PATCH_TARGET_NOT_FOUND: () => '确认该 overlay 是否适用于当前 profile,并检查 bundle 顺序。',
153
162
  PATCH_TARGET_NOT_GROUP: () => '请选择 group 行,或使用根级 insert。',
154
163
  PATCH_NAME_MISMATCH: () => '请更新 name 断言,或改为指向正确的配置行。',
164
+ PATCH_INCOMPATIBLE_WITH_CURRENT_DSH: () => '请与当前默认配置对比后调整或移除这个旧 patch。',
165
+ DUPLICATE_ENTRY_ID: () => '请保留一个明确的 entry 来源,并为其他 entry 使用唯一 id。',
166
+ DUPLICATE_PLUGIN_MOUNT: () => '请检查 bundle 与手工 insert,只保留一个需要的插件挂载。',
167
+ CORE_ENTRY_DISABLED_BY_HIGHER_LAYER: () => '请确认该禁用是否仍适用于当前 DSH;需要恢复时撤销较高层覆盖。',
168
+ ENTRY_STRUCTURE_OVERRIDDEN: () => '请检查较高层 patch 是否仍适配当前 entry 结构。',
169
+ INVALID_GROUP_CONFIG: () => '请把 group entry 的 config 修复为 entry 数组。',
170
+ GROUP_CONTENT_REPLACED: () => '请检查被整体替换的 group 内容,避免删除当前 DSH 新增的子项。',
171
+ CONFIG_REPLACED_BY_HIGHER_LAYER: () => '请检查完整 config 替换造成的字段丢失,并按当前默认值调整。',
172
+ BUNDLE_PROFILE_DECLARATION_CONFLICT: () => '请使用当前 DSH CLI 对齐直接依赖与 dsh.profile.bundles。',
155
173
  INVALID_SETTINGS_DOCUMENT: () => '修复设置文件语法;Doctor 不会猜测凭据或模型配置值。',
156
174
  INVALID_SETTINGS_ROOT: () => '把顶层标量或数组替换为映射。',
157
175
  INVALID_CREDENTIALS_DOCUMENT: () => '只修复报告的结构;Doctor 永远不会输出或重写秘密值。',
@@ -0,0 +1,195 @@
1
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
2
+ import { tmpdir } from 'node:os'
3
+ import { basename, join, resolve } from 'node:path'
4
+ import crossSpawn from 'cross-spawn'
5
+ import { analyzeMigration, publicMigrationReport } from './migrate.mjs'
6
+ import { sha256 } from './safe-write.mjs'
7
+
8
+ function commandResult(command, args, options = {}) {
9
+ const started = Date.now()
10
+ const result = crossSpawn.sync(command, args, {
11
+ cwd: options.cwd,
12
+ env: options.env,
13
+ encoding: 'utf8',
14
+ timeout: options.timeout ?? 10 * 60 * 1000,
15
+ maxBuffer: 8 * 1024 * 1024,
16
+ })
17
+ return {
18
+ command: [command, ...args],
19
+ status: result.status,
20
+ signal: result.signal,
21
+ durationMs: Date.now() - started,
22
+ stdout: result.stdout ?? '',
23
+ stderr: result.stderr ?? '',
24
+ passed: result.status === 0,
25
+ ...(result.error ? { error: result.error.message } : {}),
26
+ }
27
+ }
28
+
29
+ function commandEvidence(result) {
30
+ const { stdout, stderr, ...evidence } = result
31
+ return {
32
+ ...evidence,
33
+ stdoutBytes: Buffer.byteLength(stdout),
34
+ stderrBytes: Buffer.byteLength(stderr),
35
+ stdoutHash: sha256(stdout),
36
+ stderrHash: sha256(stderr),
37
+ }
38
+ }
39
+
40
+ function packageManager(root) {
41
+ if (existsSync(join(root, 'pnpm-lock.yaml'))) return { command: 'pnpm', run: script => ['run', script], pack: destination => ['pack', '--pack-destination', destination] }
42
+ if (existsSync(join(root, 'yarn.lock'))) return { command: 'yarn', run: script => [script], pack: destination => ['pack', '--out', join(destination, 'plugin.tgz')] }
43
+ return { command: 'npm', run: script => ['run', script], pack: destination => ['pack', '--json', '--pack-destination', destination] }
44
+ }
45
+
46
+ function discoverDsh(options) {
47
+ if (options.dshCommand) return resolve(options.dshCommand)
48
+ if (options.harnessRoot) {
49
+ const candidate = join(resolve(options.harnessRoot), 'apps', 'cli', 'lib', 'bin.js')
50
+ if (existsSync(candidate)) return candidate
51
+ }
52
+ throw new Error('runtime verification requires --dsh-command or a built --harness-root/apps/cli/lib/bin.js')
53
+ }
54
+
55
+ function dshInvocation(file, args) {
56
+ return /\.(?:mjs|cjs|js)$/i.test(file) ? [process.execPath, [file, ...args]] : [file, args]
57
+ }
58
+
59
+ function tarballFromPack(step, destination) {
60
+ try {
61
+ const parsed = JSON.parse(step.stdout)
62
+ const filename = Array.isArray(parsed) ? parsed[0]?.filename : parsed?.filename
63
+ if (filename) return join(destination, filename)
64
+ } catch {}
65
+ const match = step.stdout.match(/(?:^|\s)([^\s]+\.tgz)(?:\s|$)/m)
66
+ if (match) return resolve(destination, basename(match[1]))
67
+ const fallback = join(destination, 'plugin.tgz')
68
+ return existsSync(fallback) ? fallback : undefined
69
+ }
70
+
71
+ function runtimeProfileEvidence(dshHome, pluginName, dumpOutput) {
72
+ const profileDir = join(dshHome, 'profiles', 'web')
73
+ const manifestFile = join(profileDir, 'package.json')
74
+ const installedManifestFile = join(profileDir, 'node_modules', ...pluginName.split('/'), 'package.json')
75
+ try {
76
+ const profile = JSON.parse(readFileSync(manifestFile, 'utf8'))
77
+ const installed = JSON.parse(readFileSync(installedManifestFile, 'utf8'))
78
+ return {
79
+ profileCreated: true,
80
+ dependencyInstalled: profile.dependencies?.[pluginName] !== undefined,
81
+ bundleActivated: Array.isArray(profile.dsh?.profile?.bundles) && profile.dsh.profile.bundles.includes(pluginName),
82
+ installedPackageResolved: installed.name === pluginName,
83
+ effectiveConfigIncludesPlugin: dumpOutput.includes(pluginName),
84
+ }
85
+ } catch (error) {
86
+ return {
87
+ profileCreated: existsSync(manifestFile),
88
+ dependencyInstalled: false,
89
+ bundleActivated: false,
90
+ installedPackageResolved: false,
91
+ effectiveConfigIncludesPlugin: false,
92
+ evidenceError: error instanceof Error ? error.message : String(error),
93
+ }
94
+ }
95
+ }
96
+
97
+ export function verifyMigration(pluginRoot = process.cwd(), options = {}) {
98
+ const level = options.level ?? 'static'
99
+ if (!['static', 'build', 'runtime'].includes(level)) throw new Error(`unsupported verification level ${level}`)
100
+ if (level !== 'static' && options.yes !== true) throw new Error(`migrate verify --level ${level} executes project commands and requires --yes`)
101
+ const analysis = analyzeMigration(pluginRoot, options)
102
+ const result = {
103
+ schemaVersion: 1,
104
+ command: 'migrate verify',
105
+ level,
106
+ plugin: analysis.plugin,
107
+ migration: publicMigrationReport(analysis).migration,
108
+ stages: [{ name: level === 'static' ? 'static' : 'preflight-static', passed: analysis.summary.errors === 0, report: publicMigrationReport(analysis) }],
109
+ status: analysis.summary.errors === 0 ? 'source-migrated' : 'analyzed',
110
+ passed: analysis.summary.errors === 0,
111
+ manualBehaviorVerificationRequired: true,
112
+ }
113
+ if (level === 'static') return result
114
+
115
+ const root = analysis.plugin.root
116
+ const manifest = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'))
117
+ const manager = packageManager(root)
118
+ const scripts = ['typecheck', 'build', 'test', 'pack:check'].filter(name => typeof manifest.scripts?.[name] === 'string')
119
+ const commandRuns = []
120
+ for (const script of scripts) {
121
+ const run = { name: script, ...commandResult(manager.command, manager.run(script), { cwd: root }) }
122
+ commandRuns.push(run)
123
+ if (!run.passed) break
124
+ }
125
+ const commands = commandRuns.map(commandEvidence)
126
+ const artifactProducerDeclared = scripts.includes('build') || scripts.includes('pack:check')
127
+ const commandsPassed = artifactProducerDeclared && commandRuns.length === scripts.length && commandRuns.every(item => item.passed)
128
+ const buildNote = scripts.length === 0
129
+ ? 'No recognized verification scripts were declared.'
130
+ : !artifactProducerDeclared ? 'Artifact verification requires a build or pack:check script; typecheck/test alone are insufficient.' : undefined
131
+ result.stages.push({ name: 'build', passed: commandsPassed, packageManager: manager.command, commands, note: buildNote })
132
+ const postBuild = analyzeMigration(root, options)
133
+ const postBuildPassed = postBuild.summary.errors === 0
134
+ result.stages.push({ name: 'static-after-build', passed: postBuildPassed, report: publicMigrationReport(postBuild) })
135
+ const buildPassed = commandsPassed && postBuildPassed
136
+ result.passed = buildPassed
137
+ if (result.passed) result.status = 'artifact-verified'
138
+ if (level === 'build' || !result.passed) return result
139
+
140
+ const dsh = discoverDsh(options)
141
+ const verificationRoot = mkdtempSync(join(tmpdir(), 'dsh-doctor-migrate-'))
142
+ const packDir = join(verificationRoot, 'pack')
143
+ const dshHome = join(verificationRoot, 'dsh-home')
144
+ mkdirSync(packDir, { recursive: true })
145
+ mkdirSync(dshHome, { recursive: true })
146
+ const env = { ...process.env, DSH_HOME: dshHome }
147
+ const [versionCommand, versionArgs] = dshInvocation(dsh, ['--version'])
148
+ const versionRun = commandResult(versionCommand, versionArgs, { cwd: root, env })
149
+ const actualDshVersion = versionRun.stdout.trim()
150
+ const versionPassed = versionRun.passed && actualDshVersion === analysis.migration.to.version
151
+ const pack = commandResult(manager.command, manager.pack(packDir), { cwd: root })
152
+ const tarball = tarballFromPack(pack, packDir)
153
+ const runtimeCommands = []
154
+ if (versionPassed && pack.passed && tarball && existsSync(tarball)) {
155
+ for (const args of [
156
+ ['plugin', '--profile', 'web', 'add', tarball],
157
+ ['--profile', 'web', '--dump-config'],
158
+ ['--profile', 'web', '--help'],
159
+ ]) {
160
+ const [command, commandArgs] = dshInvocation(dsh, args)
161
+ const step = commandResult(command, commandArgs, { cwd: root, env })
162
+ runtimeCommands.push(step)
163
+ if (!step.passed) break
164
+ }
165
+ }
166
+ const profileEvidence = runtimeProfileEvidence(dshHome, analysis.plugin.name, runtimeCommands[1]?.stdout ?? '')
167
+ const evidencePassed = Object.entries(profileEvidence).filter(([key]) => key !== 'evidenceError').every(([, value]) => value === true)
168
+ const runtimePassed = versionPassed && pack.passed && tarball !== undefined && runtimeCommands.length === 3 && runtimeCommands.every(item => item.passed) && evidencePassed
169
+ result.stages.push({
170
+ name: 'runtime',
171
+ passed: runtimePassed,
172
+ dshVersion: { expected: analysis.migration.to.version, actual: actualDshVersion, passed: versionPassed, command: commandEvidence(versionRun) },
173
+ pack: commandEvidence(pack),
174
+ tarball,
175
+ commands: runtimeCommands.map(commandEvidence),
176
+ profileEvidence,
177
+ isolatedDshHome: dshHome,
178
+ })
179
+ result.passed = result.passed && runtimePassed
180
+ if (runtimePassed) result.status = 'runtime-verified'
181
+ if (runtimePassed && !options.keepTemp) {
182
+ rmSync(verificationRoot, { recursive: true, force: true })
183
+ result.stages.at(-1).isolatedDshHome = '(removed after successful verification)'
184
+ } else result.retainedTemporaryDirectory = verificationRoot
185
+ return result
186
+ }
187
+
188
+ export function formatVerification(result, language = 'en') {
189
+ const zh = language === 'zh'
190
+ const lines = [`${zh ? '升级验证' : 'Migration verification'}: ${result.plugin.name}`, `${zh ? '状态' : 'Status'}: ${result.status} (${result.passed ? 'PASS' : 'FAIL'})`]
191
+ for (const stage of result.stages) lines.push(`- ${stage.name}: ${stage.passed ? 'PASS' : 'FAIL'}`)
192
+ if (result.retainedTemporaryDirectory) lines.push(`${zh ? '失败现场保留在' : 'Failure workspace retained at'}: ${result.retainedTemporaryDirectory}`)
193
+ if (result.manualBehaviorVerificationRequired) lines.push(zh ? '仍需人工验证插件的交互、生命周期与业务行为。' : 'Interaction, lifecycle, and business behavior still require manual verification.')
194
+ return `${lines.join('\n')}\n`
195
+ }