@bruc3van/dsh-doctor 0.1.6 → 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,6 +6,8 @@ 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
 
10
12
  // Mirrors packages/client/web/src/platform.ts in DeepSeek Harness. Installed
11
13
  // DSH packages do not expose this source list, so update this baseline when
@@ -48,6 +50,13 @@ function finding(severity, code, message, options = {}) {
48
50
  }
49
51
  }
50
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
+
51
60
  function readJson(file, subject, findings) {
52
61
  let text
53
62
  try {
@@ -510,6 +519,39 @@ function stringArray(value) {
510
519
  return Array.isArray(value) && value.every(item => typeof item === 'string') ? value : undefined
511
520
  }
512
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
+
513
555
  function inspectClientPackage(record, context) {
514
556
  const {
515
557
  commandRepair, findings, harnessPackages, harnessPackagesAuthoritative, profile, resolvePackage,
@@ -689,7 +731,9 @@ function inspectPatchFile(file, subject, findings, packageName) {
689
731
  } catch (error) {
690
732
  findings.push(finding('error', 'INVALID_PATCH_YAML', `${subject} cannot be parsed.`, {
691
733
  package: packageName,
692
- 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)}`,
693
737
  suggestion: 'Repair the YAML syntax before starting this profile.',
694
738
  }))
695
739
  return undefined
@@ -1110,7 +1154,8 @@ function inspectCompatibility(record, context) {
1110
1154
  }))
1111
1155
  continue
1112
1156
  }
1113
- const supplier = harnessPackages.get(name) ?? resolvePackage(name)
1157
+ const supplier = harnessPackages.get(name)
1158
+ ?? (harnessPackagesAuthoritative ? undefined : resolvePackage(name))
1114
1159
  if (supplier === undefined) continue
1115
1160
  const version = supplier.manifest?.version
1116
1161
  if (typeof version !== 'string' || semver.valid(version) === null) continue
@@ -1321,6 +1366,11 @@ export function diagnose(options = {}) {
1321
1366
  operation: { type: 'add-bundle', name },
1322
1367
  },
1323
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
+ }))
1324
1374
  }
1325
1375
  }
1326
1376
  const patchLayers = []
@@ -1328,7 +1378,7 @@ export function diagnose(options = {}) {
1328
1378
  for (const name of bundleNames) {
1329
1379
  const layer = inspectBundle(name, bundleRecords.get(name), findings)
1330
1380
  if (layer === undefined) patchCompositionValid = false
1331
- else patchLayers.push(layer)
1381
+ else patchLayers.push({ ...layer, kind: 'bundle' })
1332
1382
  }
1333
1383
 
1334
1384
  for (const [file, subject, label] of [
@@ -1336,10 +1386,87 @@ export function diagnose(options = {}) {
1336
1386
  [join(home, 'cordis.patch.yml'), 'home patch', 'home patch'],
1337
1387
  ]) {
1338
1388
  const patches = inspectPatchFileIfPresent(file, subject, findings)
1339
- 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
+ })
1340
1395
  else if (existsSync(file)) patchCompositionValid = false
1341
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
+ }
1342
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
+ }
1343
1470
  inspectSettings(join(home, 'settings.yaml'), findings)
1344
1471
  inspectCredentials(join(home, '.credentials.yaml'), findings)
1345
1472
 
@@ -1372,32 +1499,65 @@ export function diagnose(options = {}) {
1372
1499
  )
1373
1500
  }
1374
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
+ })
1375
1545
  return finish({
1376
1546
  home,
1377
1547
  profile,
1378
1548
  profileDir,
1379
- harness: { root: harness.root, version: harness.version },
1549
+ harness: {
1550
+ root: harness.root,
1551
+ version: harness.version,
1552
+ packages: activeHarnessVersions,
1553
+ },
1380
1554
  lockfile,
1381
1555
  dshCli: dshCli === undefined
1382
1556
  ? { available: false, commandRepairNeeded }
1383
1557
  : { available: true, commandRepairNeeded, ...dshCli },
1384
- packages: dependencyNames.map(name => {
1385
- const record = records.get(name)
1386
- if (record === undefined) return { name, installed: false, compatibility: 'incompatible' }
1387
- return {
1388
- name: record.requestedName ?? record.manifest.name,
1389
- version: record.manifest.version,
1390
- directory: record.directory,
1391
- installed: true,
1392
- client: record.manifest?.dsh?.client !== undefined,
1393
- bundle: record.manifest?.dsh?.bundle !== undefined,
1394
- compatibility: pluginCompatibility(
1395
- record,
1396
- findings,
1397
- compatibilityChecks.get(record.requestedName ?? record.manifest.name),
1398
- ),
1399
- }
1400
- }),
1558
+ packages,
1559
+ configuration,
1560
+ pluginDiagnoses,
1401
1561
  }, findings)
1402
1562
  }
1403
1563
 
@@ -1418,7 +1578,7 @@ function finish(context, findings) {
1418
1578
  compatible: context.packages.filter(item => item.compatibility === 'compatible').length,
1419
1579
  }
1420
1580
  context = { ...context, compatibility }
1421
- return { version: 1, ok: summary.errors === 0, context, summary, findings }
1581
+ return { version: 2, ok: summary.errors === 0, context, summary, findings }
1422
1582
  }
1423
1583
 
1424
1584
  function appendReportField(lines, label, value, indent = ' ') {
@@ -1463,6 +1623,7 @@ export function formatReport(report, options = {}) {
1463
1623
  '',
1464
1624
  ]
1465
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]))
1466
1627
  const pluginFindings = new Map()
1467
1628
  const environmentFindings = []
1468
1629
  for (const item of report.findings) {
@@ -1501,6 +1662,14 @@ export function formatReport(report, options = {}) {
1501
1662
  appendReportField(lines, zh ? '版本' : 'Version', plugin.installed === false
1502
1663
  ? zh ? '未安装' : 'not installed'
1503
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
+ }
1504
1673
  lines.push(zh
1505
1674
  ? ` 问题(${String(originals.length)}):`
1506
1675
  : ` Problems (${String(originals.length)}):`)
@@ -1532,6 +1701,15 @@ export function formatReport(report, options = {}) {
1532
1701
  lines.push(` ${zh ? '可执行命令' : 'Available commands'}:`)
1533
1702
  commands.forEach(command => lines.push(` $ ${command}`))
1534
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
+ }
1535
1713
  lines.push('')
1536
1714
  }
1537
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
+ }