@bruc3van/dsh-doctor 0.5.5 → 0.5.7

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.
Files changed (30) hide show
  1. package/README.en.md +77 -181
  2. package/README.md +77 -181
  3. package/migrations/dsh-v0.1.1-rc.2__dsh-v0.1.2-alpha.2/packages.json +1 -3
  4. package/migrations/dsh-v0.1.1-rc.2__dsh-v0.1.2-alpha.2/symbols.json +8 -0
  5. package/migrations/dsh-v0.1.1-rc.2__dsh-v0.1.2-alpha.3/behavior.md +14 -0
  6. package/migrations/dsh-v0.1.1-rc.2__dsh-v0.1.2-alpha.3/config-rules.json +36 -0
  7. package/migrations/dsh-v0.1.1-rc.2__dsh-v0.1.2-alpha.3/manifest.json +19 -0
  8. package/migrations/dsh-v0.1.1-rc.2__dsh-v0.1.2-alpha.3/packages.json +74 -0
  9. package/migrations/dsh-v0.1.1-rc.2__dsh-v0.1.2-alpha.3/services.json +28 -0
  10. package/migrations/dsh-v0.1.1-rc.2__dsh-v0.1.2-alpha.3/symbols.json +134 -0
  11. package/package.json +1 -1
  12. package/skills/dsh-plugin-upgrade/SKILL.md +54 -23
  13. package/skills/dsh-plugin-upgrade/evals/evals.json +55 -0
  14. package/skills/dsh-plugin-upgrade/evals/files/import-move-plugin/README.md +5 -0
  15. package/skills/dsh-plugin-upgrade/evals/files/import-move-plugin/package.json +31 -0
  16. package/skills/dsh-plugin-upgrade/evals/files/import-move-plugin/src/index.ts +20 -0
  17. package/skills/dsh-plugin-upgrade/evals/files/import-move-plugin/src/stores.ts +15 -0
  18. package/skills/dsh-plugin-upgrade/evals/files/settings-plugin/README.md +7 -0
  19. package/skills/dsh-plugin-upgrade/evals/files/settings-plugin/package.json +20 -0
  20. package/skills/dsh-plugin-upgrade/evals/files/settings-plugin/src/index.ts +2 -0
  21. package/skills/dsh-plugin-upgrade/evals/files/settings-plugin/src/namespace-read.ts +6 -0
  22. package/skills/dsh-plugin-upgrade/evals/files/settings-plugin/src/settings.ts +21 -0
  23. package/skills/dsh-plugin-upgrade/evals/trigger-evals.json +22 -0
  24. package/skills/dsh-plugin-upgrade/references/cli-bootstrap.md +6 -4
  25. package/skills/dsh-plugin-upgrade/references/migration-map.md +5 -1
  26. package/skills/dsh-plugin-upgrade/references/source-investigation.md +2 -0
  27. package/skills/dsh-plugin-upgrade/references/verification.md +6 -0
  28. package/src/cli.mjs +7 -3
  29. package/src/migrate-verify.mjs +3 -2
  30. package/src/migrate.mjs +53 -22
@@ -4,12 +4,16 @@
4
4
 
5
5
  Static analysis covers source/type imports, dependency ranges, client graph declarations, and generated artifacts. A pass means no known blocking finding; it does not execute project code.
6
6
 
7
+ The current known static target is the catalog target declared in SKILL.md. When the actual target is later than that catalog ref on the same 0.1.2 release line, first inspect the additional interval, then use `--target-version <version>` at every phase. The report keeps `migration.to` as the catalog ref and records `migration.actualTarget` separately, so a successful dependency or runtime check cannot be mistaken for catalog coverage of the additional API delta.
8
+
7
9
  ## Build
8
10
 
