minecraft-dev-starter 0.0.0-stage → 0.1.1

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,460 @@
1
+ /**
2
+ * 工程识别:把一个目录翻译成结构化画像(loader / 版本 / 模块 / 可运行目标 / JDK / 证据)。
3
+ *
4
+ * 设计原则:
5
+ * - 一切结论附证据,读不出就报 unknown,不猜;
6
+ * - 任务目标来自构建脚本里的 `runs` 块,读不到时给"推测"标记;
7
+ * - 不执行 Gradle(代价与失败率见调研文档)。
8
+ * @module minecraft-dev-starter/lib/detect
9
+ */
10
+
11
+ import { basename, join } from 'node:path'
12
+ import { stat } from 'node:fs/promises'
13
+ import {
14
+ minecraftFromNeoVersion,
15
+ parseJavaLevel,
16
+ parseMinecraftVersion,
17
+ parsePluginIds,
18
+ parseRuns,
19
+ parseSettingsIncludes,
20
+ parseWrapperVersion,
21
+ } from './gradle.js'
22
+ import { dependencyRanges, locateMetadata, primaryMetadata } from './metadata.js'
23
+ import { readText, resolvePlaceholders, parseProperties } from './text.js'
24
+ import { gradleSupportsJava, pickJdk, requiredJava } from './java.js'
25
+
26
+ /**
27
+ * @typedef {{ file: string, kind: string, detail: string }} Evidence
28
+ * @typedef {{ task: string, label: string, kind: string, guessed: boolean }} Target
29
+ * @typedef {{ path: string, dir: string, platform: string, pluginIds: string[], targets: Target[] }} ModuleInfo
30
+ */
31
+
32
+ /** loader 判定:插件 id 正则 → loader。顺序有意义,先命中先得。 */
33
+ const LOADER_RULES = [
34
+ { loader: 'neoforge', ids: [/^net\.neoforged\.moddev$/, /^net\.neoforged\.gradle\.userdev$/, /^net\.neoforged\.gradle$/] },
35
+ { loader: 'fabric', ids: [/^fabric-loom$/, /^net\.fabricmc\.fabric-loom$/] },
36
+ { loader: 'forge', ids: [/^net\.minecraftforge\.gradle(\.userdev)?$/, /^forge$/] },
37
+ { loader: 'paper', ids: [/^paperweight/, /^io\.papermc\.paper/] },
38
+ { loader: 'spigot', ids: [/^spigot/] },
39
+ { loader: 'architectury', ids: [/^architectury-plugin$/, /^architectury$/, /^dev\.architectury\.loom$/] },
40
+ ]
41
+
42
+ /** 推测默认目标:只在构建脚本读不到 runs 块时使用。 */
43
+ const DEFAULT_TARGETS = {
44
+ fabric: [
45
+ { block: 'client', kind: 'client' },
46
+ { block: 'server', kind: 'server' },
47
+ ],
48
+ forge: [
49
+ { block: 'client', kind: 'client' },
50
+ { block: 'server', kind: 'server' },
51
+ { block: 'data', kind: 'data' },
52
+ ],
53
+ neoforge: [
54
+ { block: 'client', kind: 'client' },
55
+ { block: 'server', kind: 'server' },
56
+ { block: 'data', kind: 'data' },
57
+ ],
58
+ }
59
+
60
+ /**
61
+ * 识别一个工程。
62
+ * @param {string} root 工程根目录(绝对路径)
63
+ * @param {{ jdks?: import('./java.js').JdkInfo[], maxModules?: number }} [options]
64
+ * @returns {Promise<Record<string, unknown>>}
65
+ */
66
+ export async function detectProject(root, options = {}) {
67
+ /** @type {Evidence[]} */
68
+ const evidence = []
69
+ /** @type {string[]} */
70
+ const warnings = []
71
+ /** @type {string[]} */
72
+ const conflicts = []
73
+
74
+ const gradleFile = await pickExisting(root, ['build.gradle.kts', 'build.gradle'])
75
+ const gradleText = gradleFile === undefined ? undefined : await readText(join(root, gradleFile))
76
+ const settingsFile = await pickExisting(root, ['settings.gradle.kts', 'settings.gradle'])
77
+ const settingsText = settingsFile === undefined ? undefined : await readText(join(root, settingsFile))
78
+ const properties = parseProperties(await readText(join(root, 'gradle.properties')))
79
+ const wrapperProperties = parseProperties(await readText(join(root, 'gradle/wrapper/gradle-wrapper.properties')))
80
+ const gradleVersion = parseWrapperVersion(wrapperProperties)
81
+
82
+ const pluginIds = [...parsePluginIds(gradleText ?? ''), ...parsePluginIds(settingsText ?? '')]
83
+ const includes = parseSettingsIncludes(settingsText ?? '')
84
+
85
+ if (gradleFile !== undefined) {
86
+ evidence.push({ file: gradleFile, kind: 'build-script', detail: `Gradle 构建脚本(${gradleFile.endsWith('.kts') ? 'Kotlin DSL' : 'Groovy'})` })
87
+ }
88
+ if (gradleVersion !== undefined) {
89
+ evidence.push({ file: 'gradle/wrapper/gradle-wrapper.properties', kind: 'wrapper', detail: `Gradle ${gradleVersion}` })
90
+ } else if (gradleFile !== undefined) {
91
+ warnings.push('没有 gradle wrapper,需要本机装 Gradle 才能构建')
92
+ }
93
+
94
+ // ── 模块展开 ────────────────────────────────────────────────────────
95
+ const modulePaths = includes.length > 0 ? ['.', ...includes.slice(0, options.maxModules ?? 12)] : ['.']
96
+ /** @type {ModuleInfo[]} */
97
+ const modules = []
98
+ /** @type {Awaited<ReturnType<typeof locateMetadata>>} */
99
+ let allMetadata = []
100
+ /** @type {string[]} */
101
+ const modulePluginIds = []
102
+ /** @type {Array<Record<string, string>>} 模块级 gradle.properties,聚合仓库的版本常写在子模块里 */
103
+ const moduleProperties = []
104
+ /** @type {number[]} 模块级 Java toolchain */
105
+ const moduleJavaLevels = []
106
+
107
+ for (const modulePath of modulePaths) {
108
+ const dir = modulePath === '.' ? root : join(root, modulePath)
109
+ const moduleGradleFile = await pickExisting(dir, ['build.gradle.kts', 'build.gradle'])
110
+ const moduleText = moduleGradleFile === undefined ? undefined : await readText(join(dir, moduleGradleFile))
111
+ const moduleIds = parsePluginIds(moduleText ?? '')
112
+ modulePluginIds.push(...moduleIds)
113
+ const runs = parseRuns(moduleText ?? '')
114
+ const metadata = await locateMetadata(dir)
115
+ allMetadata = allMetadata.concat(metadata)
116
+ const platform = detectPlatform(moduleIds, moduleText ?? '')
117
+ moduleProperties.push(parseProperties(await readText(join(dir, 'gradle.properties'))))
118
+ const javaLevel = parseJavaLevel(moduleText ?? '')
119
+ if (javaLevel !== undefined) moduleJavaLevels.push(javaLevel)
120
+ if (metadata.length > 0) {
121
+ evidence.push({
122
+ file: modulePath === '.' ? metadata[0].file : `${modulePath}/${metadata[0].file}`,
123
+ kind: `metadata:${metadata[0].kind}`,
124
+ detail: `${metadata[0].kind} 元数据`,
125
+ })
126
+ }
127
+ modules.push({ path: modulePath, dir, platform: platform ?? 'unknown', pluginIds: moduleIds, targets: runs.map(toTarget) })
128
+ }
129
+
130
+ // 聚合仓库(如 AnnoCore)把版本写在子模块里:根属性优先,模块属性补齐。
131
+ /** @type {Record<string, string>} */
132
+ const mergedProperties = Object.assign({}, ...moduleProperties.slice().reverse(), properties)
133
+
134
+ // ── loader ─────────────────────────────────────────────────────────
135
+ // 先看根脚本;根脚本没声明时再看子模块(聚合仓库常见:AnnoCore 的 loader 全在子模块里)。
136
+ const allPluginIds = [...new Set([...pluginIds, ...modulePluginIds])]
137
+ /** @type {string | undefined} */
138
+ let loader
139
+ /** @type {string | undefined} */
140
+ let loaderEvidenceId
141
+ for (const rule of LOADER_RULES) {
142
+ const hit = pluginIds.find((id) => rule.ids.some((pattern) => pattern.test(id)))
143
+ if (hit !== undefined) {
144
+ loader = rule.loader
145
+ loaderEvidenceId = hit
146
+ evidence.push({ file: gradleFile ?? 'build.gradle', kind: 'plugin', detail: `插件 ${hit}` })
147
+ break
148
+ }
149
+ }
150
+ if (loader === undefined) {
151
+ for (const rule of LOADER_RULES) {
152
+ const hit = modulePluginIds.find((id) => rule.ids.some((pattern) => pattern.test(id)))
153
+ if (hit !== undefined) {
154
+ loader = rule.loader
155
+ loaderEvidenceId = hit
156
+ evidence.push({ file: '子模块构建脚本', kind: 'plugin', detail: `插件 ${hit}(只在子模块声明)` })
157
+ break
158
+ }
159
+ }
160
+ }
161
+
162
+ // 所有命中过的 loader(多平台工程可能同时有 fabric 与 neoforge)
163
+ const loaders = [
164
+ ...new Set(
165
+ LOADER_RULES.filter((rule) => allPluginIds.some((id) => rule.ids.some((pattern) => pattern.test(id)))).map(
166
+ (rule) => rule.loader,
167
+ ),
168
+ ),
169
+ ]
170
+
171
+ const metadataKind = primaryMetadata(allMetadata)?.kind
172
+ if (loader === undefined && metadataKind !== undefined) {
173
+ loader = metadataKind === 'bukkit' ? 'spigot' : metadataKind
174
+ loaders.push(loader)
175
+ evidence.push({ file: 'metadata', kind: 'metadata-only', detail: `只有 ${metadataKind} 元数据,没有构建脚本特征` })
176
+ }
177
+
178
+ // 元数据与构建脚本矛盾时记冲突,不静默选一个
179
+ if (loader !== undefined && metadataKind !== undefined) {
180
+ const mapped = metadataKind === 'bukkit' ? 'spigot' : metadataKind
181
+ const compatible = loader === mapped || (loader === 'architectury' && mapped !== 'architectury')
182
+ if (!compatible) {
183
+ conflicts.push(`构建脚本表明 ${loader},元数据却是 ${mapped}`)
184
+ }
185
+ }
186
+
187
+ // ── 版本 ────────────────────────────────────────────────────────────
188
+ const minecraftVersion =
189
+ pickProperty(mergedProperties, ['minecraft_version', 'minecraftVersion', 'mc_version', 'mcVersion', 'version_minecraft']) ??
190
+ minecraftFromNeoVersion(pickProperty(mergedProperties, ['neo_version', 'neoVersion'])) ??
191
+ (await versionFromMetadata(allMetadata)) ??
192
+ parseMinecraftVersion(gradleText ?? '', mergedProperties) ??
193
+ pickProperty(mergedProperties, ['parchment_minecraft_version'])
194
+
195
+ const loaderVersion = pickProperty(mergedProperties, [
196
+ 'neo_version',
197
+ 'neoVersion',
198
+ 'loader_version',
199
+ 'loaderVersion',
200
+ 'forge_version',
201
+ 'forgeVersion',
202
+ 'fabric_loader_version',
203
+ 'fabric_api_version',
204
+ 'paper_api_version',
205
+ 'spigot_version',
206
+ ])
207
+
208
+ // ── 身份 ────────────────────────────────────────────────────────────
209
+ const identity = readIdentity(allMetadata, mergedProperties, root, warnings)
210
+
211
+ // ── 可运行目标 ──────────────────────────────────────────────────────
212
+ const hasDeclaredTargets = modules.some((module) => module.targets.length > 0)
213
+ if (!hasDeclaredTargets && loader !== undefined) {
214
+ applyGuessedTargets(modules, loader)
215
+ if (modules.some((module) => module.targets.length > 0)) {
216
+ warnings.push('构建脚本里没读到 runs 块,目标是按 loader 惯例推测的(未验证)')
217
+ }
218
+ }
219
+ const runTargets = modules.flatMap((module) =>
220
+ module.targets.map((target) => ({
221
+ ...target,
222
+ module: module.path,
223
+ taskPath: module.path === '.' ? target.task : `:${module.path}:${target.task}`,
224
+ platform: module.platform,
225
+ })),
226
+ )
227
+
228
+ // ── JDK ────────────────────────────────────────────────────────────
229
+ const toolchain = parseJavaLevel(gradleText ?? '') ?? (moduleJavaLevels.length > 0 ? Math.max(...moduleJavaLevels) : undefined)
230
+ const javaRequired = toolchain ?? requiredJava(/** @type {string | undefined} */ (minecraftVersion))
231
+ const configuredJavaHome = pickProperty(properties, ['org.gradle.java.home'])
232
+ const jdks = options.jdks ?? []
233
+ const picked = pickJdk(jdks, javaRequired, configuredJavaHome)
234
+ if (picked === undefined) {
235
+ warnings.push(`没找到可用的 JDK${javaRequired === undefined ? '' : ` ${javaRequired}`}`)
236
+ } else if (!picked.exact && javaRequired !== undefined) {
237
+ warnings.push(`JDK ${javaRequired} 未找到,退而使用 ${picked.home}`)
238
+ }
239
+ const gradleCheck = gradleSupportsJava(gradleVersion, javaRequired)
240
+ if (!gradleCheck.ok && gradleCheck.reason !== undefined) warnings.push(gradleCheck.reason)
241
+ if (properties['org.gradle.configuration-cache'] === 'true') {
242
+ warnings.push('已开启 org.gradle.configuration-cache,与部分 mod 插件组合可能不兼容')
243
+ }
244
+ if (conflicts.length > 0) warnings.push(`识别到矛盾:${conflicts.join(';')}`)
245
+
246
+ const kind = classify({ modules, loader, loaders, metadataKind })
247
+ const confidence = scoreConfidence({ loader, metadataKind, configuredJavaHome })
248
+
249
+ return {
250
+ root,
251
+ name: basename(root),
252
+ kind,
253
+ loader: loader ?? 'unknown',
254
+ loaders,
255
+ loaderEvidence: loaderEvidenceId,
256
+ loaderVersion,
257
+ minecraftVersion,
258
+ modId: identity.modId,
259
+ modName: identity.modName,
260
+ modVersion: identity.modVersion,
261
+ javaVersion: javaRequired,
262
+ java: {
263
+ required: javaRequired,
264
+ toolchain,
265
+ configured: configuredJavaHome,
266
+ selected: picked?.home,
267
+ exact: picked?.exact ?? false,
268
+ available: jdks.map((jdk) => ({ home: jdk.home, major: jdk.major })),
269
+ },
270
+ gradleVersion,
271
+ wrapper: gradleVersion !== undefined,
272
+ modules: modules.map((module) => ({
273
+ path: module.path,
274
+ platform: module.platform,
275
+ targets: module.targets.map((target) => ({
276
+ ...target,
277
+ taskPath: module.path === '.' ? target.task : `:${module.path}:${target.task}`,
278
+ })),
279
+ })),
280
+ runTargets,
281
+ evidence,
282
+ conflicts,
283
+ warnings,
284
+ confidence,
285
+ }
286
+ }
287
+
288
+ /**
289
+ * @param {string} root
290
+ * @param {string[]} names
291
+ * @returns {Promise<string | undefined>}
292
+ */
293
+ async function pickExisting(root, names) {
294
+ for (const name of names) {
295
+ try {
296
+ const info = await stat(join(root, name))
297
+ if (info.isFile()) return name
298
+ } catch {
299
+ /* 继续找下一个 */
300
+ }
301
+ }
302
+ return undefined
303
+ }
304
+
305
+ /**
306
+ * @param {string[]} pluginIds
307
+ * @param {string} moduleText
308
+ * @returns {string | undefined} fabric / forge / neoforge / common / undefined
309
+ */
310
+ function detectPlatform(pluginIds, moduleText) {
311
+ if (pluginIds.some((id) => /fabric-loom/.test(id))) return 'fabric'
312
+ if (pluginIds.some((id) => /neoforged/.test(id))) return 'neoforge'
313
+ if (pluginIds.some((id) => /minecraftforge/.test(id))) return 'forge'
314
+ // Architectury 的平台由 architectury { fabric() } / { forge() } 声明
315
+ if (/\barchitectury\s*\{[^}]*\bfabric\s*\(/.test(moduleText)) return 'fabric'
316
+ if (/\barchitectury\s*\{[^}]*\bforge\s*\(/.test(moduleText)) return 'forge'
317
+ if (/\bmedialib\s*\(|\bcommon\s*\(\s*\)/.test(moduleText)) return 'common'
318
+ return undefined
319
+ }
320
+
321
+ /**
322
+ * @param {{ block: string, kind: string, task: string, label: string }} run
323
+ * @returns {Target}
324
+ */
325
+ function toTarget(run) {
326
+ return { task: run.task, label: run.label, kind: run.kind, guessed: false }
327
+ }
328
+
329
+ /**
330
+ * 读不到 runs 时,按多少给候选:
331
+ * - 单模块工程:给完整的默认集(Client/Server[/Data]),因为只有一个入口;
332
+ * - 多模块工程:每个平台模块只给一个 Client,避免聚合仓库列出十几行噪音。
333
+ * architectury 的 common 模块不猜(它通常没有客户端)。
334
+ * @param {ModuleInfo[]} modules
335
+ * @param {string} loader
336
+ */
337
+ function applyGuessedTargets(modules, loader) {
338
+ const concrete = modules.filter(
339
+ (module) => module.targets.length === 0 && module.platform !== 'unknown' && module.platform !== 'common',
340
+ )
341
+ if (concrete.length === 0) return
342
+ const singleRoot = concrete.length === 1 && concrete[0].path === '.'
343
+
344
+ for (const module of concrete) {
345
+ const defaults = DEFAULT_TARGETS[/** @type {keyof typeof DEFAULT_TARGETS} */ (module.platform)]
346
+ if (defaults === undefined) continue
347
+ const chosen = singleRoot ? defaults : defaults.filter((entry) => entry.kind === 'client')
348
+ module.targets = chosen.map((entry) => ({
349
+ task: `run${entry.block[0].toUpperCase()}${entry.block.slice(1)}`,
350
+ label: entry.kind === 'gameTestServer' ? 'GameTestServer' : entry.kind[0].toUpperCase() + entry.kind.slice(1),
351
+ kind: entry.kind,
352
+ guessed: true,
353
+ }))
354
+ }
355
+ void loader
356
+ }
357
+
358
+ /**
359
+ * @param {Record<string, string>} properties
360
+ * @param {string[]} keys
361
+ * @returns {string | undefined}
362
+ */
363
+ function pickProperty(properties, keys) {
364
+ for (const key of keys) {
365
+ const value = properties[key]
366
+ if (value !== undefined && value !== '') return value
367
+ }
368
+ return undefined
369
+ }
370
+
371
+ /**
372
+ * @param {Awaited<ReturnType<typeof locateMetadata>>} hits
373
+ * @returns {Promise<string | undefined>}
374
+ */
375
+ async function versionFromMetadata(hits) {
376
+ const primary = primaryMetadata(hits)
377
+ if (primary === undefined) return undefined
378
+ const ranges = dependencyRanges(primary)
379
+ const range = ranges.minecraft
380
+ if (range !== undefined) {
381
+ const match = /(\d+\.\d+(?:\.\d+)?)/.exec(range)
382
+ if (match) return match[1]
383
+ }
384
+ if (primary.kind === 'mcmod' && typeof primary.data.mcversion === 'string') {
385
+ return primary.data.mcversion
386
+ }
387
+ return undefined
388
+ }
389
+
390
+ /**
391
+ * @param {Awaited<ReturnType<typeof locateMetadata>>} hits
392
+ * @param {Record<string, string>} properties
393
+ * @param {string} root
394
+ * @param {string[]} warnings
395
+ */
396
+ function readIdentity(hits, properties, root, warnings) {
397
+ const primary = primaryMetadata(hits)
398
+ const data = primary?.data ?? {}
399
+ /** @type {string[]} */
400
+ const unresolved = []
401
+
402
+ const raw = {
403
+ modId: asString(data.id) ?? asString(data.modId) ?? asString(data.modid) ?? asString(data.name),
404
+ modName: asString(data.name) ?? asString(data.displayName),
405
+ modVersion: asString(data.version),
406
+ }
407
+
408
+ const resolve = (value) => {
409
+ if (value === undefined) return undefined
410
+ const result = resolvePlaceholders(value, properties)
411
+ if (result.unresolved.length > 0) unresolved.push(...result.unresolved)
412
+ return result.value
413
+ }
414
+
415
+ const modId = resolve(raw.modId) ?? properties.mod_id ?? properties.modid
416
+ const modName = resolve(raw.modName) ?? properties.mod_name ?? modId ?? basename(root)
417
+ const modVersion = resolve(raw.modVersion) ?? properties.mod_version ?? properties.version
418
+
419
+ if (unresolved.length > 0) {
420
+ warnings.push(`元数据里有未解析的占位符:${[...new Set(unresolved)].join(', ')}`)
421
+ }
422
+ return { modId, modName, modVersion }
423
+ }
424
+
425
+ /**
426
+ * @param {unknown} value
427
+ * @returns {string | undefined}
428
+ */
429
+ function asString(value) {
430
+ return typeof value === 'string' && value !== '' ? value : undefined
431
+ }
432
+
433
+ /**
434
+ * 工程种类。判 multi-loader 要求"至少认出一种 loader"——什么都没认出来时
435
+ * 只能说 unknown,不能因为模块平台标签不一致就升级成多平台。
436
+ * @param {{ modules: ModuleInfo[], loader?: string, loaders: string[], metadataKind?: string }} input
437
+ * @returns {string}
438
+ */
439
+ function classify(input) {
440
+ if (input.metadataKind === 'bukkit' || input.metadataKind === 'paper') return 'server-plugin'
441
+ if (input.loader === undefined) return 'unknown'
442
+ if (input.loader === 'architectury') return 'multi-loader'
443
+ const concrete = input.loaders.filter((loader) => loader !== 'architectury')
444
+ if (concrete.length > 1) return 'multi-loader'
445
+ const platforms = new Set(
446
+ input.modules.map((module) => module.platform).filter((platform) => platform !== 'unknown' && platform !== 'common'),
447
+ )
448
+ if (platforms.size > 1) return 'multi-loader'
449
+ return 'mod'
450
+ }
451
+
452
+ /**
453
+ * @param {{ loader?: string, metadataKind?: string, configuredJavaHome?: string }} input
454
+ * @returns {number}
455
+ */
456
+ function scoreConfidence(input) {
457
+ if (input.loader === undefined) return 0.2
458
+ if (input.metadataKind !== undefined) return 0.95
459
+ return input.loader === 'architectury' ? 0.7 : 0.8
460
+ }