@bruc3van/dsh-doctor 0.5.4 → 0.5.6
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 +14 -12
- package/README.md +14 -12
- package/migrations/dsh-v0.1.1-rc.2__dsh-v0.1.2-alpha.2/packages.json +12 -3
- package/migrations/dsh-v0.1.1-rc.2__dsh-v0.1.2-alpha.2/symbols.json +8 -0
- package/migrations/dsh-v0.1.1-rc.2__dsh-v0.1.2-alpha.3/behavior.md +14 -0
- package/migrations/dsh-v0.1.1-rc.2__dsh-v0.1.2-alpha.3/config-rules.json +36 -0
- package/migrations/dsh-v0.1.1-rc.2__dsh-v0.1.2-alpha.3/manifest.json +19 -0
- package/migrations/dsh-v0.1.1-rc.2__dsh-v0.1.2-alpha.3/packages.json +74 -0
- package/migrations/dsh-v0.1.1-rc.2__dsh-v0.1.2-alpha.3/services.json +28 -0
- package/migrations/dsh-v0.1.1-rc.2__dsh-v0.1.2-alpha.3/symbols.json +134 -0
- package/package.json +1 -1
- package/skills/dsh-plugin-upgrade/SKILL.md +29 -14
- package/skills/dsh-plugin-upgrade/references/cli-bootstrap.md +6 -4
- package/skills/dsh-plugin-upgrade/references/migration-map.md +15 -1
- package/skills/dsh-plugin-upgrade/references/source-investigation.md +99 -0
- package/skills/dsh-plugin-upgrade/references/verification.md +9 -1
- package/src/cli.mjs +14 -3
- package/src/migrate-verify.mjs +74 -6
- package/src/migrate.mjs +148 -31
- package/src/safe-write.mjs +28 -1
- package/skills/dsh-plugin-upgrade/evals/evals.json +0 -76
package/src/migrate.mjs
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
|
|
2
|
-
import { basename, join, relative, resolve } from 'node:path'
|
|
2
|
+
import { basename, isAbsolute, join, relative, resolve } from 'node:path'
|
|
3
3
|
import semver from 'semver'
|
|
4
4
|
import ts from 'typescript'
|
|
5
5
|
import { parseDocument } from 'yaml'
|
|
6
|
-
import { atomicWrite, sha256 } from './safe-write.mjs'
|
|
6
|
+
import { atomicWrite, sha256, sha256File, writeNewFile } from './safe-write.mjs'
|
|
7
7
|
import { loadMigration, verifyHarnessCheckout } from './migration-catalog.mjs'
|
|
8
8
|
|
|
9
9
|
const SOURCE_EXTENSIONS = new Set(['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs', '.mts', '.cts'])
|
|
10
10
|
const SOURCE_DIRS_TO_SKIP = new Set(['.git', 'node_modules', 'coverage'])
|
|
11
|
-
const ARTIFACT_DIRS = ['lib', 'dist', 'build']
|
|
11
|
+
const ARTIFACT_DIRS = ['lib', 'dist', 'build', 'out']
|
|
12
12
|
const DEPENDENCY_FIELDS = ['dependencies', 'peerDependencies', 'devDependencies', 'optionalDependencies']
|
|
13
13
|
const LOCKFILES = new Set(['package-lock.json', 'pnpm-lock.yaml', 'yarn.lock'])
|
|
14
14
|
const NON_RUNTIME_TEXT_EXTENSIONS = new Set(['.md', '.mdx', '.txt', '.snap'])
|
|
@@ -80,12 +80,17 @@ function moduleLiteral(node) {
|
|
|
80
80
|
|
|
81
81
|
function namedImports(node) {
|
|
82
82
|
const bindings = node.importClause?.namedBindings
|
|
83
|
-
if (
|
|
84
|
-
return bindings.elements.map(item => ({
|
|
83
|
+
if (bindings && ts.isNamedImports(bindings)) return bindings.elements.map(item => ({
|
|
85
84
|
imported: item.propertyName?.text ?? item.name.text,
|
|
86
85
|
local: item.name.text,
|
|
87
86
|
typeOnly: node.importClause.isTypeOnly || item.isTypeOnly,
|
|
88
87
|
}))
|
|
88
|
+
if (ts.isExportDeclaration(node) && node.exportClause && ts.isNamedExports(node.exportClause)) return node.exportClause.elements.map(item => ({
|
|
89
|
+
imported: item.propertyName?.text ?? item.name.text,
|
|
90
|
+
local: item.name.text,
|
|
91
|
+
typeOnly: node.isTypeOnly || item.isTypeOnly,
|
|
92
|
+
}))
|
|
93
|
+
return []
|
|
89
94
|
}
|
|
90
95
|
|
|
91
96
|
function quoteModule(text) {
|
|
@@ -117,10 +122,11 @@ function analyzeFile(file, pluginRoot, catalog, origin) {
|
|
|
117
122
|
handledLiterals.add(literal)
|
|
118
123
|
const moduleName = literal.text
|
|
119
124
|
const rootName = packageRoot(moduleName)
|
|
120
|
-
|
|
125
|
+
const moduleRemoved = removed.has(rootName)
|
|
126
|
+
const symbolRules = catalog.symbols.modules[moduleName]
|
|
127
|
+
if (moduleRemoved || symbolRules !== undefined) {
|
|
121
128
|
const where = location(source, literal, pluginRoot)
|
|
122
|
-
const
|
|
123
|
-
const specs = ts.isImportDeclaration(node) ? namedImports(node) : []
|
|
129
|
+
const specs = namedImports(node)
|
|
124
130
|
const exact = []
|
|
125
131
|
const remaining = []
|
|
126
132
|
for (const spec of specs) {
|
|
@@ -128,17 +134,29 @@ function analyzeFile(file, pluginRoot, catalog, origin) {
|
|
|
128
134
|
if (rule?.confidence === 'exact') {
|
|
129
135
|
exact.push({ ...spec, imported: rule.toSymbol, toModule: rule.toModule, fromSymbol: spec.imported, reason: rule.reason })
|
|
130
136
|
findings.push({ code: 'MIG_MOVED_SYMBOL', severity: 'error', message: `${spec.imported} moved to ${rule.toModule}`, location: where, evidence: { module: moduleName, symbol: spec.imported, targetModule: rule.toModule, replacement: rule.toSymbol }, autoFix: 'safe' })
|
|
131
|
-
} else {
|
|
137
|
+
} else if (rule?.confidence === 'semantic' || moduleRemoved) {
|
|
132
138
|
remaining.push(spec)
|
|
133
139
|
const semantic = rule?.confidence === 'semantic'
|
|
134
140
|
findings.push({ code: semantic ? 'MIG_SEMANTIC_API_CHANGE' : 'MIG_REMOVED_PACKAGE_REFERENCE', severity: 'error', message: semantic ? `${spec.imported} requires a semantic migration: ${rule.reason}` : `${moduleName} was removed without a safe automatic replacement`, location: where, evidence: { module: moduleName, ...(spec.imported ? { symbol: spec.imported } : {}) }, autoFix: 'none' })
|
|
135
|
-
unresolved.push({
|
|
136
|
-
|
|
141
|
+
unresolved.push({
|
|
142
|
+
file: where.file,
|
|
143
|
+
package: rootName,
|
|
144
|
+
symbol: spec.imported,
|
|
145
|
+
...(rule?.toModule ? { targetModule: rule.toModule } : {}),
|
|
146
|
+
...(rule?.toSymbol ? { targetSymbol: rule.toSymbol } : {}),
|
|
147
|
+
reason: rule?.reason ?? 'No exact replacement is known.',
|
|
148
|
+
})
|
|
149
|
+
} else remaining.push(spec)
|
|
137
150
|
}
|
|
138
151
|
const declarationText = ts.isImportDeclaration(node) ? text.slice(node.getStart(source), node.getEnd()) : ''
|
|
139
152
|
const simpleNamedImport = exact.length > 0 && ts.isImportDeclaration(node) && node.importClause?.namedBindings && ts.isNamedImports(node.importClause.namedBindings) && node.importClause.name === undefined && node.attributes === undefined && node.assertClause === undefined && !/\/(?:\/|\*)/.test(declarationText)
|
|
140
153
|
if (simpleNamedImport) {
|
|
141
|
-
for (const item of exact) exactTargets.set(`${rootName}\0${item.toModule}`, {
|
|
154
|
+
for (const item of exact) exactTargets.set(`${rootName}\0${item.toModule}`, {
|
|
155
|
+
fromPackage: rootName,
|
|
156
|
+
toPackage: packageRoot(item.toModule),
|
|
157
|
+
relationship: 'client',
|
|
158
|
+
typeOnly: item.typeOnly,
|
|
159
|
+
})
|
|
142
160
|
const groups = new Map()
|
|
143
161
|
for (const item of exact) {
|
|
144
162
|
const list = groups.get(item.toModule) ?? []
|
|
@@ -148,7 +166,7 @@ function analyzeFile(file, pluginRoot, catalog, origin) {
|
|
|
148
166
|
const generated = [...groups].map(([target, items]) => importText(target, items, node.importClause.isTypeOnly))
|
|
149
167
|
if (remaining.length > 0) generated.unshift(importText(moduleName, remaining, node.importClause.isTypeOnly))
|
|
150
168
|
replacements.push({ start: node.getStart(source), end: node.getEnd(), next: generated.join('\n'), exact })
|
|
151
|
-
} else if (specs.length === 0 || exact.length > 0) {
|
|
169
|
+
} else if (moduleRemoved && (specs.length === 0 || exact.length > 0)) {
|
|
152
170
|
for (const finding of findings) if (finding.code === 'MIG_MOVED_SYMBOL' && finding.location.line === where.line && exact.some(item => item.fromSymbol === finding.evidence.symbol)) finding.autoFix = 'none'
|
|
153
171
|
findings.push({ code: 'MIG_REMOVED_PACKAGE_REFERENCE', severity: 'error', message: `${moduleName} was removed and this reference cannot be rewritten safely`, location: where, evidence: { module: moduleName, kind: ts.SyntaxKind[node.kind] }, autoFix: 'none' })
|
|
154
172
|
unresolved.push({ file: where.file, package: rootName, module: moduleName, reason: 'Default, namespace, side-effect, export, require, dynamic import, or module augmentation reference.' })
|
|
@@ -177,12 +195,28 @@ function readManifest(pluginRoot) {
|
|
|
177
195
|
return { file, value, text }
|
|
178
196
|
}
|
|
179
197
|
|
|
180
|
-
function
|
|
198
|
+
function actualTargetVersion(catalog, requested) {
|
|
199
|
+
if (requested === undefined) return catalog.manifest.to.version
|
|
200
|
+
const version = semver.valid(requested)
|
|
201
|
+
if (version === null) throw new Error(`--target-version must be an exact semantic version, received ${requested}`)
|
|
202
|
+
const catalogVersion = semver.parse(catalog.manifest.to.version)
|
|
203
|
+
const target = semver.parse(version)
|
|
204
|
+
if (catalogVersion === null || target === null || target.major !== catalogVersion.major || target.minor !== catalogVersion.minor || target.patch !== catalogVersion.patch) {
|
|
205
|
+
throw new Error(`--target-version ${requested} is outside the catalog's ${catalogVersion?.major}.${catalogVersion?.minor}.${catalogVersion?.patch} release line`)
|
|
206
|
+
}
|
|
207
|
+
if (semver.lt(target, catalogVersion)) {
|
|
208
|
+
throw new Error(`--target-version ${requested} predates the catalog target ${catalog.manifest.to.version}`)
|
|
209
|
+
}
|
|
210
|
+
return version
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function targetVersion(name, catalog, actualTarget) {
|
|
214
|
+
if (name.startsWith('@deepseek-ai/dsh-')) return actualTarget
|
|
181
215
|
if (catalog.packages.targetVersions?.[name] !== undefined) return catalog.packages.targetVersions[name]
|
|
182
|
-
return
|
|
216
|
+
return undefined
|
|
183
217
|
}
|
|
184
218
|
|
|
185
|
-
function manifestFindings(manifest, catalog) {
|
|
219
|
+
function manifestFindings(manifest, catalog, actualTarget) {
|
|
186
220
|
const findings = []
|
|
187
221
|
for (const field of DEPENDENCY_FIELDS) {
|
|
188
222
|
const dependencyMap = manifest.value[field]
|
|
@@ -195,7 +229,7 @@ function manifestFindings(manifest, catalog) {
|
|
|
195
229
|
findings.push({ code: 'MIG_REMOVED_PACKAGE_REFERENCE', severity: 'error', message: `${field}.${name} targets a removed package`, location: { file: 'package.json' }, evidence: { field, package: name, range }, autoFix: 'conditional' })
|
|
196
230
|
continue
|
|
197
231
|
}
|
|
198
|
-
const target = targetVersion(name, catalog)
|
|
232
|
+
const target = targetVersion(name, catalog, actualTarget)
|
|
199
233
|
if (target === undefined) continue
|
|
200
234
|
const validRange = typeof range === 'string' ? semver.validRange(range) : null
|
|
201
235
|
if (validRange === null) findings.push({ code: 'MIG_INVALID_DEPENDENCY_RANGE', severity: 'error', message: `${field}.${name} has a non-registry range that cannot prove target compatibility`, location: { file: 'package.json' }, evidence: { field, package: name, range, target }, autoFix: field === 'devDependencies' ? 'safe' : 'none' })
|
|
@@ -203,6 +237,12 @@ function manifestFindings(manifest, catalog) {
|
|
|
203
237
|
}
|
|
204
238
|
}
|
|
205
239
|
const client = manifest.value.dsh?.client
|
|
240
|
+
if (client !== undefined && (client === null || Array.isArray(client) || typeof client !== 'object')) {
|
|
241
|
+
findings.push({ code: 'MIG_INVALID_MANIFEST', severity: 'error', message: 'dsh.client must be an object', location: { file: 'package.json' }, evidence: { field: 'dsh.client' }, autoFix: 'none' })
|
|
242
|
+
return findings
|
|
243
|
+
}
|
|
244
|
+
if (client !== undefined && typeof client.platform !== 'string') findings.push({ code: 'MIG_INVALID_MANIFEST', severity: 'error', message: 'dsh.client.platform must be a string', location: { file: 'package.json' }, evidence: { field: 'dsh.client.platform' }, autoFix: 'none' })
|
|
245
|
+
if (client?.immediately !== undefined && typeof client.immediately !== 'boolean') findings.push({ code: 'MIG_INVALID_MANIFEST', severity: 'error', message: 'dsh.client.immediately must be a boolean', location: { file: 'package.json' }, evidence: { field: 'dsh.client.immediately' }, autoFix: 'none' })
|
|
206
246
|
for (const field of ['inject', 'external']) {
|
|
207
247
|
const values = client?.[field]
|
|
208
248
|
if (values !== undefined && !Array.isArray(values)) {
|
|
@@ -289,7 +329,7 @@ function patchTargetFindings(pluginRoot, manifest, harness) {
|
|
|
289
329
|
return findings
|
|
290
330
|
}
|
|
291
331
|
|
|
292
|
-
function planManifest(manifest, sourceResults, artifactResults, opaqueReferences, catalog) {
|
|
332
|
+
function planManifest(manifest, sourceResults, artifactResults, opaqueReferences, catalog, actualTarget) {
|
|
293
333
|
const next = structuredClone(manifest.value)
|
|
294
334
|
const exactTargets = sourceResults.flatMap(result => result.exactTargets)
|
|
295
335
|
const sourceReferences = new Set(sourceResults.flatMap(result => result.remainingPackages))
|
|
@@ -302,18 +342,24 @@ function planManifest(manifest, sourceResults, artifactResults, opaqueReferences
|
|
|
302
342
|
for (const removedPackage of presentRemoved) {
|
|
303
343
|
const stillUsed = sourceReferences.has(removedPackage) || clientGraph.includes(removedPackage) || opaqueReferences.has(removedPackage) || (sourceResults.length === 0 && artifactText.includes(removedPackage))
|
|
304
344
|
if (!stillUsed) delete deps[removedPackage]
|
|
305
|
-
for (const move of exactTargets.filter(item => item.fromPackage === removedPackage)) {
|
|
306
|
-
const version = targetVersion(move.toPackage, catalog)
|
|
307
|
-
if (version !== undefined && deps[move.toPackage] === undefined) deps[move.toPackage] = version
|
|
308
|
-
}
|
|
309
345
|
}
|
|
310
346
|
if (field === 'devDependencies') {
|
|
311
347
|
for (const [name, range] of Object.entries(deps)) {
|
|
312
|
-
const target = targetVersion(name, catalog)
|
|
348
|
+
const target = targetVersion(name, catalog, actualTarget)
|
|
313
349
|
if (target !== undefined && !catalog.packages.removed.includes(name) && (semver.validRange(range) === null || !semver.satisfies(target, range))) deps[name] = target
|
|
314
350
|
}
|
|
315
351
|
}
|
|
316
352
|
}
|
|
353
|
+
for (const move of new Map(exactTargets.map(item => [`${item.relationship}\0${item.toPackage}`, item])).values()) {
|
|
354
|
+
const fields = catalog.packages.dependencyPolicies?.[move.toPackage]?.[move.relationship]
|
|
355
|
+
const version = targetVersion(move.toPackage, catalog, actualTarget)
|
|
356
|
+
if (!Array.isArray(fields) || version === undefined) continue
|
|
357
|
+
for (const field of fields) {
|
|
358
|
+
if (!DEPENDENCY_FIELDS.includes(field)) throw new Error(`migration catalog has an invalid dependency field ${field}`)
|
|
359
|
+
const deps = next[field] ??= {}
|
|
360
|
+
if (deps[move.toPackage] === undefined) deps[move.toPackage] = version
|
|
361
|
+
}
|
|
362
|
+
}
|
|
317
363
|
const changes = []
|
|
318
364
|
for (const field of DEPENDENCY_FIELDS) {
|
|
319
365
|
const before = manifest.value[field] ?? {}
|
|
@@ -336,10 +382,17 @@ function summarize(findings, safeEdits, unresolved) {
|
|
|
336
382
|
}
|
|
337
383
|
}
|
|
338
384
|
|
|
385
|
+
function analysisInputs(root) {
|
|
386
|
+
return walkAll(root, SOURCE_DIRS_TO_SKIP)
|
|
387
|
+
.map(file => ({ file: relativePath(root, file), hash: sha256File(file) }))
|
|
388
|
+
.sort((left, right) => left.file.localeCompare(right.file))
|
|
389
|
+
}
|
|
390
|
+
|
|
339
391
|
export function analyzeMigration(pluginRoot = process.cwd(), options = {}) {
|
|
340
392
|
const root = resolve(pluginRoot)
|
|
341
393
|
if (!statSync(root).isDirectory()) throw new Error(`plugin root is not a directory: ${root}`)
|
|
342
|
-
const catalog = loadMigration(options.from ?? 'dsh-v0.1.1-rc.2', options.to ?? 'dsh-v0.1.2-alpha.
|
|
394
|
+
const catalog = loadMigration(options.from ?? 'dsh-v0.1.1-rc.2', options.to ?? 'dsh-v0.1.2-alpha.3')
|
|
395
|
+
const actualTarget = actualTargetVersion(catalog, options.targetVersion)
|
|
343
396
|
const harness = verifyHarnessCheckout(catalog, options.harnessRoot)
|
|
344
397
|
const manifest = readManifest(root)
|
|
345
398
|
const sourceFiles = walk(root, SOURCE_DIRS_TO_SKIP).filter(file => !isTopLevelArtifact(root, file))
|
|
@@ -348,10 +401,10 @@ export function analyzeMigration(pluginRoot = process.cwd(), options = {}) {
|
|
|
348
401
|
const opaqueReferences = opaqueReferenceFindings(root, sourceFiles, manifest, catalog)
|
|
349
402
|
const sourceRemoved = sources.some(item => item.findings.some(finding => ['MIG_REMOVED_PACKAGE_REFERENCE', 'MIG_SEMANTIC_API_CHANGE', 'MIG_MOVED_SYMBOL'].includes(finding.code)))
|
|
350
403
|
const artifactRemoved = artifacts.some(item => item.findings.some(finding => ['MIG_REMOVED_PACKAGE_REFERENCE', 'MIG_SEMANTIC_API_CHANGE', 'MIG_MOVED_SYMBOL'].includes(finding.code)))
|
|
351
|
-
const findings = [...manifestFindings(manifest, catalog), ...patchTargetFindings(root, manifest, harness), ...sources.flatMap(item => item.findings), ...opaqueReferences.findings, ...artifacts.flatMap(item => item.findings)]
|
|
404
|
+
const findings = [...manifestFindings(manifest, catalog, actualTarget), ...patchTargetFindings(root, manifest, harness), ...sources.flatMap(item => item.findings), ...opaqueReferences.findings, ...artifacts.flatMap(item => item.findings)]
|
|
352
405
|
if (!sourceRemoved && artifactRemoved) findings.push({ code: 'MIG_SOURCE_ARTIFACT_DRIFT', severity: 'error', message: 'built artifacts still reference removed APIs although source files do not', location: { file: '.' }, evidence: { artifactDirectories: ARTIFACT_DIRS }, autoFix: 'none' })
|
|
353
406
|
findings.push({ code: 'MIG_RUNTIME_VERIFICATION_REQUIRED', severity: 'info', message: 'static analysis cannot prove activation and lifecycle behavior; run migrate verify --level runtime', location: { file: '.' }, evidence: {}, autoFix: 'none' })
|
|
354
|
-
const manifestPlan = planManifest(manifest, sources, artifacts, opaqueReferences.packages, catalog)
|
|
407
|
+
const manifestPlan = planManifest(manifest, sources, artifacts, opaqueReferences.packages, catalog, actualTarget)
|
|
355
408
|
const changed = [...sources.filter(item => item.changed), ...(manifestPlan.changed ? [manifestPlan] : [])]
|
|
356
409
|
const safeEdits = changed.map(item => ({
|
|
357
410
|
file: relativePath(root, item.file),
|
|
@@ -363,28 +416,92 @@ export function analyzeMigration(pluginRoot = process.cwd(), options = {}) {
|
|
|
363
416
|
return {
|
|
364
417
|
schemaVersion: 1,
|
|
365
418
|
command: 'migrate analyze',
|
|
366
|
-
migration: {
|
|
419
|
+
migration: {
|
|
420
|
+
id: catalog.manifest.id,
|
|
421
|
+
from: catalog.manifest.from,
|
|
422
|
+
to: catalog.manifest.to,
|
|
423
|
+
actualTarget: { version: actualTarget, catalogVersion: catalog.manifest.to.version, catalogExact: actualTarget === catalog.manifest.to.version },
|
|
424
|
+
references: catalog.manifest.references ?? {},
|
|
425
|
+
harness,
|
|
426
|
+
},
|
|
367
427
|
plugin: { root, name: manifest.value.name ?? basename(root), version: manifest.value.version ?? 'unknown', manifestFile: manifest.file, packageManager: existsSync(join(root, 'pnpm-lock.yaml')) ? 'pnpm' : existsSync(join(root, 'yarn.lock')) ? 'yarn' : 'npm' },
|
|
368
428
|
summary: summarize(findings, safeEdits, unresolved),
|
|
369
429
|
findings,
|
|
370
430
|
safeEdits,
|
|
371
431
|
semanticTasks: unresolved,
|
|
432
|
+
sourceInvestigation: {
|
|
433
|
+
required: harness.exact !== true || unresolved.length > 0 || actualTarget !== catalog.manifest.to.version,
|
|
434
|
+
targetRef: catalog.manifest.to.ref,
|
|
435
|
+
catalogReferences: catalog.manifest.references ?? {},
|
|
436
|
+
semanticTargets: [...new Set(unresolved.map(item => item.targetModule).filter(Boolean))],
|
|
437
|
+
},
|
|
372
438
|
verification: { status: 'analyzed', level: 'static-analysis', passed: findings.every(item => item.severity !== 'error') },
|
|
439
|
+
_inputs: analysisInputs(root),
|
|
373
440
|
_plan: changed.map(item => ({ file: item.file, snapshot: { file: item.file, exists: true, hash: sha256(item.text) }, nextText: item.nextText })),
|
|
374
441
|
}
|
|
375
442
|
}
|
|
376
443
|
|
|
377
444
|
export function publicMigrationReport(report) {
|
|
378
|
-
const { _plan, ...value } = report
|
|
445
|
+
const { _inputs, _plan, ...value } = report
|
|
379
446
|
const harness = { ...value.migration.harness }
|
|
380
447
|
delete harness.fromEntryIds
|
|
381
448
|
delete harness.toEntryIds
|
|
382
449
|
return { ...value, migration: { ...value.migration, harness } }
|
|
383
450
|
}
|
|
384
451
|
|
|
452
|
+
export function createMigrationPlan(report) {
|
|
453
|
+
const publicReport = publicMigrationReport(report)
|
|
454
|
+
const payload = {
|
|
455
|
+
schemaVersion: 1,
|
|
456
|
+
command: 'migrate apply plan',
|
|
457
|
+
plugin: { root: report.plugin.root, manifestFile: report.plugin.manifestFile },
|
|
458
|
+
migration: {
|
|
459
|
+
id: report.migration.id,
|
|
460
|
+
from: report.migration.from.ref,
|
|
461
|
+
to: report.migration.to.ref,
|
|
462
|
+
targetVersion: report.migration.actualTarget.version,
|
|
463
|
+
harness: {
|
|
464
|
+
status: report.migration.harness.status,
|
|
465
|
+
fromCommit: report.migration.harness.fromCommit,
|
|
466
|
+
toCommit: report.migration.harness.toCommit,
|
|
467
|
+
},
|
|
468
|
+
},
|
|
469
|
+
reportHash: sha256(JSON.stringify(publicReport)),
|
|
470
|
+
inputs: report._inputs,
|
|
471
|
+
edits: report.safeEdits,
|
|
472
|
+
}
|
|
473
|
+
return { ...payload, planId: sha256(JSON.stringify(payload)) }
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function readMigrationPlan(file) {
|
|
477
|
+
const value = JSON.parse(readFileSync(resolve(file), 'utf8'))
|
|
478
|
+
if (value?.schemaVersion !== 1 || value.command !== 'migrate apply plan' || typeof value.planId !== 'string') throw new Error('invalid migration plan file')
|
|
479
|
+
const { planId, ...payload } = value
|
|
480
|
+
if (sha256(JSON.stringify(payload)) !== planId) throw new Error('migration plan file digest does not match its contents')
|
|
481
|
+
return value
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function assertMigrationPlan(report, plan) {
|
|
485
|
+
const current = createMigrationPlan(report)
|
|
486
|
+
if (plan.plugin.root !== current.plugin.root || plan.plugin.manifestFile !== current.plugin.manifestFile) throw new Error('migration plan targets a different plugin')
|
|
487
|
+
if (plan.migration.id !== current.migration.id || plan.migration.from !== current.migration.from || plan.migration.to !== current.migration.to || plan.migration.targetVersion !== current.migration.targetVersion) throw new Error('migration plan targets a different catalog or actual target version')
|
|
488
|
+
if (plan.reportHash !== current.reportHash || JSON.stringify(plan.inputs) !== JSON.stringify(current.inputs) || JSON.stringify(plan.edits) !== JSON.stringify(current.edits)) throw new Error('plugin analysis changed after the preview; create and review a new migration plan')
|
|
489
|
+
return current
|
|
490
|
+
}
|
|
491
|
+
|
|
385
492
|
export function applyMigration(report, options = {}) {
|
|
386
493
|
if (options.safe !== true) throw new Error('migrate apply requires --safe')
|
|
387
|
-
if (options.
|
|
494
|
+
if (options.planFile === undefined) throw new Error('migrate apply requires --plan-file so the confirmed preview can be verified')
|
|
495
|
+
const planFile = resolve(options.planFile)
|
|
496
|
+
const planRelative = relative(report.plugin.root, planFile)
|
|
497
|
+
if (planRelative === '' || (planRelative !== '..' && !planRelative.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`) && !isAbsolute(planRelative))) throw new Error('migration plan file must be outside the plugin root')
|
|
498
|
+
if (options.yes !== true) {
|
|
499
|
+
const plan = createMigrationPlan(report)
|
|
500
|
+
writeNewFile(planFile, `${JSON.stringify(plan, null, 2)}\n`)
|
|
501
|
+
return { mode: 'preview', plan: { file: planFile, id: plan.planId }, ...publicMigrationReport(report) }
|
|
502
|
+
}
|
|
503
|
+
const plan = readMigrationPlan(planFile)
|
|
504
|
+
const verifiedPlan = assertMigrationPlan(report, plan)
|
|
388
505
|
// Exact edits remain safe when semantic work remains; unresolved references keep the removed dependency.
|
|
389
506
|
for (const item of report._plan) {
|
|
390
507
|
const currentExists = existsSync(item.snapshot.file)
|
|
@@ -392,8 +509,8 @@ export function applyMigration(report, options = {}) {
|
|
|
392
509
|
if (currentExists !== item.snapshot.exists || sha256(current) !== item.snapshot.hash) throw new Error(`${item.snapshot.file} changed after the preview; diagnose again before applying`)
|
|
393
510
|
}
|
|
394
511
|
const writes = report._plan.map(item => ({ ...atomicWrite(item.snapshot, item.nextText), beforeHash: item.snapshot.hash, afterHash: sha256(item.nextText) }))
|
|
395
|
-
const verification = analyzeMigration(report.plugin.root, { from: report.migration.from.ref, to: report.migration.to.ref, harnessRoot: report.migration.harness.root })
|
|
396
|
-
return { mode: 'applied', writes, report: publicMigrationReport(verification) }
|
|
512
|
+
const verification = analyzeMigration(report.plugin.root, { from: report.migration.from.ref, to: report.migration.to.ref, targetVersion: report.migration.actualTarget.version, harnessRoot: report.migration.harness.root })
|
|
513
|
+
return { mode: 'applied', plan: { file: planFile, id: verifiedPlan.planId }, writes, report: publicMigrationReport(verification) }
|
|
397
514
|
}
|
|
398
515
|
|
|
399
516
|
export function formatMigrationReport(report, language = 'en') {
|
package/src/safe-write.mjs
CHANGED
|
@@ -1,11 +1,38 @@
|
|
|
1
1
|
import { createHash, randomBytes } from 'node:crypto'
|
|
2
|
-
import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { closeSync, copyFileSync, existsSync, mkdirSync, openSync, readFileSync, readSync, renameSync, statSync, writeFileSync } from 'node:fs'
|
|
3
3
|
import { dirname, join } from 'node:path'
|
|
4
4
|
|
|
5
5
|
export function sha256(text) {
|
|
6
6
|
return createHash('sha256').update(text).digest('hex')
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
+
export function sha256File(file) {
|
|
10
|
+
const hash = createHash('sha256')
|
|
11
|
+
const descriptor = openSync(file, 'r')
|
|
12
|
+
const buffer = Buffer.allocUnsafe(64 * 1024)
|
|
13
|
+
try {
|
|
14
|
+
let bytesRead
|
|
15
|
+
do {
|
|
16
|
+
bytesRead = readSync(descriptor, buffer, 0, buffer.length, null)
|
|
17
|
+
if (bytesRead > 0) hash.update(buffer.subarray(0, bytesRead))
|
|
18
|
+
} while (bytesRead > 0)
|
|
19
|
+
} finally {
|
|
20
|
+
closeSync(descriptor)
|
|
21
|
+
}
|
|
22
|
+
return hash.digest('hex')
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function writeNewFile(file, text) {
|
|
26
|
+
mkdirSync(dirname(file), { recursive: true })
|
|
27
|
+
try {
|
|
28
|
+
writeFileSync(file, text, { flag: 'wx', mode: 0o600 })
|
|
29
|
+
} catch (error) {
|
|
30
|
+
if (error?.code === 'EEXIST') throw new Error(`${file} already exists; choose a new migration plan path`)
|
|
31
|
+
throw error
|
|
32
|
+
}
|
|
33
|
+
return { file }
|
|
34
|
+
}
|
|
35
|
+
|
|
9
36
|
export function snapshotFile(file) {
|
|
10
37
|
const exists = existsSync(file)
|
|
11
38
|
const text = exists ? readFileSync(file, 'utf8') : ''
|
|
@@ -1,76 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"skill_name": "dsh-plugin-upgrade",
|
|
3
|
-
"evals": [
|
|
4
|
-
{
|
|
5
|
-
"id": 1,
|
|
6
|
-
"prompt": "检查这个只有后端 bundle 的 DSH 插件能否从 DSH 0.1.1 升级到 0.1.2。它的 peerDependencies 仍是 ^0.1.1,不要改文件。",
|
|
7
|
-
"expected_output": "Records the plugin's actual version ranges, uses the current exact-ref catalog as the analysis baseline, identifies target range mismatch, and does not claim runtime compatibility.",
|
|
8
|
-
"expectations": [
|
|
9
|
-
"Uses migrate analyze before proposing edits",
|
|
10
|
-
"Distinguishes the requested 0.1.1 to 0.1.2 upgrade from the catalog's exact reference refs",
|
|
11
|
-
"Reports the exact verification status as analyzed",
|
|
12
|
-
"Does not write files or execute build scripts"
|
|
13
|
-
]
|
|
14
|
-
},
|
|
15
|
-
{
|
|
16
|
-
"id": 2,
|
|
17
|
-
"prompt": "把这个前端插件升级到 DSH 0.1.2。源码里有 import type { ClientContext, ISessions } from '@deepseek-ai/dsh-client-runtime/client',可以安全改的帮我改,其他的列出来。",
|
|
18
|
-
"expected_output": "Recognizes that write authorization does not decide whether 0.1.1 may be dropped, asks the compatibility question, and waits before applying changes; after a 0.1.2-only answer, safely moves ClientContext while leaving ISessions as a semantic task.",
|
|
19
|
-
"expectations": [
|
|
20
|
-
"Detects type-only imports from source rather than relying on the bundle",
|
|
21
|
-
"Explicitly asks whether the same release must preserve DSH 0.1.1 compatibility",
|
|
22
|
-
"Does not preview, apply, edit, install, build, or run the plugin until that decision is answered",
|
|
23
|
-
"Uses migrate apply --safe and requires explicit confirmation for writes",
|
|
24
|
-
"Does not mechanically rewrite the semantic ISessions contract"
|
|
25
|
-
]
|
|
26
|
-
},
|
|
27
|
-
{
|
|
28
|
-
"id": 3,
|
|
29
|
-
"prompt": "完整升级这个混合前后端插件并验证,Harness 在 /workspace/deepseek-harness。构建失败也要把现场路径告诉我。",
|
|
30
|
-
"expected_output": "Analyzes first, asks whether the same release must retain 0.1.1, and waits before safe apply or executable gates; after the decision, performs the selected migration and verification matrix and reports retained failure state.",
|
|
31
|
-
"expectations": [
|
|
32
|
-
"Uses the exact Harness refs and verifies the catalog commits",
|
|
33
|
-
"Treats legacy compatibility as an explicit product decision rather than inferring it from an upgrade request",
|
|
34
|
-
"Runs static, build, then runtime verification in order",
|
|
35
|
-
"Uses a temporary DSH_HOME and reports retained failure state",
|
|
36
|
-
"Keeps business behavior verification separate from activation smoke"
|
|
37
|
-
]
|
|
38
|
-
},
|
|
39
|
-
{
|
|
40
|
-
"id": 4,
|
|
41
|
-
"prompt": "帮我升级这个插件,但机器上的 dsh-doctor 是旧版,而且我没有授权修改全局 npm 包。请先检查有没有新版本,再继续迁移。",
|
|
42
|
-
"expected_output": "Checks the registry read-only, selects one exact-version npx CLI with the required catalog, avoids global installation, asks for the unresolved compatibility intent, and reports the selected version and update status without starting writes or executable verification.",
|
|
43
|
-
"expectations": [
|
|
44
|
-
"Checks the local CLI version and exact migration catalog before use",
|
|
45
|
-
"Uses npm view as a read-only registry update check",
|
|
46
|
-
"Pins one resolved npx package version for all migration phases",
|
|
47
|
-
"Does not install or update a global CLI without explicit authorization",
|
|
48
|
-
"Reports local, registry, selected CLI, catalog, and update-status evidence",
|
|
49
|
-
"Stops at analyzed until the developer chooses 0.1.2-only or dual-version support"
|
|
50
|
-
]
|
|
51
|
-
},
|
|
52
|
-
{
|
|
53
|
-
"id": 5,
|
|
54
|
-
"prompt": "升级这个插件到 DSH 0.1.2,但同一个 npm 版本还必须继续支持 0.1.1。可以修改代码并执行测试;如果单一产物做不到,先告诉我冲突和可选方案,不要自行放弃旧版。",
|
|
55
|
-
"expected_output": "Records an explicit dual-version requirement, assesses whether imports, manifests, graph declarations, and artifacts can coexist, selects or proposes an adapter/conditional-build/separate-release strategy, and verifies both version rows before claiming compatibility.",
|
|
56
|
-
"expectations": [
|
|
57
|
-
"Records the compatibility intent as dual-version without asking a redundant question",
|
|
58
|
-
"Treats 0.1.2 safe codemods as candidates that still require 0.1.1 review",
|
|
59
|
-
"Does not widen peer ranges or claim one-artifact support without installation and runtime evidence",
|
|
60
|
-
"Separately verifies build, artifact, runtime, and behavior evidence for DSH 0.1.1 and 0.1.2",
|
|
61
|
-
"Reports an exact incompatibility and asks before switching to separate releases or dropping 0.1.1"
|
|
62
|
-
]
|
|
63
|
-
},
|
|
64
|
-
{
|
|
65
|
-
"id": 6,
|
|
66
|
-
"prompt": "这是一次 breaking release,只需要支持 DSH 0.1.2,不再兼容 0.1.1。先分析再按安全流程修改和验证。",
|
|
67
|
-
"expected_output": "Records the explicit 0.1.2-only intent without asking a redundant compatibility question, then follows analyze, safe preview/apply, semantic migration, and target verification gates.",
|
|
68
|
-
"expectations": [
|
|
69
|
-
"Records the compatibility intent as 0.1.2-only",
|
|
70
|
-
"Does not ask again whether 0.1.1 compatibility must be retained",
|
|
71
|
-
"Still separates write authorization from compatibility intent and follows preview and confirmation safeguards",
|
|
72
|
-
"Reports only the 0.1.2 verification evidence actually achieved"
|
|
73
|
-
]
|
|
74
|
-
}
|
|
75
|
-
]
|
|
76
|
-
}
|