9
11
  Build and runtime verification require `--yes --install`. The CLI selects the package manager from lockfiles, synchronizes dependencies with dependency lifecycle scripts disabled, records the lockfile hash before and after, and verifies installed DSH/Cordis versions against `dependencies`, `devDependencies`, `peerDependencies`, and `optionalDependencies`. Required peers must resolve and satisfy every declared range. Missing optional dependencies or peers are recorded as `optional-missing`; when installed, they must satisfy their ranges. It then runs declared scripts in this order: `typecheck`, `build`, `test`, `pack:check`, stopping at the first failure. Artifact verification requires a successful `build` or `pack:check`; `typecheck`/`test` alone do not pass this gate.
10
12
 
11
13
  If dependency installation or resolution verification fails, stop before running project scripts. Review and retain package manifest and lockfile changes with the source migration.
12
14
 
15
+ If pnpm's `minimumReleaseAge` blocks a newly published target prerelease, do not disable or weaken the repository-wide supply-chain policy. Confirm the selected target and registry provenance, then add only the exact `package@version` rows required under `minimumReleaseAgeExclude`, including transitive DSH packages reported by pnpm. Review this workspace-policy change alongside the lockfile and report it explicitly.
16
+
13
17
  ## Runtime
14
18
 
15
19
  Runtime verification:
@@ -24,6 +28,8 @@ The gate also checks the DSH CLI's exact target version, the created profile man
24
28
 
25
29
  Use a built target Harness CLI through `--harness-root`, or pass `--dsh-command` explicitly. The temporary home is deleted only after a successful run unless `--keep-temp` is set.
26
30
 
31
+ The runtime executable must report the exact actual target selected by `--target-version` (or the catalog target when the option is omitted). A later prerelease is not accepted implicitly because that would disconnect the runtime evidence from the reviewed dependency and source target.
32
+
27
33
  ## Behavior
28
34
 
29
35
  Choose focused checks from the plugin contract: render the real UI, invoke each command, exercise settings, verify service availability, repeat activation/deactivation, check listener cleanup, and cover failure recovery. Record exact commands, screenshots, logs, or tests. This gate is deliberately not inferred by dsh-doctor.
package/src/cli.mjs CHANGED
@@ -43,7 +43,8 @@ Options:
43
43
  --fix, --repair legacy confirmed repairs (never removes a plugin)
44
44
  --yes confirm an explicit write or command
45
45
  --from <ref> source DSH ref (default: dsh-v0.1.1-rc.2)
46
- --to <ref> target DSH ref (default: dsh-v0.1.2-alpha.2)
46
+ --to <ref> target DSH ref (default: dsh-v0.1.2-alpha.3)
47
+ --target-version <ver> actual DSH 0.1.2 version for dependency/runtime checks
47
48
  --safe restrict migrate apply to catalog-confirmed exact rewrites
48
49
  --plan-file <path> persist or consume the reviewed migrate apply plan
49
50
  --level <level> static, build, or runtime verification
