@adhisang/minecraft-modding-mcp 3.2.0 → 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 +25 -0
- package/README.md +25 -18
- package/dist/cache-registry.d.ts +1 -1
- package/dist/cache-registry.js +10 -2
- package/dist/config.d.ts +10 -1
- package/dist/config.js +52 -1
- package/dist/entry-tools/analyze-symbol-service.d.ts +2 -2
- package/dist/entry-tools/analyze-symbol-service.js +13 -2
- package/dist/entry-tools/inspect-minecraft-service.d.ts +20 -20
- 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.js +84 -4
- package/dist/index.js +99 -33
- package/dist/lru-list.d.ts +31 -0
- package/dist/lru-list.js +102 -0
- package/dist/mapping-pipeline-service.d.ts +10 -1
- package/dist/mapping-pipeline-service.js +13 -1
- package/dist/mapping-service.d.ts +12 -0
- package/dist/mapping-service.js +252 -10
- package/dist/mixin-validator.js +22 -7
- package/dist/observability.d.ts +18 -1
- package/dist/observability.js +44 -1
- package/dist/response-utils.d.ts +44 -10
- package/dist/response-utils.js +131 -17
- package/dist/source-resolver.d.ts +9 -1
- package/dist/source-resolver.js +14 -6
- package/dist/source-service.d.ts +97 -1
- package/dist/source-service.js +922 -113
- 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/types.d.ts +3 -0
- package/package.json +3 -1
package/dist/source-service.js
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import { existsSync } from "node:fs";
|
|
3
|
-
import { access, readFile, writeFile } from "node:fs/promises";
|
|
4
|
-
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";
|
|
5
5
|
import fastGlob from "fast-glob";
|
|
6
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
15
|
import { MinecraftExplorerService, modifierPrefix, parseFieldType, parseMethodDescriptor } from "./minecraft-explorer-service.js";
|
|
13
16
|
import { parseMixinSource } from "./mixin-parser.js";
|
|
@@ -25,6 +28,7 @@ import { FilesRepo } from "./storage/files-repo.js";
|
|
|
25
28
|
import { IndexMetaRepo } from "./storage/index-meta-repo.js";
|
|
26
29
|
import { SymbolsRepo } from "./storage/symbols-repo.js";
|
|
27
30
|
import { RuntimeMetrics } from "./observability.js";
|
|
31
|
+
import { LruList } from "./lru-list.js";
|
|
28
32
|
import { log } from "./logger.js";
|
|
29
33
|
import { normalizePathForHost } from "./path-converter.js";
|
|
30
34
|
import { buildLoaderRuntimeSearchRoots, buildVersionSourceSearchRoots, normalizeOptionalProjectPath } from "./gradle-paths.js";
|
|
@@ -148,6 +152,26 @@ function sameMixinValidationProvenance(left, right) {
|
|
|
148
152
|
sameScopeFallback(left.scopeFallback, right.scopeFallback) &&
|
|
149
153
|
sameResolutionTrace(left.resolutionTrace, right.resolutionTrace));
|
|
150
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
|
+
}
|
|
151
175
|
const INDEX_SCHEMA_VERSION = 1;
|
|
152
176
|
const SYMBOL_KINDS = ["class", "interface", "enum", "record", "method", "field"];
|
|
153
177
|
function isSymbolKind(value) {
|
|
@@ -768,11 +792,12 @@ export class SourceService {
|
|
|
768
792
|
versionDiffService;
|
|
769
793
|
modDecompileService;
|
|
770
794
|
modSearchService;
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
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();
|
|
776
801
|
constructor(explicitConfig, metrics = new RuntimeMetrics()) {
|
|
777
802
|
this.config = explicitConfig ?? loadConfig();
|
|
778
803
|
this.metrics = metrics;
|
|
@@ -1238,6 +1263,91 @@ export class SourceService {
|
|
|
1238
1263
|
: "";
|
|
1239
1264
|
return `${prefix}./gradlew genSources --no-daemon`;
|
|
1240
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
|
+
}
|
|
1241
1351
|
buildArtifactContentsSummary(input) {
|
|
1242
1352
|
const sourceKind = input.isDecompiled || input.origin === "decompiled" || !normalizeOptionalString(input.sourceJarPath)
|
|
1243
1353
|
? "decompiled-binary"
|
|
@@ -1377,10 +1487,29 @@ export class SourceService {
|
|
|
1377
1487
|
warnings.push(`Resolved source-backed artifact from Loom cache candidate: ${versionSourceDiscovery.selectedSourceJarPath}.`);
|
|
1378
1488
|
}
|
|
1379
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
|
+
}
|
|
1380
1508
|
const resolved = await resolveSourceTargetInternal(resolvedTarget, {
|
|
1381
1509
|
// mojang requires source-backed artifact guarantee; force resolution to consider decompile candidate
|
|
1382
1510
|
// and reject later if mapping cannot be applied.
|
|
1383
1511
|
allowDecompile: effectiveMapping === "mojang" ? true : input.allowDecompile ?? true,
|
|
1512
|
+
mappingVariant: binaryRemapGate.mappingVariant,
|
|
1384
1513
|
onRepoFailover: (event) => {
|
|
1385
1514
|
this.metrics.recordRepoFailover();
|
|
1386
1515
|
log("warn", "repo.failover", {
|
|
@@ -1400,7 +1529,8 @@ export class SourceService {
|
|
|
1400
1529
|
requestedMapping: effectiveMapping,
|
|
1401
1530
|
target: { kind, value },
|
|
1402
1531
|
resolved,
|
|
1403
|
-
runtimeNamesUnobfuscated
|
|
1532
|
+
runtimeNamesUnobfuscated,
|
|
1533
|
+
allowBinaryRemap: binaryRemapGate.allowBinaryRemap
|
|
1404
1534
|
});
|
|
1405
1535
|
}
|
|
1406
1536
|
catch (caughtError) {
|
|
@@ -1517,9 +1647,29 @@ export class SourceService {
|
|
|
1517
1647
|
}
|
|
1518
1648
|
}
|
|
1519
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;
|
|
1520
1670
|
await this.ingestIfNeeded(resolved);
|
|
1521
1671
|
let sampleEntries;
|
|
1522
|
-
if (resolved.sourceJarPath) {
|
|
1672
|
+
if (input.compact === false && resolved.sourceJarPath) {
|
|
1523
1673
|
try {
|
|
1524
1674
|
const javaEntries = await listJavaEntries(resolved.sourceJarPath);
|
|
1525
1675
|
const MAX_SAMPLE = 10;
|
|
@@ -1534,6 +1684,7 @@ export class SourceService {
|
|
|
1534
1684
|
}
|
|
1535
1685
|
return {
|
|
1536
1686
|
artifactId: resolved.artifactId,
|
|
1687
|
+
artifactAlias,
|
|
1537
1688
|
origin: resolved.origin,
|
|
1538
1689
|
isDecompiled: resolved.isDecompiled,
|
|
1539
1690
|
resolvedSourceJarPath: resolved.sourceJarPath,
|
|
@@ -1578,8 +1729,8 @@ export class SourceService {
|
|
|
1578
1729
|
const startedAt = Date.now();
|
|
1579
1730
|
try {
|
|
1580
1731
|
const artifact = this.getArtifact(input.artifactId);
|
|
1581
|
-
const
|
|
1582
|
-
if (!
|
|
1732
|
+
const originalQuery = input.query.trim();
|
|
1733
|
+
if (!originalQuery) {
|
|
1583
1734
|
return {
|
|
1584
1735
|
hits: [],
|
|
1585
1736
|
mappingApplied: artifact.mappingApplied ?? "obfuscated",
|
|
@@ -1594,6 +1745,80 @@ export class SourceService {
|
|
|
1594
1745
|
}
|
|
1595
1746
|
const intent = normalizeIntent(input.intent);
|
|
1596
1747
|
const match = normalizeMatch(input.match);
|
|
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
|
+
}
|
|
1597
1822
|
if (match === "regex" && query.length > MAX_REGEX_QUERY_LENGTH) {
|
|
1598
1823
|
throw createError({
|
|
1599
1824
|
code: ERROR_CODES.INVALID_INPUT,
|
|
@@ -1607,7 +1832,9 @@ export class SourceService {
|
|
|
1607
1832
|
const searchLimitCap = match === "regex"
|
|
1608
1833
|
? Math.max(1, Math.min(this.config.maxSearchHits, MAX_REGEX_RESULT_LIMIT))
|
|
1609
1834
|
: this.config.maxSearchHits;
|
|
1610
|
-
const scope =
|
|
1835
|
+
const scope = translationPackagePrefix
|
|
1836
|
+
? { ...(input.scope ?? {}), packagePrefix: translationPackagePrefix }
|
|
1837
|
+
: input.scope;
|
|
1611
1838
|
if (scope?.symbolKind && intent !== "symbol") {
|
|
1612
1839
|
throw createError({
|
|
1613
1840
|
code: ERROR_CODES.INVALID_INPUT,
|
|
@@ -1716,7 +1943,9 @@ export class SourceService {
|
|
|
1716
1943
|
sourceJarPath: artifact.sourceJarPath,
|
|
1717
1944
|
isDecompiled: artifact.isDecompiled,
|
|
1718
1945
|
qualityFlags: artifact.qualityFlags
|
|
1719
|
-
})
|
|
1946
|
+
}),
|
|
1947
|
+
...(translatedInfo ? { translatedQuery: translatedInfo } : {}),
|
|
1948
|
+
...(searchWarnings.length > 0 ? { warnings: searchWarnings } : {})
|
|
1720
1949
|
};
|
|
1721
1950
|
}
|
|
1722
1951
|
finally {
|
|
@@ -2020,6 +2249,10 @@ export class SourceService {
|
|
|
2020
2249
|
warnings: [...warnings, ...matrix.warnings]
|
|
2021
2250
|
};
|
|
2022
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).
|
|
2023
2256
|
const mapped = await this.mappingService.findMapping({
|
|
2024
2257
|
version,
|
|
2025
2258
|
kind,
|
|
@@ -2645,15 +2878,18 @@ export class SourceService {
|
|
|
2645
2878
|
message: "className must be non-empty."
|
|
2646
2879
|
});
|
|
2647
2880
|
}
|
|
2648
|
-
const
|
|
2649
|
-
if (!
|
|
2881
|
+
const inputArtifactId = input.artifactId.trim();
|
|
2882
|
+
if (!inputArtifactId) {
|
|
2650
2883
|
throw createError({
|
|
2651
2884
|
code: ERROR_CODES.INVALID_INPUT,
|
|
2652
2885
|
message: "artifactId must be non-empty."
|
|
2653
2886
|
});
|
|
2654
2887
|
}
|
|
2655
|
-
// Verify artifact exists
|
|
2656
|
-
|
|
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;
|
|
2657
2893
|
const limit = Math.max(1, Math.min(input.limit ?? 20, 200));
|
|
2658
2894
|
const warnings = [];
|
|
2659
2895
|
const isQualified = className.includes(".");
|
|
@@ -2810,6 +3046,11 @@ export class SourceService {
|
|
|
2810
3046
|
}
|
|
2811
3047
|
else {
|
|
2812
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;
|
|
2813
3054
|
origin = artifact.origin;
|
|
2814
3055
|
requestedMapping = artifact.requestedMapping ?? requestedMapping;
|
|
2815
3056
|
mappingApplied = artifact.mappingApplied ?? requestedMapping;
|
|
@@ -3093,6 +3334,9 @@ export class SourceService {
|
|
|
3093
3334
|
}
|
|
3094
3335
|
else {
|
|
3095
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;
|
|
3096
3340
|
origin = artifact.origin;
|
|
3097
3341
|
mappingApplied = artifact.mappingApplied ?? requestedMapping;
|
|
3098
3342
|
provenance = artifact.provenance;
|
|
@@ -3199,6 +3443,36 @@ export class SourceService {
|
|
|
3199
3443
|
requestedMapping,
|
|
3200
3444
|
mappingApplied
|
|
3201
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
|
+
}
|
|
3202
3476
|
return {
|
|
3203
3477
|
className,
|
|
3204
3478
|
members: {
|
|
@@ -3215,17 +3489,83 @@ export class SourceService {
|
|
|
3215
3489
|
mappingApplied,
|
|
3216
3490
|
returnedNamespace: requestedMapping,
|
|
3217
3491
|
provenance: normalizedProvenance,
|
|
3218
|
-
qualityFlags,
|
|
3492
|
+
qualityFlags: fallbackQualityFlags,
|
|
3219
3493
|
artifactContents: this.buildArtifactContentsSummary({
|
|
3220
3494
|
origin,
|
|
3221
3495
|
sourceJarPath,
|
|
3222
3496
|
isDecompiled: origin === "decompiled",
|
|
3223
|
-
qualityFlags
|
|
3497
|
+
qualityFlags: fallbackQualityFlags
|
|
3224
3498
|
}),
|
|
3499
|
+
...(decompiledFallback ? { decompiledFallback } : {}),
|
|
3500
|
+
...(decompiledMemberCounts ? { decompiledMemberCounts } : {}),
|
|
3225
3501
|
warnings
|
|
3226
3502
|
};
|
|
3227
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
|
+
}
|
|
3228
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) {
|
|
3229
3569
|
const { input: sourceInput, ...sharedInput } = input;
|
|
3230
3570
|
const mode = sourceInput.mode;
|
|
3231
3571
|
if (mode === "inline") {
|
|
@@ -3307,6 +3647,7 @@ export class SourceService {
|
|
|
3307
3647
|
code: ERROR_CODES.INVALID_INPUT,
|
|
3308
3648
|
message: `No mixin config JSON files were found under project path "${input.input.path}".`,
|
|
3309
3649
|
details: {
|
|
3650
|
+
failedStage: "input-validation",
|
|
3310
3651
|
nextAction: "Use input.mode='config' with explicit configPaths[], or point input.path at the workspace root that contains *.mixins.json files."
|
|
3311
3652
|
}
|
|
3312
3653
|
});
|
|
@@ -3376,37 +3717,71 @@ export class SourceService {
|
|
|
3376
3717
|
return pending;
|
|
3377
3718
|
}
|
|
3378
3719
|
async validateMixinSingle(input) {
|
|
3379
|
-
|
|
3380
|
-
|
|
3381
|
-
|
|
3382
|
-
|
|
3383
|
-
|
|
3384
|
-
|
|
3385
|
-
|
|
3386
|
-
|
|
3387
|
-
|
|
3388
|
-
|
|
3389
|
-
const
|
|
3390
|
-
|
|
3391
|
-
|
|
3392
|
-
|
|
3393
|
-
|
|
3394
|
-
|
|
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
|
+
});
|
|
3395
3737
|
}
|
|
3396
|
-
|
|
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()) {
|
|
3397
3761
|
throw createError({
|
|
3398
3762
|
code: ERROR_CODES.INVALID_INPUT,
|
|
3399
|
-
message:
|
|
3400
|
-
|
|
3763
|
+
message: "source must be non-empty.",
|
|
3764
|
+
details: { failedStage: "input-validation" }
|
|
3401
3765
|
});
|
|
3402
3766
|
}
|
|
3767
|
+
return await this.runValidateMixinPipeline({
|
|
3768
|
+
input,
|
|
3769
|
+
version,
|
|
3770
|
+
source,
|
|
3771
|
+
requestedScope,
|
|
3772
|
+
currentSourcePriority,
|
|
3773
|
+
initialSourcePriority,
|
|
3774
|
+
onStage: (stage) => { currentStage = stage; }
|
|
3775
|
+
});
|
|
3403
3776
|
}
|
|
3404
|
-
|
|
3405
|
-
|
|
3406
|
-
}
|
|
3407
|
-
if (!source.trim()) {
|
|
3408
|
-
throw createError({ code: ERROR_CODES.INVALID_INPUT, message: "source must be non-empty." });
|
|
3777
|
+
catch (err) {
|
|
3778
|
+
throw annotateValidateMixinError(err, currentStage);
|
|
3409
3779
|
}
|
|
3780
|
+
}
|
|
3781
|
+
async runValidateMixinPipeline(ctx) {
|
|
3782
|
+
const { input, source, requestedScope, currentSourcePriority, initialSourcePriority, onStage } = ctx;
|
|
3783
|
+
let { version } = ctx;
|
|
3784
|
+
onStage("resolve");
|
|
3410
3785
|
const warnings = [];
|
|
3411
3786
|
let mappingAutoDetected = false;
|
|
3412
3787
|
// Auto-detect mapping from project config when not explicitly provided (or when preferProjectMapping is set)
|
|
@@ -3487,6 +3862,7 @@ export class SourceService {
|
|
|
3487
3862
|
};
|
|
3488
3863
|
}
|
|
3489
3864
|
// Health check: probe mapping infrastructure
|
|
3865
|
+
onStage("mapping-health");
|
|
3490
3866
|
let healthReport;
|
|
3491
3867
|
try {
|
|
3492
3868
|
const health = await this.mappingService.checkMappingHealth({
|
|
@@ -3508,10 +3884,24 @@ export class SourceService {
|
|
|
3508
3884
|
]
|
|
3509
3885
|
};
|
|
3510
3886
|
}
|
|
3511
|
-
catch {
|
|
3512
|
-
//
|
|
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
|
+
};
|
|
3513
3901
|
}
|
|
3902
|
+
onStage("parse");
|
|
3514
3903
|
const parsed = parseMixinSource(source);
|
|
3904
|
+
onStage("target-lookup");
|
|
3515
3905
|
const targetMembers = new Map();
|
|
3516
3906
|
const mappingFailedTargets = new Set();
|
|
3517
3907
|
const remapFailedMembers = new Map();
|
|
@@ -3772,8 +4162,12 @@ export class SourceService {
|
|
|
3772
4162
|
if (result.structuredWarnings.length === 0)
|
|
3773
4163
|
result.structuredWarnings = undefined;
|
|
3774
4164
|
}
|
|
3775
|
-
// 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.
|
|
3776
4169
|
if (input.reportMode === "compact") {
|
|
4170
|
+
refreshMixinValidationOutcome(result);
|
|
3777
4171
|
result.resolvedMembers = undefined;
|
|
3778
4172
|
result.structuredWarnings = undefined;
|
|
3779
4173
|
result.aggregatedWarnings = undefined;
|
|
@@ -3783,7 +4177,9 @@ export class SourceService {
|
|
|
3783
4177
|
result.provenance.resolutionTrace = undefined;
|
|
3784
4178
|
}
|
|
3785
4179
|
}
|
|
3786
|
-
|
|
4180
|
+
else {
|
|
4181
|
+
refreshMixinValidationOutcome(result);
|
|
4182
|
+
}
|
|
3787
4183
|
if (this.shouldRetryValidateMixinWithMavenFirst(input, result)) {
|
|
3788
4184
|
const retryWarning = `Retrying validate-mixin with sourcePriority="maven-first" after partial validation using "${currentSourcePriority}".`;
|
|
3789
4185
|
try {
|
|
@@ -3806,7 +4202,14 @@ export class SourceService {
|
|
|
3806
4202
|
`Validation retried with sourcePriority "maven-first" after partial result from "${currentSourcePriority}".`
|
|
3807
4203
|
];
|
|
3808
4204
|
}
|
|
3809
|
-
|
|
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;
|
|
3810
4213
|
}
|
|
3811
4214
|
catch (retryErr) {
|
|
3812
4215
|
result.warnings.unshift(`${retryWarning} Retry failed: ${retryErr instanceof Error ? retryErr.message : String(retryErr)}`);
|
|
@@ -3840,7 +4243,8 @@ export class SourceService {
|
|
|
3840
4243
|
catch (err) {
|
|
3841
4244
|
throw createError({
|
|
3842
4245
|
code: ERROR_CODES.INVALID_INPUT,
|
|
3843
|
-
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" }
|
|
3844
4248
|
});
|
|
3845
4249
|
}
|
|
3846
4250
|
const pkg = configJson.package ?? "";
|
|
@@ -4291,7 +4695,12 @@ export class SourceService {
|
|
|
4291
4695
|
}
|
|
4292
4696
|
return result;
|
|
4293
4697
|
}
|
|
4698
|
+
recordToolCall(tool, durationMs) {
|
|
4699
|
+
this.metrics.recordToolCall(tool, durationMs);
|
|
4700
|
+
}
|
|
4294
4701
|
getRuntimeMetrics() {
|
|
4702
|
+
this.snapshotLruAccounting();
|
|
4703
|
+
this.metrics.setMappingResolutionCacheStats(this.mappingService.resolutionCacheStats);
|
|
4295
4704
|
return this.metrics.snapshot();
|
|
4296
4705
|
}
|
|
4297
4706
|
async indexArtifact(input) {
|
|
@@ -4619,6 +5028,117 @@ export class SourceService {
|
|
|
4619
5028
|
}
|
|
4620
5029
|
return outputParts.join("\n");
|
|
4621
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
|
+
}
|
|
4622
5142
|
resolveClassFilePath(artifactId, className) {
|
|
4623
5143
|
const normalizedClassName = className.trim();
|
|
4624
5144
|
const classPath = classNameToClassPath(normalizedClassName);
|
|
@@ -4871,7 +5391,15 @@ export class SourceService {
|
|
|
4871
5391
|
name,
|
|
4872
5392
|
owner: ownerInSourceMapping,
|
|
4873
5393
|
descriptor,
|
|
4874
|
-
|
|
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,
|
|
4875
5403
|
sourceMapping: mapping,
|
|
4876
5404
|
targetMapping: "obfuscated",
|
|
4877
5405
|
sourcePriority
|
|
@@ -4883,6 +5411,33 @@ export class SourceService {
|
|
|
4883
5411
|
descriptor: kind === "method" ? mapped.resolvedSymbol.descriptor ?? descriptor : undefined
|
|
4884
5412
|
};
|
|
4885
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
|
+
}
|
|
4886
5441
|
warnings.push(`Could not map ${kind} "${name}" from ${mapping} to obfuscated.`);
|
|
4887
5442
|
}
|
|
4888
5443
|
catch (caughtError) {
|
|
@@ -5019,6 +5574,10 @@ export class SourceService {
|
|
|
5019
5574
|
name,
|
|
5020
5575
|
owner: ownerFqn,
|
|
5021
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,
|
|
5022
5581
|
sourceMapping,
|
|
5023
5582
|
targetMapping,
|
|
5024
5583
|
sourcePriority,
|
|
@@ -5100,6 +5659,7 @@ export class SourceService {
|
|
|
5100
5659
|
toResolvedArtifact(artifact) {
|
|
5101
5660
|
return {
|
|
5102
5661
|
artifactId: artifact.artifactId,
|
|
5662
|
+
artifactAlias: artifact.alias,
|
|
5103
5663
|
artifactSignature: artifact.artifactSignature ?? this.fallbackArtifactSignature(artifact.artifactId),
|
|
5104
5664
|
origin: artifact.origin,
|
|
5105
5665
|
binaryJarPath: artifact.binaryJarPath,
|
|
@@ -5122,6 +5682,7 @@ export class SourceService {
|
|
|
5122
5682
|
const tx = this.db.transaction(() => {
|
|
5123
5683
|
this.artifactsRepo.upsertArtifact({
|
|
5124
5684
|
artifactId: resolved.artifactId,
|
|
5685
|
+
alias: resolved.artifactAlias,
|
|
5125
5686
|
origin: resolved.origin,
|
|
5126
5687
|
coordinate: resolved.coordinate,
|
|
5127
5688
|
version: resolved.version,
|
|
@@ -5173,10 +5734,20 @@ export class SourceService {
|
|
|
5173
5734
|
files = await this.loadFromSourceJar(resolved.sourceJarPath);
|
|
5174
5735
|
}
|
|
5175
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
|
+
}
|
|
5176
5747
|
const vineflowerPath = await resolveVineflowerJar(this.config.cacheDir, this.config.vineflowerJarPath);
|
|
5177
5748
|
const decompileStartedAt = Date.now();
|
|
5178
5749
|
try {
|
|
5179
|
-
const decompileResult = await decompileBinaryJar(
|
|
5750
|
+
const decompileResult = await decompileBinaryJar(decompileInputJarPath, this.config.cacheDir, {
|
|
5180
5751
|
vineflowerJarPath: vineflowerPath,
|
|
5181
5752
|
artifactIdCandidate: resolved.artifactId,
|
|
5182
5753
|
timeoutMs: 120_000,
|
|
@@ -5278,6 +5849,30 @@ export class SourceService {
|
|
|
5278
5849
|
meta
|
|
5279
5850
|
});
|
|
5280
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
|
+
}
|
|
5281
5876
|
this.metrics.recordArtifactCacheHit();
|
|
5282
5877
|
const touchedAt = new Date().toISOString();
|
|
5283
5878
|
this.artifactsRepo.touchArtifact(resolved.artifactId, touchedAt);
|
|
@@ -5293,6 +5888,164 @@ export class SourceService {
|
|
|
5293
5888
|
await this.rebuildAndPersistArtifactIndex(resolved, reason === "already_current" ? "missing_meta" : reason);
|
|
5294
5889
|
this.enforceCacheLimits();
|
|
5295
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
|
+
}
|
|
5296
6049
|
async loadFromSourceJar(sourceJarPath) {
|
|
5297
6050
|
const files = [];
|
|
5298
6051
|
for await (const entry of iterateJavaEntriesAsUtf8(sourceJarPath, this.config.maxContentBytes)) {
|
|
@@ -5308,13 +6061,55 @@ export class SourceService {
|
|
|
5308
6061
|
hasAnyFiles(artifactId) {
|
|
5309
6062
|
return this.filesRepo.listFiles(artifactId, { limit: 1 }).items.length > 0;
|
|
5310
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
|
+
}
|
|
5311
6106
|
enforceCacheLimits() {
|
|
5312
|
-
let artifactCount = this.
|
|
5313
|
-
let totalBytes = this.
|
|
6107
|
+
let artifactCount = this.lru.size;
|
|
6108
|
+
let totalBytes = this.cacheTotalContentBytes;
|
|
5314
6109
|
if (artifactCount <= this.config.maxArtifacts && totalBytes <= this.config.maxCacheBytes) {
|
|
5315
6110
|
return;
|
|
5316
6111
|
}
|
|
5317
|
-
const candidates =
|
|
6112
|
+
const candidates = this.lru.toArray();
|
|
5318
6113
|
for (const candidate of candidates) {
|
|
5319
6114
|
const shouldEvict = artifactCount > this.config.maxArtifacts || totalBytes > this.config.maxCacheBytes;
|
|
5320
6115
|
if (!shouldEvict || artifactCount <= 1) {
|
|
@@ -5322,17 +6117,19 @@ export class SourceService {
|
|
|
5322
6117
|
}
|
|
5323
6118
|
const artifactCountBefore = artifactCount;
|
|
5324
6119
|
const totalBytesBefore = totalBytes;
|
|
5325
|
-
this.
|
|
5326
|
-
this.
|
|
5327
|
-
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);
|
|
5328
6125
|
artifactCount = Math.max(0, artifactCount - 1);
|
|
5329
|
-
totalBytes = Math.max(0, totalBytes - candidate.totalContentBytes);
|
|
6126
|
+
totalBytes = Math.max(0, totalBytes - candidate.value.totalContentBytes - remappedBytesForCandidate);
|
|
5330
6127
|
this.metrics.recordCacheEviction();
|
|
5331
6128
|
log("warn", "cache.evict", {
|
|
5332
|
-
artifactId: candidate.
|
|
6129
|
+
artifactId: candidate.key,
|
|
5333
6130
|
artifactCountBefore,
|
|
5334
6131
|
totalBytesBefore,
|
|
5335
|
-
artifactBytes: candidate.totalContentBytes
|
|
6132
|
+
artifactBytes: candidate.value.totalContentBytes + remappedBytesForCandidate
|
|
5336
6133
|
});
|
|
5337
6134
|
}
|
|
5338
6135
|
this.publishCacheMetrics();
|
|
@@ -5340,82 +6137,94 @@ export class SourceService {
|
|
|
5340
6137
|
refreshCacheMetrics() {
|
|
5341
6138
|
const cacheEntries = this.artifactsRepo.countArtifacts();
|
|
5342
6139
|
const totalContentBytes = this.artifactsRepo.totalContentBytes();
|
|
5343
|
-
const lruAccounting = this.artifactsRepo
|
|
5344
|
-
|
|
5345
|
-
|
|
5346
|
-
|
|
5347
|
-
|
|
5348
|
-
|
|
5349
|
-
|
|
5350
|
-
|
|
5351
|
-
|
|
5352
|
-
|
|
5353
|
-
|
|
5354
|
-
|
|
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;
|
|
5355
6184
|
this.publishCacheMetrics();
|
|
5356
6185
|
}
|
|
5357
6186
|
touchCacheMetrics(artifactId, updatedAt) {
|
|
5358
|
-
const
|
|
5359
|
-
if (
|
|
6187
|
+
const entry = this.lru.touch(artifactId);
|
|
6188
|
+
if (!entry) {
|
|
5360
6189
|
this.refreshCacheMetrics();
|
|
5361
6190
|
return;
|
|
5362
6191
|
}
|
|
5363
|
-
|
|
5364
|
-
if (!existing) {
|
|
5365
|
-
this.refreshCacheMetrics();
|
|
5366
|
-
return;
|
|
5367
|
-
}
|
|
5368
|
-
existing.updatedAt = updatedAt;
|
|
5369
|
-
this.cacheMetricsState.lru.push(existing);
|
|
6192
|
+
entry.updatedAt = updatedAt;
|
|
5370
6193
|
this.publishCacheMetrics();
|
|
5371
6194
|
}
|
|
5372
6195
|
upsertCacheMetrics(artifactId, totalContentBytes, updatedAt) {
|
|
5373
6196
|
const normalizedBytes = Math.max(0, Math.trunc(totalContentBytes));
|
|
5374
|
-
const
|
|
5375
|
-
if (
|
|
5376
|
-
|
|
5377
|
-
if (!existing) {
|
|
5378
|
-
this.refreshCacheMetrics();
|
|
5379
|
-
return;
|
|
5380
|
-
}
|
|
5381
|
-
this.cacheMetricsState.totalContentBytes = Math.max(0, this.cacheMetricsState.totalContentBytes - existing.totalContentBytes + normalizedBytes);
|
|
5382
|
-
existing.totalContentBytes = normalizedBytes;
|
|
5383
|
-
existing.updatedAt = updatedAt;
|
|
5384
|
-
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);
|
|
5385
6200
|
}
|
|
5386
6201
|
else {
|
|
5387
|
-
this.
|
|
5388
|
-
this.cacheMetricsState.totalContentBytes += normalizedBytes;
|
|
5389
|
-
this.cacheMetricsState.lru.push({
|
|
5390
|
-
artifactId,
|
|
5391
|
-
totalContentBytes: normalizedBytes,
|
|
5392
|
-
updatedAt
|
|
5393
|
-
});
|
|
6202
|
+
this.cacheTotalContentBytes += normalizedBytes;
|
|
5394
6203
|
}
|
|
5395
|
-
this.
|
|
6204
|
+
this.lru.upsert(artifactId, { totalContentBytes: normalizedBytes, updatedAt });
|
|
5396
6205
|
this.publishCacheMetrics();
|
|
5397
6206
|
}
|
|
5398
6207
|
removeCacheMetrics(artifactId, publish = true) {
|
|
5399
|
-
const
|
|
5400
|
-
if (existingIndex < 0) {
|
|
5401
|
-
this.refreshCacheMetrics();
|
|
5402
|
-
return;
|
|
5403
|
-
}
|
|
5404
|
-
const [existing] = this.cacheMetricsState.lru.splice(existingIndex, 1);
|
|
6208
|
+
const existing = this.lru.remove(artifactId);
|
|
5405
6209
|
if (!existing) {
|
|
5406
6210
|
this.refreshCacheMetrics();
|
|
5407
6211
|
return;
|
|
5408
6212
|
}
|
|
5409
|
-
this.
|
|
5410
|
-
this.cacheMetricsState.totalContentBytes = Math.max(0, this.cacheMetricsState.totalContentBytes - existing.totalContentBytes);
|
|
6213
|
+
this.cacheTotalContentBytes = Math.max(0, this.cacheTotalContentBytes - existing.totalContentBytes);
|
|
5411
6214
|
if (publish) {
|
|
5412
6215
|
this.publishCacheMetrics();
|
|
5413
6216
|
}
|
|
5414
6217
|
}
|
|
5415
6218
|
publishCacheMetrics() {
|
|
5416
|
-
this.metrics.setCacheEntries(this.
|
|
5417
|
-
this.metrics.setCacheTotalContentBytes(this.
|
|
5418
|
-
|
|
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
|
+
})));
|
|
5419
6228
|
}
|
|
5420
6229
|
}
|
|
5421
6230
|
function remapJvmDescriptor(descriptor, classMap) {
|