@adhisang/minecraft-modding-mcp 3.1.1 → 4.0.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/CHANGELOG.md +49 -0
- package/README.md +37 -18
- package/dist/access-transformer-parser.d.ts +17 -0
- package/dist/access-transformer-parser.js +97 -0
- package/dist/cache-registry.d.ts +1 -1
- package/dist/cache-registry.js +10 -2
- package/dist/concurrency.d.ts +1 -0
- package/dist/concurrency.js +24 -0
- package/dist/config.d.ts +10 -1
- package/dist/config.js +52 -1
- package/dist/decompiler/vineflower.js +22 -21
- package/dist/entry-tools/analyze-mod-service.d.ts +4 -4
- package/dist/entry-tools/analyze-symbol-service.d.ts +22 -22
- package/dist/entry-tools/analyze-symbol-service.js +13 -2
- package/dist/entry-tools/inspect-minecraft-service.d.ts +168 -168
- package/dist/entry-tools/inspect-minecraft-service.js +8 -2
- package/dist/entry-tools/manage-cache-service.d.ts +4 -4
- package/dist/entry-tools/validate-project-service.d.ts +153 -16
- package/dist/entry-tools/validate-project-service.js +442 -25
- package/dist/gradle-paths.d.ts +4 -0
- package/dist/gradle-paths.js +57 -0
- package/dist/index.js +148 -30
- package/dist/lru-list.d.ts +31 -0
- package/dist/lru-list.js +102 -0
- package/dist/mapping-pipeline-service.d.ts +12 -1
- package/dist/mapping-pipeline-service.js +28 -1
- package/dist/mapping-service.d.ts +16 -0
- package/dist/mapping-service.js +405 -68
- package/dist/minecraft-explorer-service.d.ts +13 -0
- package/dist/minecraft-explorer-service.js +8 -4
- package/dist/mixin-validator.d.ts +33 -2
- package/dist/mixin-validator.js +218 -17
- package/dist/mod-analyzer.d.ts +1 -0
- package/dist/mod-analyzer.js +17 -1
- package/dist/mod-decompile-service.js +4 -4
- package/dist/mod-remap-service.js +1 -54
- package/dist/mod-search-service.d.ts +1 -0
- package/dist/mod-search-service.js +84 -51
- package/dist/observability.d.ts +18 -1
- package/dist/observability.js +44 -1
- package/dist/response-utils.d.ts +69 -0
- package/dist/response-utils.js +227 -0
- package/dist/source-jar-reader.d.ts +16 -0
- package/dist/source-jar-reader.js +103 -1
- package/dist/source-resolver.d.ts +9 -1
- package/dist/source-resolver.js +23 -16
- package/dist/source-service.d.ts +119 -3
- package/dist/source-service.js +1836 -218
- package/dist/storage/artifacts-repo.d.ts +4 -1
- package/dist/storage/artifacts-repo.js +33 -5
- package/dist/storage/files-repo.d.ts +0 -2
- package/dist/storage/files-repo.js +0 -11
- package/dist/storage/migrations.d.ts +1 -1
- package/dist/storage/migrations.js +10 -2
- package/dist/storage/schema.d.ts +2 -0
- package/dist/storage/schema.js +25 -0
- package/dist/tool-contract-manifest.js +8 -6
- package/dist/types.d.ts +20 -0
- package/dist/workspace-mapping-service.d.ts +13 -0
- package/dist/workspace-mapping-service.js +146 -14
- package/package.json +3 -1
package/dist/source-service.js
CHANGED
|
@@ -1,31 +1,37 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import { existsSync } from "node:fs";
|
|
3
|
-
import { readFile, writeFile } from "node:fs/promises";
|
|
4
|
-
import {
|
|
5
|
-
import { dirname, isAbsolute, resolve as resolvePath } from "node:path";
|
|
2
|
+
import { existsSync, readdirSync, statSync, unlinkSync } from "node:fs";
|
|
3
|
+
import { access, mkdir, open, readFile, rename, stat, unlink, writeFile } from "node:fs/promises";
|
|
4
|
+
import { dirname, isAbsolute, join, resolve as resolvePath } from "node:path";
|
|
6
5
|
import fastGlob from "fast-glob";
|
|
6
|
+
import { mapWithConcurrencyLimit } from "./concurrency.js";
|
|
7
7
|
import { createError, ERROR_CODES, isAppError } from "./errors.js";
|
|
8
|
-
import { loadConfig } from "./config.js";
|
|
8
|
+
import { buildArtifactAlias, loadConfig } from "./config.js";
|
|
9
9
|
import { decompileBinaryJar } from "./decompiler/vineflower.js";
|
|
10
10
|
import { resolveVineflowerJar } from "./vineflower-resolver.js";
|
|
11
|
+
import { remapJar } from "./tiny-remapper-service.js";
|
|
12
|
+
import { resolveTinyRemapperJar } from "./tiny-remapper-resolver.js";
|
|
13
|
+
import { resolveMojangTinyFile } from "./mojang-tiny-mapping-service.js";
|
|
11
14
|
import { parseCoordinate } from "./maven-resolver.js";
|
|
12
|
-
import { MinecraftExplorerService } from "./minecraft-explorer-service.js";
|
|
15
|
+
import { MinecraftExplorerService, modifierPrefix, parseFieldType, parseMethodDescriptor } from "./minecraft-explorer-service.js";
|
|
13
16
|
import { parseMixinSource } from "./mixin-parser.js";
|
|
14
17
|
import { parseAccessWidener } from "./access-widener-parser.js";
|
|
15
|
-
import {
|
|
18
|
+
import { parseAccessTransformer } from "./access-transformer-parser.js";
|
|
19
|
+
import { validateParsedMixin, refreshMixinValidationOutcome, validateParsedAccessWidener, validateParsedAccessTransformer } from "./mixin-validator.js";
|
|
16
20
|
import { resolveSourceTarget as resolveSourceTargetInternal } from "./source-resolver.js";
|
|
17
21
|
import { applyMappingPipeline } from "./mapping-pipeline-service.js";
|
|
18
22
|
import { MappingService } from "./mapping-service.js";
|
|
19
23
|
import { extractSymbolsFromSource } from "./symbols/symbol-extractor.js";
|
|
20
|
-
import { iterateJavaEntriesAsUtf8, listJavaEntries } from "./source-jar-reader.js";
|
|
24
|
+
import { detectFabricLikeInputNamespace, iterateJavaEntriesAsUtf8, listJavaEntries } from "./source-jar-reader.js";
|
|
21
25
|
import { openDatabase } from "./storage/db.js";
|
|
22
26
|
import { ArtifactsRepo } from "./storage/artifacts-repo.js";
|
|
23
27
|
import { FilesRepo } from "./storage/files-repo.js";
|
|
24
28
|
import { IndexMetaRepo } from "./storage/index-meta-repo.js";
|
|
25
29
|
import { SymbolsRepo } from "./storage/symbols-repo.js";
|
|
26
30
|
import { RuntimeMetrics } from "./observability.js";
|
|
31
|
+
import { LruList } from "./lru-list.js";
|
|
27
32
|
import { log } from "./logger.js";
|
|
28
33
|
import { normalizePathForHost } from "./path-converter.js";
|
|
34
|
+
import { buildLoaderRuntimeSearchRoots, buildVersionSourceSearchRoots, normalizeOptionalProjectPath } from "./gradle-paths.js";
|
|
29
35
|
import { createSearchHitAccumulator, decodeSearchCursor, encodeSearchCursor } from "./search-hit-accumulator.js";
|
|
30
36
|
import { WorkspaceMappingService } from "./workspace-mapping-service.js";
|
|
31
37
|
import { VersionService, isUnobfuscatedVersion } from "./version-service.js";
|
|
@@ -146,6 +152,26 @@ function sameMixinValidationProvenance(left, right) {
|
|
|
146
152
|
sameScopeFallback(left.scopeFallback, right.scopeFallback) &&
|
|
147
153
|
sameResolutionTrace(left.resolutionTrace, right.resolutionTrace));
|
|
148
154
|
}
|
|
155
|
+
function annotateValidateMixinError(err, stage) {
|
|
156
|
+
if (isAppError(err)) {
|
|
157
|
+
const existing = (err.details ?? {});
|
|
158
|
+
if (typeof existing.failedStage === "string") {
|
|
159
|
+
// A nested call already tagged this error (e.g. input-validation). Preserve it.
|
|
160
|
+
return err;
|
|
161
|
+
}
|
|
162
|
+
return createError({
|
|
163
|
+
code: err.code,
|
|
164
|
+
message: err.message,
|
|
165
|
+
details: { ...existing, failedStage: stage }
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
169
|
+
return createError({
|
|
170
|
+
code: ERROR_CODES.INTERNAL,
|
|
171
|
+
message: `validate-mixin failed during stage "${stage}": ${message}`,
|
|
172
|
+
details: { failedStage: stage }
|
|
173
|
+
});
|
|
174
|
+
}
|
|
149
175
|
const INDEX_SCHEMA_VERSION = 1;
|
|
150
176
|
const SYMBOL_KINDS = ["class", "interface", "enum", "record", "method", "field"];
|
|
151
177
|
function isSymbolKind(value) {
|
|
@@ -178,6 +204,30 @@ function hasExactVersionToken(path, version) {
|
|
|
178
204
|
?? rememberCachedRegex(VERSION_TOKEN_REGEX_CACHE, normalizedVersion, new RegExp(`(^|[^0-9a-z])${escapeRegexLiteral(normalizedVersion)}([^0-9a-z]|$)`, "i"));
|
|
179
205
|
return pattern.test(normalizedPath);
|
|
180
206
|
}
|
|
207
|
+
function inferMergedRuntimeNamespaceHint(path) {
|
|
208
|
+
const normalizedPath = normalizePathStyle(path).toLowerCase();
|
|
209
|
+
if (normalizedPath.includes("merged-intermediary-v2") ||
|
|
210
|
+
normalizedPath.includes("merged-intermediary")) {
|
|
211
|
+
return "intermediary";
|
|
212
|
+
}
|
|
213
|
+
if (normalizedPath.includes("minecraft-merged-mojang") ||
|
|
214
|
+
normalizedPath.includes("merged-mojang")) {
|
|
215
|
+
return "mojang";
|
|
216
|
+
}
|
|
217
|
+
if (normalizedPath.includes("merged-named")) {
|
|
218
|
+
return "named";
|
|
219
|
+
}
|
|
220
|
+
return undefined;
|
|
221
|
+
}
|
|
222
|
+
function runtimeJarNamespaceHintScore(hint) {
|
|
223
|
+
if (hint === "intermediary" || hint === "mojang") {
|
|
224
|
+
return 8_000;
|
|
225
|
+
}
|
|
226
|
+
if (hint === "named") {
|
|
227
|
+
return 1_000;
|
|
228
|
+
}
|
|
229
|
+
return 0;
|
|
230
|
+
}
|
|
181
231
|
function looksLikeDeobfuscatedClassName(value) {
|
|
182
232
|
const trimmed = value.trim();
|
|
183
233
|
if (!trimmed) {
|
|
@@ -204,17 +254,6 @@ function buildResolveArtifactParams(target, extra = {}) {
|
|
|
204
254
|
...extra
|
|
205
255
|
};
|
|
206
256
|
}
|
|
207
|
-
function normalizeOptionalProjectPath(projectPath) {
|
|
208
|
-
if (!projectPath) {
|
|
209
|
-
return undefined;
|
|
210
|
-
}
|
|
211
|
-
const trimmed = projectPath.trim();
|
|
212
|
-
if (!trimmed) {
|
|
213
|
-
return undefined;
|
|
214
|
-
}
|
|
215
|
-
const normalized = normalizePathForHost(trimmed, undefined, "projectPath");
|
|
216
|
-
return isAbsolute(normalized) ? normalized : resolvePath(process.cwd(), normalized);
|
|
217
|
-
}
|
|
218
257
|
function looksLikeClassSegment(name) {
|
|
219
258
|
const trimmed = name.trim();
|
|
220
259
|
return /^[A-Z_$]/.test(trimmed);
|
|
@@ -227,29 +266,6 @@ function looksLikeJvmMethodDescriptor(descriptor) {
|
|
|
227
266
|
const closing = trimmed.indexOf(")");
|
|
228
267
|
return closing > 0 && closing < trimmed.length - 1;
|
|
229
268
|
}
|
|
230
|
-
function resolveGradleUserHomePath() {
|
|
231
|
-
const configured = process.env.GRADLE_USER_HOME?.trim();
|
|
232
|
-
if (!configured) {
|
|
233
|
-
return resolvePath(homedir(), ".gradle");
|
|
234
|
-
}
|
|
235
|
-
const normalized = normalizePathForHost(configured, undefined, "GRADLE_USER_HOME");
|
|
236
|
-
return isAbsolute(normalized) ? normalized : resolvePath(process.cwd(), normalized);
|
|
237
|
-
}
|
|
238
|
-
function buildVersionSourceSearchRoots(projectPath) {
|
|
239
|
-
const roots = new Set();
|
|
240
|
-
if (projectPath) {
|
|
241
|
-
roots.add(resolvePath(projectPath, ".gradle", "loom-cache"));
|
|
242
|
-
roots.add(resolvePath(projectPath, ".gradle-user", "caches", "fabric-loom"));
|
|
243
|
-
roots.add(resolvePath(projectPath, ".gradle", "caches", "fabric-loom"));
|
|
244
|
-
const projectParent = dirname(projectPath);
|
|
245
|
-
roots.add(resolvePath(projectParent, ".gradle-user-home", "loom-cache"));
|
|
246
|
-
roots.add(resolvePath(projectParent, ".gradle-user-home", "caches", "fabric-loom"));
|
|
247
|
-
}
|
|
248
|
-
const homeGradle = resolveGradleUserHomePath();
|
|
249
|
-
roots.add(resolvePath(homeGradle, "loom-cache"));
|
|
250
|
-
roots.add(resolvePath(homeGradle, "caches", "fabric-loom"));
|
|
251
|
-
return [...roots];
|
|
252
|
-
}
|
|
253
269
|
function looksLikeMinecraftSourceArtifact(path, hasMinecraftNamespace) {
|
|
254
270
|
if (hasMinecraftNamespace) {
|
|
255
271
|
return true;
|
|
@@ -328,27 +344,6 @@ function normalizeOptionalString(value) {
|
|
|
328
344
|
const trimmed = value.trim();
|
|
329
345
|
return trimmed ? trimmed : undefined;
|
|
330
346
|
}
|
|
331
|
-
async function mapWithConcurrencyLimit(items, limit, mapper) {
|
|
332
|
-
if (items.length === 0) {
|
|
333
|
-
return [];
|
|
334
|
-
}
|
|
335
|
-
const results = new Array(items.length);
|
|
336
|
-
let nextIndex = 0;
|
|
337
|
-
const workerCount = Math.max(1, Math.min(Math.trunc(limit), items.length));
|
|
338
|
-
await Promise.all(Array.from({ length: workerCount }, async () => {
|
|
339
|
-
while (true) {
|
|
340
|
-
// Safe in Node's single-threaded event loop because no await occurs between
|
|
341
|
-
// reading and incrementing nextIndex inside this synchronous dispatch section.
|
|
342
|
-
const currentIndex = nextIndex;
|
|
343
|
-
nextIndex += 1;
|
|
344
|
-
if (currentIndex >= items.length) {
|
|
345
|
-
return;
|
|
346
|
-
}
|
|
347
|
-
results[currentIndex] = await mapper(items[currentIndex], currentIndex);
|
|
348
|
-
}
|
|
349
|
-
}));
|
|
350
|
-
return results;
|
|
351
|
-
}
|
|
352
347
|
function normalizeStrictPositiveInt(value, field) {
|
|
353
348
|
if (value == null) {
|
|
354
349
|
return undefined;
|
|
@@ -386,6 +381,15 @@ const MIXIN_PROJECT_DISCOVERY_IGNORES = [
|
|
|
386
381
|
"**/out/**",
|
|
387
382
|
"**/node_modules/**"
|
|
388
383
|
];
|
|
384
|
+
async function pathExists(filePath) {
|
|
385
|
+
try {
|
|
386
|
+
await access(filePath);
|
|
387
|
+
return true;
|
|
388
|
+
}
|
|
389
|
+
catch {
|
|
390
|
+
return false;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
389
393
|
function normalizeMapping(mapping) {
|
|
390
394
|
if (mapping == null) {
|
|
391
395
|
return "obfuscated";
|
|
@@ -422,6 +426,19 @@ function normalizeAccessWidenerNamespace(namespace) {
|
|
|
422
426
|
}
|
|
423
427
|
return undefined;
|
|
424
428
|
}
|
|
429
|
+
function normalizeAccessTransformerNamespace(namespace) {
|
|
430
|
+
const normalized = namespace?.trim().toLowerCase();
|
|
431
|
+
if (!normalized) {
|
|
432
|
+
return undefined;
|
|
433
|
+
}
|
|
434
|
+
if (normalized === "srg" || normalized === "mojang" || normalized === "obfuscated") {
|
|
435
|
+
return normalized;
|
|
436
|
+
}
|
|
437
|
+
return undefined;
|
|
438
|
+
}
|
|
439
|
+
function isSourceMappingNamespace(namespace) {
|
|
440
|
+
return namespace === "obfuscated" || namespace === "mojang" || namespace === "intermediary" || namespace === "yarn";
|
|
441
|
+
}
|
|
425
442
|
function normalizeMemberAccess(access) {
|
|
426
443
|
if (access == null) {
|
|
427
444
|
return "public";
|
|
@@ -775,11 +792,12 @@ export class SourceService {
|
|
|
775
792
|
versionDiffService;
|
|
776
793
|
modDecompileService;
|
|
777
794
|
modSearchService;
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
795
|
+
lru = new LruList();
|
|
796
|
+
cacheTotalContentBytes = 0;
|
|
797
|
+
remappedJarBytes = new Map();
|
|
798
|
+
/** In-flight binary-remap jobs keyed by remapped jar path so concurrent
|
|
799
|
+
* resolveArtifact calls for the same artifactId share a single tiny-remapper run. */
|
|
800
|
+
inflightRemaps = new Map();
|
|
783
801
|
constructor(explicitConfig, metrics = new RuntimeMetrics()) {
|
|
784
802
|
this.config = explicitConfig ?? loadConfig();
|
|
785
803
|
this.metrics = metrics;
|
|
@@ -809,7 +827,7 @@ export class SourceService {
|
|
|
809
827
|
searchedPaths.push(root);
|
|
810
828
|
let discovered = [];
|
|
811
829
|
try {
|
|
812
|
-
discovered = fastGlob.
|
|
830
|
+
discovered = await fastGlob.glob("**/*sources.jar", {
|
|
813
831
|
cwd: root,
|
|
814
832
|
absolute: true,
|
|
815
833
|
onlyFiles: true
|
|
@@ -873,6 +891,371 @@ export class SourceService {
|
|
|
873
891
|
selectedHasMinecraftNamespace: selected?.hasMinecraftNamespace
|
|
874
892
|
};
|
|
875
893
|
}
|
|
894
|
+
async discoverAccessWidenerRuntimeCandidates(input) {
|
|
895
|
+
const normalizedProjectPath = normalizeOptionalProjectPath(input.projectPath);
|
|
896
|
+
const normalizedProjectPathLower = normalizedProjectPath
|
|
897
|
+
? normalizePathStyle(normalizedProjectPath).toLowerCase()
|
|
898
|
+
: undefined;
|
|
899
|
+
const searchRoots = buildVersionSourceSearchRoots(normalizedProjectPath);
|
|
900
|
+
const searchedPaths = [];
|
|
901
|
+
const candidates = [];
|
|
902
|
+
const seen = new Set();
|
|
903
|
+
for (const root of searchRoots) {
|
|
904
|
+
searchedPaths.push(root);
|
|
905
|
+
let discovered = [];
|
|
906
|
+
try {
|
|
907
|
+
discovered = await fastGlob.glob(["**/*minecraft*.jar", "**/*merged*.jar"], {
|
|
908
|
+
cwd: root,
|
|
909
|
+
absolute: true,
|
|
910
|
+
onlyFiles: true,
|
|
911
|
+
ignore: ["**/*sources.jar", "**/node_modules/**", "**/.git/**", "**/build/**", "**/out/**"]
|
|
912
|
+
});
|
|
913
|
+
}
|
|
914
|
+
catch {
|
|
915
|
+
continue;
|
|
916
|
+
}
|
|
917
|
+
for (const candidatePath of discovered) {
|
|
918
|
+
const normalizedPath = normalizePathStyle(candidatePath);
|
|
919
|
+
if (seen.has(normalizedPath)) {
|
|
920
|
+
continue;
|
|
921
|
+
}
|
|
922
|
+
seen.add(normalizedPath);
|
|
923
|
+
const lower = normalizedPath.toLowerCase();
|
|
924
|
+
if (!lower.includes("minecraft")) {
|
|
925
|
+
continue;
|
|
926
|
+
}
|
|
927
|
+
const exactVersionMatch = hasExactVersionToken(normalizedPath, input.version);
|
|
928
|
+
const looksMerged = lower.includes("minecraft-merged") || lower.includes("/merged/") || lower.includes("-merged");
|
|
929
|
+
const namespaceHint = inferMergedRuntimeNamespaceHint(normalizedPath);
|
|
930
|
+
const appliedScope = looksMerged
|
|
931
|
+
? "merged"
|
|
932
|
+
: input.requestedScope === "loader"
|
|
933
|
+
? "merged"
|
|
934
|
+
: input.requestedScope;
|
|
935
|
+
const score = (exactVersionMatch ? 5_000 : 0) +
|
|
936
|
+
(looksMerged ? 4_000 : 0) +
|
|
937
|
+
runtimeJarNamespaceHintScore(namespaceHint) +
|
|
938
|
+
(normalizedProjectPathLower && lower.startsWith(normalizedProjectPathLower) ? 2_000 : 0) +
|
|
939
|
+
(lower.includes("loom-cache") || lower.includes("/caches/fabric-loom/") ? 500 : 0) +
|
|
940
|
+
(lower.includes("minecraft-client") || lower.includes("client") ? 100 : 0);
|
|
941
|
+
candidates.push({
|
|
942
|
+
jarPath: normalizedPath,
|
|
943
|
+
score,
|
|
944
|
+
appliedScope,
|
|
945
|
+
origin: lower.includes("loom-cache") || lower.includes("/caches/fabric-loom/")
|
|
946
|
+
? "loom-cache"
|
|
947
|
+
: "local-jar",
|
|
948
|
+
namespaceHint
|
|
949
|
+
});
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
candidates.sort((left, right) => {
|
|
953
|
+
if (right.score !== left.score) {
|
|
954
|
+
return right.score - left.score;
|
|
955
|
+
}
|
|
956
|
+
return left.jarPath.localeCompare(right.jarPath);
|
|
957
|
+
});
|
|
958
|
+
return {
|
|
959
|
+
searchedPaths,
|
|
960
|
+
candidateArtifacts: candidates.slice(0, 20).map((candidate) => candidate.jarPath),
|
|
961
|
+
selected: candidates[0]
|
|
962
|
+
};
|
|
963
|
+
}
|
|
964
|
+
async discoverAccessTransformerRuntimeCandidates(input) {
|
|
965
|
+
const normalizedProjectPath = normalizeOptionalProjectPath(input.projectPath);
|
|
966
|
+
const normalizedProjectPathLower = normalizedProjectPath
|
|
967
|
+
? normalizePathStyle(normalizedProjectPath).toLowerCase()
|
|
968
|
+
: undefined;
|
|
969
|
+
const searchRoots = buildLoaderRuntimeSearchRoots(normalizedProjectPath);
|
|
970
|
+
const searchedPaths = [];
|
|
971
|
+
const candidates = [];
|
|
972
|
+
const seen = new Set();
|
|
973
|
+
const globs = [
|
|
974
|
+
"**/*minecraft*.jar",
|
|
975
|
+
"**/*patched*.jar",
|
|
976
|
+
"**/*srg*.jar",
|
|
977
|
+
"**/*joined*.jar",
|
|
978
|
+
"**/*client-extra*.jar",
|
|
979
|
+
"**/*forge*.jar",
|
|
980
|
+
"**/*neoforge*.jar",
|
|
981
|
+
"**/*moddev*.jar",
|
|
982
|
+
"**/*neoform*.jar"
|
|
983
|
+
];
|
|
984
|
+
for (const root of searchRoots) {
|
|
985
|
+
searchedPaths.push(root);
|
|
986
|
+
if (!(await pathExists(root))) {
|
|
987
|
+
continue;
|
|
988
|
+
}
|
|
989
|
+
let discovered = [];
|
|
990
|
+
try {
|
|
991
|
+
discovered = await fastGlob.glob(globs, {
|
|
992
|
+
cwd: root,
|
|
993
|
+
absolute: true,
|
|
994
|
+
onlyFiles: true,
|
|
995
|
+
ignore: ["**/*sources.jar", "**/node_modules/**", "**/.git/**", "**/out/**"]
|
|
996
|
+
});
|
|
997
|
+
}
|
|
998
|
+
catch {
|
|
999
|
+
continue;
|
|
1000
|
+
}
|
|
1001
|
+
for (const candidatePath of discovered) {
|
|
1002
|
+
const normalizedPath = normalizePathStyle(candidatePath);
|
|
1003
|
+
if (seen.has(normalizedPath)) {
|
|
1004
|
+
continue;
|
|
1005
|
+
}
|
|
1006
|
+
seen.add(normalizedPath);
|
|
1007
|
+
const lower = normalizedPath.toLowerCase();
|
|
1008
|
+
if (!hasExactVersionToken(normalizedPath, input.version)) {
|
|
1009
|
+
continue;
|
|
1010
|
+
}
|
|
1011
|
+
const looksMerged = lower.includes("merged");
|
|
1012
|
+
const looksSrg = lower.includes("srg");
|
|
1013
|
+
const looksForge = lower.includes("forge");
|
|
1014
|
+
const looksNeoForge = lower.includes("neoforge") || lower.includes("moddev") || lower.includes("neoform");
|
|
1015
|
+
const looksPatchedRuntime = lower.includes("patched") || lower.includes("client-extra") || lower.includes("joined");
|
|
1016
|
+
const appliedScope = looksMerged
|
|
1017
|
+
? "merged"
|
|
1018
|
+
: "loader";
|
|
1019
|
+
if (input.atNamespace === "srg" && !looksSrg) {
|
|
1020
|
+
continue;
|
|
1021
|
+
}
|
|
1022
|
+
if (input.loader === "forge" && !looksForge && !looksSrg && !looksPatchedRuntime) {
|
|
1023
|
+
continue;
|
|
1024
|
+
}
|
|
1025
|
+
if (input.loader === "neoforge" && !looksNeoForge && !looksPatchedRuntime && !lower.includes("minecraft")) {
|
|
1026
|
+
continue;
|
|
1027
|
+
}
|
|
1028
|
+
const score = 10_000 +
|
|
1029
|
+
(normalizedProjectPathLower && lower.startsWith(normalizedProjectPathLower) ? 4_000 : 0) +
|
|
1030
|
+
(looksPatchedRuntime ? 3_000 : 0) +
|
|
1031
|
+
(looksSrg ? 2_500 : 0) +
|
|
1032
|
+
(input.loader === "forge" && looksForge ? 1_500 : 0) +
|
|
1033
|
+
(input.loader === "neoforge" && looksNeoForge ? 1_500 : 0) +
|
|
1034
|
+
(input.requestedScope === appliedScope ? 1_000 : 0) +
|
|
1035
|
+
(looksMerged ? -500 : 0);
|
|
1036
|
+
candidates.push({
|
|
1037
|
+
jarPath: normalizedPath,
|
|
1038
|
+
score,
|
|
1039
|
+
appliedScope,
|
|
1040
|
+
origin: "local-jar"
|
|
1041
|
+
});
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
candidates.sort((left, right) => {
|
|
1045
|
+
if (right.score !== left.score) {
|
|
1046
|
+
return right.score - left.score;
|
|
1047
|
+
}
|
|
1048
|
+
return left.jarPath.localeCompare(right.jarPath);
|
|
1049
|
+
});
|
|
1050
|
+
return {
|
|
1051
|
+
searchedPaths,
|
|
1052
|
+
candidateArtifacts: candidates.slice(0, 20).map((candidate) => candidate.jarPath),
|
|
1053
|
+
selected: candidates[0]
|
|
1054
|
+
};
|
|
1055
|
+
}
|
|
1056
|
+
async resolveAccessWidenerRuntimeArtifact(input) {
|
|
1057
|
+
const normalizedProjectPath = normalizeOptionalProjectPath(input.projectPath);
|
|
1058
|
+
let version = input.version;
|
|
1059
|
+
if (input.preferProjectVersion && normalizedProjectPath) {
|
|
1060
|
+
const detected = await this.workspaceMappingService.detectProjectMinecraftVersion(normalizedProjectPath);
|
|
1061
|
+
version = detected ?? version;
|
|
1062
|
+
}
|
|
1063
|
+
const requestedScope = input.scope ?? (normalizedProjectPath ? "loader" : "vanilla");
|
|
1064
|
+
if (requestedScope === "vanilla") {
|
|
1065
|
+
const versionJar = await this.versionService.resolveVersionJar(version);
|
|
1066
|
+
return {
|
|
1067
|
+
version: versionJar.version,
|
|
1068
|
+
jarPath: versionJar.jarPath,
|
|
1069
|
+
requestedScope,
|
|
1070
|
+
appliedScope: "vanilla",
|
|
1071
|
+
requestedMapping: input.awNamespace,
|
|
1072
|
+
mappingApplied: "obfuscated",
|
|
1073
|
+
origin: "version-jar"
|
|
1074
|
+
};
|
|
1075
|
+
}
|
|
1076
|
+
const discovery = await this.discoverAccessWidenerRuntimeCandidates({
|
|
1077
|
+
version,
|
|
1078
|
+
projectPath: normalizedProjectPath,
|
|
1079
|
+
requestedScope
|
|
1080
|
+
});
|
|
1081
|
+
if (!discovery.selected) {
|
|
1082
|
+
throw createError({
|
|
1083
|
+
code: ERROR_CODES.CONTEXT_UNRESOLVED,
|
|
1084
|
+
message: "Could not resolve a runtime jar for Access Widener validation.",
|
|
1085
|
+
details: {
|
|
1086
|
+
version,
|
|
1087
|
+
requestedScope,
|
|
1088
|
+
projectPath: normalizedProjectPath,
|
|
1089
|
+
searchedPaths: discovery.searchedPaths,
|
|
1090
|
+
candidateArtifacts: discovery.candidateArtifacts,
|
|
1091
|
+
nextAction: "Provide projectPath for a Loom workspace with generated runtime jars, or run Gradle tasks that populate the Loom cache before retrying.",
|
|
1092
|
+
suggestedCall: {
|
|
1093
|
+
tool: "validate-access-widener",
|
|
1094
|
+
params: {
|
|
1095
|
+
version,
|
|
1096
|
+
scope: requestedScope,
|
|
1097
|
+
...(normalizedProjectPath ? { projectPath: normalizedProjectPath } : {})
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
});
|
|
1102
|
+
}
|
|
1103
|
+
const appliedScope = discovery.selected.appliedScope;
|
|
1104
|
+
const scopeFallback = requestedScope !== appliedScope
|
|
1105
|
+
? {
|
|
1106
|
+
requested: requestedScope,
|
|
1107
|
+
applied: appliedScope,
|
|
1108
|
+
reason: requestedScope === "loader"
|
|
1109
|
+
? "Fabric loader runtime validation currently reuses the merged runtime jar."
|
|
1110
|
+
: "Selected runtime jar matched a nearby merged artifact."
|
|
1111
|
+
}
|
|
1112
|
+
: undefined;
|
|
1113
|
+
let detectedMapping;
|
|
1114
|
+
const notes = [];
|
|
1115
|
+
if (scopeFallback) {
|
|
1116
|
+
notes.push(scopeFallback.reason);
|
|
1117
|
+
}
|
|
1118
|
+
if (isUnobfuscatedVersion(version)) {
|
|
1119
|
+
detectedMapping = "obfuscated";
|
|
1120
|
+
}
|
|
1121
|
+
else if (discovery.selected.namespaceHint === "intermediary" ||
|
|
1122
|
+
discovery.selected.namespaceHint === "mojang") {
|
|
1123
|
+
detectedMapping = discovery.selected.namespaceHint;
|
|
1124
|
+
}
|
|
1125
|
+
else {
|
|
1126
|
+
const detection = await detectFabricLikeInputNamespace(discovery.selected.jarPath);
|
|
1127
|
+
detectedMapping = detection.fromNamespace;
|
|
1128
|
+
if (detection.warnings.length > 0) {
|
|
1129
|
+
notes.push(...detection.warnings);
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
return {
|
|
1133
|
+
version,
|
|
1134
|
+
jarPath: discovery.selected.jarPath,
|
|
1135
|
+
requestedScope,
|
|
1136
|
+
appliedScope,
|
|
1137
|
+
requestedMapping: input.awNamespace,
|
|
1138
|
+
mappingApplied: detectedMapping,
|
|
1139
|
+
origin: discovery.selected.origin,
|
|
1140
|
+
resolutionNotes: notes.length > 0 ? notes : undefined,
|
|
1141
|
+
scopeFallback
|
|
1142
|
+
};
|
|
1143
|
+
}
|
|
1144
|
+
async resolveAccessTransformerNamespace(input) {
|
|
1145
|
+
const explicit = normalizeAccessTransformerNamespace(input.atNamespace);
|
|
1146
|
+
if (explicit) {
|
|
1147
|
+
return explicit;
|
|
1148
|
+
}
|
|
1149
|
+
const normalizedProjectPath = normalizeOptionalProjectPath(input.projectPath);
|
|
1150
|
+
if (!normalizedProjectPath) {
|
|
1151
|
+
throw createError({
|
|
1152
|
+
code: ERROR_CODES.INVALID_INPUT,
|
|
1153
|
+
message: "atNamespace is required when projectPath is not provided.",
|
|
1154
|
+
details: {
|
|
1155
|
+
nextAction: "Pass atNamespace explicitly, or provide projectPath for a Forge/NeoForge workspace so the namespace can be inferred."
|
|
1156
|
+
}
|
|
1157
|
+
});
|
|
1158
|
+
}
|
|
1159
|
+
const loaderDetection = await this.workspaceMappingService.detectProjectLoader(normalizedProjectPath);
|
|
1160
|
+
if (loaderDetection.resolved && loaderDetection.loader === "forge") {
|
|
1161
|
+
return "srg";
|
|
1162
|
+
}
|
|
1163
|
+
if (loaderDetection.resolved && loaderDetection.loader === "neoforge") {
|
|
1164
|
+
return "mojang";
|
|
1165
|
+
}
|
|
1166
|
+
throw createError({
|
|
1167
|
+
code: ERROR_CODES.INVALID_INPUT,
|
|
1168
|
+
message: "Could not infer atNamespace from the workspace.",
|
|
1169
|
+
details: {
|
|
1170
|
+
projectPath: normalizedProjectPath,
|
|
1171
|
+
evidence: loaderDetection.evidence,
|
|
1172
|
+
warnings: loaderDetection.warnings,
|
|
1173
|
+
nextAction: "Pass atNamespace explicitly, or point projectPath at a Forge/NeoForge workspace."
|
|
1174
|
+
}
|
|
1175
|
+
});
|
|
1176
|
+
}
|
|
1177
|
+
async resolveAccessTransformerRuntimeArtifact(input) {
|
|
1178
|
+
const normalizedProjectPath = normalizeOptionalProjectPath(input.projectPath);
|
|
1179
|
+
let version = input.version;
|
|
1180
|
+
if (input.preferProjectVersion && normalizedProjectPath) {
|
|
1181
|
+
const detected = await this.workspaceMappingService.detectProjectMinecraftVersion(normalizedProjectPath);
|
|
1182
|
+
version = detected ?? version;
|
|
1183
|
+
}
|
|
1184
|
+
const requestedScope = input.scope ?? (normalizedProjectPath ? "loader" : "vanilla");
|
|
1185
|
+
if (requestedScope === "vanilla") {
|
|
1186
|
+
if (input.atNamespace === "srg") {
|
|
1187
|
+
throw createError({
|
|
1188
|
+
code: ERROR_CODES.INVALID_INPUT,
|
|
1189
|
+
message: "atNamespace=srg requires projectPath and scope=loader so a Forge runtime jar can be resolved."
|
|
1190
|
+
});
|
|
1191
|
+
}
|
|
1192
|
+
const versionJar = await this.versionService.resolveVersionJar(version);
|
|
1193
|
+
return {
|
|
1194
|
+
version: versionJar.version,
|
|
1195
|
+
jarPath: versionJar.jarPath,
|
|
1196
|
+
requestedScope,
|
|
1197
|
+
appliedScope: "vanilla",
|
|
1198
|
+
requestedMapping: input.atNamespace,
|
|
1199
|
+
mappingApplied: "obfuscated",
|
|
1200
|
+
origin: "version-jar"
|
|
1201
|
+
};
|
|
1202
|
+
}
|
|
1203
|
+
const loaderDetection = normalizedProjectPath
|
|
1204
|
+
? await this.workspaceMappingService.detectProjectLoader(normalizedProjectPath)
|
|
1205
|
+
: { resolved: false, loader: undefined, evidence: [], warnings: [] };
|
|
1206
|
+
const loader = loaderDetection.resolved ? loaderDetection.loader ?? "unknown" : "unknown";
|
|
1207
|
+
const discovery = await this.discoverAccessTransformerRuntimeCandidates({
|
|
1208
|
+
version,
|
|
1209
|
+
projectPath: normalizedProjectPath,
|
|
1210
|
+
requestedScope,
|
|
1211
|
+
atNamespace: input.atNamespace,
|
|
1212
|
+
loader
|
|
1213
|
+
});
|
|
1214
|
+
if (!discovery.selected) {
|
|
1215
|
+
throw createError({
|
|
1216
|
+
code: ERROR_CODES.CONTEXT_UNRESOLVED,
|
|
1217
|
+
message: "Could not resolve a runtime jar for Access Transformer validation.",
|
|
1218
|
+
details: {
|
|
1219
|
+
version,
|
|
1220
|
+
requestedScope,
|
|
1221
|
+
atNamespace: input.atNamespace,
|
|
1222
|
+
projectPath: normalizedProjectPath,
|
|
1223
|
+
searchedPaths: discovery.searchedPaths,
|
|
1224
|
+
candidateArtifacts: discovery.candidateArtifacts,
|
|
1225
|
+
loaderEvidence: loaderDetection.evidence,
|
|
1226
|
+
loaderWarnings: loaderDetection.warnings,
|
|
1227
|
+
nextAction: "Provide projectPath for a Forge/NeoForge workspace with generated runtime jars, or run the Gradle tasks that populate transformed runtime artifacts before retrying."
|
|
1228
|
+
}
|
|
1229
|
+
});
|
|
1230
|
+
}
|
|
1231
|
+
const selected = discovery.selected;
|
|
1232
|
+
const selectedLower = selected.jarPath.toLowerCase();
|
|
1233
|
+
const mappingApplied = input.atNamespace === "srg" || selectedLower.includes("srg")
|
|
1234
|
+
? "srg"
|
|
1235
|
+
: loader === "neoforge" || selectedLower.includes("moddev") || selectedLower.includes("neoforge")
|
|
1236
|
+
? "mojang"
|
|
1237
|
+
: "obfuscated";
|
|
1238
|
+
const scopeFallback = requestedScope !== selected.appliedScope
|
|
1239
|
+
? {
|
|
1240
|
+
requested: requestedScope,
|
|
1241
|
+
applied: selected.appliedScope,
|
|
1242
|
+
reason: selected.appliedScope === "merged"
|
|
1243
|
+
? "Resolved a nearby merged runtime jar because no transformed loader artifact was available."
|
|
1244
|
+
: "Resolved the closest transformed runtime artifact for validation."
|
|
1245
|
+
}
|
|
1246
|
+
: undefined;
|
|
1247
|
+
return {
|
|
1248
|
+
version,
|
|
1249
|
+
jarPath: selected.jarPath,
|
|
1250
|
+
requestedScope,
|
|
1251
|
+
appliedScope: selected.appliedScope,
|
|
1252
|
+
requestedMapping: input.atNamespace,
|
|
1253
|
+
mappingApplied,
|
|
1254
|
+
origin: selected.origin,
|
|
1255
|
+
resolutionNotes: scopeFallback ? [scopeFallback.reason] : undefined,
|
|
1256
|
+
scopeFallback
|
|
1257
|
+
};
|
|
1258
|
+
}
|
|
876
1259
|
buildVersionSourceRecoveryCommand(projectPath) {
|
|
877
1260
|
const normalizedProjectPath = normalizeOptionalProjectPath(projectPath);
|
|
878
1261
|
const prefix = normalizedProjectPath
|
|
@@ -880,6 +1263,91 @@ export class SourceService {
|
|
|
880
1263
|
: "";
|
|
881
1264
|
return `${prefix}./gradlew genSources --no-daemon`;
|
|
882
1265
|
}
|
|
1266
|
+
/**
|
|
1267
|
+
* Decide whether the upcoming resolveArtifact call may transparently remap a
|
|
1268
|
+
* binary-only artifact (obfuscated -> mojang) and decompile it. The gate
|
|
1269
|
+
* succeeds only when:
|
|
1270
|
+
* - the requested mapping is "mojang" on a still-obfuscated runtime
|
|
1271
|
+
* - tiny-remapper jar is downloadable / available locally
|
|
1272
|
+
* - the version's Mojang tiny mapping file can be produced
|
|
1273
|
+
* - checkMappingHealth reports mojang mappings are usable
|
|
1274
|
+
* On any failure the variant defaults to "pass" so the legacy
|
|
1275
|
+
* MAPPING_NOT_APPLIED fallback (or the existing source-backed flow) keeps
|
|
1276
|
+
* its existing artifactId hash.
|
|
1277
|
+
*/
|
|
1278
|
+
async computeBinaryRemapGate(input) {
|
|
1279
|
+
const baseline = {
|
|
1280
|
+
allowBinaryRemap: false,
|
|
1281
|
+
mappingVariant: "pass",
|
|
1282
|
+
warnings: []
|
|
1283
|
+
};
|
|
1284
|
+
if (input.requestedMapping !== "mojang" ||
|
|
1285
|
+
input.runtimeNamesUnobfuscated ||
|
|
1286
|
+
!input.version) {
|
|
1287
|
+
return baseline;
|
|
1288
|
+
}
|
|
1289
|
+
// The Mojang tiny mapping file is only valid for vanilla Minecraft client/server jars.
|
|
1290
|
+
// For coordinate / jar inputs the resolver cannot prove the artifact identity, so
|
|
1291
|
+
// applying Minecraft mappings to an unrelated library/mod jar would produce a
|
|
1292
|
+
// "successful" but corrupted artifact instead of the safe MAPPING_NOT_APPLIED reject.
|
|
1293
|
+
// Only target.kind="version" goes through versionService.resolveVersionJar with a
|
|
1294
|
+
// verified Minecraft download URL, so we restrict the gate to that input shape.
|
|
1295
|
+
if (input.targetKind !== "version") {
|
|
1296
|
+
return baseline;
|
|
1297
|
+
}
|
|
1298
|
+
let tinyRemapperJarPath;
|
|
1299
|
+
try {
|
|
1300
|
+
tinyRemapperJarPath = await resolveTinyRemapperJar(this.config.cacheDir, this.config.tinyRemapperJarPath);
|
|
1301
|
+
}
|
|
1302
|
+
catch (caughtError) {
|
|
1303
|
+
log("warn", "binary-remap.gate.tiny-remapper-unavailable", {
|
|
1304
|
+
version: input.version,
|
|
1305
|
+
error: caughtError instanceof Error ? caughtError.message : String(caughtError)
|
|
1306
|
+
});
|
|
1307
|
+
return baseline;
|
|
1308
|
+
}
|
|
1309
|
+
let mojangTinyFilePath;
|
|
1310
|
+
try {
|
|
1311
|
+
const mojangTiny = await resolveMojangTinyFile(input.version, this.config);
|
|
1312
|
+
mojangTinyFilePath = mojangTiny.path;
|
|
1313
|
+
if (mojangTiny.warnings.length > 0) {
|
|
1314
|
+
baseline.warnings.push(...mojangTiny.warnings);
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
catch (caughtError) {
|
|
1318
|
+
log("warn", "binary-remap.gate.mojang-tiny-unavailable", {
|
|
1319
|
+
version: input.version,
|
|
1320
|
+
error: caughtError instanceof Error ? caughtError.message : String(caughtError)
|
|
1321
|
+
});
|
|
1322
|
+
return baseline;
|
|
1323
|
+
}
|
|
1324
|
+
let mojangAvailable = false;
|
|
1325
|
+
try {
|
|
1326
|
+
const health = await this.mappingService.checkMappingHealth({
|
|
1327
|
+
version: input.version,
|
|
1328
|
+
requestedMapping: "mojang",
|
|
1329
|
+
sourcePriority: input.sourcePriority
|
|
1330
|
+
});
|
|
1331
|
+
mojangAvailable = health.mojangMappingsAvailable;
|
|
1332
|
+
}
|
|
1333
|
+
catch (caughtError) {
|
|
1334
|
+
log("warn", "binary-remap.gate.health-check-failed", {
|
|
1335
|
+
version: input.version,
|
|
1336
|
+
error: caughtError instanceof Error ? caughtError.message : String(caughtError)
|
|
1337
|
+
});
|
|
1338
|
+
return baseline;
|
|
1339
|
+
}
|
|
1340
|
+
if (!mojangAvailable) {
|
|
1341
|
+
return baseline;
|
|
1342
|
+
}
|
|
1343
|
+
return {
|
|
1344
|
+
allowBinaryRemap: true,
|
|
1345
|
+
mappingVariant: "mojang-remapped",
|
|
1346
|
+
tinyRemapperJarPath,
|
|
1347
|
+
mojangTinyFilePath,
|
|
1348
|
+
warnings: baseline.warnings
|
|
1349
|
+
};
|
|
1350
|
+
}
|
|
883
1351
|
buildArtifactContentsSummary(input) {
|
|
884
1352
|
const sourceKind = input.isDecompiled || input.origin === "decompiled" || !normalizeOptionalString(input.sourceJarPath)
|
|
885
1353
|
? "decompiled-binary"
|
|
@@ -972,9 +1440,11 @@ export class SourceService {
|
|
|
972
1440
|
let resolvedTarget = { kind, value };
|
|
973
1441
|
let resolvedVersion;
|
|
974
1442
|
let versionSourceDiscovery;
|
|
1443
|
+
let runtimeNamesUnobfuscated = false;
|
|
975
1444
|
if (kind === "version") {
|
|
976
1445
|
const versionJar = await this.versionService.resolveVersionJar(value);
|
|
977
1446
|
resolvedVersion = versionJar.version;
|
|
1447
|
+
runtimeNamesUnobfuscated = isUnobfuscatedVersion(resolvedVersion);
|
|
978
1448
|
resolvedTarget = {
|
|
979
1449
|
kind: "jar",
|
|
980
1450
|
value: versionJar.jarPath
|
|
@@ -989,6 +1459,9 @@ export class SourceService {
|
|
|
989
1459
|
// coordinate validity is validated by resolver, keep version undefined on parse failure.
|
|
990
1460
|
}
|
|
991
1461
|
}
|
|
1462
|
+
if (!runtimeNamesUnobfuscated && resolvedVersion && isUnobfuscatedVersion(resolvedVersion)) {
|
|
1463
|
+
runtimeNamesUnobfuscated = true;
|
|
1464
|
+
}
|
|
992
1465
|
// Unobfuscated versions (MC 26.1+) ship with deobfuscated runtime names; intermediary/yarn are not applicable.
|
|
993
1466
|
let effectiveMapping = mapping;
|
|
994
1467
|
if ((mapping === "intermediary" || mapping === "yarn") &&
|
|
@@ -997,7 +1470,11 @@ export class SourceService {
|
|
|
997
1470
|
warnings.push(`Version ${resolvedVersion} is unobfuscated; ${mapping} mappings are not applicable. Using the obfuscated namespace label for the deobfuscated runtime names.`);
|
|
998
1471
|
effectiveMapping = "obfuscated";
|
|
999
1472
|
}
|
|
1000
|
-
if (kind === "version" &&
|
|
1473
|
+
if (kind === "version" &&
|
|
1474
|
+
resolvedVersion &&
|
|
1475
|
+
effectiveMapping === "mojang" &&
|
|
1476
|
+
!runtimeNamesUnobfuscated &&
|
|
1477
|
+
scope !== "vanilla") {
|
|
1001
1478
|
versionSourceDiscovery = await this.discoverVersionSourceJar({
|
|
1002
1479
|
version: resolvedVersion,
|
|
1003
1480
|
projectPath: input.projectPath
|
|
@@ -1010,10 +1487,29 @@ export class SourceService {
|
|
|
1010
1487
|
warnings.push(`Resolved source-backed artifact from Loom cache candidate: ${versionSourceDiscovery.selectedSourceJarPath}.`);
|
|
1011
1488
|
}
|
|
1012
1489
|
}
|
|
1490
|
+
// The mojang binary-remap gate is only relevant when no source jar has been pre-selected.
|
|
1491
|
+
// If discoverVersionSourceJar already chose a source jar, the resolver will return a
|
|
1492
|
+
// source-backed artifact and applyMappingPipeline will take the source-backed branch,
|
|
1493
|
+
// which never consults allowBinaryRemap. Skipping the gate avoids paying for tiny-remapper
|
|
1494
|
+
// download / mojang tiny generation / mapping-health probe on healthy source-backed paths.
|
|
1495
|
+
const sourceJarPreSelected = Boolean(versionSourceDiscovery?.selectedSourceJarPath);
|
|
1496
|
+
const binaryRemapGate = sourceJarPreSelected
|
|
1497
|
+
? { allowBinaryRemap: false, mappingVariant: "pass", warnings: [] }
|
|
1498
|
+
: await this.computeBinaryRemapGate({
|
|
1499
|
+
requestedMapping: effectiveMapping,
|
|
1500
|
+
runtimeNamesUnobfuscated,
|
|
1501
|
+
version: resolvedVersion,
|
|
1502
|
+
targetKind: kind,
|
|
1503
|
+
sourcePriority: input.sourcePriority
|
|
1504
|
+
});
|
|
1505
|
+
if (binaryRemapGate.warnings.length > 0) {
|
|
1506
|
+
warnings.push(...binaryRemapGate.warnings);
|
|
1507
|
+
}
|
|
1013
1508
|
const resolved = await resolveSourceTargetInternal(resolvedTarget, {
|
|
1014
1509
|
// mojang requires source-backed artifact guarantee; force resolution to consider decompile candidate
|
|
1015
1510
|
// and reject later if mapping cannot be applied.
|
|
1016
1511
|
allowDecompile: effectiveMapping === "mojang" ? true : input.allowDecompile ?? true,
|
|
1512
|
+
mappingVariant: binaryRemapGate.mappingVariant,
|
|
1017
1513
|
onRepoFailover: (event) => {
|
|
1018
1514
|
this.metrics.recordRepoFailover();
|
|
1019
1515
|
log("warn", "repo.failover", {
|
|
@@ -1032,7 +1528,9 @@ export class SourceService {
|
|
|
1032
1528
|
mappingDecision = applyMappingPipeline({
|
|
1033
1529
|
requestedMapping: effectiveMapping,
|
|
1034
1530
|
target: { kind, value },
|
|
1035
|
-
resolved
|
|
1531
|
+
resolved,
|
|
1532
|
+
runtimeNamesUnobfuscated,
|
|
1533
|
+
allowBinaryRemap: binaryRemapGate.allowBinaryRemap
|
|
1036
1534
|
});
|
|
1037
1535
|
}
|
|
1038
1536
|
catch (caughtError) {
|
|
@@ -1149,9 +1647,29 @@ export class SourceService {
|
|
|
1149
1647
|
}
|
|
1150
1648
|
}
|
|
1151
1649
|
resolved.qualityFlags = dedupeQualityFlags(resolved.qualityFlags);
|
|
1650
|
+
// Use the resolver's canonical path (already normalizeJarPath-applied)
|
|
1651
|
+
// for the readable jar token. Without this, two requests for the same
|
|
1652
|
+
// artifact via a symlink path and the real path would share artifactId
|
|
1653
|
+
// but produce different alias readable tokens, and setAlias rotation
|
|
1654
|
+
// on the warm-cache hit would invalidate the alias returned to the
|
|
1655
|
+
// earlier caller. Coordinate / version paths are already canonical:
|
|
1656
|
+
// resolved.coordinate is normalized inside the resolver and
|
|
1657
|
+
// resolvedVersion comes from versionService.resolveVersionJar().
|
|
1658
|
+
const aliasValue = kind === "jar"
|
|
1659
|
+
? (resolved.sourceJarPath ?? resolved.binaryJarPath ?? value)
|
|
1660
|
+
: value;
|
|
1661
|
+
const artifactAlias = buildArtifactAlias({
|
|
1662
|
+
artifactId: resolved.artifactId,
|
|
1663
|
+
kind,
|
|
1664
|
+
value: aliasValue,
|
|
1665
|
+
mappingVariant: binaryRemapGate.mappingVariant,
|
|
1666
|
+
resolvedVersion: resolvedVersion ?? resolved.version,
|
|
1667
|
+
coordinate: resolved.coordinate
|
|
1668
|
+
});
|
|
1669
|
+
resolved.artifactAlias = artifactAlias;
|
|
1152
1670
|
await this.ingestIfNeeded(resolved);
|
|
1153
1671
|
let sampleEntries;
|
|
1154
|
-
if (resolved.sourceJarPath) {
|
|
1672
|
+
if (input.compact === false && resolved.sourceJarPath) {
|
|
1155
1673
|
try {
|
|
1156
1674
|
const javaEntries = await listJavaEntries(resolved.sourceJarPath);
|
|
1157
1675
|
const MAX_SAMPLE = 10;
|
|
@@ -1166,6 +1684,7 @@ export class SourceService {
|
|
|
1166
1684
|
}
|
|
1167
1685
|
return {
|
|
1168
1686
|
artifactId: resolved.artifactId,
|
|
1687
|
+
artifactAlias,
|
|
1169
1688
|
origin: resolved.origin,
|
|
1170
1689
|
isDecompiled: resolved.isDecompiled,
|
|
1171
1690
|
resolvedSourceJarPath: resolved.sourceJarPath,
|
|
@@ -1210,8 +1729,8 @@ export class SourceService {
|
|
|
1210
1729
|
const startedAt = Date.now();
|
|
1211
1730
|
try {
|
|
1212
1731
|
const artifact = this.getArtifact(input.artifactId);
|
|
1213
|
-
const
|
|
1214
|
-
if (!
|
|
1732
|
+
const originalQuery = input.query.trim();
|
|
1733
|
+
if (!originalQuery) {
|
|
1215
1734
|
return {
|
|
1216
1735
|
hits: [],
|
|
1217
1736
|
mappingApplied: artifact.mappingApplied ?? "obfuscated",
|
|
@@ -1226,8 +1745,82 @@ export class SourceService {
|
|
|
1226
1745
|
}
|
|
1227
1746
|
const intent = normalizeIntent(input.intent);
|
|
1228
1747
|
const match = normalizeMatch(input.match);
|
|
1229
|
-
|
|
1230
|
-
|
|
1748
|
+
const artifactMapping = artifact.mappingApplied ?? "obfuscated";
|
|
1749
|
+
const searchWarnings = [];
|
|
1750
|
+
let translatedInfo;
|
|
1751
|
+
let query = originalQuery;
|
|
1752
|
+
let translationPackagePrefix;
|
|
1753
|
+
if (input.queryNamespace
|
|
1754
|
+
&& input.queryNamespace !== artifactMapping
|
|
1755
|
+
&& !artifact.version) {
|
|
1756
|
+
searchWarnings.push(`queryNamespace=${input.queryNamespace} could not be applied because the artifact has no version recorded; namespace translation requires a version. Running literal search in ${artifactMapping} instead.`);
|
|
1757
|
+
}
|
|
1758
|
+
if (input.queryNamespace
|
|
1759
|
+
&& input.queryNamespace !== artifactMapping
|
|
1760
|
+
&& artifact.version) {
|
|
1761
|
+
if (intent === "symbol" && originalQuery.includes(".") && /^[\w.$]+$/.test(originalQuery)) {
|
|
1762
|
+
try {
|
|
1763
|
+
const translated = await this.mappingService.findMapping({
|
|
1764
|
+
version: artifact.version,
|
|
1765
|
+
kind: "class",
|
|
1766
|
+
name: originalQuery,
|
|
1767
|
+
sourceMapping: input.queryNamespace,
|
|
1768
|
+
targetMapping: artifactMapping,
|
|
1769
|
+
sourcePriority: input.sourcePriority,
|
|
1770
|
+
signatureMode: "name-only",
|
|
1771
|
+
maxCandidates: 5
|
|
1772
|
+
});
|
|
1773
|
+
if (translated.resolved === true && translated.resolvedSymbol) {
|
|
1774
|
+
const resolvedName = translated.resolvedSymbol.symbol
|
|
1775
|
+
?? translated.resolvedSymbol.name;
|
|
1776
|
+
if (resolvedName && resolvedName !== originalQuery) {
|
|
1777
|
+
translatedInfo = {
|
|
1778
|
+
original: originalQuery,
|
|
1779
|
+
translated: resolvedName,
|
|
1780
|
+
fromNamespace: input.queryNamespace,
|
|
1781
|
+
toNamespace: artifactMapping
|
|
1782
|
+
};
|
|
1783
|
+
// Downstream symbol search matches on simpleName only, so split
|
|
1784
|
+
// the translated FQCN into simpleName + derived packagePrefix
|
|
1785
|
+
// scope. Preserve a caller-supplied packagePrefix if present.
|
|
1786
|
+
if (resolvedName.includes(".")) {
|
|
1787
|
+
const lastDot = resolvedName.lastIndexOf(".");
|
|
1788
|
+
const simpleName = resolvedName.slice(lastDot + 1);
|
|
1789
|
+
const packagePart = resolvedName.slice(0, lastDot);
|
|
1790
|
+
query = simpleName;
|
|
1791
|
+
if (!input.scope?.packagePrefix) {
|
|
1792
|
+
translationPackagePrefix = packagePart;
|
|
1793
|
+
}
|
|
1794
|
+
}
|
|
1795
|
+
else {
|
|
1796
|
+
query = resolvedName;
|
|
1797
|
+
}
|
|
1798
|
+
}
|
|
1799
|
+
}
|
|
1800
|
+
else if (translated.status === "ambiguous") {
|
|
1801
|
+
const candidateCount = translated.candidateCount ?? translated.candidates?.length ?? 0;
|
|
1802
|
+
searchWarnings.push(`queryNamespace=${input.queryNamespace}: translation for "${originalQuery}" was ambiguous (${candidateCount} candidates); running literal search instead. Narrow the query with a more specific FQCN or call find-mapping directly.`);
|
|
1803
|
+
}
|
|
1804
|
+
else if (translated.status === "not_found") {
|
|
1805
|
+
searchWarnings.push(`queryNamespace=${input.queryNamespace}: no ${artifactMapping} mapping found for "${originalQuery}"; running literal search instead.`);
|
|
1806
|
+
}
|
|
1807
|
+
else if (translated.status === "mapping_unavailable") {
|
|
1808
|
+
searchWarnings.push(`queryNamespace=${input.queryNamespace}: mapping data unavailable for version ${artifact.version}; running literal search instead.`);
|
|
1809
|
+
}
|
|
1810
|
+
else {
|
|
1811
|
+
searchWarnings.push(`queryNamespace=${input.queryNamespace}: could not translate "${originalQuery}" to ${artifactMapping}; running literal search instead.`);
|
|
1812
|
+
}
|
|
1813
|
+
}
|
|
1814
|
+
catch (caughtError) {
|
|
1815
|
+
searchWarnings.push(`queryNamespace=${input.queryNamespace}: translation failed (${caughtError instanceof Error ? caughtError.message : String(caughtError)}); running literal search instead.`);
|
|
1816
|
+
}
|
|
1817
|
+
}
|
|
1818
|
+
else if (intent === "text" || intent === "path") {
|
|
1819
|
+
searchWarnings.push(`queryNamespace=${input.queryNamespace} has no effect when intent="${intent}" — ${intent} search is a literal match against the artifact's ${artifactMapping} index. Use intent="symbol" for namespace translation.`);
|
|
1820
|
+
}
|
|
1821
|
+
}
|
|
1822
|
+
if (match === "regex" && query.length > MAX_REGEX_QUERY_LENGTH) {
|
|
1823
|
+
throw createError({
|
|
1231
1824
|
code: ERROR_CODES.INVALID_INPUT,
|
|
1232
1825
|
message: `Regex query exceeds max length of ${MAX_REGEX_QUERY_LENGTH} characters.`,
|
|
1233
1826
|
details: {
|
|
@@ -1239,7 +1832,9 @@ export class SourceService {
|
|
|
1239
1832
|
const searchLimitCap = match === "regex"
|
|
1240
1833
|
? Math.max(1, Math.min(this.config.maxSearchHits, MAX_REGEX_RESULT_LIMIT))
|
|
1241
1834
|
: this.config.maxSearchHits;
|
|
1242
|
-
const scope =
|
|
1835
|
+
const scope = translationPackagePrefix
|
|
1836
|
+
? { ...(input.scope ?? {}), packagePrefix: translationPackagePrefix }
|
|
1837
|
+
: input.scope;
|
|
1243
1838
|
if (scope?.symbolKind && intent !== "symbol") {
|
|
1244
1839
|
throw createError({
|
|
1245
1840
|
code: ERROR_CODES.INVALID_INPUT,
|
|
@@ -1348,7 +1943,9 @@ export class SourceService {
|
|
|
1348
1943
|
sourceJarPath: artifact.sourceJarPath,
|
|
1349
1944
|
isDecompiled: artifact.isDecompiled,
|
|
1350
1945
|
qualityFlags: artifact.qualityFlags
|
|
1351
|
-
})
|
|
1946
|
+
}),
|
|
1947
|
+
...(translatedInfo ? { translatedQuery: translatedInfo } : {}),
|
|
1948
|
+
...(searchWarnings.length > 0 ? { warnings: searchWarnings } : {})
|
|
1352
1949
|
};
|
|
1353
1950
|
}
|
|
1354
1951
|
finally {
|
|
@@ -1461,7 +2058,14 @@ export class SourceService {
|
|
|
1461
2058
|
return this.mappingService.getClassApiMatrix(input);
|
|
1462
2059
|
}
|
|
1463
2060
|
async checkSymbolExists(input) {
|
|
1464
|
-
|
|
2061
|
+
const result = await this.mappingService.checkSymbolExists(input);
|
|
2062
|
+
if (result.status !== "mapping_unavailable" ||
|
|
2063
|
+
!isUnobfuscatedVersion(input.version) ||
|
|
2064
|
+
(input.sourceMapping !== "mojang" && input.sourceMapping !== "obfuscated")) {
|
|
2065
|
+
return result;
|
|
2066
|
+
}
|
|
2067
|
+
const runtimeFallback = await this.checkSymbolExistsInUnobfuscatedRuntime(input, result);
|
|
2068
|
+
return runtimeFallback ?? result;
|
|
1465
2069
|
}
|
|
1466
2070
|
async resolveWorkspaceSymbol(input) {
|
|
1467
2071
|
const projectPath = input.projectPath?.trim();
|
|
@@ -1645,6 +2249,10 @@ export class SourceService {
|
|
|
1645
2249
|
warnings: [...warnings, ...matrix.warnings]
|
|
1646
2250
|
};
|
|
1647
2251
|
}
|
|
2252
|
+
// By this point the method and class branches have already returned; only the field
|
|
2253
|
+
// branch reaches the generic findMapping fallthrough, and fields do not consume
|
|
2254
|
+
// signatureMode on the service side. Leave signatureMode undefined (service default =
|
|
2255
|
+
// "name-only" for any accidental future non-field caller hitting this path).
|
|
1648
2256
|
const mapped = await this.mappingService.findMapping({
|
|
1649
2257
|
version,
|
|
1650
2258
|
kind,
|
|
@@ -1683,6 +2291,147 @@ export class SourceService {
|
|
|
1683
2291
|
warnings: [...warnings, ...mapped.warnings]
|
|
1684
2292
|
};
|
|
1685
2293
|
}
|
|
2294
|
+
async checkSymbolExistsInUnobfuscatedRuntime(input, fallbackBase) {
|
|
2295
|
+
const version = input.version.trim();
|
|
2296
|
+
const name = input.name.trim();
|
|
2297
|
+
const owner = input.owner?.trim();
|
|
2298
|
+
if (!version || !name) {
|
|
2299
|
+
return undefined;
|
|
2300
|
+
}
|
|
2301
|
+
if (input.kind === "class" && input.nameMode !== "fqcn" && !name.includes(".")) {
|
|
2302
|
+
return {
|
|
2303
|
+
...fallbackBase,
|
|
2304
|
+
warnings: [
|
|
2305
|
+
...fallbackBase.warnings,
|
|
2306
|
+
`Version ${version} is unobfuscated, but short class name "${name}" could not be checked against runtime bytecode without a fully-qualified name.`
|
|
2307
|
+
]
|
|
2308
|
+
};
|
|
2309
|
+
}
|
|
2310
|
+
const querySymbol = input.kind === "class"
|
|
2311
|
+
? {
|
|
2312
|
+
kind: "class",
|
|
2313
|
+
name,
|
|
2314
|
+
symbol: name
|
|
2315
|
+
}
|
|
2316
|
+
: input.kind === "field"
|
|
2317
|
+
? {
|
|
2318
|
+
kind: "field",
|
|
2319
|
+
owner,
|
|
2320
|
+
name,
|
|
2321
|
+
symbol: `${owner}.${name}`
|
|
2322
|
+
}
|
|
2323
|
+
: {
|
|
2324
|
+
kind: "method",
|
|
2325
|
+
owner,
|
|
2326
|
+
name,
|
|
2327
|
+
descriptor: input.descriptor?.trim(),
|
|
2328
|
+
symbol: `${owner}.${name}${input.descriptor?.trim() ?? ""}`
|
|
2329
|
+
};
|
|
2330
|
+
const targetClass = input.kind === "class" ? name : owner;
|
|
2331
|
+
if (!targetClass) {
|
|
2332
|
+
return fallbackBase;
|
|
2333
|
+
}
|
|
2334
|
+
let jarPath;
|
|
2335
|
+
try {
|
|
2336
|
+
({ jarPath } = await this.versionService.resolveVersionJar(version));
|
|
2337
|
+
}
|
|
2338
|
+
catch {
|
|
2339
|
+
return undefined;
|
|
2340
|
+
}
|
|
2341
|
+
let signature;
|
|
2342
|
+
try {
|
|
2343
|
+
signature = await this.explorerService.getSignature({
|
|
2344
|
+
fqn: targetClass,
|
|
2345
|
+
jarPath,
|
|
2346
|
+
access: "all"
|
|
2347
|
+
});
|
|
2348
|
+
}
|
|
2349
|
+
catch {
|
|
2350
|
+
return {
|
|
2351
|
+
...fallbackBase,
|
|
2352
|
+
querySymbol,
|
|
2353
|
+
warnings: [
|
|
2354
|
+
...fallbackBase.warnings,
|
|
2355
|
+
`Version ${version} is unobfuscated; runtime bytecode lookup could not load class "${targetClass}".`
|
|
2356
|
+
]
|
|
2357
|
+
};
|
|
2358
|
+
}
|
|
2359
|
+
const warnings = [
|
|
2360
|
+
...fallbackBase.warnings,
|
|
2361
|
+
...signature.warnings,
|
|
2362
|
+
`Version ${version} is unobfuscated; validated symbol existence against runtime bytecode.`
|
|
2363
|
+
];
|
|
2364
|
+
const buildResolved = (resolvedSymbol) => ({
|
|
2365
|
+
...fallbackBase,
|
|
2366
|
+
querySymbol,
|
|
2367
|
+
resolved: true,
|
|
2368
|
+
status: "resolved",
|
|
2369
|
+
resolvedSymbol,
|
|
2370
|
+
candidates: resolvedSymbol
|
|
2371
|
+
? [{
|
|
2372
|
+
...resolvedSymbol,
|
|
2373
|
+
matchKind: "exact",
|
|
2374
|
+
confidence: 1
|
|
2375
|
+
}]
|
|
2376
|
+
: [],
|
|
2377
|
+
candidateCount: resolvedSymbol ? 1 : 0,
|
|
2378
|
+
warnings
|
|
2379
|
+
});
|
|
2380
|
+
const buildUnresolved = (status) => ({
|
|
2381
|
+
...fallbackBase,
|
|
2382
|
+
querySymbol,
|
|
2383
|
+
resolved: false,
|
|
2384
|
+
status,
|
|
2385
|
+
resolvedSymbol: undefined,
|
|
2386
|
+
candidates: [],
|
|
2387
|
+
candidateCount: 0,
|
|
2388
|
+
warnings
|
|
2389
|
+
});
|
|
2390
|
+
if (input.kind === "class") {
|
|
2391
|
+
return buildResolved({
|
|
2392
|
+
kind: "class",
|
|
2393
|
+
name,
|
|
2394
|
+
symbol: name
|
|
2395
|
+
});
|
|
2396
|
+
}
|
|
2397
|
+
if (input.kind === "field") {
|
|
2398
|
+
const matched = signature.fields.filter((field) => field.name === name);
|
|
2399
|
+
if (matched.length !== 1) {
|
|
2400
|
+
return buildUnresolved(matched.length > 1 ? "ambiguous" : "not_found");
|
|
2401
|
+
}
|
|
2402
|
+
return buildResolved({
|
|
2403
|
+
kind: "field",
|
|
2404
|
+
owner,
|
|
2405
|
+
name,
|
|
2406
|
+
symbol: `${owner}.${name}`
|
|
2407
|
+
});
|
|
2408
|
+
}
|
|
2409
|
+
const methodCandidates = signature.methods.filter((method) => method.name === name);
|
|
2410
|
+
if (input.signatureMode === "name-only") {
|
|
2411
|
+
if (methodCandidates.length !== 1) {
|
|
2412
|
+
return buildUnresolved(methodCandidates.length > 1 ? "ambiguous" : "not_found");
|
|
2413
|
+
}
|
|
2414
|
+
return buildResolved({
|
|
2415
|
+
kind: "method",
|
|
2416
|
+
owner,
|
|
2417
|
+
name,
|
|
2418
|
+
descriptor: methodCandidates[0]?.jvmDescriptor,
|
|
2419
|
+
symbol: `${owner}.${name}${methodCandidates[0]?.jvmDescriptor ?? ""}`
|
|
2420
|
+
});
|
|
2421
|
+
}
|
|
2422
|
+
const descriptor = input.descriptor?.trim();
|
|
2423
|
+
const matched = methodCandidates.filter((method) => method.jvmDescriptor === descriptor);
|
|
2424
|
+
if (matched.length !== 1) {
|
|
2425
|
+
return buildUnresolved(matched.length > 1 ? "ambiguous" : "not_found");
|
|
2426
|
+
}
|
|
2427
|
+
return buildResolved({
|
|
2428
|
+
kind: "method",
|
|
2429
|
+
owner,
|
|
2430
|
+
name,
|
|
2431
|
+
descriptor,
|
|
2432
|
+
symbol: `${owner}.${name}${descriptor ?? ""}`
|
|
2433
|
+
});
|
|
2434
|
+
}
|
|
1686
2435
|
async traceSymbolLifecycle(input) {
|
|
1687
2436
|
const mapping = normalizeMapping(input.mapping);
|
|
1688
2437
|
const { className: userClassName, methodName: userMethodName, inlineDescriptor } = parseQualifiedMethodSymbol(input.symbol);
|
|
@@ -2129,15 +2878,18 @@ export class SourceService {
|
|
|
2129
2878
|
message: "className must be non-empty."
|
|
2130
2879
|
});
|
|
2131
2880
|
}
|
|
2132
|
-
const
|
|
2133
|
-
if (!
|
|
2881
|
+
const inputArtifactId = input.artifactId.trim();
|
|
2882
|
+
if (!inputArtifactId) {
|
|
2134
2883
|
throw createError({
|
|
2135
2884
|
code: ERROR_CODES.INVALID_INPUT,
|
|
2136
2885
|
message: "artifactId must be non-empty."
|
|
2137
2886
|
});
|
|
2138
2887
|
}
|
|
2139
|
-
// Verify artifact exists
|
|
2140
|
-
|
|
2888
|
+
// Verify artifact exists. The input may be either a 64-char artifactId or
|
|
2889
|
+
// an alias; normalize to the canonical row id so downstream symbolsRepo
|
|
2890
|
+
// calls (keyed by artifact_id only) do not silently miss for alias input.
|
|
2891
|
+
const artifact = this.getArtifact(inputArtifactId);
|
|
2892
|
+
const artifactId = artifact.artifactId;
|
|
2141
2893
|
const limit = Math.max(1, Math.min(input.limit ?? 20, 200));
|
|
2142
2894
|
const warnings = [];
|
|
2143
2895
|
const isQualified = className.includes(".");
|
|
@@ -2294,6 +3046,11 @@ export class SourceService {
|
|
|
2294
3046
|
}
|
|
2295
3047
|
else {
|
|
2296
3048
|
const artifact = this.getArtifact(artifactId);
|
|
3049
|
+
// Normalize alias input to the canonical row id so downstream
|
|
3050
|
+
// resolveClassFilePath / filesRepo lookups (keyed by 64-char
|
|
3051
|
+
// artifact_id only) do not silently miss when the caller passed an
|
|
3052
|
+
// alias from a previous resolveArtifact response.
|
|
3053
|
+
artifactId = artifact.artifactId;
|
|
2297
3054
|
origin = artifact.origin;
|
|
2298
3055
|
requestedMapping = artifact.requestedMapping ?? requestedMapping;
|
|
2299
3056
|
mappingApplied = artifact.mappingApplied ?? requestedMapping;
|
|
@@ -2577,6 +3334,9 @@ export class SourceService {
|
|
|
2577
3334
|
}
|
|
2578
3335
|
else {
|
|
2579
3336
|
const artifact = this.getArtifact(artifactId);
|
|
3337
|
+
// Normalize alias input to canonical row id; downstream files/symbols
|
|
3338
|
+
// lookups use this id directly.
|
|
3339
|
+
artifactId = artifact.artifactId;
|
|
2580
3340
|
origin = artifact.origin;
|
|
2581
3341
|
mappingApplied = artifact.mappingApplied ?? requestedMapping;
|
|
2582
3342
|
provenance = artifact.provenance;
|
|
@@ -2683,6 +3443,36 @@ export class SourceService {
|
|
|
2683
3443
|
requestedMapping,
|
|
2684
3444
|
mappingApplied
|
|
2685
3445
|
});
|
|
3446
|
+
let decompiledFallback;
|
|
3447
|
+
let decompiledMemberCounts;
|
|
3448
|
+
let fallbackQualityFlags = qualityFlags;
|
|
3449
|
+
if (counts.total === 0) {
|
|
3450
|
+
// When the request namespace differs from the artifact namespace, the
|
|
3451
|
+
// caller's memberPattern is authored against requested-namespace names
|
|
3452
|
+
// (e.g. a Mojang pattern). The fallback extracts artifact-namespace
|
|
3453
|
+
// names (e.g. obfuscated), so filtering by the raw pattern would
|
|
3454
|
+
// silently drop every entry. Skip the filter in that case and warn.
|
|
3455
|
+
const namespaceMismatch = requestedMapping !== mappingApplied;
|
|
3456
|
+
const fallbackPattern = namespaceMismatch ? undefined : memberPattern;
|
|
3457
|
+
const sourceFallback = this.buildDecompiledFallback(artifactId, lookupClassName, fallbackPattern, maxMembers);
|
|
3458
|
+
if (sourceFallback) {
|
|
3459
|
+
decompiledFallback = sourceFallback.fallback;
|
|
3460
|
+
decompiledMemberCounts = sourceFallback.counts;
|
|
3461
|
+
fallbackQualityFlags = dedupeQualityFlags([
|
|
3462
|
+
...qualityFlags,
|
|
3463
|
+
"members-from-decompiled-source"
|
|
3464
|
+
]);
|
|
3465
|
+
const namespaceNote = namespaceMismatch
|
|
3466
|
+
? ` Member names are in ${mappingApplied} (artifact namespace); the request asked for ${requestedMapping}.`
|
|
3467
|
+
: "";
|
|
3468
|
+
warnings.push("Bytecode member enumeration returned zero; populated decompiledFallback from decompiled source. "
|
|
3469
|
+
+ "Descriptors and access modifiers are unavailable — use get-class-source for full details."
|
|
3470
|
+
+ namespaceNote);
|
|
3471
|
+
if (namespaceMismatch && memberPattern) {
|
|
3472
|
+
warnings.push(`memberPattern="${memberPattern}" was not applied to decompiledFallback because the artifact namespace (${mappingApplied}) differs from the requested namespace (${requestedMapping}); filter the response client-side after mapping.`);
|
|
3473
|
+
}
|
|
3474
|
+
}
|
|
3475
|
+
}
|
|
2686
3476
|
return {
|
|
2687
3477
|
className,
|
|
2688
3478
|
members: {
|
|
@@ -2699,17 +3489,83 @@ export class SourceService {
|
|
|
2699
3489
|
mappingApplied,
|
|
2700
3490
|
returnedNamespace: requestedMapping,
|
|
2701
3491
|
provenance: normalizedProvenance,
|
|
2702
|
-
qualityFlags,
|
|
3492
|
+
qualityFlags: fallbackQualityFlags,
|
|
2703
3493
|
artifactContents: this.buildArtifactContentsSummary({
|
|
2704
3494
|
origin,
|
|
2705
3495
|
sourceJarPath,
|
|
2706
3496
|
isDecompiled: origin === "decompiled",
|
|
2707
|
-
qualityFlags
|
|
3497
|
+
qualityFlags: fallbackQualityFlags
|
|
2708
3498
|
}),
|
|
3499
|
+
...(decompiledFallback ? { decompiledFallback } : {}),
|
|
3500
|
+
...(decompiledMemberCounts ? { decompiledMemberCounts } : {}),
|
|
2709
3501
|
warnings
|
|
2710
3502
|
};
|
|
2711
3503
|
}
|
|
3504
|
+
buildDecompiledFallback(artifactId, lookupClassName, memberPattern, maxMembers) {
|
|
3505
|
+
const filePath = this.resolveClassFilePath(artifactId, lookupClassName);
|
|
3506
|
+
if (!filePath) {
|
|
3507
|
+
return undefined;
|
|
3508
|
+
}
|
|
3509
|
+
const row = this.filesRepo.getFileContent(artifactId, filePath);
|
|
3510
|
+
if (!row) {
|
|
3511
|
+
return undefined;
|
|
3512
|
+
}
|
|
3513
|
+
const extracted = this.extractDecompiledMembers(lookupClassName, filePath, row.content);
|
|
3514
|
+
const filterByPattern = (list) => {
|
|
3515
|
+
if (!memberPattern) {
|
|
3516
|
+
return list;
|
|
3517
|
+
}
|
|
3518
|
+
const lower = memberPattern.toLowerCase();
|
|
3519
|
+
return list.filter((entry) => entry.name.toLowerCase().includes(lower));
|
|
3520
|
+
};
|
|
3521
|
+
let constructors = filterByPattern(extracted.constructors);
|
|
3522
|
+
let fields = filterByPattern(extracted.fields);
|
|
3523
|
+
let methods = filterByPattern(extracted.methods);
|
|
3524
|
+
const totalBefore = constructors.length + fields.length + methods.length;
|
|
3525
|
+
if (totalBefore === 0) {
|
|
3526
|
+
return undefined;
|
|
3527
|
+
}
|
|
3528
|
+
let remaining = maxMembers;
|
|
3529
|
+
const takeWithinLimit = (list) => {
|
|
3530
|
+
if (remaining <= 0) {
|
|
3531
|
+
return [];
|
|
3532
|
+
}
|
|
3533
|
+
const slice = list.slice(0, remaining);
|
|
3534
|
+
remaining -= slice.length;
|
|
3535
|
+
return slice;
|
|
3536
|
+
};
|
|
3537
|
+
constructors = takeWithinLimit(constructors);
|
|
3538
|
+
fields = takeWithinLimit(fields);
|
|
3539
|
+
methods = takeWithinLimit(methods);
|
|
3540
|
+
return {
|
|
3541
|
+
fallback: {
|
|
3542
|
+
constructors,
|
|
3543
|
+
fields,
|
|
3544
|
+
methods,
|
|
3545
|
+
origin: "source-extracted"
|
|
3546
|
+
},
|
|
3547
|
+
counts: {
|
|
3548
|
+
constructors: constructors.length,
|
|
3549
|
+
fields: fields.length,
|
|
3550
|
+
methods: methods.length,
|
|
3551
|
+
total: constructors.length + fields.length + methods.length
|
|
3552
|
+
}
|
|
3553
|
+
};
|
|
3554
|
+
}
|
|
2712
3555
|
async validateMixin(input) {
|
|
3556
|
+
// Wrap the dispatcher so any untagged AppError coming out of path
|
|
3557
|
+
// normalization, preflight discovery, or config resolution surfaces with a
|
|
3558
|
+
// meaningful failedStage. annotateValidateMixinError preserves any
|
|
3559
|
+
// inner-pipeline tag (resolve/mapping-health/parse/target-lookup) so only
|
|
3560
|
+
// the dispatcher-level errors default to input-validation.
|
|
3561
|
+
try {
|
|
3562
|
+
return await this.runValidateMixinDispatcher(input);
|
|
3563
|
+
}
|
|
3564
|
+
catch (err) {
|
|
3565
|
+
throw annotateValidateMixinError(err, "input-validation");
|
|
3566
|
+
}
|
|
3567
|
+
}
|
|
3568
|
+
async runValidateMixinDispatcher(input) {
|
|
2713
3569
|
const { input: sourceInput, ...sharedInput } = input;
|
|
2714
3570
|
const mode = sourceInput.mode;
|
|
2715
3571
|
if (mode === "inline") {
|
|
@@ -2755,7 +3611,7 @@ export class SourceService {
|
|
|
2755
3611
|
})), input, []);
|
|
2756
3612
|
}
|
|
2757
3613
|
const resolvedInput = mode === "project"
|
|
2758
|
-
? this.createProjectValidateMixinConfigInput(input)
|
|
3614
|
+
? await this.createProjectValidateMixinConfigInput(input)
|
|
2759
3615
|
: input;
|
|
2760
3616
|
const { sources: configSources, warnings: configWarnings } = await this.resolveMixinConfigSources(resolvedInput);
|
|
2761
3617
|
if (configSources.length === 0) {
|
|
@@ -2775,22 +3631,23 @@ export class SourceService {
|
|
|
2775
3631
|
sourcePath: entry.sourcePath
|
|
2776
3632
|
})), resolvedInput, configWarnings);
|
|
2777
3633
|
}
|
|
2778
|
-
createProjectValidateMixinConfigInput(input) {
|
|
3634
|
+
async createProjectValidateMixinConfigInput(input) {
|
|
2779
3635
|
if (input.input.mode !== "project") {
|
|
2780
3636
|
return input;
|
|
2781
3637
|
}
|
|
2782
3638
|
const resolvedProjectPath = this.resolveMixinInputPath(input.input.path, "path");
|
|
2783
|
-
const configPaths = fastGlob.
|
|
3639
|
+
const configPaths = (await fastGlob.glob(["**/*.mixins.json"], {
|
|
2784
3640
|
cwd: resolvedProjectPath,
|
|
2785
3641
|
absolute: true,
|
|
2786
3642
|
onlyFiles: true,
|
|
2787
3643
|
ignore: [...MIXIN_PROJECT_DISCOVERY_IGNORES]
|
|
2788
|
-
}).sort((left, right) => left.localeCompare(right));
|
|
3644
|
+
})).sort((left, right) => left.localeCompare(right));
|
|
2789
3645
|
if (configPaths.length === 0) {
|
|
2790
3646
|
throw createError({
|
|
2791
3647
|
code: ERROR_CODES.INVALID_INPUT,
|
|
2792
3648
|
message: `No mixin config JSON files were found under project path "${input.input.path}".`,
|
|
2793
3649
|
details: {
|
|
3650
|
+
failedStage: "input-validation",
|
|
2794
3651
|
nextAction: "Use input.mode='config' with explicit configPaths[], or point input.path at the workspace root that contains *.mixins.json files."
|
|
2795
3652
|
}
|
|
2796
3653
|
});
|
|
@@ -2828,7 +3685,8 @@ export class SourceService {
|
|
|
2828
3685
|
name: input.className,
|
|
2829
3686
|
sourceMapping: input.sourceMapping,
|
|
2830
3687
|
targetMapping: input.targetMapping,
|
|
2831
|
-
sourcePriority: input.sourcePriority
|
|
3688
|
+
sourcePriority: input.sourcePriority,
|
|
3689
|
+
projectPath: input.projectPath
|
|
2832
3690
|
});
|
|
2833
3691
|
}
|
|
2834
3692
|
const cacheKey = [
|
|
@@ -2836,7 +3694,8 @@ export class SourceService {
|
|
|
2836
3694
|
input.className,
|
|
2837
3695
|
input.sourceMapping,
|
|
2838
3696
|
input.targetMapping,
|
|
2839
|
-
input.sourcePriority
|
|
3697
|
+
input.sourcePriority,
|
|
3698
|
+
input.projectPath ?? ""
|
|
2840
3699
|
].join("\0");
|
|
2841
3700
|
const cached = cache.get(cacheKey);
|
|
2842
3701
|
if (cached) {
|
|
@@ -2848,7 +3707,8 @@ export class SourceService {
|
|
|
2848
3707
|
name: input.className,
|
|
2849
3708
|
sourceMapping: input.sourceMapping,
|
|
2850
3709
|
targetMapping: input.targetMapping,
|
|
2851
|
-
sourcePriority: input.sourcePriority
|
|
3710
|
+
sourcePriority: input.sourcePriority,
|
|
3711
|
+
projectPath: input.projectPath
|
|
2852
3712
|
}).catch((error) => {
|
|
2853
3713
|
cache.delete(cacheKey);
|
|
2854
3714
|
throw error;
|
|
@@ -2857,37 +3717,71 @@ export class SourceService {
|
|
|
2857
3717
|
return pending;
|
|
2858
3718
|
}
|
|
2859
3719
|
async validateMixinSingle(input) {
|
|
2860
|
-
|
|
2861
|
-
|
|
2862
|
-
|
|
2863
|
-
|
|
2864
|
-
|
|
2865
|
-
|
|
2866
|
-
|
|
2867
|
-
|
|
2868
|
-
|
|
2869
|
-
|
|
2870
|
-
const
|
|
2871
|
-
|
|
2872
|
-
|
|
2873
|
-
|
|
2874
|
-
|
|
2875
|
-
|
|
3720
|
+
// Start at input-validation so path normalization, file reads, and the
|
|
3721
|
+
// simple guard checks all land under that stage. The pipeline callback
|
|
3722
|
+
// shifts the stage tracker before the first non-input-validation action,
|
|
3723
|
+
// and annotateValidateMixinError() preserves any nested-call stage
|
|
3724
|
+
// (e.g. a deeper resolver that set failedStage="version-manifest").
|
|
3725
|
+
let currentStage = "input-validation";
|
|
3726
|
+
try {
|
|
3727
|
+
let version = input.version.trim();
|
|
3728
|
+
const requestedScope = normalizeRequestedArtifactScope(input.scope);
|
|
3729
|
+
const currentSourcePriority = input.sourcePriority ?? this.config.mappingSourcePriority;
|
|
3730
|
+
const initialSourcePriority = input.retryState?.initialSourcePriority ?? currentSourcePriority;
|
|
3731
|
+
if (!version) {
|
|
3732
|
+
throw createError({
|
|
3733
|
+
code: ERROR_CODES.INVALID_INPUT,
|
|
3734
|
+
message: "version must be non-empty.",
|
|
3735
|
+
details: { failedStage: "input-validation" }
|
|
3736
|
+
});
|
|
2876
3737
|
}
|
|
2877
|
-
|
|
3738
|
+
// Resolve source from source or sourcePath
|
|
3739
|
+
let source;
|
|
3740
|
+
if (input.sourcePath) {
|
|
3741
|
+
const normalizedSourcePath = normalizePathForHost(input.sourcePath, undefined, "sourcePath");
|
|
3742
|
+
const resolvedSourcePath = isAbsolute(normalizedSourcePath)
|
|
3743
|
+
? normalizedSourcePath
|
|
3744
|
+
: resolvePath(process.cwd(), normalizedSourcePath);
|
|
3745
|
+
try {
|
|
3746
|
+
source = await readFile(resolvedSourcePath, "utf-8");
|
|
3747
|
+
}
|
|
3748
|
+
catch (err) {
|
|
3749
|
+
throw createError({
|
|
3750
|
+
code: ERROR_CODES.INVALID_INPUT,
|
|
3751
|
+
message: `Could not read sourcePath "${input.sourcePath}" (resolved to "${resolvedSourcePath}"):` +
|
|
3752
|
+
` ${err instanceof Error ? err.message : String(err)}`,
|
|
3753
|
+
details: { failedStage: "input-validation" }
|
|
3754
|
+
});
|
|
3755
|
+
}
|
|
3756
|
+
}
|
|
3757
|
+
else {
|
|
3758
|
+
source = input.source ?? "";
|
|
3759
|
+
}
|
|
3760
|
+
if (!source.trim()) {
|
|
2878
3761
|
throw createError({
|
|
2879
3762
|
code: ERROR_CODES.INVALID_INPUT,
|
|
2880
|
-
message:
|
|
2881
|
-
|
|
3763
|
+
message: "source must be non-empty.",
|
|
3764
|
+
details: { failedStage: "input-validation" }
|
|
2882
3765
|
});
|
|
2883
3766
|
}
|
|
3767
|
+
return await this.runValidateMixinPipeline({
|
|
3768
|
+
input,
|
|
3769
|
+
version,
|
|
3770
|
+
source,
|
|
3771
|
+
requestedScope,
|
|
3772
|
+
currentSourcePriority,
|
|
3773
|
+
initialSourcePriority,
|
|
3774
|
+
onStage: (stage) => { currentStage = stage; }
|
|
3775
|
+
});
|
|
2884
3776
|
}
|
|
2885
|
-
|
|
2886
|
-
|
|
2887
|
-
}
|
|
2888
|
-
if (!source.trim()) {
|
|
2889
|
-
throw createError({ code: ERROR_CODES.INVALID_INPUT, message: "source must be non-empty." });
|
|
3777
|
+
catch (err) {
|
|
3778
|
+
throw annotateValidateMixinError(err, currentStage);
|
|
2890
3779
|
}
|
|
3780
|
+
}
|
|
3781
|
+
async runValidateMixinPipeline(ctx) {
|
|
3782
|
+
const { input, source, requestedScope, currentSourcePriority, initialSourcePriority, onStage } = ctx;
|
|
3783
|
+
let { version } = ctx;
|
|
3784
|
+
onStage("resolve");
|
|
2891
3785
|
const warnings = [];
|
|
2892
3786
|
let mappingAutoDetected = false;
|
|
2893
3787
|
// Auto-detect mapping from project config when not explicitly provided (or when preferProjectMapping is set)
|
|
@@ -2968,6 +3862,7 @@ export class SourceService {
|
|
|
2968
3862
|
};
|
|
2969
3863
|
}
|
|
2970
3864
|
// Health check: probe mapping infrastructure
|
|
3865
|
+
onStage("mapping-health");
|
|
2971
3866
|
let healthReport;
|
|
2972
3867
|
try {
|
|
2973
3868
|
const health = await this.mappingService.checkMappingHealth({
|
|
@@ -2989,10 +3884,24 @@ export class SourceService {
|
|
|
2989
3884
|
]
|
|
2990
3885
|
};
|
|
2991
3886
|
}
|
|
2992
|
-
catch {
|
|
2993
|
-
//
|
|
3887
|
+
catch (err) {
|
|
3888
|
+
// Probe itself failed — surface the degradation instead of swallowing it
|
|
3889
|
+
// silently, so quickSummary and toolHealth reflect the unknown-health
|
|
3890
|
+
// state rather than looking clean.
|
|
3891
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
3892
|
+
healthReport = {
|
|
3893
|
+
jarAvailable: existsSync(jarPath),
|
|
3894
|
+
jarPath,
|
|
3895
|
+
mojangMappingsAvailable: false,
|
|
3896
|
+
tinyMappingsAvailable: false,
|
|
3897
|
+
memberRemapAvailable: false,
|
|
3898
|
+
overallHealthy: false,
|
|
3899
|
+
degradations: [`Mapping health probe failed: ${reason}`]
|
|
3900
|
+
};
|
|
2994
3901
|
}
|
|
3902
|
+
onStage("parse");
|
|
2995
3903
|
const parsed = parseMixinSource(source);
|
|
3904
|
+
onStage("target-lookup");
|
|
2996
3905
|
const targetMembers = new Map();
|
|
2997
3906
|
const mappingFailedTargets = new Set();
|
|
2998
3907
|
const remapFailedMembers = new Map();
|
|
@@ -3029,6 +3938,7 @@ export class SourceService {
|
|
|
3029
3938
|
sourceMapping: requestedMapping,
|
|
3030
3939
|
targetMapping: signatureLookupMapping,
|
|
3031
3940
|
sourcePriority: currentSourcePriority,
|
|
3941
|
+
projectPath: input.projectPath,
|
|
3032
3942
|
batchCaches: input.batchCaches
|
|
3033
3943
|
});
|
|
3034
3944
|
if (mapped.resolved && mapped.resolvedSymbol) {
|
|
@@ -3062,9 +3972,9 @@ export class SourceService {
|
|
|
3062
3972
|
if (requestedMapping !== signatureLookupMapping) {
|
|
3063
3973
|
try {
|
|
3064
3974
|
const [ctorResult, methodResult, fieldResult] = await Promise.all([
|
|
3065
|
-
this.remapSignatureMembers(sig.constructors, "method", version, signatureLookupMapping, requestedMapping, currentSourcePriority, warnings),
|
|
3066
|
-
this.remapSignatureMembers(sig.methods, "method", version, signatureLookupMapping, requestedMapping, currentSourcePriority, warnings),
|
|
3067
|
-
this.remapSignatureMembers(sig.fields, "field", version, signatureLookupMapping, requestedMapping, currentSourcePriority, warnings)
|
|
3975
|
+
this.remapSignatureMembers(sig.constructors, "method", version, signatureLookupMapping, requestedMapping, currentSourcePriority, warnings, input.projectPath),
|
|
3976
|
+
this.remapSignatureMembers(sig.methods, "method", version, signatureLookupMapping, requestedMapping, currentSourcePriority, warnings, input.projectPath),
|
|
3977
|
+
this.remapSignatureMembers(sig.fields, "field", version, signatureLookupMapping, requestedMapping, currentSourcePriority, warnings, input.projectPath)
|
|
3068
3978
|
]);
|
|
3069
3979
|
constructors = ctorResult.members;
|
|
3070
3980
|
methods = methodResult.members;
|
|
@@ -3252,8 +4162,12 @@ export class SourceService {
|
|
|
3252
4162
|
if (result.structuredWarnings.length === 0)
|
|
3253
4163
|
result.structuredWarnings = undefined;
|
|
3254
4164
|
}
|
|
3255
|
-
// Apply compact report mode
|
|
4165
|
+
// Apply compact report mode. refreshMixinValidationOutcome reads
|
|
4166
|
+
// result.toolHealth / result.provenance when it rebuilds quickSummary, so
|
|
4167
|
+
// refresh BEFORE stripping those fields; otherwise compact mode would
|
|
4168
|
+
// silently drop the mapping-health-degraded and scope-fallback notes.
|
|
3256
4169
|
if (input.reportMode === "compact") {
|
|
4170
|
+
refreshMixinValidationOutcome(result);
|
|
3257
4171
|
result.resolvedMembers = undefined;
|
|
3258
4172
|
result.structuredWarnings = undefined;
|
|
3259
4173
|
result.aggregatedWarnings = undefined;
|
|
@@ -3263,7 +4177,9 @@ export class SourceService {
|
|
|
3263
4177
|
result.provenance.resolutionTrace = undefined;
|
|
3264
4178
|
}
|
|
3265
4179
|
}
|
|
3266
|
-
|
|
4180
|
+
else {
|
|
4181
|
+
refreshMixinValidationOutcome(result);
|
|
4182
|
+
}
|
|
3267
4183
|
if (this.shouldRetryValidateMixinWithMavenFirst(input, result)) {
|
|
3268
4184
|
const retryWarning = `Retrying validate-mixin with sourcePriority="maven-first" after partial validation using "${currentSourcePriority}".`;
|
|
3269
4185
|
try {
|
|
@@ -3286,7 +4202,14 @@ export class SourceService {
|
|
|
3286
4202
|
`Validation retried with sourcePriority "maven-first" after partial result from "${currentSourcePriority}".`
|
|
3287
4203
|
];
|
|
3288
4204
|
}
|
|
3289
|
-
|
|
4205
|
+
// The recursive validateMixinSingle call already produced final summary /
|
|
4206
|
+
// status / quickSummary. The only mutations above are warnings and
|
|
4207
|
+
// provenance.resolutionNotes, which quickSummary does not read. Calling
|
|
4208
|
+
// refreshMixinValidationOutcome(retried) here would rebuild quickSummary
|
|
4209
|
+
// against the already-compacted `retried.toolHealth === undefined`, which
|
|
4210
|
+
// silently strips the mapping-health-degraded note we just preserved in
|
|
4211
|
+
// the compact branch above.
|
|
4212
|
+
return retried;
|
|
3290
4213
|
}
|
|
3291
4214
|
catch (retryErr) {
|
|
3292
4215
|
result.warnings.unshift(`${retryWarning} Retry failed: ${retryErr instanceof Error ? retryErr.message : String(retryErr)}`);
|
|
@@ -3320,7 +4243,8 @@ export class SourceService {
|
|
|
3320
4243
|
catch (err) {
|
|
3321
4244
|
throw createError({
|
|
3322
4245
|
code: ERROR_CODES.INVALID_INPUT,
|
|
3323
|
-
message: `Could not read/parse mixin config "${rawConfigPath}": ${err instanceof Error ? err.message : String(err)}
|
|
4246
|
+
message: `Could not read/parse mixin config "${rawConfigPath}": ${err instanceof Error ? err.message : String(err)}`,
|
|
4247
|
+
details: { failedStage: "input-validation" }
|
|
3324
4248
|
});
|
|
3325
4249
|
}
|
|
3326
4250
|
const pkg = configJson.package ?? "";
|
|
@@ -3341,11 +4265,21 @@ export class SourceService {
|
|
|
3341
4265
|
sourceRootCandidates = input.sourceRoots;
|
|
3342
4266
|
}
|
|
3343
4267
|
else {
|
|
3344
|
-
const detected =
|
|
3345
|
-
|
|
3346
|
-
|
|
3347
|
-
|
|
3348
|
-
|
|
4268
|
+
const detected = [];
|
|
4269
|
+
for (const candidateRoot of COMMON_SOURCE_ROOTS) {
|
|
4270
|
+
let foundInRoot = false;
|
|
4271
|
+
for (const className of classNames) {
|
|
4272
|
+
const fqcn = pkg ? `${pkg}.${className}` : className;
|
|
4273
|
+
const relative = fqcn.replace(/\./g, "/") + ".java";
|
|
4274
|
+
if (await pathExists(resolvePath(projectBase, candidateRoot, relative))) {
|
|
4275
|
+
foundInRoot = true;
|
|
4276
|
+
break;
|
|
4277
|
+
}
|
|
4278
|
+
}
|
|
4279
|
+
if (foundInRoot) {
|
|
4280
|
+
detected.push(candidateRoot);
|
|
4281
|
+
}
|
|
4282
|
+
}
|
|
3349
4283
|
sourceRootCandidates = detected.length > 0 ? detected : ["src/main/java"];
|
|
3350
4284
|
}
|
|
3351
4285
|
for (const cls of classNames) {
|
|
@@ -3354,7 +4288,7 @@ export class SourceService {
|
|
|
3354
4288
|
let sourcePath = resolvePath(projectBase, sourceRootCandidates[0], relativePath);
|
|
3355
4289
|
for (const root of sourceRootCandidates) {
|
|
3356
4290
|
const candidate = resolvePath(projectBase, root, relativePath);
|
|
3357
|
-
if (
|
|
4291
|
+
if (await pathExists(candidate)) {
|
|
3358
4292
|
sourcePath = candidate;
|
|
3359
4293
|
break;
|
|
3360
4294
|
}
|
|
@@ -3545,7 +4479,6 @@ export class SourceService {
|
|
|
3545
4479
|
throw createError({ code: ERROR_CODES.INVALID_INPUT, message: "content must be non-empty." });
|
|
3546
4480
|
}
|
|
3547
4481
|
const warnings = [];
|
|
3548
|
-
const { jarPath } = await this.versionService.resolveVersionJar(version);
|
|
3549
4482
|
const parsed = parseAccessWidener(content);
|
|
3550
4483
|
const headerNamespaceRaw = normalizeOptionalString(parsed.namespace);
|
|
3551
4484
|
const overrideMapping = input.mapping ? normalizeMapping(input.mapping) : undefined;
|
|
@@ -3557,7 +4490,27 @@ export class SourceService {
|
|
|
3557
4490
|
if (overrideMapping && headerNamespace && overrideMapping !== headerNamespace) {
|
|
3558
4491
|
warnings.push(`Using mapping override "${overrideMapping}" instead of header namespace "${headerNamespaceRaw}".`);
|
|
3559
4492
|
}
|
|
3560
|
-
const
|
|
4493
|
+
const runtimeAware = input.projectPath != null || input.scope != null || input.preferProjectVersion === true;
|
|
4494
|
+
let resolvedVersion = version;
|
|
4495
|
+
let jarPath;
|
|
4496
|
+
let lookupMapping = "obfuscated";
|
|
4497
|
+
let provenance;
|
|
4498
|
+
if (runtimeAware) {
|
|
4499
|
+
provenance = await this.resolveAccessWidenerRuntimeArtifact({
|
|
4500
|
+
version,
|
|
4501
|
+
awNamespace,
|
|
4502
|
+
projectPath: input.projectPath,
|
|
4503
|
+
scope: input.scope,
|
|
4504
|
+
preferProjectVersion: input.preferProjectVersion
|
|
4505
|
+
});
|
|
4506
|
+
resolvedVersion = provenance.version;
|
|
4507
|
+
jarPath = provenance.jarPath;
|
|
4508
|
+
lookupMapping = provenance.mappingApplied;
|
|
4509
|
+
}
|
|
4510
|
+
else {
|
|
4511
|
+
({ jarPath } = await this.versionService.resolveVersionJar(version));
|
|
4512
|
+
}
|
|
4513
|
+
const needsLookupMapping = awNamespace !== lookupMapping;
|
|
3561
4514
|
// Collect unique class FQNs from entries
|
|
3562
4515
|
const classFqns = new Set();
|
|
3563
4516
|
for (const entry of parsed.entries) {
|
|
@@ -3566,22 +4519,23 @@ export class SourceService {
|
|
|
3566
4519
|
}
|
|
3567
4520
|
const membersByClass = new Map();
|
|
3568
4521
|
for (const fqn of classFqns) {
|
|
3569
|
-
let
|
|
3570
|
-
if (
|
|
4522
|
+
let lookupFqn = fqn;
|
|
4523
|
+
if (needsLookupMapping) {
|
|
3571
4524
|
try {
|
|
3572
4525
|
const mapped = await this.mappingService.findMapping({
|
|
3573
|
-
version,
|
|
4526
|
+
version: resolvedVersion,
|
|
3574
4527
|
kind: "class",
|
|
3575
4528
|
name: fqn,
|
|
3576
4529
|
sourceMapping: awNamespace,
|
|
3577
|
-
targetMapping:
|
|
3578
|
-
sourcePriority: input.sourcePriority
|
|
4530
|
+
targetMapping: lookupMapping,
|
|
4531
|
+
sourcePriority: input.sourcePriority,
|
|
4532
|
+
projectPath: input.projectPath
|
|
3579
4533
|
});
|
|
3580
4534
|
if (mapped.resolved && mapped.resolvedSymbol) {
|
|
3581
|
-
|
|
4535
|
+
lookupFqn = mapped.resolvedSymbol.name;
|
|
3582
4536
|
}
|
|
3583
4537
|
else {
|
|
3584
|
-
warnings.push(`Could not map class "${fqn}" from ${awNamespace} to
|
|
4538
|
+
warnings.push(`Could not map class "${fqn}" from ${awNamespace} to ${lookupMapping}.`);
|
|
3585
4539
|
}
|
|
3586
4540
|
}
|
|
3587
4541
|
catch {
|
|
@@ -3590,25 +4544,163 @@ export class SourceService {
|
|
|
3590
4544
|
}
|
|
3591
4545
|
try {
|
|
3592
4546
|
const sig = await this.explorerService.getSignature({
|
|
3593
|
-
fqn:
|
|
4547
|
+
fqn: lookupFqn,
|
|
4548
|
+
jarPath,
|
|
4549
|
+
access: "all"
|
|
4550
|
+
});
|
|
4551
|
+
warnings.push(...sig.warnings);
|
|
4552
|
+
let constructors = sig.constructors;
|
|
4553
|
+
let methods = sig.methods;
|
|
4554
|
+
let fields = sig.fields;
|
|
4555
|
+
if (needsLookupMapping) {
|
|
4556
|
+
const [ctorResult, methodResult, fieldResult] = await Promise.all([
|
|
4557
|
+
this.remapSignatureMembers(sig.constructors, "method", resolvedVersion, lookupMapping, awNamespace, input.sourcePriority, warnings, input.projectPath),
|
|
4558
|
+
this.remapSignatureMembers(sig.methods, "method", resolvedVersion, lookupMapping, awNamespace, input.sourcePriority, warnings, input.projectPath),
|
|
4559
|
+
this.remapSignatureMembers(sig.fields, "field", resolvedVersion, lookupMapping, awNamespace, input.sourcePriority, warnings, input.projectPath)
|
|
4560
|
+
]);
|
|
4561
|
+
constructors = ctorResult.members;
|
|
4562
|
+
methods = methodResult.members;
|
|
4563
|
+
fields = fieldResult.members;
|
|
4564
|
+
}
|
|
4565
|
+
membersByClass.set(fqn, {
|
|
4566
|
+
className: fqn,
|
|
4567
|
+
classAccessFlags: sig.classAccessFlags,
|
|
4568
|
+
constructors,
|
|
4569
|
+
methods,
|
|
4570
|
+
fields
|
|
4571
|
+
});
|
|
4572
|
+
}
|
|
4573
|
+
catch {
|
|
4574
|
+
warnings.push(`Could not load signature for class "${lookupFqn}".`);
|
|
4575
|
+
}
|
|
4576
|
+
}
|
|
4577
|
+
const result = validateParsedAccessWidener(parsed, membersByClass, warnings, {
|
|
4578
|
+
includeRuntimeEvidence: runtimeAware
|
|
4579
|
+
});
|
|
4580
|
+
if (provenance) {
|
|
4581
|
+
result.provenance = provenance;
|
|
4582
|
+
}
|
|
4583
|
+
return result;
|
|
4584
|
+
}
|
|
4585
|
+
async validateAccessTransformer(input) {
|
|
4586
|
+
const version = input.version.trim();
|
|
4587
|
+
if (!version) {
|
|
4588
|
+
throw createError({ code: ERROR_CODES.INVALID_INPUT, message: "version must be non-empty." });
|
|
4589
|
+
}
|
|
4590
|
+
const content = input.content;
|
|
4591
|
+
if (!content.trim()) {
|
|
4592
|
+
throw createError({ code: ERROR_CODES.INVALID_INPUT, message: "content must be non-empty." });
|
|
4593
|
+
}
|
|
4594
|
+
const warnings = [];
|
|
4595
|
+
const parsed = parseAccessTransformer(content);
|
|
4596
|
+
const atNamespace = await this.resolveAccessTransformerNamespace({
|
|
4597
|
+
atNamespace: input.atNamespace,
|
|
4598
|
+
projectPath: input.projectPath
|
|
4599
|
+
});
|
|
4600
|
+
const runtimeAware = input.projectPath != null || input.scope != null || input.preferProjectVersion === true;
|
|
4601
|
+
let resolvedVersion = version;
|
|
4602
|
+
let jarPath;
|
|
4603
|
+
let lookupMapping = "obfuscated";
|
|
4604
|
+
let provenance;
|
|
4605
|
+
if (runtimeAware) {
|
|
4606
|
+
provenance = await this.resolveAccessTransformerRuntimeArtifact({
|
|
4607
|
+
version,
|
|
4608
|
+
atNamespace,
|
|
4609
|
+
projectPath: input.projectPath,
|
|
4610
|
+
scope: input.scope,
|
|
4611
|
+
preferProjectVersion: input.preferProjectVersion
|
|
4612
|
+
});
|
|
4613
|
+
resolvedVersion = provenance.version;
|
|
4614
|
+
jarPath = provenance.jarPath;
|
|
4615
|
+
lookupMapping = provenance.mappingApplied;
|
|
4616
|
+
}
|
|
4617
|
+
else {
|
|
4618
|
+
if (atNamespace === "srg") {
|
|
4619
|
+
throw createError({
|
|
4620
|
+
code: ERROR_CODES.INVALID_INPUT,
|
|
4621
|
+
message: "atNamespace=srg requires projectPath and scope=loader so a Forge runtime jar can be resolved."
|
|
4622
|
+
});
|
|
4623
|
+
}
|
|
4624
|
+
({ jarPath } = await this.versionService.resolveVersionJar(version));
|
|
4625
|
+
}
|
|
4626
|
+
const needsLookupMapping = atNamespace !== lookupMapping;
|
|
4627
|
+
const classFqns = new Set(parsed.entries.map((entry) => entry.owner));
|
|
4628
|
+
const membersByClass = new Map();
|
|
4629
|
+
for (const fqn of classFqns) {
|
|
4630
|
+
let lookupFqn = fqn;
|
|
4631
|
+
if (needsLookupMapping) {
|
|
4632
|
+
if (!isSourceMappingNamespace(atNamespace) || !isSourceMappingNamespace(lookupMapping)) {
|
|
4633
|
+
warnings.push(`Could not map class "${fqn}" from ${atNamespace} to ${lookupMapping}.`);
|
|
4634
|
+
}
|
|
4635
|
+
else {
|
|
4636
|
+
try {
|
|
4637
|
+
const mapped = await this.mappingService.findMapping({
|
|
4638
|
+
version: resolvedVersion,
|
|
4639
|
+
kind: "class",
|
|
4640
|
+
name: fqn,
|
|
4641
|
+
sourceMapping: atNamespace,
|
|
4642
|
+
targetMapping: lookupMapping,
|
|
4643
|
+
sourcePriority: input.sourcePriority,
|
|
4644
|
+
projectPath: input.projectPath
|
|
4645
|
+
});
|
|
4646
|
+
if (mapped.resolved && mapped.resolvedSymbol) {
|
|
4647
|
+
lookupFqn = mapped.resolvedSymbol.name;
|
|
4648
|
+
}
|
|
4649
|
+
else {
|
|
4650
|
+
warnings.push(`Could not map class "${fqn}" from ${atNamespace} to ${lookupMapping}.`);
|
|
4651
|
+
}
|
|
4652
|
+
}
|
|
4653
|
+
catch {
|
|
4654
|
+
warnings.push(`Mapping lookup failed for class "${fqn}".`);
|
|
4655
|
+
}
|
|
4656
|
+
}
|
|
4657
|
+
}
|
|
4658
|
+
try {
|
|
4659
|
+
const sig = await this.explorerService.getSignature({
|
|
4660
|
+
fqn: lookupFqn,
|
|
3594
4661
|
jarPath,
|
|
3595
4662
|
access: "all"
|
|
3596
4663
|
});
|
|
3597
4664
|
warnings.push(...sig.warnings);
|
|
4665
|
+
let constructors = sig.constructors;
|
|
4666
|
+
let methods = sig.methods;
|
|
4667
|
+
let fields = sig.fields;
|
|
4668
|
+
if (needsLookupMapping && isSourceMappingNamespace(atNamespace) && isSourceMappingNamespace(lookupMapping)) {
|
|
4669
|
+
const [ctorResult, methodResult, fieldResult] = await Promise.all([
|
|
4670
|
+
this.remapSignatureMembers(sig.constructors, "method", resolvedVersion, lookupMapping, atNamespace, input.sourcePriority, warnings, input.projectPath),
|
|
4671
|
+
this.remapSignatureMembers(sig.methods, "method", resolvedVersion, lookupMapping, atNamespace, input.sourcePriority, warnings, input.projectPath),
|
|
4672
|
+
this.remapSignatureMembers(sig.fields, "field", resolvedVersion, lookupMapping, atNamespace, input.sourcePriority, warnings, input.projectPath)
|
|
4673
|
+
]);
|
|
4674
|
+
constructors = ctorResult.members;
|
|
4675
|
+
methods = methodResult.members;
|
|
4676
|
+
fields = fieldResult.members;
|
|
4677
|
+
}
|
|
3598
4678
|
membersByClass.set(fqn, {
|
|
3599
4679
|
className: fqn,
|
|
3600
|
-
|
|
3601
|
-
|
|
3602
|
-
|
|
4680
|
+
classAccessFlags: sig.classAccessFlags,
|
|
4681
|
+
constructors,
|
|
4682
|
+
methods,
|
|
4683
|
+
fields
|
|
3603
4684
|
});
|
|
3604
4685
|
}
|
|
3605
4686
|
catch {
|
|
3606
|
-
warnings.push(`Could not load signature for class "${
|
|
4687
|
+
warnings.push(`Could not load signature for class "${lookupFqn}".`);
|
|
3607
4688
|
}
|
|
3608
4689
|
}
|
|
3609
|
-
|
|
4690
|
+
const result = validateParsedAccessTransformer(parsed, membersByClass, warnings, {
|
|
4691
|
+
includeRuntimeEvidence: runtimeAware
|
|
4692
|
+
});
|
|
4693
|
+
if (provenance) {
|
|
4694
|
+
result.provenance = provenance;
|
|
4695
|
+
}
|
|
4696
|
+
return result;
|
|
4697
|
+
}
|
|
4698
|
+
recordToolCall(tool, durationMs) {
|
|
4699
|
+
this.metrics.recordToolCall(tool, durationMs);
|
|
3610
4700
|
}
|
|
3611
4701
|
getRuntimeMetrics() {
|
|
4702
|
+
this.snapshotLruAccounting();
|
|
4703
|
+
this.metrics.setMappingResolutionCacheStats(this.mappingService.resolutionCacheStats);
|
|
3612
4704
|
return this.metrics.snapshot();
|
|
3613
4705
|
}
|
|
3614
4706
|
async indexArtifact(input) {
|
|
@@ -3936,6 +5028,117 @@ export class SourceService {
|
|
|
3936
5028
|
}
|
|
3937
5029
|
return outputParts.join("\n");
|
|
3938
5030
|
}
|
|
5031
|
+
extractDecompiledMembers(className, filePath, content) {
|
|
5032
|
+
const symbols = extractSymbolsFromSource(filePath, content);
|
|
5033
|
+
const simpleName = className.split(/[.$]/).at(-1) ?? className;
|
|
5034
|
+
const lines = content.split(/\r?\n/);
|
|
5035
|
+
const body = this.computeBraceRange(lines, symbols, simpleName);
|
|
5036
|
+
if (!body) {
|
|
5037
|
+
return { constructors: [], fields: [], methods: [] };
|
|
5038
|
+
}
|
|
5039
|
+
const depths = this.computeLineBraceDepths(lines);
|
|
5040
|
+
const baseDepth = depths[body.declarationLine - 1] ?? 0;
|
|
5041
|
+
const nestedRanges = this.computeNestedTypeRanges(lines, symbols, body);
|
|
5042
|
+
const constructors = [];
|
|
5043
|
+
const fields = [];
|
|
5044
|
+
const methods = [];
|
|
5045
|
+
for (const symbol of symbols) {
|
|
5046
|
+
if (symbol.line <= body.declarationLine || symbol.line > body.endLine) {
|
|
5047
|
+
continue;
|
|
5048
|
+
}
|
|
5049
|
+
if (nestedRanges.some((range) => symbol.line >= range.declarationLine && symbol.line <= range.endLine)) {
|
|
5050
|
+
continue;
|
|
5051
|
+
}
|
|
5052
|
+
const lineDepth = depths[symbol.line - 1] ?? baseDepth;
|
|
5053
|
+
// Declarations directly inside the class body sit at baseDepth+1; anything
|
|
5054
|
+
// deeper is a method/constructor body, an initializer block, etc.
|
|
5055
|
+
if (lineDepth !== baseDepth + 1) {
|
|
5056
|
+
continue;
|
|
5057
|
+
}
|
|
5058
|
+
if (symbol.symbolKind === "method") {
|
|
5059
|
+
if (symbol.symbolName === simpleName) {
|
|
5060
|
+
constructors.push({ name: "<init>", line: symbol.line, kind: "constructor" });
|
|
5061
|
+
}
|
|
5062
|
+
else {
|
|
5063
|
+
methods.push({ name: symbol.symbolName, line: symbol.line, kind: "method" });
|
|
5064
|
+
}
|
|
5065
|
+
}
|
|
5066
|
+
else if (symbol.symbolKind === "field") {
|
|
5067
|
+
fields.push({ name: symbol.symbolName, line: symbol.line, kind: "field" });
|
|
5068
|
+
}
|
|
5069
|
+
}
|
|
5070
|
+
return { constructors, fields, methods };
|
|
5071
|
+
}
|
|
5072
|
+
computeLineBraceDepths(lines) {
|
|
5073
|
+
const depths = new Array(lines.length).fill(0);
|
|
5074
|
+
let depth = 0;
|
|
5075
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
5076
|
+
// Entry depth for this line = depth observed before any brace on it.
|
|
5077
|
+
depths[i] = depth;
|
|
5078
|
+
const stripped = (lines[i] ?? "")
|
|
5079
|
+
.replace(/\/\/.*/g, "")
|
|
5080
|
+
.replace(/"(?:\\.|[^"\\])*"/g, "\"\"")
|
|
5081
|
+
.replace(/'(?:\\.|[^'\\])*'/g, "''");
|
|
5082
|
+
for (const char of stripped) {
|
|
5083
|
+
if (char === "{") {
|
|
5084
|
+
depth += 1;
|
|
5085
|
+
}
|
|
5086
|
+
else if (char === "}") {
|
|
5087
|
+
depth -= 1;
|
|
5088
|
+
}
|
|
5089
|
+
}
|
|
5090
|
+
}
|
|
5091
|
+
return depths;
|
|
5092
|
+
}
|
|
5093
|
+
computeBraceRange(lines, symbols, simpleName) {
|
|
5094
|
+
const classSymbol = symbols.find((symbol) => (symbol.symbolKind === "class" || symbol.symbolKind === "interface"
|
|
5095
|
+
|| symbol.symbolKind === "enum" || symbol.symbolKind === "record")
|
|
5096
|
+
&& symbol.symbolName === simpleName);
|
|
5097
|
+
if (!classSymbol) {
|
|
5098
|
+
return undefined;
|
|
5099
|
+
}
|
|
5100
|
+
return this.scanBraceRange(lines, classSymbol.line);
|
|
5101
|
+
}
|
|
5102
|
+
scanBraceRange(lines, declarationLine) {
|
|
5103
|
+
let depth = 0;
|
|
5104
|
+
let started = false;
|
|
5105
|
+
for (let i = declarationLine - 1; i < lines.length; i += 1) {
|
|
5106
|
+
const stripped = (lines[i] ?? "")
|
|
5107
|
+
.replace(/\/\/.*/g, "")
|
|
5108
|
+
.replace(/"(?:\\.|[^"\\])*"/g, "\"\"");
|
|
5109
|
+
for (const char of stripped) {
|
|
5110
|
+
if (char === "{") {
|
|
5111
|
+
depth += 1;
|
|
5112
|
+
started = true;
|
|
5113
|
+
}
|
|
5114
|
+
else if (char === "}") {
|
|
5115
|
+
depth -= 1;
|
|
5116
|
+
if (started && depth === 0) {
|
|
5117
|
+
return { declarationLine, endLine: i + 1 };
|
|
5118
|
+
}
|
|
5119
|
+
}
|
|
5120
|
+
}
|
|
5121
|
+
}
|
|
5122
|
+
return { declarationLine, endLine: lines.length };
|
|
5123
|
+
}
|
|
5124
|
+
computeNestedTypeRanges(lines, symbols, outerBody) {
|
|
5125
|
+
const ranges = [];
|
|
5126
|
+
for (const candidate of symbols) {
|
|
5127
|
+
if (candidate.symbolKind !== "class" && candidate.symbolKind !== "interface"
|
|
5128
|
+
&& candidate.symbolKind !== "enum" && candidate.symbolKind !== "record") {
|
|
5129
|
+
continue;
|
|
5130
|
+
}
|
|
5131
|
+
if (candidate.line <= outerBody.declarationLine || candidate.line > outerBody.endLine) {
|
|
5132
|
+
continue;
|
|
5133
|
+
}
|
|
5134
|
+
if (ranges.some((range) => candidate.line >= range.declarationLine && candidate.line <= range.endLine)) {
|
|
5135
|
+
continue;
|
|
5136
|
+
}
|
|
5137
|
+
const nestedRange = this.scanBraceRange(lines, candidate.line);
|
|
5138
|
+
ranges.push(nestedRange);
|
|
5139
|
+
}
|
|
5140
|
+
return ranges;
|
|
5141
|
+
}
|
|
3939
5142
|
resolveClassFilePath(artifactId, className) {
|
|
3940
5143
|
const normalizedClassName = className.trim();
|
|
3941
5144
|
const classPath = classNameToClassPath(normalizedClassName);
|
|
@@ -4188,7 +5391,15 @@ export class SourceService {
|
|
|
4188
5391
|
name,
|
|
4189
5392
|
owner: ownerInSourceMapping,
|
|
4190
5393
|
descriptor,
|
|
4191
|
-
|
|
5394
|
+
// When we do have a descriptor this path is an exact lookup (the resolveMethodMappingExact
|
|
5395
|
+
// fast path is chosen instead whenever possible). findMapping's service-layer default is
|
|
5396
|
+
// "name-only" to match the public tool schema, so we must opt in to strict semantics here
|
|
5397
|
+
// to preserve the descriptor-aware overload selection this caller relies on.
|
|
5398
|
+
signatureMode: kind === "method"
|
|
5399
|
+
? descriptor
|
|
5400
|
+
? "exact"
|
|
5401
|
+
: "name-only"
|
|
5402
|
+
: undefined,
|
|
4192
5403
|
sourceMapping: mapping,
|
|
4193
5404
|
targetMapping: "obfuscated",
|
|
4194
5405
|
sourcePriority
|
|
@@ -4200,6 +5411,33 @@ export class SourceService {
|
|
|
4200
5411
|
descriptor: kind === "method" ? mapped.resolvedSymbol.descriptor ?? descriptor : undefined
|
|
4201
5412
|
};
|
|
4202
5413
|
}
|
|
5414
|
+
// resolveMethodMappingExact still rejects partial descriptor projections, so a
|
|
5415
|
+
// Mojang / Yarn method whose descriptor mixes a remapped Minecraft class with a JDK
|
|
5416
|
+
// type (e.g. `(L...ItemStack;Ljava/lang/String;)V`) bottoms out as unresolved /
|
|
5417
|
+
// mapping_unavailable here even though `findMapping` with signatureMode="exact" would
|
|
5418
|
+
// accept the partially projected descriptor. Fall back to `findMapping` before giving
|
|
5419
|
+
// up so downstream paths (access-widener remap, trace-symbol-lifecycle, signature
|
|
5420
|
+
// member remap) do not silently miss real methods.
|
|
5421
|
+
if (canResolveMethodExactly && (mapped.status === "not_found" || mapped.status === "mapping_unavailable")) {
|
|
5422
|
+
const fallbackMapped = await this.mappingService.findMapping({
|
|
5423
|
+
version,
|
|
5424
|
+
kind,
|
|
5425
|
+
name,
|
|
5426
|
+
owner: ownerInSourceMapping,
|
|
5427
|
+
descriptor,
|
|
5428
|
+
signatureMode: "exact",
|
|
5429
|
+
sourceMapping: mapping,
|
|
5430
|
+
targetMapping: "obfuscated",
|
|
5431
|
+
sourcePriority
|
|
5432
|
+
});
|
|
5433
|
+
warnings.push(...fallbackMapped.warnings);
|
|
5434
|
+
if (fallbackMapped.resolved && fallbackMapped.resolvedSymbol) {
|
|
5435
|
+
return {
|
|
5436
|
+
name: fallbackMapped.resolvedSymbol.name,
|
|
5437
|
+
descriptor: kind === "method" ? fallbackMapped.resolvedSymbol.descriptor ?? descriptor : undefined
|
|
5438
|
+
};
|
|
5439
|
+
}
|
|
5440
|
+
}
|
|
4203
5441
|
warnings.push(`Could not map ${kind} "${name}" from ${mapping} to obfuscated.`);
|
|
4204
5442
|
}
|
|
4205
5443
|
catch (caughtError) {
|
|
@@ -4210,21 +5448,22 @@ export class SourceService {
|
|
|
4210
5448
|
descriptor: kind === "method" ? descriptor : undefined
|
|
4211
5449
|
};
|
|
4212
5450
|
}
|
|
4213
|
-
async remapSignatureMembers(members, kind, version, sourceMapping, targetMapping, sourcePriority, warnings) {
|
|
5451
|
+
async remapSignatureMembers(members, kind, version, sourceMapping, targetMapping, sourcePriority, warnings, projectPath) {
|
|
4214
5452
|
const failedNames = new Set();
|
|
4215
5453
|
if (sourceMapping === targetMapping) {
|
|
4216
5454
|
return { members, failedNames };
|
|
4217
5455
|
}
|
|
4218
5456
|
// Build deduplicated lookup tables for member names and owner FQNs
|
|
4219
5457
|
const memberKeyToRemapped = new Map();
|
|
5458
|
+
const memberDescriptorRemapped = new Map();
|
|
4220
5459
|
const ownerToRemapped = new Map();
|
|
4221
5460
|
for (const member of members) {
|
|
4222
5461
|
const memberKey = `${member.ownerFqn}\0${member.name}\0${member.jvmDescriptor}`;
|
|
4223
5462
|
if (!memberKeyToRemapped.has(memberKey)) {
|
|
4224
|
-
memberKeyToRemapped.set(memberKey, member.name); // default =
|
|
5463
|
+
memberKeyToRemapped.set(memberKey, member.name); // default = source name
|
|
4225
5464
|
}
|
|
4226
5465
|
if (!ownerToRemapped.has(member.ownerFqn)) {
|
|
4227
|
-
ownerToRemapped.set(member.ownerFqn, member.ownerFqn); // default =
|
|
5466
|
+
ownerToRemapped.set(member.ownerFqn, member.ownerFqn); // default = source FQN
|
|
4228
5467
|
}
|
|
4229
5468
|
}
|
|
4230
5469
|
// Phase 1: Remap owner FQNs first (needed for member disambiguation)
|
|
@@ -4237,35 +5476,122 @@ export class SourceService {
|
|
|
4237
5476
|
name: obfuscatedFqn,
|
|
4238
5477
|
sourceMapping,
|
|
4239
5478
|
targetMapping,
|
|
4240
|
-
sourcePriority
|
|
5479
|
+
sourcePriority,
|
|
5480
|
+
projectPath
|
|
4241
5481
|
});
|
|
4242
5482
|
if (mapped.resolved && mapped.resolvedSymbol) {
|
|
4243
5483
|
ownerToRemapped.set(obfuscatedFqn, mapped.resolvedSymbol.name);
|
|
4244
5484
|
}
|
|
4245
5485
|
}
|
|
4246
5486
|
catch {
|
|
4247
|
-
// keep
|
|
5487
|
+
// keep source FQN as fallback
|
|
4248
5488
|
}
|
|
4249
5489
|
}));
|
|
4250
|
-
// Phase
|
|
5490
|
+
// Phase 1.5: Collect class references from descriptors and remap them
|
|
5491
|
+
const descriptorClassRefs = new Set();
|
|
5492
|
+
for (const member of members) {
|
|
5493
|
+
for (const match of member.jvmDescriptor.matchAll(/L([^;]+);/g)) {
|
|
5494
|
+
const dotFqn = match[1].replace(/\//g, ".");
|
|
5495
|
+
if (!ownerToRemapped.has(dotFqn)) {
|
|
5496
|
+
descriptorClassRefs.add(dotFqn);
|
|
5497
|
+
}
|
|
5498
|
+
}
|
|
5499
|
+
}
|
|
5500
|
+
if (descriptorClassRefs.size > 0) {
|
|
5501
|
+
const refs = [...descriptorClassRefs];
|
|
5502
|
+
for (const ref of refs) {
|
|
5503
|
+
ownerToRemapped.set(ref, ref); // default = source name
|
|
5504
|
+
}
|
|
5505
|
+
await Promise.all(refs.map(async (dotFqn) => {
|
|
5506
|
+
try {
|
|
5507
|
+
const mapped = await this.mappingService.findMapping({
|
|
5508
|
+
version,
|
|
5509
|
+
kind: "class",
|
|
5510
|
+
name: dotFqn,
|
|
5511
|
+
sourceMapping,
|
|
5512
|
+
targetMapping,
|
|
5513
|
+
sourcePriority,
|
|
5514
|
+
projectPath
|
|
5515
|
+
});
|
|
5516
|
+
if (mapped.resolved && mapped.resolvedSymbol) {
|
|
5517
|
+
ownerToRemapped.set(dotFqn, mapped.resolvedSymbol.name);
|
|
5518
|
+
}
|
|
5519
|
+
}
|
|
5520
|
+
catch {
|
|
5521
|
+
// keep source name as fallback
|
|
5522
|
+
}
|
|
5523
|
+
}));
|
|
5524
|
+
}
|
|
5525
|
+
// Build a class map for descriptor remapping (dot-FQN → dot-FQN)
|
|
5526
|
+
const classMap = new Map();
|
|
5527
|
+
for (const [src, tgt] of ownerToRemapped) {
|
|
5528
|
+
if (src !== tgt) {
|
|
5529
|
+
classMap.set(src, tgt);
|
|
5530
|
+
}
|
|
5531
|
+
}
|
|
5532
|
+
// Phase 2: Remap member names (and descriptors for methods) using resolved owners
|
|
5533
|
+
const canResolveMethodExactly = kind === "method" &&
|
|
5534
|
+
"resolveMethodMappingExact" in this.mappingService &&
|
|
5535
|
+
typeof this.mappingService.resolveMethodMappingExact === "function";
|
|
4251
5536
|
const memberEntries = [...memberKeyToRemapped.entries()];
|
|
4252
|
-
await Promise.all(memberEntries.map(async ([key,
|
|
5537
|
+
await Promise.all(memberEntries.map(async ([key, _sourceName]) => {
|
|
4253
5538
|
const [ownerFqn, name, descriptor] = key.split("\0");
|
|
4254
5539
|
try {
|
|
4255
5540
|
const targetOwner = ownerToRemapped.get(ownerFqn) ?? ownerFqn;
|
|
5541
|
+
// For methods with descriptors, try exact resolution first
|
|
5542
|
+
if (canResolveMethodExactly && descriptor) {
|
|
5543
|
+
try {
|
|
5544
|
+
const exactResult = await this.mappingService.resolveMethodMappingExact({
|
|
5545
|
+
version,
|
|
5546
|
+
owner: ownerFqn,
|
|
5547
|
+
name: name,
|
|
5548
|
+
descriptor,
|
|
5549
|
+
sourceMapping,
|
|
5550
|
+
targetMapping,
|
|
5551
|
+
sourcePriority,
|
|
5552
|
+
projectPath
|
|
5553
|
+
});
|
|
5554
|
+
if (exactResult.resolved && exactResult.resolvedSymbol) {
|
|
5555
|
+
memberKeyToRemapped.set(key, exactResult.resolvedSymbol.name);
|
|
5556
|
+
if (exactResult.resolvedSymbol.descriptor) {
|
|
5557
|
+
memberDescriptorRemapped.set(key, exactResult.resolvedSymbol.descriptor);
|
|
5558
|
+
}
|
|
5559
|
+
return; // exact resolution succeeded
|
|
5560
|
+
}
|
|
5561
|
+
// Fall through to findMapping with descriptorHint
|
|
5562
|
+
}
|
|
5563
|
+
catch (exactError) {
|
|
5564
|
+
warnings.push(`Exact method resolution failed for "${name}" (falling back to name-based lookup): ${exactError instanceof Error ? exactError.message : String(exactError)}`);
|
|
5565
|
+
}
|
|
5566
|
+
}
|
|
5567
|
+
// Fallback: findMapping with descriptorHint for overload disambiguation
|
|
5568
|
+
const remappedDescriptorHint = kind === "method" && descriptor
|
|
5569
|
+
? remapJvmDescriptor(descriptor, classMap)
|
|
5570
|
+
: undefined;
|
|
4256
5571
|
const mapped = await this.mappingService.findMapping({
|
|
4257
5572
|
version,
|
|
4258
5573
|
kind,
|
|
4259
5574
|
name,
|
|
4260
5575
|
owner: ownerFqn,
|
|
4261
5576
|
descriptor: kind === "method" ? descriptor : undefined,
|
|
5577
|
+
// Access-widener / access-transformer remap runs after validation has accepted
|
|
5578
|
+
// the descriptor as authoritative, so preserve exact overload matching even
|
|
5579
|
+
// though the descriptorHint path also exists for ambiguity fallback.
|
|
5580
|
+
signatureMode: kind === "method" && descriptor ? "exact" : undefined,
|
|
4262
5581
|
sourceMapping,
|
|
4263
5582
|
targetMapping,
|
|
4264
5583
|
sourcePriority,
|
|
4265
|
-
|
|
5584
|
+
projectPath,
|
|
5585
|
+
disambiguation: {
|
|
5586
|
+
ownerHint: targetOwner,
|
|
5587
|
+
descriptorHint: remappedDescriptorHint
|
|
5588
|
+
}
|
|
4266
5589
|
});
|
|
4267
5590
|
if (mapped.resolved && mapped.resolvedSymbol) {
|
|
4268
5591
|
memberKeyToRemapped.set(key, mapped.resolvedSymbol.name);
|
|
5592
|
+
if (kind === "method" && mapped.resolvedSymbol.descriptor) {
|
|
5593
|
+
memberDescriptorRemapped.set(key, mapped.resolvedSymbol.descriptor);
|
|
5594
|
+
}
|
|
4269
5595
|
}
|
|
4270
5596
|
else if (mapped.status === "ambiguous" && mapped.candidates && mapped.candidates.length > 0) {
|
|
4271
5597
|
// Disambiguate: filter by target owner and pick the best candidate
|
|
@@ -4293,13 +5619,20 @@ export class SourceService {
|
|
|
4293
5619
|
failedNames.add(name);
|
|
4294
5620
|
}
|
|
4295
5621
|
}));
|
|
5622
|
+
const isField = kind === "field";
|
|
4296
5623
|
return {
|
|
4297
5624
|
members: members.map((member) => {
|
|
4298
5625
|
const memberKey = `${member.ownerFqn}\0${member.name}\0${member.jvmDescriptor}`;
|
|
5626
|
+
const remappedName = memberKeyToRemapped.get(memberKey) ?? member.name;
|
|
5627
|
+
const remappedOwner = ownerToRemapped.get(member.ownerFqn) ?? member.ownerFqn;
|
|
5628
|
+
const remappedDescriptor = memberDescriptorRemapped.get(memberKey)
|
|
5629
|
+
?? remapJvmDescriptor(member.jvmDescriptor, classMap);
|
|
4299
5630
|
return {
|
|
4300
5631
|
...member,
|
|
4301
|
-
name:
|
|
4302
|
-
ownerFqn:
|
|
5632
|
+
name: remappedName,
|
|
5633
|
+
ownerFqn: remappedOwner,
|
|
5634
|
+
jvmDescriptor: remappedDescriptor,
|
|
5635
|
+
javaSignature: rebuildJavaSignature({ name: remappedName, ownerFqn: remappedOwner, accessFlags: member.accessFlags }, remappedDescriptor, isField)
|
|
4303
5636
|
};
|
|
4304
5637
|
}),
|
|
4305
5638
|
failedNames
|
|
@@ -4326,6 +5659,7 @@ export class SourceService {
|
|
|
4326
5659
|
toResolvedArtifact(artifact) {
|
|
4327
5660
|
return {
|
|
4328
5661
|
artifactId: artifact.artifactId,
|
|
5662
|
+
artifactAlias: artifact.alias,
|
|
4329
5663
|
artifactSignature: artifact.artifactSignature ?? this.fallbackArtifactSignature(artifact.artifactId),
|
|
4330
5664
|
origin: artifact.origin,
|
|
4331
5665
|
binaryJarPath: artifact.binaryJarPath,
|
|
@@ -4348,6 +5682,7 @@ export class SourceService {
|
|
|
4348
5682
|
const tx = this.db.transaction(() => {
|
|
4349
5683
|
this.artifactsRepo.upsertArtifact({
|
|
4350
5684
|
artifactId: resolved.artifactId,
|
|
5685
|
+
alias: resolved.artifactAlias,
|
|
4351
5686
|
origin: resolved.origin,
|
|
4352
5687
|
coordinate: resolved.coordinate,
|
|
4353
5688
|
version: resolved.version,
|
|
@@ -4399,10 +5734,20 @@ export class SourceService {
|
|
|
4399
5734
|
files = await this.loadFromSourceJar(resolved.sourceJarPath);
|
|
4400
5735
|
}
|
|
4401
5736
|
else if (resolved.binaryJarPath) {
|
|
5737
|
+
const decompileInputJarPath = await this.maybeRemapBinaryForMojang(resolved);
|
|
5738
|
+
// When the binary jar was remapped from obfuscated to mojang, swap the resolved
|
|
5739
|
+
// artifact's binaryJarPath to the remapped jar so downstream bytecode consumers
|
|
5740
|
+
// (getClassMembers, validateMixin) look up mojang names in the mojang jar — not
|
|
5741
|
+
// the original obfuscated jar. Persistence in upsertArtifact happens after this
|
|
5742
|
+
// function returns, so the swap reaches both the database row and the
|
|
5743
|
+
// resolveArtifact response.
|
|
5744
|
+
if (decompileInputJarPath !== resolved.binaryJarPath) {
|
|
5745
|
+
resolved.binaryJarPath = decompileInputJarPath;
|
|
5746
|
+
}
|
|
4402
5747
|
const vineflowerPath = await resolveVineflowerJar(this.config.cacheDir, this.config.vineflowerJarPath);
|
|
4403
5748
|
const decompileStartedAt = Date.now();
|
|
4404
5749
|
try {
|
|
4405
|
-
const decompileResult = await decompileBinaryJar(
|
|
5750
|
+
const decompileResult = await decompileBinaryJar(decompileInputJarPath, this.config.cacheDir, {
|
|
4406
5751
|
vineflowerJarPath: vineflowerPath,
|
|
4407
5752
|
artifactIdCandidate: resolved.artifactId,
|
|
4408
5753
|
timeoutMs: 120_000,
|
|
@@ -4504,6 +5849,30 @@ export class SourceService {
|
|
|
4504
5849
|
meta
|
|
4505
5850
|
});
|
|
4506
5851
|
if (existing && reason === "already_current") {
|
|
5852
|
+
// Mojang binary-remap reconciliation on the warm cache hit path:
|
|
5853
|
+
// resolveSourceTargetInternal always returns the original binary jar
|
|
5854
|
+
// (resolver does not know about prior remap output), so without this
|
|
5855
|
+
// step a warm-cache resolve would return mappingApplied="mojang"
|
|
5856
|
+
// alongside binaryJarPath pointing at the obfuscated client jar.
|
|
5857
|
+
// maybeRemapBinaryForMojang short-circuits on a healthy cache hit
|
|
5858
|
+
// (existsSync + ZIP magic) and re-remaps when the cache is missing
|
|
5859
|
+
// or corrupted, so this also recovers from out-of-band cache loss.
|
|
5860
|
+
const transformChain = resolved.provenance?.transformChain ?? [];
|
|
5861
|
+
if (transformChain.includes("binary-remap:obf->mojang") && resolved.binaryJarPath) {
|
|
5862
|
+
const reconciledBinaryJarPath = await this.maybeRemapBinaryForMojang(resolved);
|
|
5863
|
+
if (reconciledBinaryJarPath !== resolved.binaryJarPath) {
|
|
5864
|
+
resolved.binaryJarPath = reconciledBinaryJarPath;
|
|
5865
|
+
}
|
|
5866
|
+
}
|
|
5867
|
+
// Backfill / rotate alias on the warm-cache path. Without this, schema-v4
|
|
5868
|
+
// migrated rows (alias=NULL) and rows whose alias parameters changed since
|
|
5869
|
+
// the last upsert would return an artifactAlias from resolveArtifact that
|
|
5870
|
+
// does not resolve back via getArtifact(alias), breaking the 3.1b lookup
|
|
5871
|
+
// contract. UNIQUE conflicts here are caller bugs (two distinct artifactIds
|
|
5872
|
+
// colliding on alias) and surface as DB errors rather than silent drift.
|
|
5873
|
+
if (resolved.artifactAlias && existing.alias !== resolved.artifactAlias) {
|
|
5874
|
+
this.artifactsRepo.setAlias(resolved.artifactId, resolved.artifactAlias);
|
|
5875
|
+
}
|
|
4507
5876
|
this.metrics.recordArtifactCacheHit();
|
|
4508
5877
|
const touchedAt = new Date().toISOString();
|
|
4509
5878
|
this.artifactsRepo.touchArtifact(resolved.artifactId, touchedAt);
|
|
@@ -4519,6 +5888,164 @@ export class SourceService {
|
|
|
4519
5888
|
await this.rebuildAndPersistArtifactIndex(resolved, reason === "already_current" ? "missing_meta" : reason);
|
|
4520
5889
|
this.enforceCacheLimits();
|
|
4521
5890
|
}
|
|
5891
|
+
/**
|
|
5892
|
+
* If the resolved artifact's transformChain promised an "obf -> mojang"
|
|
5893
|
+
* binary remap, run tiny-remapper now and return the remapped jar path.
|
|
5894
|
+
* Otherwise return the original binaryJarPath unchanged.
|
|
5895
|
+
*
|
|
5896
|
+
* Cache safety: writes to a per-attempt temp file then atomic-renames into
|
|
5897
|
+
* <cacheDir>/remapped/<artifactId>.jar. A per-target inflight Promise map
|
|
5898
|
+
* collapses concurrent calls so two simultaneous resolveArtifact calls for
|
|
5899
|
+
* the same artifactId share one tiny-remapper run instead of racing on the
|
|
5900
|
+
* same output path.
|
|
5901
|
+
*/
|
|
5902
|
+
async maybeRemapBinaryForMojang(resolved) {
|
|
5903
|
+
const binaryJarPath = resolved.binaryJarPath;
|
|
5904
|
+
if (!binaryJarPath) {
|
|
5905
|
+
throw createError({
|
|
5906
|
+
code: ERROR_CODES.SOURCE_NOT_FOUND,
|
|
5907
|
+
message: "Cannot run binary remap: resolved artifact has no binary jar path.",
|
|
5908
|
+
details: { artifactId: resolved.artifactId }
|
|
5909
|
+
});
|
|
5910
|
+
}
|
|
5911
|
+
const transformChain = resolved.provenance?.transformChain ?? [];
|
|
5912
|
+
if (!transformChain.includes("binary-remap:obf->mojang")) {
|
|
5913
|
+
return binaryJarPath;
|
|
5914
|
+
}
|
|
5915
|
+
if (!resolved.version) {
|
|
5916
|
+
throw createError({
|
|
5917
|
+
code: ERROR_CODES.MAPPING_NOT_APPLIED,
|
|
5918
|
+
message: "Binary remap promised but artifact has no resolved Minecraft version.",
|
|
5919
|
+
details: {
|
|
5920
|
+
artifactId: resolved.artifactId,
|
|
5921
|
+
binaryJarPath,
|
|
5922
|
+
nextAction: "Use target.kind=\"version\" so the remap pipeline can locate Mojang mappings."
|
|
5923
|
+
}
|
|
5924
|
+
});
|
|
5925
|
+
}
|
|
5926
|
+
const remappedDir = join(this.config.cacheDir, "remapped");
|
|
5927
|
+
const remappedJarPath = join(remappedDir, `${resolved.artifactId}.jar`);
|
|
5928
|
+
if (existsSync(remappedJarPath)) {
|
|
5929
|
+
// Validate the cached jar is at least structurally a ZIP (`PK\x03\x04`) and
|
|
5930
|
+
// non-empty before reusing. If a prior atomic-rename window was interrupted
|
|
5931
|
+
// or the cache file was hand-edited, drop it and re-remap rather than
|
|
5932
|
+
// silently feeding a corrupt jar into Vineflower.
|
|
5933
|
+
if (await this.isUsableJarFile(remappedJarPath)) {
|
|
5934
|
+
await this.recordRemappedJarBytesFromDisk(resolved.artifactId, remappedJarPath);
|
|
5935
|
+
return remappedJarPath;
|
|
5936
|
+
}
|
|
5937
|
+
log("warn", "binary-remap.cache.evict-corrupt", {
|
|
5938
|
+
artifactId: resolved.artifactId,
|
|
5939
|
+
remappedJarPath
|
|
5940
|
+
});
|
|
5941
|
+
try {
|
|
5942
|
+
await unlink(remappedJarPath);
|
|
5943
|
+
}
|
|
5944
|
+
catch {
|
|
5945
|
+
// ignore: race with another process or already-deleted file.
|
|
5946
|
+
}
|
|
5947
|
+
this.releaseRemappedJarBytes(resolved.artifactId);
|
|
5948
|
+
}
|
|
5949
|
+
const inflight = this.inflightRemaps.get(remappedJarPath);
|
|
5950
|
+
if (inflight) {
|
|
5951
|
+
return inflight;
|
|
5952
|
+
}
|
|
5953
|
+
const remapPromise = this.runBinaryRemap({
|
|
5954
|
+
version: resolved.version,
|
|
5955
|
+
inputJar: binaryJarPath,
|
|
5956
|
+
remappedDir,
|
|
5957
|
+
remappedJarPath
|
|
5958
|
+
});
|
|
5959
|
+
this.inflightRemaps.set(remappedJarPath, remapPromise);
|
|
5960
|
+
try {
|
|
5961
|
+
const path = await remapPromise;
|
|
5962
|
+
await this.recordRemappedJarBytesFromDisk(resolved.artifactId, path);
|
|
5963
|
+
return path;
|
|
5964
|
+
}
|
|
5965
|
+
finally {
|
|
5966
|
+
this.inflightRemaps.delete(remappedJarPath);
|
|
5967
|
+
}
|
|
5968
|
+
}
|
|
5969
|
+
async recordRemappedJarBytesFromDisk(artifactId, path) {
|
|
5970
|
+
try {
|
|
5971
|
+
const fileStat = await stat(path);
|
|
5972
|
+
this.recordRemappedJarBytes(artifactId, fileStat.size);
|
|
5973
|
+
}
|
|
5974
|
+
catch {
|
|
5975
|
+
// best-effort: accounting will be rebuilt on the next refreshCacheMetrics.
|
|
5976
|
+
}
|
|
5977
|
+
}
|
|
5978
|
+
/**
|
|
5979
|
+
* Best-effort structural check that `path` is a non-empty file beginning with
|
|
5980
|
+
* the ZIP local-file-header magic (`50 4B 03 04`). Used to drop partial /
|
|
5981
|
+
* corrupt remap-cache entries before they reach Vineflower. False positives
|
|
5982
|
+
* are acceptable (Vineflower will surface a clearer error); false negatives
|
|
5983
|
+
* are not (a corrupt cache hit must be evicted).
|
|
5984
|
+
*/
|
|
5985
|
+
async isUsableJarFile(path) {
|
|
5986
|
+
try {
|
|
5987
|
+
const stats = await stat(path);
|
|
5988
|
+
if (!stats.isFile() || stats.size < 4) {
|
|
5989
|
+
return false;
|
|
5990
|
+
}
|
|
5991
|
+
}
|
|
5992
|
+
catch {
|
|
5993
|
+
return false;
|
|
5994
|
+
}
|
|
5995
|
+
let handle;
|
|
5996
|
+
try {
|
|
5997
|
+
handle = await open(path, "r");
|
|
5998
|
+
const header = Buffer.alloc(4);
|
|
5999
|
+
const { bytesRead } = await handle.read(header, 0, 4, 0);
|
|
6000
|
+
return bytesRead === 4 && header[0] === 0x50 && header[1] === 0x4b && header[2] === 0x03 && header[3] === 0x04;
|
|
6001
|
+
}
|
|
6002
|
+
catch {
|
|
6003
|
+
return false;
|
|
6004
|
+
}
|
|
6005
|
+
finally {
|
|
6006
|
+
await handle?.close().catch(() => undefined);
|
|
6007
|
+
}
|
|
6008
|
+
}
|
|
6009
|
+
async runBinaryRemap(input) {
|
|
6010
|
+
const tinyRemapperJarPath = await resolveTinyRemapperJar(this.config.cacheDir, this.config.tinyRemapperJarPath);
|
|
6011
|
+
const mojangTiny = await resolveMojangTinyFile(input.version, this.config);
|
|
6012
|
+
await mkdir(input.remappedDir, { recursive: true });
|
|
6013
|
+
const tempPath = `${input.remappedJarPath}.tmp.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}`;
|
|
6014
|
+
const remapStartedAt = Date.now();
|
|
6015
|
+
try {
|
|
6016
|
+
await remapJar(tinyRemapperJarPath, {
|
|
6017
|
+
inputJar: input.inputJar,
|
|
6018
|
+
outputJar: tempPath,
|
|
6019
|
+
mappingsFile: mojangTiny.path,
|
|
6020
|
+
fromNamespace: "obfuscated",
|
|
6021
|
+
toNamespace: "mojang",
|
|
6022
|
+
timeoutMs: this.config.remapTimeoutMs,
|
|
6023
|
+
maxMemoryMb: this.config.remapMaxMemoryMb
|
|
6024
|
+
});
|
|
6025
|
+
const tempStats = await stat(tempPath);
|
|
6026
|
+
if (tempStats.size === 0) {
|
|
6027
|
+
throw createError({
|
|
6028
|
+
code: ERROR_CODES.REMAP_FAILED,
|
|
6029
|
+
message: "tiny-remapper produced an empty output jar.",
|
|
6030
|
+
details: { inputJar: input.inputJar, tempPath }
|
|
6031
|
+
});
|
|
6032
|
+
}
|
|
6033
|
+
await rename(tempPath, input.remappedJarPath);
|
|
6034
|
+
return input.remappedJarPath;
|
|
6035
|
+
}
|
|
6036
|
+
catch (caughtError) {
|
|
6037
|
+
try {
|
|
6038
|
+
await unlink(tempPath);
|
|
6039
|
+
}
|
|
6040
|
+
catch {
|
|
6041
|
+
// tempPath may not exist if remapJar failed before writing anything; ignore.
|
|
6042
|
+
}
|
|
6043
|
+
throw caughtError;
|
|
6044
|
+
}
|
|
6045
|
+
finally {
|
|
6046
|
+
this.metrics.recordDuration("binary_remap_duration_ms", Date.now() - remapStartedAt);
|
|
6047
|
+
}
|
|
6048
|
+
}
|
|
4522
6049
|
async loadFromSourceJar(sourceJarPath) {
|
|
4523
6050
|
const files = [];
|
|
4524
6051
|
for await (const entry of iterateJavaEntriesAsUtf8(sourceJarPath, this.config.maxContentBytes)) {
|
|
@@ -4534,13 +6061,55 @@ export class SourceService {
|
|
|
4534
6061
|
hasAnyFiles(artifactId) {
|
|
4535
6062
|
return this.filesRepo.listFiles(artifactId, { limit: 1 }).items.length > 0;
|
|
4536
6063
|
}
|
|
6064
|
+
/**
|
|
6065
|
+
* Best-effort cleanup of `<cacheDir>/remapped/<artifactId>.jar` written by
|
|
6066
|
+
* `maybeRemapBinaryForMojang`. Called from cache-eviction paths so the
|
|
6067
|
+
* Mojang-remapped binary jar does not outlive the artifact row that owns it.
|
|
6068
|
+
* Also releases the jar's bytes from `cacheTotalContentBytes`. Errors are
|
|
6069
|
+
* swallowed: orphaned jars remain visible to `manage-cache` under the
|
|
6070
|
+
* `binary-remap` cache kind and can be reclaimed there.
|
|
6071
|
+
*/
|
|
6072
|
+
unlinkRemappedJarForArtifact(artifactId) {
|
|
6073
|
+
this.releaseRemappedJarBytes(artifactId);
|
|
6074
|
+
const remappedJarPath = join(this.config.cacheDir, "remapped", `${artifactId}.jar`);
|
|
6075
|
+
try {
|
|
6076
|
+
if (existsSync(remappedJarPath)) {
|
|
6077
|
+
unlinkSync(remappedJarPath);
|
|
6078
|
+
}
|
|
6079
|
+
}
|
|
6080
|
+
catch {
|
|
6081
|
+
// ignore: orphaned jar is still reclaimable via manage-cache binary-remap kind.
|
|
6082
|
+
}
|
|
6083
|
+
}
|
|
6084
|
+
/**
|
|
6085
|
+
* Add the remapped jar's on-disk size to `cacheTotalContentBytes` so the
|
|
6086
|
+
* `enforceCacheLimits` byte gate sees the jar before deciding to evict.
|
|
6087
|
+
* Without this, a Mojang-remapped client jar (tens of MB) can accumulate
|
|
6088
|
+
* silently while the indexed-source byte total stays below `maxCacheBytes`.
|
|
6089
|
+
*/
|
|
6090
|
+
recordRemappedJarBytes(artifactId, sizeBytes) {
|
|
6091
|
+
const normalized = Math.max(0, Math.trunc(sizeBytes));
|
|
6092
|
+
const existing = this.remappedJarBytes.get(artifactId) ?? 0;
|
|
6093
|
+
this.cacheTotalContentBytes = Math.max(0, this.cacheTotalContentBytes - existing + normalized);
|
|
6094
|
+
this.remappedJarBytes.set(artifactId, normalized);
|
|
6095
|
+
this.publishCacheMetrics();
|
|
6096
|
+
}
|
|
6097
|
+
releaseRemappedJarBytes(artifactId) {
|
|
6098
|
+
const existing = this.remappedJarBytes.get(artifactId);
|
|
6099
|
+
if (!existing) {
|
|
6100
|
+
return;
|
|
6101
|
+
}
|
|
6102
|
+
this.cacheTotalContentBytes = Math.max(0, this.cacheTotalContentBytes - existing);
|
|
6103
|
+
this.remappedJarBytes.delete(artifactId);
|
|
6104
|
+
this.publishCacheMetrics();
|
|
6105
|
+
}
|
|
4537
6106
|
enforceCacheLimits() {
|
|
4538
|
-
let artifactCount = this.
|
|
4539
|
-
let totalBytes = this.
|
|
6107
|
+
let artifactCount = this.lru.size;
|
|
6108
|
+
let totalBytes = this.cacheTotalContentBytes;
|
|
4540
6109
|
if (artifactCount <= this.config.maxArtifacts && totalBytes <= this.config.maxCacheBytes) {
|
|
4541
6110
|
return;
|
|
4542
6111
|
}
|
|
4543
|
-
const candidates =
|
|
6112
|
+
const candidates = this.lru.toArray();
|
|
4544
6113
|
for (const candidate of candidates) {
|
|
4545
6114
|
const shouldEvict = artifactCount > this.config.maxArtifacts || totalBytes > this.config.maxCacheBytes;
|
|
4546
6115
|
if (!shouldEvict || artifactCount <= 1) {
|
|
@@ -4548,17 +6117,19 @@ export class SourceService {
|
|
|
4548
6117
|
}
|
|
4549
6118
|
const artifactCountBefore = artifactCount;
|
|
4550
6119
|
const totalBytesBefore = totalBytes;
|
|
4551
|
-
this.
|
|
4552
|
-
this.
|
|
4553
|
-
this.
|
|
6120
|
+
const remappedBytesForCandidate = this.remappedJarBytes.get(candidate.key) ?? 0;
|
|
6121
|
+
this.filesRepo.deleteFilesForArtifact(candidate.key);
|
|
6122
|
+
this.artifactsRepo.deleteArtifact(candidate.key);
|
|
6123
|
+
this.unlinkRemappedJarForArtifact(candidate.key);
|
|
6124
|
+
this.removeCacheMetrics(candidate.key, false);
|
|
4554
6125
|
artifactCount = Math.max(0, artifactCount - 1);
|
|
4555
|
-
totalBytes = Math.max(0, totalBytes - candidate.totalContentBytes);
|
|
6126
|
+
totalBytes = Math.max(0, totalBytes - candidate.value.totalContentBytes - remappedBytesForCandidate);
|
|
4556
6127
|
this.metrics.recordCacheEviction();
|
|
4557
6128
|
log("warn", "cache.evict", {
|
|
4558
|
-
artifactId: candidate.
|
|
6129
|
+
artifactId: candidate.key,
|
|
4559
6130
|
artifactCountBefore,
|
|
4560
6131
|
totalBytesBefore,
|
|
4561
|
-
artifactBytes: candidate.totalContentBytes
|
|
6132
|
+
artifactBytes: candidate.value.totalContentBytes + remappedBytesForCandidate
|
|
4562
6133
|
});
|
|
4563
6134
|
}
|
|
4564
6135
|
this.publishCacheMetrics();
|
|
@@ -4566,82 +6137,129 @@ export class SourceService {
|
|
|
4566
6137
|
refreshCacheMetrics() {
|
|
4567
6138
|
const cacheEntries = this.artifactsRepo.countArtifacts();
|
|
4568
6139
|
const totalContentBytes = this.artifactsRepo.totalContentBytes();
|
|
4569
|
-
const lruAccounting = this.artifactsRepo
|
|
4570
|
-
|
|
4571
|
-
|
|
4572
|
-
|
|
4573
|
-
|
|
4574
|
-
|
|
4575
|
-
|
|
4576
|
-
|
|
4577
|
-
|
|
4578
|
-
|
|
4579
|
-
|
|
4580
|
-
|
|
6140
|
+
const lruAccounting = this.artifactsRepo.listArtifactsByLruWithContentBytes(Math.max(cacheEntries, 1));
|
|
6141
|
+
this.lru.clear();
|
|
6142
|
+
for (const row of lruAccounting) {
|
|
6143
|
+
this.lru.upsert(row.artifactId, {
|
|
6144
|
+
totalContentBytes: row.totalContentBytes,
|
|
6145
|
+
updatedAt: row.updatedAt
|
|
6146
|
+
});
|
|
6147
|
+
}
|
|
6148
|
+
this.remappedJarBytes.clear();
|
|
6149
|
+
let remappedTotal = 0;
|
|
6150
|
+
const remappedDir = join(this.config.cacheDir, "remapped");
|
|
6151
|
+
if (existsSync(remappedDir)) {
|
|
6152
|
+
// Only count remapped jars whose owning artifact is still in the LRU set.
|
|
6153
|
+
// Orphaned jars (artifact deleted, prior unlink lost a race, externally
|
|
6154
|
+
// placed) stay visible to manage-cache under the `binary-remap` kind
|
|
6155
|
+
// for prune, but must not be folded into `cacheTotalContentBytes` here:
|
|
6156
|
+
// enforceCacheLimits cannot evict them, so counting their bytes would
|
|
6157
|
+
// force unrelated live artifacts to be evicted to chase orphan bytes.
|
|
6158
|
+
const liveArtifactIds = new Set(this.lru.toArray().map((entry) => entry.key));
|
|
6159
|
+
try {
|
|
6160
|
+
for (const entry of readdirSync(remappedDir, { withFileTypes: true })) {
|
|
6161
|
+
if (!entry.isFile() || !entry.name.endsWith(".jar")) {
|
|
6162
|
+
continue;
|
|
6163
|
+
}
|
|
6164
|
+
const artifactId = entry.name.slice(0, -".jar".length);
|
|
6165
|
+
if (!liveArtifactIds.has(artifactId)) {
|
|
6166
|
+
continue;
|
|
6167
|
+
}
|
|
6168
|
+
try {
|
|
6169
|
+
const fileStat = statSync(join(remappedDir, entry.name));
|
|
6170
|
+
const size = Math.max(0, Math.trunc(fileStat.size));
|
|
6171
|
+
this.remappedJarBytes.set(artifactId, size);
|
|
6172
|
+
remappedTotal += size;
|
|
6173
|
+
}
|
|
6174
|
+
catch {
|
|
6175
|
+
// ignore stat failure on a single jar; total stays best-effort.
|
|
6176
|
+
}
|
|
6177
|
+
}
|
|
6178
|
+
}
|
|
6179
|
+
catch {
|
|
6180
|
+
// ignore listing failure; remapped accounting stays empty until next remap.
|
|
6181
|
+
}
|
|
6182
|
+
}
|
|
6183
|
+
this.cacheTotalContentBytes = totalContentBytes + remappedTotal;
|
|
4581
6184
|
this.publishCacheMetrics();
|
|
4582
6185
|
}
|
|
4583
6186
|
touchCacheMetrics(artifactId, updatedAt) {
|
|
4584
|
-
const
|
|
4585
|
-
if (
|
|
4586
|
-
this.refreshCacheMetrics();
|
|
4587
|
-
return;
|
|
4588
|
-
}
|
|
4589
|
-
const [existing] = this.cacheMetricsState.lru.splice(existingIndex, 1);
|
|
4590
|
-
if (!existing) {
|
|
6187
|
+
const entry = this.lru.touch(artifactId);
|
|
6188
|
+
if (!entry) {
|
|
4591
6189
|
this.refreshCacheMetrics();
|
|
4592
6190
|
return;
|
|
4593
6191
|
}
|
|
4594
|
-
|
|
4595
|
-
this.cacheMetricsState.lru.push(existing);
|
|
6192
|
+
entry.updatedAt = updatedAt;
|
|
4596
6193
|
this.publishCacheMetrics();
|
|
4597
6194
|
}
|
|
4598
6195
|
upsertCacheMetrics(artifactId, totalContentBytes, updatedAt) {
|
|
4599
6196
|
const normalizedBytes = Math.max(0, Math.trunc(totalContentBytes));
|
|
4600
|
-
const
|
|
4601
|
-
if (
|
|
4602
|
-
|
|
4603
|
-
if (!existing) {
|
|
4604
|
-
this.refreshCacheMetrics();
|
|
4605
|
-
return;
|
|
4606
|
-
}
|
|
4607
|
-
this.cacheMetricsState.totalContentBytes = Math.max(0, this.cacheMetricsState.totalContentBytes - existing.totalContentBytes + normalizedBytes);
|
|
4608
|
-
existing.totalContentBytes = normalizedBytes;
|
|
4609
|
-
existing.updatedAt = updatedAt;
|
|
4610
|
-
this.cacheMetricsState.lru.push(existing);
|
|
6197
|
+
const existing = this.lru.remove(artifactId);
|
|
6198
|
+
if (existing) {
|
|
6199
|
+
this.cacheTotalContentBytes = Math.max(0, this.cacheTotalContentBytes - existing.totalContentBytes + normalizedBytes);
|
|
4611
6200
|
}
|
|
4612
6201
|
else {
|
|
4613
|
-
this.
|
|
4614
|
-
this.cacheMetricsState.totalContentBytes += normalizedBytes;
|
|
4615
|
-
this.cacheMetricsState.lru.push({
|
|
4616
|
-
artifactId,
|
|
4617
|
-
totalContentBytes: normalizedBytes,
|
|
4618
|
-
updatedAt
|
|
4619
|
-
});
|
|
6202
|
+
this.cacheTotalContentBytes += normalizedBytes;
|
|
4620
6203
|
}
|
|
4621
|
-
this.
|
|
6204
|
+
this.lru.upsert(artifactId, { totalContentBytes: normalizedBytes, updatedAt });
|
|
4622
6205
|
this.publishCacheMetrics();
|
|
4623
6206
|
}
|
|
4624
6207
|
removeCacheMetrics(artifactId, publish = true) {
|
|
4625
|
-
const
|
|
4626
|
-
if (existingIndex < 0) {
|
|
4627
|
-
this.refreshCacheMetrics();
|
|
4628
|
-
return;
|
|
4629
|
-
}
|
|
4630
|
-
const [existing] = this.cacheMetricsState.lru.splice(existingIndex, 1);
|
|
6208
|
+
const existing = this.lru.remove(artifactId);
|
|
4631
6209
|
if (!existing) {
|
|
4632
6210
|
this.refreshCacheMetrics();
|
|
4633
6211
|
return;
|
|
4634
6212
|
}
|
|
4635
|
-
this.
|
|
4636
|
-
this.cacheMetricsState.totalContentBytes = Math.max(0, this.cacheMetricsState.totalContentBytes - existing.totalContentBytes);
|
|
6213
|
+
this.cacheTotalContentBytes = Math.max(0, this.cacheTotalContentBytes - existing.totalContentBytes);
|
|
4637
6214
|
if (publish) {
|
|
4638
6215
|
this.publishCacheMetrics();
|
|
4639
6216
|
}
|
|
4640
6217
|
}
|
|
4641
6218
|
publishCacheMetrics() {
|
|
4642
|
-
this.metrics.setCacheEntries(this.
|
|
4643
|
-
this.metrics.setCacheTotalContentBytes(this.
|
|
4644
|
-
|
|
6219
|
+
this.metrics.setCacheEntries(this.lru.size);
|
|
6220
|
+
this.metrics.setCacheTotalContentBytes(this.cacheTotalContentBytes);
|
|
6221
|
+
}
|
|
6222
|
+
snapshotLruAccounting() {
|
|
6223
|
+
this.metrics.setCacheArtifactByteAccountingRef(this.lru.toArray().map(({ key, value }) => ({
|
|
6224
|
+
artifactId: key,
|
|
6225
|
+
totalContentBytes: value.totalContentBytes,
|
|
6226
|
+
updatedAt: value.updatedAt
|
|
6227
|
+
})));
|
|
6228
|
+
}
|
|
6229
|
+
}
|
|
6230
|
+
function remapJvmDescriptor(descriptor, classMap) {
|
|
6231
|
+
if (classMap.size === 0) {
|
|
6232
|
+
return descriptor;
|
|
6233
|
+
}
|
|
6234
|
+
return descriptor.replace(/L([^;]+);/g, (match, ref) => {
|
|
6235
|
+
const dotFqn = ref.replace(/\//g, ".");
|
|
6236
|
+
const remapped = classMap.get(dotFqn);
|
|
6237
|
+
return remapped ? `L${remapped.replace(/\./g, "/")};` : match;
|
|
6238
|
+
});
|
|
6239
|
+
}
|
|
6240
|
+
function rebuildJavaSignature(member, remappedDescriptor, isField) {
|
|
6241
|
+
const modifiers = modifierPrefix(member.accessFlags, isField ? "field" : "method");
|
|
6242
|
+
const prefix = modifiers ? `${modifiers} ` : "";
|
|
6243
|
+
if (isField) {
|
|
6244
|
+
try {
|
|
6245
|
+
const { type } = parseFieldType(remappedDescriptor, 0, { allowVoid: false });
|
|
6246
|
+
return `${prefix}${type} ${member.name}`.trim();
|
|
6247
|
+
}
|
|
6248
|
+
catch {
|
|
6249
|
+
return `${prefix}${member.name}`.trim();
|
|
6250
|
+
}
|
|
6251
|
+
}
|
|
6252
|
+
try {
|
|
6253
|
+
const { args, returnType } = parseMethodDescriptor(remappedDescriptor);
|
|
6254
|
+
const argStr = args.join(", ");
|
|
6255
|
+
if (member.name === "<init>") {
|
|
6256
|
+
const ownerSimple = member.ownerFqn.split(".").pop();
|
|
6257
|
+
return `${prefix}${ownerSimple}(${argStr})`.trim();
|
|
6258
|
+
}
|
|
6259
|
+
return `${prefix}${returnType} ${member.name}(${argStr})`.trim();
|
|
6260
|
+
}
|
|
6261
|
+
catch {
|
|
6262
|
+
return `${prefix}${member.name}`.trim();
|
|
4645
6263
|
}
|
|
4646
6264
|
}
|
|
4647
6265
|
//# sourceMappingURL=source-service.js.map
|