@@ -85,7 +86,8 @@ const HELP_ZH = `用法:
85
86
  --fix, --repair 旧式确认修复(绝不移除插件)
86
87
  --yes 确认一个明确的写入或命令动作
87
88
  --from <ref> 源 DSH ref(默认:dsh-v0.1.1-rc.2)
88
- --to <ref> 目标 DSH ref(默认:dsh-v0.1.2-alpha.2
89
+ --to <ref> 目标 DSH ref(默认:dsh-v0.1.2-alpha.3
90
+ --target-version <版本> 依赖与 runtime 验证使用的实际 DSH 0.1.2 版本
89
91
  --safe migrate apply 仅执行 catalog 确认的精确改写
90
92
  --plan-file <路径> 保存或读取已审阅的 migrate apply 计划
91
93
  --level <级别> static、build 或 runtime
@@ -146,6 +148,8 @@ function parse(args) {
146
148
  else if (arg.startsWith('--from=')) options.from = optionValue(arg, '--from')
147
149
  else if (arg === '--to') options.to = valueAfter(args, index++, arg)
148
150
  else if (arg.startsWith('--to=')) options.to = optionValue(arg, '--to')
151
+ else if (arg === '--target-version') options.targetVersion = valueAfter(args, index++, arg)
152
+ else if (arg.startsWith('--target-version=')) options.targetVersion = optionValue(arg, '--target-version')
149
153
  else if (arg === '--safe') options.safe = true
150
154
  else if (arg === '--plan-file') options.planFile = valueAfter(args, index++, arg)
151
155
  else if (arg.startsWith('--plan-file=')) options.planFile = optionValue(arg, '--plan-file')
@@ -300,7 +304,7 @@ async function main() {
300
304
  if (!['analyze', 'apply', 'verify'].includes(options.migrateAction)) throw new Error('migrate needs analyze, apply, or verify')
301
305
  if (options.migrateAction === 'apply' && options.safe !== true) throw new Error('migrate apply requires --safe')
302
306
  if (options.migrateAction === 'apply' && options.pluginRoot === undefined) throw new Error('migrate apply requires an explicit plugin root')
303
- const migrationOptions = { from: options.from, to: options.to, harnessRoot: options.harnessRoot, dshCommand: options.dshCommand, level: options.level, yes: options.yes, safe: options.safe, keepTemp: options.keepTemp, planFile: options.planFile, install: options.install }
307
+ const migrationOptions = { from: options.from, to: options.to, targetVersion: options.targetVersion, harnessRoot: options.harnessRoot, dshCommand: options.dshCommand, level: options.level, yes: options.yes, safe: options.safe, keepTemp: options.keepTemp, planFile: options.planFile, install: options.install }
304
308
  if (options.migrateAction === 'verify') {
305
309
  const { formatVerification, verifyMigration } = await import('./migrate-verify.mjs')
306
310
  const result = verifyMigration(options.pluginRoot, migrationOptions)
@@ -214,7 +214,8 @@ export function verifyMigration(pluginRoot = process.cwd(), options = {}) {
214
214
  const [versionCommand, versionArgs] = dshInvocation(dsh, ['--version'])
215
215
  const versionRun = commandResult(versionCommand, versionArgs, { cwd: root, env })
216
216
  const actualDshVersion = versionRun.stdout.trim()
217
- const versionPassed = versionRun.passed && actualDshVersion === analysis.migration.to.version
217
+ const expectedDshVersion = analysis.migration.actualTarget.version
218
+ const versionPassed = versionRun.passed && actualDshVersion === expectedDshVersion
218
219
  const pack = commandResult(manager.command, manager.pack(packDir), { cwd: root })
219
220
  const tarball = tarballFromPack(pack, packDir)
220
221
  const runtimeCommands = []
@@ -236,7 +237,7 @@ export function verifyMigration(pluginRoot = process.cwd(), options = {}) {
236
237
  result.stages.push({
237
238
  name: 'runtime',
238
239
  passed: runtimePassed,
239
- dshVersion: { expected: analysis.migration.to.version, actual: actualDshVersion, passed: versionPassed, command: commandEvidence(versionRun) },
240
+ dshVersion: { expected: expectedDshVersion, catalog: analysis.migration.to.version, actual: actualDshVersion, passed: versionPassed, command: commandEvidence(versionRun) },
240
241
  pack: commandEvidence(pack),
241
242
  tarball,
242
243
  commands: runtimeCommands.map(commandEvidence),
package/src/migrate.mjs CHANGED
@@ -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,7 +134,7 @@ 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' })
@@ -140,7 +146,7 @@ function analyzeFile(file, pluginRoot, catalog, origin) {
140
146
  ...(rule?.toSymbol ? { targetSymbol: rule.toSymbol } : {}),
141
147
  reason: rule?.reason ?? 'No exact replacement is known.',
142
148
  })
143
- }
149
+ } else remaining.push(spec)
144
150
  }
145
151
  const declarationText = ts.isImportDeclaration(node) ? text.slice(node.getStart(source), node.getEnd()) : ''
146
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)
@@ -160,7 +166,7 @@ function analyzeFile(file, pluginRoot, catalog, origin) {
160
166
  const generated = [...groups].map(([target, items]) => importText(target, items, node.importClause.isTypeOnly))
161
167
  if (remaining.length > 0) generated.unshift(importText(moduleName, remaining, node.importClause.isTypeOnly))
162
168
  replacements.push({ start: node.getStart(source), end: node.getEnd(), next: generated.join('\n'), exact })
163
- } else if (specs.length === 0 || exact.length > 0) {
169
+ } else if (moduleRemoved && (specs.length === 0 || exact.length > 0)) {
164
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'
165
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' })
166
172
  unresolved.push({ file: where.file, package: rootName, module: moduleName, reason: 'Default, namespace, side-effect, export, require, dynamic import, or module augmentation reference.' })
@@ -189,12 +195,28 @@ function readManifest(pluginRoot) {
189
195
  return { file, value, text }
190
196
  }
191
197
 
192
- 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
193
215
  if (catalog.packages.targetVersions?.[name] !== undefined) return catalog.packages.targetVersions[name]
194
- return name.startsWith('@deepseek-ai/dsh-') ? catalog.manifest.to.version : undefined
216
+ return undefined
195
217
  }
196
218
 
197
- function manifestFindings(manifest, catalog) {
219
+ function manifestFindings(manifest, catalog, actualTarget) {
198
220
  const findings = []
199
221
  for (const field of DEPENDENCY_FIELDS) {
200
222
  const dependencyMap = manifest.value[field]
@@ -207,7 +229,7 @@ function manifestFindings(manifest, catalog) {
207
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' })
208
230
  continue
209
231
  }
210
- const target = targetVersion(name, catalog)
232
+ const target = targetVersion(name, catalog, actualTarget)
211
233
  if (target === undefined) continue
212
234
  const validRange = typeof range === 'string' ? semver.validRange(range) : null
213
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' })
@@ -307,7 +329,7 @@ function patchTargetFindings(pluginRoot, manifest, harness) {
307
329
  return findings
308
330
  }
309
331
 
310
- function planManifest(manifest, sourceResults, artifactResults, opaqueReferences, catalog) {
332
+ function planManifest(manifest, sourceResults, artifactResults, opaqueReferences, catalog, actualTarget) {
311
333
  const next = structuredClone(manifest.value)
312
334
  const exactTargets = sourceResults.flatMap(result => result.exactTargets)
313
335
  const sourceReferences = new Set(sourceResults.flatMap(result => result.remainingPackages))
@@ -323,14 +345,14 @@ function planManifest(manifest, sourceResults, artifactResults, opaqueReferences
323
345
  }
324
346
  if (field === 'devDependencies') {
325
347
  for (const [name, range] of Object.entries(deps)) {
326
- const target = targetVersion(name, catalog)
348
+ const target = targetVersion(name, catalog, actualTarget)
327
349
  if (target !== undefined && !catalog.packages.removed.includes(name) && (semver.validRange(range) === null || !semver.satisfies(target, range))) deps[name] = target
328
350
  }
329
351
  }
330
352
  }
331
353
  for (const move of new Map(exactTargets.map(item => [`${item.relationship}\0${item.toPackage}`, item])).values()) {
332
354
  const fields = catalog.packages.dependencyPolicies?.[move.toPackage]?.[move.relationship]
333
- const version = targetVersion(move.toPackage, catalog)
355
+ const version = targetVersion(move.toPackage, catalog, actualTarget)
334
356
  if (!Array.isArray(fields) || version === undefined) continue
335
357
  for (const field of fields) {
336
358
  if (!DEPENDENCY_FIELDS.includes(field)) throw new Error(`migration catalog has an invalid dependency field ${field}`)
@@ -369,7 +391,8 @@ function analysisInputs(root) {
369
391
  export function analyzeMigration(pluginRoot = process.cwd(), options = {}) {
370
392
  const root = resolve(pluginRoot)
371
393
  if (!statSync(root).isDirectory()) throw new Error(`plugin root is not a directory: ${root}`)
372
- 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)
373
396
  const harness = verifyHarnessCheckout(catalog, options.harnessRoot)
374
397
  const manifest = readManifest(root)
375
398
  const sourceFiles = walk(root, SOURCE_DIRS_TO_SKIP).filter(file => !isTopLevelArtifact(root, file))
@@ -378,10 +401,10 @@ export function analyzeMigration(pluginRoot = process.cwd(), options = {}) {
378
401
  const opaqueReferences = opaqueReferenceFindings(root, sourceFiles, manifest, catalog)
379
402
  const sourceRemoved = sources.some(item => item.findings.some(finding => ['MIG_REMOVED_PACKAGE_REFERENCE', 'MIG_SEMANTIC_API_CHANGE', 'MIG_MOVED_SYMBOL'].includes(finding.code)))
380
403
  const artifactRemoved = artifacts.some(item => item.findings.some(finding => ['MIG_REMOVED_PACKAGE_REFERENCE', 'MIG_SEMANTIC_API_CHANGE', 'MIG_MOVED_SYMBOL'].includes(finding.code)))
381
- 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)]
382
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' })
383
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' })
384
- const manifestPlan = planManifest(manifest, sources, artifacts, opaqueReferences.packages, catalog)
407
+ const manifestPlan = planManifest(manifest, sources, artifacts, opaqueReferences.packages, catalog, actualTarget)
385
408
  const changed = [...sources.filter(item => item.changed), ...(manifestPlan.changed ? [manifestPlan] : [])]
386
409
  const safeEdits = changed.map(item => ({
387
410
  file: relativePath(root, item.file),
@@ -393,14 +416,21 @@ export function analyzeMigration(pluginRoot = process.cwd(), options = {}) {
393
416
  return {
394
417
  schemaVersion: 1,
395
418
  command: 'migrate analyze',
396
- migration: { id: catalog.manifest.id, from: catalog.manifest.from, to: catalog.manifest.to, references: catalog.manifest.references ?? {}, 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
+ },
397
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' },
398
428
  summary: summarize(findings, safeEdits, unresolved),
399
429
  findings,
400
430
  safeEdits,
401
431
  semanticTasks: unresolved,
402
432
  sourceInvestigation: {
403
- required: harness.exact !== true || unresolved.length > 0,
433
+ required: harness.exact !== true || unresolved.length > 0 || actualTarget !== catalog.manifest.to.version,
404
434
  targetRef: catalog.manifest.to.ref,
405
435
  catalogReferences: catalog.manifest.references ?? {},
406
436
  semanticTargets: [...new Set(unresolved.map(item => item.targetModule).filter(Boolean))],
@@ -429,6 +459,7 @@ export function createMigrationPlan(report) {
429
459
  id: report.migration.id,
430
460
  from: report.migration.from.ref,
431
461
  to: report.migration.to.ref,
462
+ targetVersion: report.migration.actualTarget.version,
432
463
  harness: {
433
464
  status: report.migration.harness.status,
434
465
  fromCommit: report.migration.harness.fromCommit,
@@ -453,7 +484,7 @@ function readMigrationPlan(file) {
453
484
  function assertMigrationPlan(report, plan) {
454
485
  const current = createMigrationPlan(report)
455
486
  if (plan.plugin.root !== current.plugin.root || plan.plugin.manifestFile !== current.plugin.manifestFile) throw new Error('migration plan targets a different plugin')
456
- if (plan.migration.id !== current.migration.id || plan.migration.from !== current.migration.from || plan.migration.to !== current.migration.to) throw new Error('migration plan targets a different catalog')
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')
457
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')
458
489
  return current
459
490
  }
@@ -478,7 +509,7 @@ export function applyMigration(report, options = {}) {
478
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`)
479
510
  }
480
511
  const writes = report._plan.map(item => ({ ...atomicWrite(item.snapshot, item.nextText), beforeHash: item.snapshot.hash, afterHash: sha256(item.nextText) }))
481
- const verification = analyzeMigration(report.plugin.root, { from: report.migration.from.ref, to: report.migration.to.ref, harnessRoot: report.migration.harness.root })
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 })
482
513
  return { mode: 'applied', plan: { file: planFile, id: verifiedPlan.planId }, writes, report: publicMigrationReport(verification) }
483
514
  }
484
515