@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/README.en.md +80 -84
- package/README.md +130 -82
- package/migrations/dsh-v0.1.1-rc.2__dsh-v0.1.2-alpha.2/behavior.md +12 -0
- package/migrations/dsh-v0.1.1-rc.2__dsh-v0.1.2-alpha.2/config-rules.json +36 -0
- package/migrations/dsh-v0.1.1-rc.2__dsh-v0.1.2-alpha.2/manifest.json +19 -0
- package/migrations/dsh-v0.1.1-rc.2__dsh-v0.1.2-alpha.2/packages.json +62 -0
- package/migrations/dsh-v0.1.1-rc.2__dsh-v0.1.2-alpha.2/services.json +28 -0
- package/migrations/dsh-v0.1.1-rc.2__dsh-v0.1.2-alpha.2/symbols.json +126 -0
- package/package.json +6 -3
- package/skills/dsh-plugin-upgrade/SKILL.md +98 -0
- package/skills/dsh-plugin-upgrade/evals/evals.json +36 -0
- package/skills/dsh-plugin-upgrade/references/migration-map.md +32 -0
- package/skills/dsh-plugin-upgrade/references/verification.md +27 -0
- package/src/baseline.mjs +69 -0
- package/src/cli.mjs +262 -88
- package/src/config-model.mjs +167 -0
- package/src/doctor.mjs +201 -23
- package/src/i18n.mjs +18 -0
- package/src/migrate-verify.mjs +195 -0
- package/src/migrate.mjs +418 -0
- package/src/migration-catalog.mjs +60 -0
- package/src/recovery.mjs +358 -0
- package/src/redact.mjs +46 -0
- package/src/registry.mjs +61 -0
- package/src/safe-write.mjs +50 -0
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
|
-
|
|
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)
|
|
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({
|
|
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: {
|
|
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
|
|
1385
|
-
|
|
1386
|
-
|
|
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:
|
|
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
|
+
}
|