@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
@@ -4,7 +4,8 @@ import { createError, ERROR_CODES } from "./errors.js";
4
4
  * Current implementation enforces explicit guarantees:
5
5
  * - obfuscated: always pass-through
6
6
  * - mojang: requires source-backed artifacts on legacy obfuscated versions,
7
- * but unobfuscated runtime jars can pass through directly
7
+ * but unobfuscated runtime jars can pass through directly,
8
+ * or binary-only artifacts may be remapped + decompiled when allowBinaryRemap=true
8
9
  */
9
10
  export function applyMappingPipeline(input) {
10
11
  const transformChain = [];
@@ -51,6 +52,17 @@ export function applyMappingPipeline(input) {
51
52
  }
52
53
  const hasSource = Boolean(input.resolved.sourceJarPath);
53
54
  if (!hasSource) {
55
+ if (input.requestedMapping === "mojang" &&
56
+ input.allowBinaryRemap === true &&
57
+ Boolean(input.resolved.binaryJarPath)) {
58
+ transformChain.push("binary-remap:obf->mojang", "decompile:vineflower");
59
+ qualityFlags.push("binary-remapped", "decompiled");
60
+ return {
61
+ mappingApplied: "mojang",
62
+ qualityFlags,
63
+ transformChain
64
+ };
65
+ }
54
66
  throw createError({
55
67
  code: ERROR_CODES.MAPPING_NOT_APPLIED,
56
68
  message: `Requested ${input.requestedMapping} mapping cannot be guaranteed for this artifact because only decompile path is available.`,
@@ -148,6 +148,16 @@ export declare class MappingService {
148
148
  private readonly fetchFn;
149
149
  private readonly graphCache;
150
150
  private readonly buildLocks;
151
+ private readonly resolutionCache;
152
+ private static readonly RESOLUTION_CACHE_MAX;
153
+ private static readonly RESOLUTION_CACHE_TTL_MS;
154
+ private resolutionCacheHits;
155
+ private resolutionCacheMisses;
156
+ get resolutionCacheStats(): {
157
+ hits: number;
158
+ misses: number;
159
+ size: number;
160
+ };
151
161
  constructor(config: Config, versionService?: VersionMappingsResolver, fetchFn?: typeof fetch);
152
162
  findMapping(input: FindMappingInput): Promise<FindMappingOutput>;
153
163
  ensureMappingAvailable(input: EnsureMappingAvailableInput): Promise<EnsureMappingAvailableOutput>;
@@ -182,6 +192,8 @@ export declare class MappingService {
182
192
  private fetchYarnCoordinates;
183
193
  private trimGraphCache;
184
194
  releaseGraphCacheEntry(version: string, sourcePriority?: MappingSourcePriority): void;
195
+ private buildResolutionCacheKey;
196
+ private trimResolutionCache;
185
197
  }
186
198
  /**
187
199
  * Resolve and cache a Tiny v2 mapping file for the given Minecraft version.
@@ -708,15 +708,93 @@ function normalizeMemberName(name) {
708
708
  }
709
709
  return normalized;
710
710
  }
711
+ /**
712
+ * Validate a JVM method descriptor such as `(I)V`, `()Lfoo/Bar;`, `(Lfoo/Bar;[I)V`.
713
+ * Rejects empty strings, missing/mis-positioned parens, empty return type, and invalid base
714
+ * type tokens so "(" or "()" style half-descriptors surface as ERR_INVALID_INPUT instead of
715
+ * being silently accepted.
716
+ */
711
717
  function normalizeMethodDescriptor(descriptor) {
712
718
  const normalized = descriptor?.trim() ?? "";
713
- if (!normalized || !normalized.startsWith("(") || !normalized.includes(")")) {
719
+ if (!normalized) {
720
+ throw invalidInputError("descriptor must be a valid JVM descriptor when kind=method.", {
721
+ descriptor
722
+ });
723
+ }
724
+ if (!isValidMethodDescriptor(normalized)) {
714
725
  throw invalidInputError("descriptor must be a valid JVM descriptor when kind=method.", {
715
726
  descriptor
716
727
  });
717
728
  }
718
729
  return normalized;
719
730
  }
731
+ function isValidMethodDescriptor(descriptor) {
732
+ if (!descriptor.startsWith("("))
733
+ return false;
734
+ const closingIndex = descriptor.indexOf(")");
735
+ if (closingIndex < 0)
736
+ return false;
737
+ const argsSection = descriptor.slice(1, closingIndex);
738
+ const returnSection = descriptor.slice(closingIndex + 1);
739
+ if (returnSection.length === 0)
740
+ return false;
741
+ let cursor = 0;
742
+ while (cursor < argsSection.length) {
743
+ const next = consumeFieldType(argsSection, cursor, /*allowVoid*/ false);
744
+ if (next < 0)
745
+ return false;
746
+ cursor = next;
747
+ }
748
+ const returnEnd = consumeFieldType(returnSection, 0, /*allowVoid*/ true);
749
+ return returnEnd === returnSection.length;
750
+ }
751
+ /**
752
+ * JVM specification §4.3.2: "An array type descriptor is valid only if it represents a type
753
+ * with 255 or fewer dimensions." Matches the `multianewarray` / field-signature limit.
754
+ */
755
+ const JVM_MAX_ARRAY_DIMENSIONS = 255;
756
+ function consumeFieldType(descriptor, position, allowVoid) {
757
+ // Arrays are handled iteratively so pathological inputs such as `(` + "[".repeat(20000) + `I)V`
758
+ // cannot blow the call stack. After consuming every leading `[`, only the element type token
759
+ // is dispatched through the switch below. Dimensions above the JVM limit are rejected rather
760
+ // than merely accepted as "syntactically valid but semantically absurd" — clients must not be
761
+ // able to push a 20000-dimension descriptor through cache-key construction.
762
+ let cursor = position;
763
+ let arrayDimensions = 0;
764
+ while (cursor < descriptor.length && descriptor[cursor] === "[") {
765
+ cursor += 1;
766
+ arrayDimensions += 1;
767
+ if (arrayDimensions > JVM_MAX_ARRAY_DIMENSIONS)
768
+ return -1;
769
+ }
770
+ if (cursor >= descriptor.length)
771
+ return -1;
772
+ // Void is only valid at the outermost position — inside an array element it is illegal.
773
+ const elementAllowsVoid = cursor === position && allowVoid;
774
+ const token = descriptor[cursor];
775
+ switch (token) {
776
+ case "B":
777
+ case "C":
778
+ case "D":
779
+ case "F":
780
+ case "I":
781
+ case "J":
782
+ case "S":
783
+ case "Z":
784
+ return cursor + 1;
785
+ case "V":
786
+ return elementAllowsVoid ? cursor + 1 : -1;
787
+ case "L": {
788
+ const end = descriptor.indexOf(";", cursor);
789
+ // Reject empty class names like L; and unterminated references.
790
+ if (end < 0 || end === cursor + 1)
791
+ return -1;
792
+ return end + 1;
793
+ }
794
+ default:
795
+ return -1;
796
+ }
797
+ }
720
798
  function normalizeQuerySymbol(input, signatureMode, options) {
721
799
  if (input.kind !== "class" && input.kind !== "field" && input.kind !== "method") {
722
800
  throw invalidInputError('kind must be one of "class", "field", or "method".', {
@@ -773,9 +851,19 @@ function normalizeQuerySymbol(input, signatureMode, options) {
773
851
  querySymbol: toSymbolReference(record)
774
852
  };
775
853
  }
776
- const descriptor = signatureMode === "name-only"
777
- ? (input.descriptor?.trim() || "")
778
- : normalizeMethodDescriptor(input.descriptor);
854
+ let descriptor;
855
+ if (signatureMode === "name-only") {
856
+ // name-only matches by owner+name only; a supplied descriptor is validated (so malformed
857
+ // input still surfaces as ERR_INVALID_INPUT) but discarded afterwards so downstream
858
+ // projection / filtering treats the query as "no descriptor".
859
+ if (input.descriptor?.trim()) {
860
+ normalizeMethodDescriptor(input.descriptor);
861
+ }
862
+ descriptor = "";
863
+ }
864
+ else {
865
+ descriptor = normalizeMethodDescriptor(input.descriptor);
866
+ }
779
867
  const record = createMethodSymbolRecord(owner, normalizeMemberName(normalizedName), descriptor);
780
868
  return {
781
869
  record,
@@ -916,6 +1004,18 @@ export class MappingService {
916
1004
  fetchFn;
917
1005
  graphCache = new Map();
918
1006
  buildLocks = new Map();
1007
+ resolutionCache = new Map();
1008
+ static RESOLUTION_CACHE_MAX = 512;
1009
+ static RESOLUTION_CACHE_TTL_MS = 5 * 60 * 1000;
1010
+ resolutionCacheHits = 0;
1011
+ resolutionCacheMisses = 0;
1012
+ get resolutionCacheStats() {
1013
+ return {
1014
+ hits: this.resolutionCacheHits,
1015
+ misses: this.resolutionCacheMisses,
1016
+ size: this.resolutionCache.size
1017
+ };
1018
+ }
919
1019
  constructor(config, versionService = new VersionService(config), fetchFn = globalThis.fetch) {
920
1020
  this.config = config;
921
1021
  this.versionService = versionService;
@@ -932,9 +1032,25 @@ export class MappingService {
932
1032
  }
933
1033
  });
934
1034
  }
935
- const { record: queryRecord, querySymbol } = normalizeQuerySymbol(input, input.signatureMode, {
1035
+ // Normalize the effective signatureMode exactly once so every downstream path — query
1036
+ // symbol normalization, the strict-overload filter, the cache key, and warning text —
1037
+ // sees the same value. The public tool schema defaults to "name-only", so an omitted
1038
+ // signatureMode reaching the service (e.g. internal callers, MCP resource handlers, the
1039
+ // resolution cache) must default to "name-only" too, otherwise the service contradicts
1040
+ // the advertised default and silently reverts to the old descriptor-required path.
1041
+ // Callers that genuinely need strict descriptor matching pass `signatureMode: "exact"`
1042
+ // explicitly.
1043
+ const effectiveSignatureMode = input.signatureMode ?? "name-only";
1044
+ const { record: queryRecord, querySymbol } = normalizeQuerySymbol(input, effectiveSignatureMode, {
936
1045
  allowShortClassName: input.kind === "class" && input.sourceMapping === "obfuscated"
937
1046
  });
1047
+ const cacheKey = this.buildResolutionCacheKey(version, input, querySymbol, effectiveSignatureMode);
1048
+ const cached = this.resolutionCache.get(cacheKey);
1049
+ if (cached && Date.now() - cached.cachedAt < MappingService.RESOLUTION_CACHE_TTL_MS) {
1050
+ this.resolutionCacheHits += 1;
1051
+ return cached.result;
1052
+ }
1053
+ this.resolutionCacheMisses += 1;
938
1054
  const sourceMapping = input.sourceMapping;
939
1055
  const targetMapping = input.targetMapping;
940
1056
  if (!SUPPORTED_MAPPINGS.has(sourceMapping) || !SUPPORTED_MAPPINGS.has(targetMapping)) {
@@ -992,13 +1108,51 @@ export class MappingService {
992
1108
  const descriptorProjection = queryRecord.kind === "method" && queryRecord.descriptor
993
1109
  ? this.projectMethodDescriptorToTarget(graph, path, queryRecord.descriptor)
994
1110
  : undefined;
995
- const projectedDescriptor = descriptorProjection?.complete ? descriptorProjection.descriptor : undefined;
996
- const rawCandidates = this
1111
+ // Partial projections are still useful for comparison: projectMethodDescriptorToTarget
1112
+ // leaves unmapped `L...;` references unchanged, so JDK / external classes pass through
1113
+ // while Minecraft class references get rewritten to the target namespace. Using the
1114
+ // projected descriptor even when `complete === false` produces descriptors shaped like
1115
+ // the stored records (`(Lnet/minecraft/class_1799;Ljava/lang/String;)V`) and avoids
1116
+ // false negatives for the very common mixed MC + JDK descriptor shape.
1117
+ const projectedDescriptor = descriptorProjection?.hadClassReferences ? descriptorProjection.descriptor : undefined;
1118
+ let rawCandidates = this
997
1119
  .mapCandidatesAlongPath(graph, path, queryRecord)
998
1120
  .map((candidate) => queryRecord.kind === "method" && queryRecord.descriptor
999
1121
  ? projectLookupCandidateDescriptor(candidate, queryRecord.descriptor, projectedDescriptor)
1000
1122
  : candidate);
1001
1123
  const warnings = [];
1124
+ // signatureMode="exact" on kind=method must not return descriptorless fallback candidates
1125
+ // (lookupCandidates adds owner+name fallbacks by design for the loose path). Without this
1126
+ // filter a caller who supplied `foo(I)V` could be told `foo(Z)V` is the exact mapping,
1127
+ // which would be wrong for migration tooling. Mirror resolveMethodMappingExact's strict
1128
+ // behavior: keep only candidates whose descriptor equals the (projected) requested
1129
+ // descriptor. If nothing passes, the normal "candidates.length === 0 -> not_found" path
1130
+ // takes over. Partial projection is accepted here for the same reason as above — we do
1131
+ // not reject mixed MC + JDK descriptors as mapping_unavailable just because the JDK
1132
+ // class reference was not in the mapping graph.
1133
+ if (queryRecord.kind === "method" &&
1134
+ queryRecord.descriptor &&
1135
+ effectiveSignatureMode === "exact") {
1136
+ // Tiny v2 stores a single descriptor per method entry (typically in the obfuscated
1137
+ // namespace) and shares it across every column, while client mappings attach a mojang
1138
+ // descriptor on the mojang side and an obfuscated descriptor on the obfuscated side.
1139
+ // In multi-hop paths (e.g. mojang -> obfuscated -> intermediary -> yarn) the final
1140
+ // candidate's owner and name live in the target namespace but its descriptor can still
1141
+ // be the obfuscated form that rode along the Tiny hop. A strict filter that compared
1142
+ // only against the fully projected target descriptor dropped those valid candidates
1143
+ // and produced false `not_found` for common Mojang -> Yarn lookups. Accept any
1144
+ // candidate whose descriptor matches the caller's descriptor, the target-space
1145
+ // projection, or the obfuscated-space projection — the three forms that actually
1146
+ // appear in the mapping graph.
1147
+ const strictDescriptor = projectedDescriptor ?? queryRecord.descriptor;
1148
+ const acceptedDescriptors = new Set([queryRecord.descriptor, strictDescriptor]);
1149
+ const toObfuscatedPath = namespacePath(graph, sourceMapping, "obfuscated");
1150
+ if (toObfuscatedPath) {
1151
+ const obfuscatedProjection = this.projectMethodDescriptorToTarget(graph, toObfuscatedPath, queryRecord.descriptor);
1152
+ acceptedDescriptors.add(obfuscatedProjection.descriptor);
1153
+ }
1154
+ rawCandidates = rawCandidates.filter((candidate) => candidate.descriptor !== undefined && acceptedDescriptors.has(candidate.descriptor));
1155
+ }
1002
1156
  const disambiguatedCandidates = applyDisambiguationHints(rawCandidates, input.disambiguation);
1003
1157
  if (rawCandidates.length > disambiguatedCandidates.length) {
1004
1158
  warnings.push(`Disambiguation hints narrowed candidates from ${rawCandidates.length} to ${disambiguatedCandidates.length}.`);
@@ -1016,12 +1170,21 @@ export class MappingService {
1016
1170
  }
1017
1171
  else if (candidates.length > 1) {
1018
1172
  warnings.push(`Ambiguous mapping: ${candidates.length} candidates matched. Provide a stricter symbol input or disambiguation hints.`);
1173
+ if (queryRecord.kind === "method") {
1174
+ // find-mapping defaults to signatureMode="name-only", which discards any supplied
1175
+ // descriptor. Telling the caller to "add descriptor" would be ineffective unless they
1176
+ // also switch mode, so we point to the exact alternatives instead.
1177
+ warnings.push("Retry with signatureMode=\"exact\" plus a JVM descriptor, or pass disambiguation.descriptorHint, or raise maxCandidates up to 200 to inspect the full candidate list.");
1178
+ }
1179
+ else {
1180
+ warnings.push("Raise maxCandidates up to 200 to inspect the full candidate list, or use disambiguation.ownerHint to narrow the search.");
1181
+ }
1019
1182
  }
1020
1183
  const status = candidates.length === 0 ? "not_found" : candidates.length === 1 ? "resolved" : "ambiguous";
1021
1184
  const ambiguityReasons = candidates.length > 1
1022
1185
  ? inferAmbiguityReasons(candidates, pathUsesSource(graph.pairs, path, "mojang-client-mappings"))
1023
1186
  : undefined;
1024
- return {
1187
+ const output = {
1025
1188
  querySymbol,
1026
1189
  mappingContext,
1027
1190
  resolved: status === "resolved",
@@ -1034,6 +1197,9 @@ export class MappingService {
1034
1197
  provenance: this.provenanceForPath(graph, path),
1035
1198
  ambiguityReasons
1036
1199
  };
1200
+ this.resolutionCache.set(cacheKey, { result: output, cachedAt: Date.now() });
1201
+ this.trimResolutionCache();
1202
+ return output;
1037
1203
  }
1038
1204
  async ensureMappingAvailable(input) {
1039
1205
  const version = input.version.trim();
@@ -1193,6 +1359,9 @@ export class MappingService {
1193
1359
  }
1194
1360
  if (strictCandidates.length > 1) {
1195
1361
  warnings.push("Exact method mapping is ambiguous for owner+method+descriptor.");
1362
+ if (limitedCandidates.candidatesTruncated) {
1363
+ warnings.push("Raise maxCandidates up to 200 to inspect the full candidate list, or narrow the lookup via find-mapping disambiguation hints.");
1364
+ }
1196
1365
  return {
1197
1366
  querySymbol,
1198
1367
  mappingContext,
@@ -1495,6 +1664,9 @@ export class MappingService {
1495
1664
  const buildOutput = (querySymbol, matched, status) => {
1496
1665
  const candidates = matched.map((record) => toResolutionCandidate(toLookupCandidate(record)));
1497
1666
  const limitedCandidates = limitResolutionCandidates(candidates, input.maxCandidates);
1667
+ if (status === "ambiguous" && limitedCandidates.candidatesTruncated) {
1668
+ warnings.push("Raise maxCandidates up to 200 to inspect the full candidate list.");
1669
+ }
1498
1670
  return {
1499
1671
  querySymbol,
1500
1672
  mappingContext,
@@ -1537,11 +1709,40 @@ export class MappingService {
1537
1709
  if (input.signatureMode === "name-only") {
1538
1710
  const status = methodCandidates.length === 1 ? "resolved" : methodCandidates.length > 1 ? "ambiguous" : "not_found";
1539
1711
  if (status === "ambiguous") {
1540
- warnings.push(`Multiple method overloads matched name "${queryRecord.name}" in owner "${queryRecord.owner}". Provide descriptor for exact match.`);
1712
+ // name-only discards any supplied descriptor, so telling the caller to "provide
1713
+ // descriptor" would not disambiguate — they need to switch to signatureMode="exact".
1714
+ warnings.push(`Multiple method overloads matched name "${queryRecord.name}" in owner "${queryRecord.owner}". Retry with signatureMode="exact" plus a JVM descriptor to pick one overload.`);
1541
1715
  }
1542
1716
  return buildOutput(querySymbol, methodCandidates, status);
1543
1717
  }
1544
- const descriptorMatched = methodCandidates.filter((record) => record.descriptor === queryRecord.descriptor);
1718
+ // Tiny parsing stores a single descriptor per method entry (typically in the obfuscated
1719
+ // namespace) and copies it into every namespace at load time. That means a descriptor
1720
+ // supplied in `sourceMapping` coordinates will not string-compare equal to the record
1721
+ // for any method whose descriptor references remapped Minecraft classes. Project the
1722
+ // caller's descriptor to obfuscated coordinates first so class references line up with
1723
+ // the stored record descriptors. The projection is accepted even when
1724
+ // `projection.complete` is false: `projectMethodDescriptorToTarget` leaves every
1725
+ // unresolvable `L...;` reference unchanged (JDK/external classes like
1726
+ // `Ljava/lang/String;` are never in the mapping graph and pass through by design), so a
1727
+ // partial projection still aligns the Minecraft class refs with the stored descriptor
1728
+ // form while leaving external class refs identical to the user input. Falling back to
1729
+ // verbatim comparison on `complete === false` would send mixed descriptors like
1730
+ // `(Lnet/minecraft/world/item/ItemStack;Ljava/lang/String;)V` down the raw-compare path
1731
+ // and produce false negatives in the most common lookup shape. When no class references
1732
+ // exist at all (primitives-only descriptors such as `(I)V`) the projector marks
1733
+ // `hadClassReferences === false` and we simply reuse the original descriptor.
1734
+ const queryDescriptor = queryRecord.descriptor;
1735
+ let effectiveDescriptor = queryDescriptor;
1736
+ if (sourceMapping !== "obfuscated") {
1737
+ const projectionPath = namespacePath(graph, sourceMapping, "obfuscated");
1738
+ if (projectionPath) {
1739
+ const projection = this.projectMethodDescriptorToTarget(graph, projectionPath, queryDescriptor);
1740
+ if (projection.hadClassReferences) {
1741
+ effectiveDescriptor = projection.descriptor;
1742
+ }
1743
+ }
1744
+ }
1745
+ const descriptorMatched = methodCandidates.filter((record) => record.descriptor === effectiveDescriptor || record.descriptor === queryDescriptor);
1545
1746
  if (descriptorMatched.length === 1) {
1546
1747
  return buildOutput(querySymbol, descriptorMatched, "resolved");
1547
1748
  }
@@ -2094,6 +2295,11 @@ export class MappingService {
2094
2295
  this.graphCache.delete(oldestKey);
2095
2296
  }
2096
2297
  }
2298
+ // Note: in-flight buildLocks may re-populate graphCache after release.
2299
+ // Resolution cache entries created by concurrent findMapping() calls may also
2300
+ // survive this invalidation. Both are bounded by TTL (5 min) and will expire
2301
+ // naturally. A full epoch-based invalidation would add complexity for a rare
2302
+ // user-initiated operation (manage-cache).
2097
2303
  releaseGraphCacheEntry(version, sourcePriority) {
2098
2304
  const normalizedVersion = version.trim();
2099
2305
  if (!normalizedVersion) {
@@ -2106,6 +2312,42 @@ export class MappingService {
2106
2312
  this.graphCache.delete(key);
2107
2313
  }
2108
2314
  }
2315
+ const resolutionPrefix = `${normalizedVersion}\0`;
2316
+ for (const key of this.resolutionCache.keys()) {
2317
+ if (key.startsWith(resolutionPrefix)) {
2318
+ this.resolutionCache.delete(key);
2319
+ }
2320
+ }
2321
+ }
2322
+ buildResolutionCacheKey(version, input, querySymbol, effectiveSignatureMode) {
2323
+ return [
2324
+ version,
2325
+ input.kind,
2326
+ querySymbol.symbol,
2327
+ querySymbol.descriptor ?? "",
2328
+ input.sourceMapping,
2329
+ input.targetMapping,
2330
+ input.sourcePriority ?? "",
2331
+ effectiveLoomSearchProjectPath(input.projectPath) ?? "",
2332
+ effectiveSignatureMode,
2333
+ String(input.maxCandidates ?? ""),
2334
+ JSON.stringify(input.disambiguation ?? "")
2335
+ ].join("\0");
2336
+ }
2337
+ trimResolutionCache() {
2338
+ if (this.resolutionCache.size <= MappingService.RESOLUTION_CACHE_MAX)
2339
+ return;
2340
+ const now = Date.now();
2341
+ for (const [key, entry] of this.resolutionCache) {
2342
+ if (now - entry.cachedAt > MappingService.RESOLUTION_CACHE_TTL_MS) {
2343
+ this.resolutionCache.delete(key);
2344
+ }
2345
+ }
2346
+ while (this.resolutionCache.size > MappingService.RESOLUTION_CACHE_MAX) {
2347
+ const firstKey = this.resolutionCache.keys().next().value;
2348
+ if (firstKey !== undefined)
2349
+ this.resolutionCache.delete(firstKey);
2350
+ }
2109
2351
  }
2110
2352
  }
2111
2353
  // ---------------------------------------------------------------------------
@@ -225,11 +225,23 @@ function computeValidationStatus(summary) {
225
225
  }
226
226
  return "full";
227
227
  }
228
- function buildQuickSummary(status, summary) {
229
- if (status === "full") {
230
- return `${summary.membersValidated} member(s) validated successfully.`;
231
- }
232
- return `${summary.definiteErrors} error(s), ${summary.uncertainErrors} uncertain, ${summary.warnings} warning(s). ${summary.membersValidated} validated, ${summary.membersSkipped} member(s) skipped, ${summary.membersMissing} member(s) missing.`;
228
+ function buildQuickSummary(status, summary, context) {
229
+ const base = status === "full"
230
+ ? `${summary.membersValidated} member(s) validated successfully.`
231
+ : `${summary.definiteErrors} error(s), ${summary.uncertainErrors} uncertain, ${summary.warnings} warning(s). ${summary.membersValidated} validated, ${summary.membersSkipped} member(s) skipped, ${summary.membersMissing} member(s) missing.`;
232
+ const notes = [];
233
+ const scopeFallback = context?.provenance?.scopeFallback;
234
+ if (scopeFallback) {
235
+ notes.push(`Scope fell back from "${scopeFallback.requested}" to "${scopeFallback.applied}" (${scopeFallback.reason}).`);
236
+ }
237
+ const healthReport = context?.healthReport;
238
+ if (healthReport && !healthReport.overallHealthy) {
239
+ const degradations = healthReport.degradations.length > 0
240
+ ? healthReport.degradations.join("; ")
241
+ : "mapping infrastructure degraded";
242
+ notes.push(`Mapping health degraded: ${degradations}.`);
243
+ }
244
+ return notes.length > 0 ? `${base} ${notes.join(" ")}` : base;
233
245
  }
234
246
  function addSkippedMembers(parsed, resolvedMembers) {
235
247
  for (const inj of parsed.injections) {
@@ -271,7 +283,10 @@ export function refreshMixinValidationOutcome(result) {
271
283
  };
272
284
  result.validationStatus = computeValidationStatus(result.summary);
273
285
  result.valid = result.summary.definiteErrors === 0;
274
- result.quickSummary = buildQuickSummary(result.validationStatus, result.summary);
286
+ result.quickSummary = buildQuickSummary(result.validationStatus, result.summary, {
287
+ provenance: result.provenance,
288
+ healthReport: result.toolHealth
289
+ });
275
290
  return result;
276
291
  }
277
292
  function validateInjection(inj, targetMembers, targetNames, issues, resolvedMembers, confidence, confidenceReason, remapFailedMembers, signatureFailedTargets, healthReport) {
@@ -727,7 +742,7 @@ export function validateParsedMixin(parsed, targetMembers, warnings, provenance,
727
742
  parseWarnings: parseWarningCount
728
743
  };
729
744
  const validationStatus = computeValidationStatus(summary);
730
- const quickSummary = buildQuickSummary(validationStatus, summary);
745
+ const quickSummary = buildQuickSummary(validationStatus, summary, { provenance, healthReport });
731
746
  return {
732
747
  className: parsed.className,
733
748
  targets: targetNames,
@@ -22,6 +22,7 @@ export interface RuntimeMetricSnapshot {
22
22
  get_file_duration_ms: MetricTimingSnapshot;
23
23
  list_files_duration_ms: MetricTimingSnapshot;
24
24
  decompile_duration_ms: MetricTimingSnapshot;
25
+ binary_remap_duration_ms: MetricTimingSnapshot;
25
26
  search_intent_symbol_duration_ms: MetricTimingSnapshot;
26
27
  search_intent_text_duration_ms: MetricTimingSnapshot;
27
28
  search_intent_path_duration_ms: MetricTimingSnapshot;
@@ -47,8 +48,13 @@ export interface RuntimeMetricSnapshot {
47
48
  cache_artifact_bytes_lru: CacheArtifactByteAccountingRow[];
48
49
  cache_hit_rate: number;
49
50
  repo_failover_count: number;
51
+ tool_call_counts: Record<string, number>;
52
+ tool_call_duration_ms: Record<string, MetricTimingSnapshot>;
53
+ mapping_resolution_cache_hits: number;
54
+ mapping_resolution_cache_misses: number;
55
+ mapping_resolution_cache_size: number;
50
56
  }
51
- type DurationMetricName = keyof Pick<RuntimeMetricSnapshot, "resolve_duration_ms" | "search_duration_ms" | "get_file_duration_ms" | "list_files_duration_ms" | "decompile_duration_ms" | "search_intent_symbol_duration_ms" | "search_intent_text_duration_ms" | "search_intent_path_duration_ms">;
57
+ type DurationMetricName = keyof Pick<RuntimeMetricSnapshot, "resolve_duration_ms" | "search_duration_ms" | "get_file_duration_ms" | "list_files_duration_ms" | "decompile_duration_ms" | "binary_remap_duration_ms" | "search_intent_symbol_duration_ms" | "search_intent_text_duration_ms" | "search_intent_path_duration_ms">;
52
58
  export declare class RuntimeMetrics {
53
59
  private readonly timings;
54
60
  private cacheHits;
@@ -74,6 +80,11 @@ export declare class RuntimeMetrics {
74
80
  private cacheEntries;
75
81
  private cacheTotalContentBytes;
76
82
  private cacheArtifactBytesLruRef;
83
+ private toolCallCounts;
84
+ private toolCallTimings;
85
+ private mappingResolutionCacheHits;
86
+ private mappingResolutionCacheMisses;
87
+ private mappingResolutionCacheSize;
77
88
  constructor();
78
89
  recordDuration(name: DurationMetricName, durationMs: number): void;
79
90
  recordArtifactCacheHit(): void;
@@ -97,6 +108,12 @@ export declare class RuntimeMetrics {
97
108
  setCacheEntries(entries: number): void;
98
109
  setCacheTotalContentBytes(totalBytes: number): void;
99
110
  setCacheArtifactByteAccountingRef(entries: ReadonlyArray<CacheArtifactByteAccountingRefRow>): void;
111
+ recordToolCall(tool: string, durationMs: number): void;
112
+ setMappingResolutionCacheStats(stats: {
113
+ hits: number;
114
+ misses: number;
115
+ size: number;
116
+ }): void;
100
117
  snapshot(): RuntimeMetricSnapshot;
101
118
  private toSnapshot;
102
119
  private resolveCacheHitRate;
@@ -32,6 +32,11 @@ export class RuntimeMetrics {
32
32
  cacheEntries = 0;
33
33
  cacheTotalContentBytes = 0;
34
34
  cacheArtifactBytesLruRef = [];
35
+ toolCallCounts = new Map();
36
+ toolCallTimings = new Map();
37
+ mappingResolutionCacheHits = 0;
38
+ mappingResolutionCacheMisses = 0;
39
+ mappingResolutionCacheSize = 0;
35
40
  constructor() {
36
41
  const names = [
37
42
  "resolve_duration_ms",
@@ -39,6 +44,7 @@ export class RuntimeMetrics {
39
44
  "get_file_duration_ms",
40
45
  "list_files_duration_ms",
41
46
  "decompile_duration_ms",
47
+ "binary_remap_duration_ms",
42
48
  "search_intent_symbol_duration_ms",
43
49
  "search_intent_text_duration_ms",
44
50
  "search_intent_path_duration_ms"
@@ -140,6 +146,27 @@ export class RuntimeMetrics {
140
146
  setCacheArtifactByteAccountingRef(entries) {
141
147
  this.cacheArtifactBytesLruRef = entries;
142
148
  }
149
+ recordToolCall(tool, durationMs) {
150
+ this.toolCallCounts.set(tool, (this.toolCallCounts.get(tool) ?? 0) + 1);
151
+ let timing = this.toolCallTimings.get(tool);
152
+ if (!timing) {
153
+ timing = { count: 0, totalMs: 0, lastMs: 0, samples: [] };
154
+ this.toolCallTimings.set(tool, timing);
155
+ }
156
+ const normalizedDuration = Math.max(0, Math.trunc(durationMs));
157
+ timing.count += 1;
158
+ timing.totalMs += normalizedDuration;
159
+ timing.lastMs = normalizedDuration;
160
+ timing.samples.push(normalizedDuration);
161
+ if (timing.samples.length > MAX_TIMING_SAMPLES) {
162
+ timing.samples.shift();
163
+ }
164
+ }
165
+ setMappingResolutionCacheStats(stats) {
166
+ this.mappingResolutionCacheHits = stats.hits;
167
+ this.mappingResolutionCacheMisses = stats.misses;
168
+ this.mappingResolutionCacheSize = stats.size;
169
+ }
143
170
  snapshot() {
144
171
  return {
145
172
  resolve_duration_ms: this.toSnapshot("resolve_duration_ms"),
@@ -147,6 +174,7 @@ export class RuntimeMetrics {
147
174
  get_file_duration_ms: this.toSnapshot("get_file_duration_ms"),
148
175
  list_files_duration_ms: this.toSnapshot("list_files_duration_ms"),
149
176
  decompile_duration_ms: this.toSnapshot("decompile_duration_ms"),
177
+ binary_remap_duration_ms: this.toSnapshot("binary_remap_duration_ms"),
150
178
  search_intent_symbol_duration_ms: this.toSnapshot("search_intent_symbol_duration_ms"),
151
179
  search_intent_text_duration_ms: this.toSnapshot("search_intent_text_duration_ms"),
152
180
  search_intent_path_duration_ms: this.toSnapshot("search_intent_path_duration_ms"),
@@ -175,7 +203,22 @@ export class RuntimeMetrics {
175
203
  updated_at: entry.updatedAt
176
204
  })),
177
205
  cache_hit_rate: this.resolveCacheHitRate(),
178
- repo_failover_count: this.repoFailoverCount
206
+ repo_failover_count: this.repoFailoverCount,
207
+ tool_call_counts: Object.fromEntries(this.toolCallCounts),
208
+ tool_call_duration_ms: Object.fromEntries([...this.toolCallTimings].map(([tool, timing]) => [
209
+ tool,
210
+ {
211
+ count: timing.count,
212
+ totalMs: timing.totalMs,
213
+ avgMs: timing.count > 0 ? timing.totalMs / timing.count : 0,
214
+ lastMs: timing.lastMs,
215
+ p95Ms: percentile(timing.samples, 95),
216
+ p99Ms: percentile(timing.samples, 99)
217
+ }
218
+ ])),
219
+ mapping_resolution_cache_hits: this.mappingResolutionCacheHits,
220
+ mapping_resolution_cache_misses: this.mappingResolutionCacheMisses,
221
+ mapping_resolution_cache_size: this.mappingResolutionCacheSize
179
222
  };
180
223
  }
181
224
  toSnapshot(name) {