@bruc3van/dsh-doctor 0.1.5 → 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.
@@ -0,0 +1,418 @@
1
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
2
+ import { basename, join, relative, resolve } from 'node:path'
3
+ import semver from 'semver'
4
+ import ts from 'typescript'
5
+ import { parseDocument } from 'yaml'
6
+ import { atomicWrite, sha256 } from './safe-write.mjs'
7
+ import { loadMigration, verifyHarnessCheckout } from './migration-catalog.mjs'
8
+
9
+ const SOURCE_EXTENSIONS = new Set(['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs', '.mts', '.cts'])
10
+ const SOURCE_DIRS_TO_SKIP = new Set(['.git', 'node_modules', 'coverage'])
11
+ const ARTIFACT_DIRS = ['lib', 'dist', 'build']
12
+ const DEPENDENCY_FIELDS = ['dependencies', 'peerDependencies', 'devDependencies', 'optionalDependencies']
13
+ const LOCKFILES = new Set(['package-lock.json', 'pnpm-lock.yaml', 'yarn.lock'])
14
+ const NON_RUNTIME_TEXT_EXTENSIONS = new Set(['.md', '.mdx', '.txt', '.snap'])
15
+
16
+ function extension(file) {
17
+ const match = /\.[^.]+$/.exec(file)
18
+ return match?.[0]
19
+ }
20
+
21
+ function walk(root, skip, files = []) {
22
+ if (!existsSync(root)) return files
23
+ for (const entry of readdirSync(root, { withFileTypes: true })) {
24
+ if (entry.name.startsWith('.') && entry.name !== '.storybook') continue
25
+ const file = join(root, entry.name)
26
+ if (entry.isDirectory()) {
27
+ if (!skip.has(entry.name)) walk(file, skip, files)
28
+ } else if (entry.isFile() && SOURCE_EXTENSIONS.has(extension(entry.name))) files.push(file)
29
+ }
30
+ return files
31
+ }
32
+
33
+ function walkAll(root, skip, files = []) {
34
+ if (!existsSync(root)) return files
35
+ for (const entry of readdirSync(root, { withFileTypes: true })) {
36
+ if (entry.name.startsWith('.') && entry.name !== '.storybook') continue
37
+ const file = join(root, entry.name)
38
+ if (entry.isDirectory()) {
39
+ if (!skip.has(entry.name)) walkAll(file, skip, files)
40
+ } else if (entry.isFile() && !LOCKFILES.has(entry.name) && !entry.name.includes('.dsh-doctor-')) files.push(file)
41
+ }
42
+ return files
43
+ }
44
+
45
+ function artifactFiles(root) {
46
+ return ARTIFACT_DIRS.flatMap(name => walk(join(root, name), new Set()))
47
+ }
48
+
49
+ function isTopLevelArtifact(root, file) {
50
+ return ARTIFACT_DIRS.includes(relative(root, file).split(/[\\/]/)[0])
51
+ }
52
+
53
+ function scriptKind(file) {
54
+ if (/\.tsx$/i.test(file)) return ts.ScriptKind.TSX
55
+ if (/\.jsx$/i.test(file)) return ts.ScriptKind.JSX
56
+ if (/\.[cm]?ts$/i.test(file)) return ts.ScriptKind.TS
57
+ return ts.ScriptKind.JS
58
+ }
59
+
60
+ function packageRoot(moduleName) {
61
+ if (moduleName.startsWith('@')) return moduleName.split('/').slice(0, 2).join('/')
62
+ return moduleName.split('/')[0]
63
+ }
64
+
65
+ function relativePath(root, file) {
66
+ return relative(root, file).split('\\').join('/')
67
+ }
68
+
69
+ function location(source, node, pluginRoot) {
70
+ const point = source.getLineAndCharacterOfPosition(node.getStart(source))
71
+ return { file: relativePath(pluginRoot, source.fileName), line: point.line + 1, column: point.character + 1 }
72
+ }
73
+
74
+ function moduleLiteral(node) {
75
+ if (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) return node.moduleSpecifier
76
+ if (ts.isCallExpression(node) && node.arguments.length === 1 && (node.expression.kind === ts.SyntaxKind.ImportKeyword || (ts.isIdentifier(node.expression) && node.expression.text === 'require'))) return node.arguments[0]
77
+ if (ts.isModuleDeclaration(node) && ts.isStringLiteral(node.name)) return node.name
78
+ return undefined
79
+ }
80
+
81
+ function namedImports(node) {
82
+ const bindings = node.importClause?.namedBindings
83
+ if (!bindings || !ts.isNamedImports(bindings)) return []
84
+ return bindings.elements.map(item => ({
85
+ imported: item.propertyName?.text ?? item.name.text,
86
+ local: item.name.text,
87
+ typeOnly: node.importClause.isTypeOnly || item.isTypeOnly,
88
+ }))
89
+ }
90
+
91
+ function quoteModule(text) {
92
+ return `'${text}'`
93
+ }
94
+
95
+ function importText(moduleName, specs, declarationTypeOnly = false) {
96
+ const allType = declarationTypeOnly || specs.every(item => item.typeOnly)
97
+ const members = specs.map(item => {
98
+ const prefix = !allType && item.typeOnly ? 'type ' : ''
99
+ return `${prefix}${item.imported}${item.local === item.imported ? '' : ` as ${item.local}`}`
100
+ }).join(', ')
101
+ return `import${allType ? ' type' : ''} { ${members} } from ${quoteModule(moduleName)}`
102
+ }
103
+
104
+ function analyzeFile(file, pluginRoot, catalog, origin) {
105
+ const text = readFileSync(file, 'utf8')
106
+ const source = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, scriptKind(file))
107
+ const findings = []
108
+ const replacements = []
109
+ const exactTargets = new Map()
110
+ const unresolved = []
111
+ const removed = new Set(catalog.packages.removed)
112
+ const handledLiterals = new WeakSet()
113
+
114
+ function visit(node) {
115
+ const literal = moduleLiteral(node)
116
+ if (literal && ts.isStringLiteralLike(literal)) {
117
+ handledLiterals.add(literal)
118
+ const moduleName = literal.text
119
+ const rootName = packageRoot(moduleName)
120
+ if (removed.has(rootName)) {
121
+ const where = location(source, literal, pluginRoot)
122
+ const symbolRules = catalog.symbols.modules[moduleName]
123
+ const specs = ts.isImportDeclaration(node) ? namedImports(node) : []
124
+ const exact = []
125
+ const remaining = []
126
+ for (const spec of specs) {
127
+ const rule = symbolRules?.[spec.imported]
128
+ if (rule?.confidence === 'exact') {
129
+ exact.push({ ...spec, imported: rule.toSymbol, toModule: rule.toModule, fromSymbol: spec.imported, reason: rule.reason })
130
+ 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 {
132
+ remaining.push(spec)
133
+ const semantic = rule?.confidence === 'semantic'
134
+ 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
+ }
137
+ }
138
+ const declarationText = ts.isImportDeclaration(node) ? text.slice(node.getStart(source), node.getEnd()) : ''
139
+ 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
+ if (simpleNamedImport) {
141
+ for (const item of exact) exactTargets.set(`${rootName}\0${item.toModule}`, { fromPackage: rootName, toPackage: packageRoot(item.toModule) })
142
+ const groups = new Map()
143
+ for (const item of exact) {
144
+ const list = groups.get(item.toModule) ?? []
145
+ list.push(item)
146
+ groups.set(item.toModule, list)
147
+ }
148
+ const generated = [...groups].map(([target, items]) => importText(target, items, node.importClause.isTypeOnly))
149
+ if (remaining.length > 0) generated.unshift(importText(moduleName, remaining, node.importClause.isTypeOnly))
150
+ replacements.push({ start: node.getStart(source), end: node.getEnd(), next: generated.join('\n'), exact })
151
+ } else if (specs.length === 0 || exact.length > 0) {
152
+ 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
+ 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
+ unresolved.push({ file: where.file, package: rootName, module: moduleName, reason: 'Default, namespace, side-effect, export, require, dynamic import, or module augmentation reference.' })
155
+ }
156
+ }
157
+ }
158
+ if (ts.isStringLiteralLike(node) && !handledLiterals.has(node) && removed.has(packageRoot(node.text))) {
159
+ const where = location(source, node, pluginRoot)
160
+ findings.push({ code: 'MIG_REMOVED_PACKAGE_REFERENCE', severity: 'error', message: `string reference targets removed package ${node.text}`, location: where, evidence: { module: node.text, kind: 'string-literal' }, autoFix: 'none' })
161
+ unresolved.push({ file: where.file, package: packageRoot(node.text), module: node.text, reason: 'A package-like string requires caller-specific review.' })
162
+ }
163
+ ts.forEachChild(node, visit)
164
+ }
165
+ visit(source)
166
+ let nextText = text
167
+ for (const edit of replacements.sort((a, b) => b.start - a.start)) nextText = `${nextText.slice(0, edit.start)}${edit.next}${nextText.slice(edit.end)}`
168
+ return { file, origin, text, nextText, changed: nextText !== text, findings, unresolved, remainingPackages: [...new Set(unresolved.map(item => item.package))], exactTargets: [...exactTargets.values()] }
169
+ }
170
+
171
+ function readManifest(pluginRoot) {
172
+ const file = join(pluginRoot, 'package.json')
173
+ if (!existsSync(file)) throw new Error(`plugin manifest not found: ${file}`)
174
+ const text = readFileSync(file, 'utf8')
175
+ const value = JSON.parse(text)
176
+ if (value === null || Array.isArray(value) || typeof value !== 'object') throw new Error('plugin package.json must contain an object')
177
+ return { file, value, text }
178
+ }
179
+
180
+ function targetVersion(name, catalog) {
181
+ if (catalog.packages.targetVersions?.[name] !== undefined) return catalog.packages.targetVersions[name]
182
+ return name.startsWith('@deepseek-ai/dsh-') ? catalog.manifest.to.version : undefined
183
+ }
184
+
185
+ function manifestFindings(manifest, catalog) {
186
+ const findings = []
187
+ for (const field of DEPENDENCY_FIELDS) {
188
+ const dependencyMap = manifest.value[field]
189
+ if (dependencyMap !== undefined && (dependencyMap === null || Array.isArray(dependencyMap) || typeof dependencyMap !== 'object')) {
190
+ findings.push({ code: 'MIG_INVALID_MANIFEST', severity: 'error', message: `${field} must be an object`, location: { file: 'package.json' }, evidence: { field }, autoFix: 'none' })
191
+ continue
192
+ }
193
+ for (const [name, range] of Object.entries(dependencyMap ?? {})) {
194
+ if (catalog.packages.removed.includes(name)) {
195
+ 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
+ continue
197
+ }
198
+ const target = targetVersion(name, catalog)
199
+ if (target === undefined) continue
200
+ const validRange = typeof range === 'string' ? semver.validRange(range) : null
201
+ 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' })
202
+ else if (!semver.satisfies(target, validRange)) findings.push({ code: field === 'peerDependencies' ? 'MIG_TARGET_PEER_RANGE_MISMATCH' : 'MIG_TARGET_DEPENDENCY_RANGE_MISMATCH', severity: field === 'optionalDependencies' ? 'warning' : 'error', message: `${field}.${name} does not accept ${target}`, location: { file: 'package.json' }, evidence: { field, package: name, range, target }, autoFix: field === 'devDependencies' ? 'safe' : 'none' })
203
+ }
204
+ }
205
+ const client = manifest.value.dsh?.client
206
+ for (const field of ['inject', 'external']) {
207
+ const values = client?.[field]
208
+ if (values !== undefined && !Array.isArray(values)) {
209
+ findings.push({ code: 'MIG_INVALID_MANIFEST', severity: 'error', message: `dsh.client.${field} must be an array`, location: { file: 'package.json' }, evidence: { field: `dsh.client.${field}` }, autoFix: 'none' })
210
+ continue
211
+ }
212
+ for (const name of values ?? []) if (typeof name === 'string' && catalog.packages.removed.includes(packageRoot(name))) findings.push({ code: 'MIG_CLIENT_GRAPH_INVALID', severity: 'error', message: `dsh.client.${field} references removed package ${name}`, location: { file: 'package.json' }, evidence: { field, package: name }, autoFix: 'none' })
213
+ }
214
+ for (const name of Array.isArray(client?.external) ? client.external : []) if (catalog.configRules.platformModules.includes(name)) findings.push({ code: 'MIG_CLIENT_GRAPH_INVALID', severity: 'warning', message: `${name} is supplied by the 0.1.2 client baseline and should not be external`, location: { file: 'package.json' }, evidence: { field: 'external', package: name }, autoFix: 'none' })
215
+ if (client && manifest.value.exports?.['./client'] === undefined) findings.push({ code: 'MIG_CLIENT_GRAPH_INVALID', severity: 'error', message: 'dsh.client requires a published exports["./client"] entry', location: { file: 'package.json' }, evidence: { field: 'exports./client' }, autoFix: 'none' })
216
+ return findings
217
+ }
218
+
219
+ function opaqueReferenceFindings(pluginRoot, sourceFiles, manifest, catalog) {
220
+ const analyzed = new Set(sourceFiles)
221
+ const removed = catalog.packages.removed
222
+ const findings = []
223
+ const packages = new Set()
224
+ for (const file of walkAll(pluginRoot, SOURCE_DIRS_TO_SKIP)) {
225
+ if (isTopLevelArtifact(pluginRoot, file)) continue
226
+ if (file === manifest.file || analyzed.has(file)) continue
227
+ const stat = statSync(file)
228
+ if (stat.size > 2 * 1024 * 1024) continue
229
+ const buffer = readFileSync(file)
230
+ if (buffer.includes(0)) continue
231
+ const text = buffer.toString('utf8')
232
+ for (const name of removed) {
233
+ const index = text.indexOf(name)
234
+ if (index < 0) continue
235
+ const before = text.slice(0, index)
236
+ const documentationOnly = NON_RUNTIME_TEXT_EXTENSIONS.has(extension(file))
237
+ if (!documentationOnly) packages.add(name)
238
+ findings.push({
239
+ code: documentationOnly ? 'MIG_DOCUMENTATION_REFERENCE' : 'MIG_UNSUPPORTED_SOURCE_REFERENCE',
240
+ severity: documentationOnly ? 'warning' : 'error',
241
+ message: documentationOnly ? `${relativePath(pluginRoot, file)} documents removed package ${name}` : `${relativePath(pluginRoot, file)} references removed package ${name} in a file that has no safe codemod`,
242
+ location: { file: relativePath(pluginRoot, file), line: before.split('\n').length, column: index - before.lastIndexOf('\n') },
243
+ evidence: { package: name },
244
+ autoFix: 'none',
245
+ })
246
+ }
247
+ }
248
+ const structuredFields = ['imports', 'exports', 'typesVersions', 'scripts']
249
+ const structuredText = JSON.stringify(Object.fromEntries(structuredFields.filter(field => manifest.value[field] !== undefined).map(field => [field, manifest.value[field]])))
250
+ for (const name of removed) {
251
+ if (!structuredText.includes(name)) continue
252
+ packages.add(name)
253
+ findings.push({ code: 'MIG_UNSUPPORTED_SOURCE_REFERENCE', severity: 'error', message: `package.json contains a non-dependency reference to removed package ${name}`, location: { file: 'package.json' }, evidence: { package: name }, autoFix: 'none' })
254
+ }
255
+ return { findings, packages }
256
+ }
257
+
258
+ function patchTargetFindings(pluginRoot, manifest, harness) {
259
+ if (!harness.exact) return []
260
+ const declared = manifest.value.dsh?.bundle?.patch
261
+ const paths = typeof declared === 'string' ? [declared] : Array.isArray(declared) ? declared.filter(item => typeof item === 'string') : []
262
+ const fromIds = new Set(harness.fromEntryIds)
263
+ const toIds = new Set(harness.toEntryIds)
264
+ const findings = []
265
+ for (const path of paths) {
266
+ const file = resolve(pluginRoot, path)
267
+ if (!existsSync(file)) continue
268
+ const document = parseDocument(readFileSync(file, 'utf8'), {
269
+ prettyErrors: false,
270
+ customTags: [{ tag: 'tag:yaml.org,2002:js', resolve: value => value }],
271
+ })
272
+ if (document.errors.length > 0) continue
273
+ const rows = document.toJS()
274
+ if (!Array.isArray(rows)) continue
275
+ const owned = new Set(rows.flatMap(row => Array.isArray(row?.insert) ? row.insert.map(item => item?.id).filter(Boolean) : []))
276
+ for (const row of rows) {
277
+ const id = row?.id
278
+ if (typeof id !== 'string' || owned.has(id) || toIds.has(id)) continue
279
+ findings.push({
280
+ code: 'MIG_PATCH_TARGET_CHANGED',
281
+ severity: 'error',
282
+ message: fromIds.has(id) ? `patch target ${id} existed in 0.1.1 but is absent from 0.1.2` : `patch target ${id} is not present in the target Harness bundles`,
283
+ location: { file: relativePath(pluginRoot, file) },
284
+ evidence: { id, existedInSource: fromIds.has(id), existsInTarget: false },
285
+ autoFix: 'none',
286
+ })
287
+ }
288
+ }
289
+ return findings
290
+ }
291
+
292
+ function planManifest(manifest, sourceResults, artifactResults, opaqueReferences, catalog) {
293
+ const next = structuredClone(manifest.value)
294
+ const exactTargets = sourceResults.flatMap(result => result.exactTargets)
295
+ const sourceReferences = new Set(sourceResults.flatMap(result => result.remainingPackages))
296
+ const artifactText = artifactResults.map(result => result.text).join('\n')
297
+ const clientGraph = JSON.stringify(manifest.value.dsh?.client ?? {})
298
+ for (const field of DEPENDENCY_FIELDS) {
299
+ const deps = next[field]
300
+ if (!deps || Array.isArray(deps) || typeof deps !== 'object') continue
301
+ const presentRemoved = catalog.packages.removed.filter(name => deps[name] !== undefined)
302
+ for (const removedPackage of presentRemoved) {
303
+ const stillUsed = sourceReferences.has(removedPackage) || clientGraph.includes(removedPackage) || opaqueReferences.has(removedPackage) || (sourceResults.length === 0 && artifactText.includes(removedPackage))
304
+ 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
+ }
310
+ if (field === 'devDependencies') {
311
+ for (const [name, range] of Object.entries(deps)) {
312
+ const target = targetVersion(name, catalog)
313
+ if (target !== undefined && !catalog.packages.removed.includes(name) && (semver.validRange(range) === null || !semver.satisfies(target, range))) deps[name] = target
314
+ }
315
+ }
316
+ }
317
+ const changes = []
318
+ for (const field of DEPENDENCY_FIELDS) {
319
+ const before = manifest.value[field] ?? {}
320
+ const after = next[field] ?? {}
321
+ for (const name of new Set([...Object.keys(before), ...Object.keys(after)])) {
322
+ if (before[name] !== after[name]) changes.push({ kind: 'manifest-dependency', field, package: name, before: before[name] ?? null, after: after[name] ?? null })
323
+ }
324
+ }
325
+ const nextText = changes.length > 0 ? `${JSON.stringify(next, null, 2)}\n` : manifest.text
326
+ return { file: manifest.file, text: manifest.text, nextText, changed: nextText !== manifest.text, changes }
327
+ }
328
+
329
+ function summarize(findings, safeEdits, unresolved) {
330
+ return {
331
+ errors: findings.filter(item => item.severity === 'error').length,
332
+ warnings: findings.filter(item => item.severity === 'warning').length,
333
+ info: findings.filter(item => item.severity === 'info').length,
334
+ safeEdits: safeEdits.length,
335
+ semanticTasks: unresolved.length,
336
+ }
337
+ }
338
+
339
+ export function analyzeMigration(pluginRoot = process.cwd(), options = {}) {
340
+ const root = resolve(pluginRoot)
341
+ 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')
343
+ const harness = verifyHarnessCheckout(catalog, options.harnessRoot)
344
+ const manifest = readManifest(root)
345
+ const sourceFiles = walk(root, SOURCE_DIRS_TO_SKIP).filter(file => !isTopLevelArtifact(root, file))
346
+ const sources = sourceFiles.map(file => analyzeFile(file, root, catalog, 'source'))
347
+ const artifacts = artifactFiles(root).map(file => analyzeFile(file, root, catalog, 'artifact'))
348
+ const opaqueReferences = opaqueReferenceFindings(root, sourceFiles, manifest, catalog)
349
+ const sourceRemoved = sources.some(item => item.findings.some(finding => ['MIG_REMOVED_PACKAGE_REFERENCE', 'MIG_SEMANTIC_API_CHANGE', 'MIG_MOVED_SYMBOL'].includes(finding.code)))
350
+ 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)]
352
+ 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
+ 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)
355
+ const changed = [...sources.filter(item => item.changed), ...(manifestPlan.changed ? [manifestPlan] : [])]
356
+ const safeEdits = changed.map(item => ({
357
+ file: relativePath(root, item.file),
358
+ beforeHash: sha256(item.text),
359
+ afterHash: sha256(item.nextText),
360
+ changes: item.changes ?? item.findings.filter(finding => finding.autoFix === 'safe').map(finding => ({ kind: 'move-import', ...finding.evidence })),
361
+ }))
362
+ const unresolved = [...sources, ...artifacts].flatMap(item => item.unresolved)
363
+ return {
364
+ schemaVersion: 1,
365
+ command: 'migrate analyze',
366
+ migration: { id: catalog.manifest.id, from: catalog.manifest.from, to: catalog.manifest.to, harness },
367
+ 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
+ summary: summarize(findings, safeEdits, unresolved),
369
+ findings,
370
+ safeEdits,
371
+ semanticTasks: unresolved,
372
+ verification: { status: 'analyzed', level: 'static-analysis', passed: findings.every(item => item.severity !== 'error') },
373
+ _plan: changed.map(item => ({ file: item.file, snapshot: { file: item.file, exists: true, hash: sha256(item.text) }, nextText: item.nextText })),
374
+ }
375
+ }
376
+
377
+ export function publicMigrationReport(report) {
378
+ const { _plan, ...value } = report
379
+ const harness = { ...value.migration.harness }
380
+ delete harness.fromEntryIds
381
+ delete harness.toEntryIds
382
+ return { ...value, migration: { ...value.migration, harness } }
383
+ }
384
+
385
+ export function applyMigration(report, options = {}) {
386
+ if (options.safe !== true) throw new Error('migrate apply requires --safe')
387
+ if (options.yes !== true) return { mode: 'preview', ...publicMigrationReport(report) }
388
+ // Exact edits remain safe when semantic work remains; unresolved references keep the removed dependency.
389
+ for (const item of report._plan) {
390
+ const currentExists = existsSync(item.snapshot.file)
391
+ const current = currentExists ? readFileSync(item.snapshot.file, 'utf8') : ''
392
+ 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
+ }
394
+ 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) }
397
+ }
398
+
399
+ export function formatMigrationReport(report, language = 'en') {
400
+ const zh = language === 'zh'
401
+ const lines = [
402
+ `${zh ? '插件升级分析' : 'Plugin migration analysis'}: ${report.plugin.name}@${report.plugin.version}`,
403
+ `${report.migration.from.ref} -> ${report.migration.to.ref} (${report.migration.harness.status})`,
404
+ `${zh ? '结果' : 'Result'}: ${report.summary.errors} ${zh ? '错误' : 'errors'}, ${report.summary.warnings} ${zh ? '警告' : 'warnings'}, ${report.summary.safeEdits} ${zh ? '个安全改写' : 'safe edits'}, ${report.summary.semanticTasks} ${zh ? '个语义任务' : 'semantic tasks'}`,
405
+ ]
406
+ for (const finding of report.findings) lines.push(`- [${finding.severity}] ${finding.code} ${finding.location.file}${finding.location.line ? `:${finding.location.line}` : ''}: ${finding.message}`)
407
+ if (report.safeEdits.length > 0) {
408
+ lines.push(zh ? '可安全改写:' : 'Safe edit preview:')
409
+ for (const edit of report.safeEdits) {
410
+ lines.push(` ${edit.file} ${edit.beforeHash.slice(0, 8)} -> ${edit.afterHash.slice(0, 8)}`)
411
+ for (const change of edit.changes) {
412
+ if (change.kind === 'move-import') lines.push(` ${change.symbol}: ${change.module} -> ${change.targetModule}#${change.replacement}`)
413
+ else lines.push(` ${change.field}.${change.package}: ${change.before ?? '(absent)'} -> ${change.after ?? '(removed)'}`)
414
+ }
415
+ }
416
+ }
417
+ return `${lines.join('\n')}\n`
418
+ }
@@ -0,0 +1,60 @@
1
+ import { existsSync, readdirSync, readFileSync } from 'node:fs'
2
+ import { join, resolve } from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+ import { spawnSync } from 'node:child_process'
5
+
6
+ const catalogRoot = fileURLToPath(new URL('../migrations/', import.meta.url))
7
+
8
+ function readJson(file) {
9
+ return JSON.parse(readFileSync(file, 'utf8'))
10
+ }
11
+
12
+ export function listMigrations() {
13
+ if (!existsSync(catalogRoot)) return []
14
+ return readdirSync(catalogRoot, { withFileTypes: true })
15
+ .filter(entry => entry.isDirectory() && existsSync(join(catalogRoot, entry.name, 'manifest.json')))
16
+ .map(entry => readJson(join(catalogRoot, entry.name, 'manifest.json')))
17
+ .sort((left, right) => left.id.localeCompare(right.id))
18
+ }
19
+
20
+ export function loadMigration(from, to) {
21
+ const manifest = listMigrations().find(item => item.from.ref === from && item.to.ref === to)
22
+ if (manifest === undefined) throw new Error(`unsupported migration ${from} -> ${to}`)
23
+ const root = join(catalogRoot, manifest.id)
24
+ return {
25
+ root,
26
+ manifest,
27
+ packages: readJson(join(root, 'packages.json')),
28
+ symbols: readJson(join(root, 'symbols.json')),
29
+ services: readJson(join(root, 'services.json')),
30
+ configRules: readJson(join(root, 'config-rules.json')),
31
+ behavior: readFileSync(join(root, 'behavior.md'), 'utf8'),
32
+ }
33
+ }
34
+
35
+ function git(harnessRoot, args, description) {
36
+ const result = spawnSync('git', ['-C', harnessRoot, ...args], { encoding: 'utf8' })
37
+ if (result.status !== 0) {
38
+ const detail = (result.stderr || result.stdout).trim()
39
+ throw new Error(`Harness ${description} failed${detail === '' ? '' : `: ${detail}`}`)
40
+ }
41
+ return result.stdout.trim()
42
+ }
43
+
44
+ export function verifyHarnessCheckout(catalog, harnessRoot) {
45
+ if (harnessRoot === undefined) return { status: 'catalog-only', exact: false }
46
+ const root = resolve(harnessRoot)
47
+ if (!existsSync(join(root, '.git'))) throw new Error(`not a git checkout: ${harnessRoot}`)
48
+ const fromCommit = git(root, ['rev-list', '-n', '1', catalog.manifest.from.ref], `ref lookup for ${catalog.manifest.from.ref}`)
49
+ const toCommit = git(root, ['rev-list', '-n', '1', catalog.manifest.to.ref], `ref lookup for ${catalog.manifest.to.ref}`)
50
+ if (fromCommit !== catalog.manifest.from.commit || toCommit !== catalog.manifest.to.commit) {
51
+ throw new Error('Harness migration refs do not match the catalog commits')
52
+ }
53
+ const patchPaths = catalog.configRules.profilePatchPaths?.web
54
+ if (!Array.isArray(patchPaths) || patchPaths.length === 0) throw new Error('migration catalog has no authoritative web profile patch paths')
55
+ const entryIds = ref => new Set(git(root, ['grep', '-h', '--', '- id:', ref, '--', ...patchPaths], `web profile entry scan for ${ref}`)
56
+ .split('\n')
57
+ .map(line => /^\s*- id:\s*['"]?([^'"\s#]+)['"]?/.exec(line)?.[1])
58
+ .filter(Boolean))
59
+ return { status: 'verified', exact: true, root, fromCommit, toCommit, fromEntryIds: [...entryIds(catalog.manifest.from.ref)], toEntryIds: [...entryIds(catalog.manifest.to.ref)] }
60
+ }