@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/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 (!bindings || !ts.isNamedImports(bindings)) return []
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
- if (removed.has(rootName)) {
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 symbolRules = catalog.symbols.modules[moduleName]
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({ file: where.file, package: rootName, symbol: spec.imported, reason: rule?.reason ?? 'No exact replacement is known.' })
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}`, { fromPackage: rootName, toPackage: packageRoot(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 targetVersion(name, catalog) {
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 name.startsWith('@deepseek-ai/dsh-') ? catalog.manifest.to.version : undefined
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.2')
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: { id: catalog.manifest.id, from: catalog.manifest.from, to: catalog.manifest.to, harness },
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.yes !== true) return { mode: 'preview', ...publicMigrationReport(report) }
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') {
@@ -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
- }