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