@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.
Files changed (38) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +25 -18
  3. package/dist/cache-registry.d.ts +1 -1
  4. package/dist/cache-registry.js +10 -2
  5. package/dist/config.d.ts +10 -1
  6. package/dist/config.js +52 -1
  7. package/dist/entry-tools/analyze-symbol-service.d.ts +2 -2
  8. package/dist/entry-tools/analyze-symbol-service.js +13 -2
  9. package/dist/entry-tools/inspect-minecraft-service.d.ts +20 -20
  10. package/dist/entry-tools/inspect-minecraft-service.js +8 -2
  11. package/dist/entry-tools/manage-cache-service.d.ts +4 -4
  12. package/dist/entry-tools/validate-project-service.js +84 -4
  13. package/dist/index.js +99 -33
  14. package/dist/lru-list.d.ts +31 -0
  15. package/dist/lru-list.js +102 -0
  16. package/dist/mapping-pipeline-service.d.ts +10 -1
  17. package/dist/mapping-pipeline-service.js +13 -1
  18. package/dist/mapping-service.d.ts +12 -0
  19. package/dist/mapping-service.js +252 -10
  20. package/dist/mixin-validator.js +22 -7
  21. package/dist/observability.d.ts +18 -1
  22. package/dist/observability.js +44 -1
  23. package/dist/response-utils.d.ts +44 -10
  24. package/dist/response-utils.js +131 -17
  25. package/dist/source-resolver.d.ts +9 -1
  26. package/dist/source-resolver.js +14 -6
  27. package/dist/source-service.d.ts +97 -1
  28. package/dist/source-service.js +922 -113
  29. package/dist/storage/artifacts-repo.d.ts +4 -1
  30. package/dist/storage/artifacts-repo.js +33 -5
  31. package/dist/storage/files-repo.d.ts +0 -2
  32. package/dist/storage/files-repo.js +0 -11
  33. package/dist/storage/migrations.d.ts +1 -1
  34. package/dist/storage/migrations.js +10 -2
  35. package/dist/storage/schema.d.ts +2 -0
  36. package/dist/storage/schema.js +25 -0
  37. package/dist/types.d.ts +3 -0
  38. package/package.json +3 -1
@@ -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
- cacheMetricsState = {
772
- entries: 0,
773
- totalContentBytes: 0,
774
- lru: []
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 query = input.query.trim();
1582
- if (!query) {
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 = input.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 artifactId = input.artifactId.trim();
2649
- if (!artifactId) {
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
- const artifact = this.getArtifact(artifactId);
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
- let version = input.version.trim();
3380
- const requestedScope = normalizeRequestedArtifactScope(input.scope);
3381
- const currentSourcePriority = input.sourcePriority ?? this.config.mappingSourcePriority;
3382
- const initialSourcePriority = input.retryState?.initialSourcePriority ?? currentSourcePriority;
3383
- if (!version) {
3384
- throw createError({ code: ERROR_CODES.INVALID_INPUT, message: "version must be non-empty." });
3385
- }
3386
- // Resolve source from source or sourcePath
3387
- let source;
3388
- if (input.sourcePath) {
3389
- const normalizedSourcePath = normalizePathForHost(input.sourcePath, undefined, "sourcePath");
3390
- const resolvedSourcePath = isAbsolute(normalizedSourcePath)
3391
- ? normalizedSourcePath
3392
- : resolvePath(process.cwd(), normalizedSourcePath);
3393
- try {
3394
- source = await readFile(resolvedSourcePath, "utf-8");
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
- catch (err) {
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: `Could not read sourcePath "${input.sourcePath}" (resolved to "${resolvedSourcePath}"):` +
3400
- ` ${err instanceof Error ? err.message : String(err)}`
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
- else {
3405
- source = input.source ?? "";
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
- // Health check failed — proceed without it
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
- refreshMixinValidationOutcome(result);
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
- return refreshMixinValidationOutcome(retried);
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
- signatureMode: kind === "method" && !descriptor ? "name-only" : undefined,
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(resolved.binaryJarPath, this.config.cacheDir, {
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.cacheMetricsState.entries;
5313
- let totalBytes = this.cacheMetricsState.totalContentBytes;
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 = [...this.cacheMetricsState.lru];
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.filesRepo.deleteFilesForArtifact(candidate.artifactId);
5326
- this.artifactsRepo.deleteArtifact(candidate.artifactId);
5327
- this.removeCacheMetrics(candidate.artifactId, false);
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.artifactId,
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
- .listArtifactsByLruWithContentBytes(Math.max(cacheEntries, 1))
5345
- .map((row) => ({
5346
- artifactId: row.artifactId,
5347
- totalContentBytes: row.totalContentBytes,
5348
- updatedAt: row.updatedAt
5349
- }));
5350
- this.cacheMetricsState = {
5351
- entries: cacheEntries,
5352
- totalContentBytes,
5353
- lru: lruAccounting
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 existingIndex = this.cacheMetricsState.lru.findIndex((row) => row.artifactId === artifactId);
5359
- if (existingIndex < 0) {
6187
+ const entry = this.lru.touch(artifactId);
6188
+ if (!entry) {
5360
6189
  this.refreshCacheMetrics();
5361
6190
  return;
5362
6191
  }
5363
- const [existing] = this.cacheMetricsState.lru.splice(existingIndex, 1);
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 existingIndex = this.cacheMetricsState.lru.findIndex((row) => row.artifactId === artifactId);
5375
- if (existingIndex >= 0) {
5376
- const [existing] = this.cacheMetricsState.lru.splice(existingIndex, 1);
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.cacheMetricsState.entries += 1;
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.cacheMetricsState.entries = this.cacheMetricsState.lru.length;
6204
+ this.lru.upsert(artifactId, { totalContentBytes: normalizedBytes, updatedAt });
5396
6205
  this.publishCacheMetrics();
5397
6206
  }
5398
6207
  removeCacheMetrics(artifactId, publish = true) {
5399
- const existingIndex = this.cacheMetricsState.lru.findIndex((row) => row.artifactId === artifactId);
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.cacheMetricsState.entries = this.cacheMetricsState.lru.length;
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.cacheMetricsState.entries);
5417
- this.metrics.setCacheTotalContentBytes(this.cacheMetricsState.totalContentBytes);
5418
- this.metrics.setCacheArtifactByteAccountingRef(this.cacheMetricsState.lru);
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) {