@adhisang/minecraft-modding-mcp 6.1.0 → 6.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +37 -0
- package/README.md +4 -2
- package/dist/cache-registry.d.ts +1 -1
- package/dist/cache-registry.js +3 -0
- package/dist/entry-tools/analyze-mod-service.d.ts +12 -6
- package/dist/entry-tools/analyze-mod-service.js +37 -3
- package/dist/entry-tools/analyze-symbol-service.d.ts +2 -0
- package/dist/entry-tools/analyze-symbol-service.js +37 -2
- package/dist/entry-tools/inspect-minecraft/internal.d.ts +4 -0
- package/dist/entry-tools/inspect-minecraft/internal.js +28 -0
- package/dist/entry-tools/manage-cache-service.d.ts +4 -4
- package/dist/error-mapping.d.ts +13 -0
- package/dist/error-mapping.js +35 -2
- package/dist/errors.d.ts +2 -0
- package/dist/errors.js +2 -0
- package/dist/index.js +37 -17
- package/dist/mapping/internal-types.d.ts +7 -0
- package/dist/mapping/types.d.ts +18 -0
- package/dist/mapping-service.js +16 -3
- package/dist/minecraft-explorer-service.d.ts +4 -0
- package/dist/minecraft-explorer-service.js +156 -17
- package/dist/mod-analyzer.d.ts +7 -0
- package/dist/mod-analyzer.js +28 -7
- package/dist/nbt/typed-json.js +4 -1
- package/dist/source/artifact-resolver.d.ts +2 -0
- package/dist/source/artifact-resolver.js +24 -1
- package/dist/source/class-source/members-builder.d.ts +4 -0
- package/dist/source/class-source/members-builder.js +3 -1
- package/dist/source/class-source.d.ts +2 -1
- package/dist/source/class-source.js +154 -17
- package/dist/source/did-you-mean.d.ts +14 -0
- package/dist/source/did-you-mean.js +79 -0
- package/dist/source/file-access.js +159 -3
- package/dist/source/indexer.js +72 -2
- package/dist/source/lifecycle/runtime-check.js +15 -7
- package/dist/source/nested-jars.d.ts +59 -0
- package/dist/source/nested-jars.js +198 -0
- package/dist/source/workspace-target.js +5 -2
- package/dist/source-jar-reader.d.ts +16 -0
- package/dist/source-jar-reader.js +82 -0
- package/dist/source-service.d.ts +36 -0
- package/dist/source-service.js +49 -6
- package/dist/stage-emitter.js +24 -8
- package/dist/stdio-supervisor.d.ts +92 -9
- package/dist/stdio-supervisor.js +915 -103
- package/dist/tool-contract-manifest.js +1 -1
- package/dist/tool-guidance.js +3 -1
- package/dist/tool-schemas.d.ts +1337 -149
- package/dist/tool-schemas.js +38 -7
- package/dist/types.d.ts +23 -0
- package/dist/workspace-mapping-service.d.ts +1 -0
- package/dist/workspace-mapping-service.js +120 -8
- package/docs/tool-reference.md +19 -3
- package/package.json +1 -1
|
@@ -206,13 +206,32 @@ export async function discoverVersionSourceJar(_svc, input) {
|
|
|
206
206
|
const candidateArtifacts = candidates
|
|
207
207
|
.slice(0, 20)
|
|
208
208
|
.map((candidate) => `${candidate.jarPath}#java=${candidate.javaEntryCount}#net_minecraft=${candidate.hasMinecraftNamespace ? 1 : 0}`);
|
|
209
|
+
// Loom split-source workspaces publish the version as a common/clientOnly
|
|
210
|
+
// PAIR with no merged jar; selecting one loses the other half's classes
|
|
211
|
+
// (e.g. net.minecraft.client.* when common wins). Surface the best-scored
|
|
212
|
+
// jar of the other half so ingestion can index both.
|
|
213
|
+
const selectedHalf = selected ? splitSourceHalf(selected.jarPath) : undefined;
|
|
214
|
+
const companion = selectedHalf
|
|
215
|
+
? candidates.find((candidate) => candidate !== selected &&
|
|
216
|
+
candidate.looksLikeMinecraftArtifact &&
|
|
217
|
+
splitSourceHalf(candidate.jarPath) !== undefined &&
|
|
218
|
+
splitSourceHalf(candidate.jarPath) !== selectedHalf &&
|
|
219
|
+
// Version affinity: a leftover other-half jar from a different
|
|
220
|
+
// version must never be spliced into this version's index.
|
|
221
|
+
hasExactVersionToken(candidate.jarPath, input.version))
|
|
222
|
+
: undefined;
|
|
209
223
|
return {
|
|
210
224
|
searchedPaths,
|
|
211
225
|
candidateArtifacts,
|
|
212
226
|
selectedSourceJarPath: selected?.jarPath,
|
|
213
|
-
selectedHasMinecraftNamespace: selected?.hasMinecraftNamespace
|
|
227
|
+
selectedHasMinecraftNamespace: selected?.hasMinecraftNamespace,
|
|
228
|
+
...(companion ? { companionSourceJarPaths: [companion.jarPath] } : {})
|
|
214
229
|
};
|
|
215
230
|
}
|
|
231
|
+
function splitSourceHalf(jarPath) {
|
|
232
|
+
const match = /minecraft-(common|clientonly)/i.exec(jarPath);
|
|
233
|
+
return match ? match[1].toLowerCase() : undefined;
|
|
234
|
+
}
|
|
216
235
|
export async function probeMinecraftArtifact(svc, input) {
|
|
217
236
|
let value = input.target.value.trim();
|
|
218
237
|
const warnings = [];
|
|
@@ -1171,6 +1190,10 @@ export async function resolveArtifact(svc, input) {
|
|
|
1171
1190
|
if (dependencyProvenance) {
|
|
1172
1191
|
provenance.dependencyResolution = dependencyProvenance;
|
|
1173
1192
|
}
|
|
1193
|
+
if (versionSourceDiscovery?.companionSourceJarPaths?.length &&
|
|
1194
|
+
resolved.sourceJarPath === versionSourceDiscovery.selectedSourceJarPath) {
|
|
1195
|
+
provenance.companionSourceJars = versionSourceDiscovery.companionSourceJarPaths;
|
|
1196
|
+
}
|
|
1174
1197
|
if (dependencyOrigin && dependencyRequestedMapping && dependencyRequestedMapping !== "obfuscated") {
|
|
1175
1198
|
const coord = resolved.coordinate ?? value;
|
|
1176
1199
|
warnings.push(`Dependency artifact ${coord} mapping "${dependencyRequestedMapping}" is not enforced (binary remap is disabled for non-vanilla artifacts); the JAR is returned in its native namespace and mappingApplied is reported as "obfuscated" with qualityFlag "dependency-mapping-unverified". Caller must validate symbol availability.`);
|
|
@@ -50,6 +50,10 @@ export type WireMember = {
|
|
|
50
50
|
ownerFqn?: string;
|
|
51
51
|
/** Present only when true. */
|
|
52
52
|
isSynthetic?: boolean;
|
|
53
|
+
/** Rendered default value of an annotation-type member. */
|
|
54
|
+
annotationDefault?: string;
|
|
55
|
+
/** Runtime-visible annotations; present only under the opt-in projection. */
|
|
56
|
+
annotations?: string[];
|
|
53
57
|
};
|
|
54
58
|
export type WireMembersBlock = {
|
|
55
59
|
/** Hoisted owner when every member shares one owner (the common, non-inherited case). */
|
|
@@ -82,7 +82,9 @@ export function projectMembersForWire(slice, includeInherited, keepFieldDescript
|
|
|
82
82
|
javaSignature: m.javaSignature,
|
|
83
83
|
...(keepDescriptor ? { jvmDescriptor: m.jvmDescriptor } : {}),
|
|
84
84
|
...(hoistOwner ? {} : { ownerFqn: m.ownerFqn }),
|
|
85
|
-
...(m.isSynthetic ? { isSynthetic: true } : {})
|
|
85
|
+
...(m.isSynthetic ? { isSynthetic: true } : {}),
|
|
86
|
+
...(m.annotationDefault !== undefined ? { annotationDefault: m.annotationDefault } : {}),
|
|
87
|
+
...(m.annotations?.length ? { annotations: m.annotations } : {})
|
|
86
88
|
});
|
|
87
89
|
return {
|
|
88
90
|
...(hoistOwner ? { ownerFqn: [...owners][0] } : {}),
|
|
@@ -37,7 +37,7 @@ export declare function buildFallbackProvenance(svc: SourceService, input: {
|
|
|
37
37
|
requestedMapping: SourceMapping;
|
|
38
38
|
mappingApplied: SourceMapping;
|
|
39
39
|
}): ArtifactProvenance;
|
|
40
|
-
export declare function buildClassSourceNotFoundError(
|
|
40
|
+
export declare function buildClassSourceNotFoundError(svc: SourceService, input: {
|
|
41
41
|
className: string;
|
|
42
42
|
lookupClassName: string;
|
|
43
43
|
artifactId: string;
|
|
@@ -51,6 +51,7 @@ export declare function buildClassSourceNotFoundError(_svc: SourceService, input
|
|
|
51
51
|
scope?: ArtifactScope;
|
|
52
52
|
projectPath?: string;
|
|
53
53
|
version?: string;
|
|
54
|
+
nestedJars?: string[];
|
|
54
55
|
}): AppError;
|
|
55
56
|
export declare function buildDecompiledFallback(svc: SourceService, artifactId: string, lookupClassName: string, memberPattern: string | undefined, maxMembers: number): {
|
|
56
57
|
fallback: DecompiledFallback;
|
|
@@ -6,7 +6,9 @@ import * as artifactResolver from "./artifact-resolver.js";
|
|
|
6
6
|
import * as classSourceHelpers from "./class-source-helpers.js";
|
|
7
7
|
import { buildClassSourceSnippet } from "./class-source/snippet-builder.js";
|
|
8
8
|
import { remapAndCountMembers, sliceMembersWithLimit, projectMembersForWire, projectMembersByLevel } from "./class-source/members-builder.js";
|
|
9
|
+
import { collectDidYouMeanCandidates } from "./did-you-mean.js";
|
|
9
10
|
import { matchesMemberPattern } from "./member-pattern.js";
|
|
11
|
+
import { resolveUniqueNestedJarForClass } from "./nested-jars.js";
|
|
10
12
|
import { buildPageContextKey, encodeOffsetCursor, resolveCursorOffset } from "../page-cursor.js";
|
|
11
13
|
import { dedupeQualityFlags, normalizeMapping, normalizeOptionalString, normalizePathStyle } from "./shared-utils.js";
|
|
12
14
|
import { isUnobfuscatedVersion } from "../version-service.js";
|
|
@@ -165,7 +167,7 @@ export function buildFallbackProvenance(svc, input) {
|
|
|
165
167
|
transformChain
|
|
166
168
|
};
|
|
167
169
|
}
|
|
168
|
-
export function buildClassSourceNotFoundError(
|
|
170
|
+
export function buildClassSourceNotFoundError(svc, input) {
|
|
169
171
|
const simpleName = input.className.split(/[.$]/).at(-1) ?? input.className;
|
|
170
172
|
const details = {
|
|
171
173
|
artifactId: input.artifactId,
|
|
@@ -177,7 +179,11 @@ export function buildClassSourceNotFoundError(_svc, input) {
|
|
|
177
179
|
...(input.scope ? { scope: input.scope } : {}),
|
|
178
180
|
...(input.targetKind ? { targetKind: input.targetKind } : {}),
|
|
179
181
|
...(input.targetValue ? { targetValue: input.targetValue } : {}),
|
|
180
|
-
...(input.attemptedBinaryFallback ? { binaryFallbackAttempted: true } : {})
|
|
182
|
+
...(input.attemptedBinaryFallback ? { binaryFallbackAttempted: true } : {}),
|
|
183
|
+
...(input.nestedJars && input.nestedJars.length > 0 ? { nestedJars: input.nestedJars } : {}),
|
|
184
|
+
// Candidates are hints from the symbol index, never assertions that the
|
|
185
|
+
// class exists at the suggested location; empty when nothing usable.
|
|
186
|
+
didYouMean: collectDidYouMeanCandidates(svc, input.artifactId, input.className)
|
|
181
187
|
};
|
|
182
188
|
let nextAction = `Use find-class to resolve the correct fully-qualified name for "${simpleName}".`;
|
|
183
189
|
let suggestionSpec = {
|
|
@@ -220,6 +226,29 @@ export function buildClassSourceNotFoundError(_svc, input) {
|
|
|
220
226
|
}
|
|
221
227
|
details.nextAction = nextAction;
|
|
222
228
|
Object.assign(details, buildSuggestedCall(suggestionSpec));
|
|
229
|
+
// Split-source workspaces can omit client-only classes from merged indexes;
|
|
230
|
+
// the vanilla scope decompiles the client jar, which contains them. Offer
|
|
231
|
+
// the retry as an example using existing scope enum values only.
|
|
232
|
+
if (input.targetKind === "version" && input.version && input.scope !== "vanilla") {
|
|
233
|
+
const scopeRetry = buildSuggestedCall({
|
|
234
|
+
tool: "get-class-source",
|
|
235
|
+
params: undefined,
|
|
236
|
+
examples: [
|
|
237
|
+
{
|
|
238
|
+
params: {
|
|
239
|
+
className: input.className,
|
|
240
|
+
target: { kind: "version", value: input.version },
|
|
241
|
+
scope: "vanilla"
|
|
242
|
+
},
|
|
243
|
+
reason: "Client-only classes can be missing from merged split-source indexes; scope \"vanilla\" decompiles the client jar, which contains them."
|
|
244
|
+
}
|
|
245
|
+
]
|
|
246
|
+
});
|
|
247
|
+
if (scopeRetry.exampleCalls?.length) {
|
|
248
|
+
const existing = Array.isArray(details.exampleCalls) ? details.exampleCalls : [];
|
|
249
|
+
details.exampleCalls = [...existing, ...scopeRetry.exampleCalls];
|
|
250
|
+
}
|
|
251
|
+
}
|
|
223
252
|
return createError({
|
|
224
253
|
code: ERROR_CODES.CLASS_NOT_FOUND,
|
|
225
254
|
message: `Source for class "${input.className}" was not found.`,
|
|
@@ -547,6 +576,53 @@ export async function getClassSource(svc, input) {
|
|
|
547
576
|
}
|
|
548
577
|
return true;
|
|
549
578
|
};
|
|
579
|
+
let attemptedNestedJarRedirect = false;
|
|
580
|
+
const tryNestedJarRedirect = async (lookupClassName) => {
|
|
581
|
+
if (attemptedNestedJarRedirect) {
|
|
582
|
+
return false;
|
|
583
|
+
}
|
|
584
|
+
const inventory = activeProvenance?.nestedJars;
|
|
585
|
+
const outerJarPath = normalizeOptionalString(binaryJarPath);
|
|
586
|
+
if (!inventory || inventory.length === 0 || !outerJarPath) {
|
|
587
|
+
return false;
|
|
588
|
+
}
|
|
589
|
+
attemptedNestedJarRedirect = true;
|
|
590
|
+
const shellArtifactId = activeArtifactId;
|
|
591
|
+
const match = await resolveUniqueNestedJarForClass({
|
|
592
|
+
cacheDir: svc.config.cacheDir,
|
|
593
|
+
outerJarPath,
|
|
594
|
+
outerArtifactId: shellArtifactId,
|
|
595
|
+
inventory,
|
|
596
|
+
className: lookupClassName
|
|
597
|
+
});
|
|
598
|
+
if (!match) {
|
|
599
|
+
return false;
|
|
600
|
+
}
|
|
601
|
+
const redirectResolved = await svc.resolveArtifact({
|
|
602
|
+
target: { kind: "jar", value: match.extractedPath },
|
|
603
|
+
mapping: input.mapping,
|
|
604
|
+
sourcePriority: input.sourcePriority,
|
|
605
|
+
allowDecompile: input.allowDecompile,
|
|
606
|
+
projectPath: input.projectPath,
|
|
607
|
+
gradleUserHome: input.gradleUserHome
|
|
608
|
+
});
|
|
609
|
+
activeArtifactId = redirectResolved.artifactId;
|
|
610
|
+
activeOrigin = redirectResolved.origin;
|
|
611
|
+
activeMappingApplied = redirectResolved.mappingApplied ?? activeMappingApplied;
|
|
612
|
+
activeProvenance = redirectResolved.provenance
|
|
613
|
+
? {
|
|
614
|
+
...redirectResolved.provenance,
|
|
615
|
+
nestedJar: { entryName: match.entryName, shellArtifactId }
|
|
616
|
+
}
|
|
617
|
+
: activeProvenance;
|
|
618
|
+
activeQualityFlags = dedupeQualityFlags([
|
|
619
|
+
...redirectResolved.qualityFlags,
|
|
620
|
+
"nested-jar-redirect"
|
|
621
|
+
]);
|
|
622
|
+
activeSourceJarPath = redirectResolved.resolvedSourceJarPath;
|
|
623
|
+
warnings.push(`Class "${className}" lives in nested jar "${match.entryName}" bundled by the shell jar; the lookup was redirected there automatically.`);
|
|
624
|
+
return true;
|
|
625
|
+
};
|
|
550
626
|
let activeLookupClassName = await svc.resolveClassNameForLookup({
|
|
551
627
|
className,
|
|
552
628
|
version,
|
|
@@ -558,6 +634,9 @@ export async function getClassSource(svc, input) {
|
|
|
558
634
|
context: "source lookup"
|
|
559
635
|
});
|
|
560
636
|
let filePath = resolveClassFilePath(svc, activeArtifactId, activeLookupClassName);
|
|
637
|
+
if (!filePath && (await tryNestedJarRedirect(activeLookupClassName))) {
|
|
638
|
+
filePath = resolveClassFilePath(svc, activeArtifactId, activeLookupClassName);
|
|
639
|
+
}
|
|
561
640
|
if (!filePath && (await tryBinaryFallback())) {
|
|
562
641
|
activeLookupClassName = await svc.resolveClassNameForLookup({
|
|
563
642
|
className,
|
|
@@ -584,10 +663,18 @@ export async function getClassSource(svc, input) {
|
|
|
584
663
|
targetValue: input.target && "value" in input.target ? input.target.value : undefined,
|
|
585
664
|
scope: input.scope,
|
|
586
665
|
projectPath: input.projectPath,
|
|
587
|
-
version
|
|
666
|
+
version,
|
|
667
|
+
nestedJars: activeProvenance?.nestedJars
|
|
588
668
|
});
|
|
589
669
|
}
|
|
590
670
|
let row = svc.filesRepo.getFileContent(activeArtifactId, filePath);
|
|
671
|
+
if (!row && (await tryNestedJarRedirect(activeLookupClassName))) {
|
|
672
|
+
const redirectedFilePath = resolveClassFilePath(svc, activeArtifactId, activeLookupClassName);
|
|
673
|
+
if (redirectedFilePath) {
|
|
674
|
+
filePath = redirectedFilePath;
|
|
675
|
+
row = svc.filesRepo.getFileContent(activeArtifactId, filePath);
|
|
676
|
+
}
|
|
677
|
+
}
|
|
591
678
|
if (!row && (await tryBinaryFallback())) {
|
|
592
679
|
activeLookupClassName = await svc.resolveClassNameForLookup({
|
|
593
680
|
className,
|
|
@@ -616,7 +703,8 @@ export async function getClassSource(svc, input) {
|
|
|
616
703
|
targetValue: input.target && "value" in input.target ? input.target.value : undefined,
|
|
617
704
|
scope: input.scope,
|
|
618
705
|
projectPath: input.projectPath,
|
|
619
|
-
version
|
|
706
|
+
version,
|
|
707
|
+
nestedJars: activeProvenance?.nestedJars
|
|
620
708
|
});
|
|
621
709
|
}
|
|
622
710
|
const snippet = buildClassSourceSnippet({
|
|
@@ -841,22 +929,67 @@ export async function getClassMembers(svc, input) {
|
|
|
841
929
|
let signatureMethods;
|
|
842
930
|
let binaryExtractionFailed = false;
|
|
843
931
|
let binaryExtractionFailureReason;
|
|
932
|
+
let nestedJarRedirect;
|
|
933
|
+
const fetchSignature = (jarPath) => svc.explorerService.getSignature({
|
|
934
|
+
fqn: lookupClassName,
|
|
935
|
+
jarPath,
|
|
936
|
+
access,
|
|
937
|
+
includeSynthetic,
|
|
938
|
+
includeInherited,
|
|
939
|
+
memberPattern: requestedMapping === mappingApplied ? memberPattern : undefined
|
|
940
|
+
});
|
|
844
941
|
try {
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
942
|
+
let signature;
|
|
943
|
+
try {
|
|
944
|
+
signature = await fetchSignature(binaryJarPath);
|
|
945
|
+
}
|
|
946
|
+
catch (missError) {
|
|
947
|
+
const inventory = provenance?.nestedJars;
|
|
948
|
+
if (!isAppError(missError) ||
|
|
949
|
+
missError.code !== ERROR_CODES.CLASS_NOT_FOUND ||
|
|
950
|
+
!inventory ||
|
|
951
|
+
inventory.length === 0) {
|
|
952
|
+
throw missError;
|
|
953
|
+
}
|
|
954
|
+
const match = await resolveUniqueNestedJarForClass({
|
|
955
|
+
cacheDir: svc.config.cacheDir,
|
|
956
|
+
outerJarPath: binaryJarPath,
|
|
957
|
+
outerArtifactId: artifactId,
|
|
958
|
+
inventory,
|
|
959
|
+
className: lookupClassName
|
|
960
|
+
});
|
|
961
|
+
if (!match) {
|
|
962
|
+
throw missError;
|
|
963
|
+
}
|
|
964
|
+
nestedJarRedirect = { entryName: match.entryName, shellArtifactId: artifactId };
|
|
965
|
+
warnings.push(`Class "${className}" lives in nested jar "${match.entryName}" bundled by the shell jar; members were read from it.`);
|
|
966
|
+
signature = await fetchSignature(match.extractedPath);
|
|
967
|
+
}
|
|
853
968
|
warnings.push(...signature.warnings);
|
|
854
969
|
signatureContext = signature.context;
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
970
|
+
// Member annotations are opt-in: strip them unless requested. The
|
|
971
|
+
// annotationDefault of annotation-type members is always kept. Cached
|
|
972
|
+
// signature objects must not be mutated, so stripping copies.
|
|
973
|
+
const stripAnnotations = (member) => {
|
|
974
|
+
if (!member.annotations) {
|
|
975
|
+
return member;
|
|
976
|
+
}
|
|
977
|
+
const { annotations: _omitted, ...rest } = member;
|
|
978
|
+
return rest;
|
|
979
|
+
};
|
|
980
|
+
const includeAnnotations = input.includeAnnotations ?? false;
|
|
981
|
+
signatureConstructors = includeAnnotations
|
|
982
|
+
? signature.constructors
|
|
983
|
+
: signature.constructors.map(stripAnnotations);
|
|
984
|
+
signatureFields = includeAnnotations ? signature.fields : signature.fields.map(stripAnnotations);
|
|
985
|
+
signatureMethods = includeAnnotations ? signature.methods : signature.methods.map(stripAnnotations);
|
|
858
986
|
}
|
|
859
987
|
catch (error) {
|
|
988
|
+
if (isAppError(error) && error.code === ERROR_CODES.NESTED_JAR_AMBIGUOUS) {
|
|
989
|
+
// A class living in several nested jars needs the caller's choice; the
|
|
990
|
+
// candidates error must not degrade into a members_unavailable response.
|
|
991
|
+
throw error;
|
|
992
|
+
}
|
|
860
993
|
if (isAppError(error) && error.code === ERROR_CODES.CLASS_NOT_FOUND) {
|
|
861
994
|
// Re-raise with the shared recovery shape (find-class/api-matrix
|
|
862
995
|
// suggestedCall, namespace + scope hints) instead of the sparse bytecode
|
|
@@ -873,7 +1006,8 @@ export async function getClassMembers(svc, input) {
|
|
|
873
1006
|
targetValue: input.target && "value" in input.target ? input.target.value : undefined,
|
|
874
1007
|
scope: input.scope,
|
|
875
1008
|
projectPath: input.projectPath,
|
|
876
|
-
version
|
|
1009
|
+
version,
|
|
1010
|
+
nestedJars: provenance?.nestedJars
|
|
877
1011
|
});
|
|
878
1012
|
}
|
|
879
1013
|
binaryExtractionFailed = true;
|
|
@@ -926,13 +1060,16 @@ export async function getClassMembers(svc, input) {
|
|
|
926
1060
|
const projectedMembers = projectMembersByLevel(projectMembersForWire({ constructors, fields, methods }, includeInherited, input.includeDescriptors ?? false), projection);
|
|
927
1061
|
const truncated = sliced.truncated;
|
|
928
1062
|
const nextCursor = sliced.nextOffset != null ? encodeOffsetCursor(sliced.nextOffset, memberCursorContext) : undefined;
|
|
929
|
-
const
|
|
1063
|
+
const baseProvenance = provenance ??
|
|
930
1064
|
buildFallbackProvenance(svc, {
|
|
931
1065
|
artifactId,
|
|
932
1066
|
origin,
|
|
933
1067
|
requestedMapping,
|
|
934
1068
|
mappingApplied
|
|
935
1069
|
});
|
|
1070
|
+
const normalizedProvenance = nestedJarRedirect
|
|
1071
|
+
? { ...baseProvenance, nestedJar: nestedJarRedirect }
|
|
1072
|
+
: baseProvenance;
|
|
936
1073
|
let decompiledFallback;
|
|
937
1074
|
let decompiledMemberCounts;
|
|
938
1075
|
let fallbackQualityFlags = qualityFlags;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { SourceService } from "../source-service.js";
|
|
2
|
+
export interface DidYouMeanCandidate {
|
|
3
|
+
className: string;
|
|
4
|
+
matchReason: string;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Collects ranked near-miss class candidates from the artifact's symbol
|
|
8
|
+
* index for a class that was not found: exact simple-name matches first
|
|
9
|
+
* (the moved-FQN case), then case-insensitive matches, then bounded
|
|
10
|
+
* edit-distance suggestions. Candidates are hints, never assertions that the
|
|
11
|
+
* class exists at the suggested location. Returns an empty array when the
|
|
12
|
+
* index has nothing usable.
|
|
13
|
+
*/
|
|
14
|
+
export declare function collectDidYouMeanCandidates(svc: SourceService, artifactId: string, className: string): DidYouMeanCandidate[];
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { levenshteinDistance, suggestSimilar } from "../mixin/helpers.js";
|
|
2
|
+
const TYPE_SYMBOL_KINDS = ["class", "interface", "enum", "record"];
|
|
3
|
+
const MAX_CANDIDATES = 8;
|
|
4
|
+
// Near-miss candidates come from a bounded prefix pool: typos inside the
|
|
5
|
+
// first characters are rare compared to suffix/mid-word slips, and an
|
|
6
|
+
// unbounded scan over every type symbol would not stay index-backed.
|
|
7
|
+
const EDIT_DISTANCE_POOL_PREFIX = 4;
|
|
8
|
+
function fqnOfRow(row) {
|
|
9
|
+
return row.qualifiedName ?? row.filePath.replace(/\.java$/, "").replaceAll("/", ".");
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Collects ranked near-miss class candidates from the artifact's symbol
|
|
13
|
+
* index for a class that was not found: exact simple-name matches first
|
|
14
|
+
* (the moved-FQN case), then case-insensitive matches, then bounded
|
|
15
|
+
* edit-distance suggestions. Candidates are hints, never assertions that the
|
|
16
|
+
* class exists at the suggested location. Returns an empty array when the
|
|
17
|
+
* index has nothing usable.
|
|
18
|
+
*/
|
|
19
|
+
export function collectDidYouMeanCandidates(svc, artifactId, className) {
|
|
20
|
+
try {
|
|
21
|
+
const simpleName = className.split(/[.$]/).at(-1) ?? className;
|
|
22
|
+
if (!simpleName) {
|
|
23
|
+
return [];
|
|
24
|
+
}
|
|
25
|
+
const out = [];
|
|
26
|
+
const seen = new Set([className]);
|
|
27
|
+
const push = (fqn, matchReason) => {
|
|
28
|
+
if (seen.has(fqn) || out.length >= MAX_CANDIDATES) {
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
seen.add(fqn);
|
|
32
|
+
out.push({ className: fqn, matchReason });
|
|
33
|
+
};
|
|
34
|
+
const exact = svc.symbolsRepo.findScopedSymbols({
|
|
35
|
+
artifactId,
|
|
36
|
+
query: simpleName,
|
|
37
|
+
match: "exact",
|
|
38
|
+
symbolKinds: TYPE_SYMBOL_KINDS,
|
|
39
|
+
limit: 50
|
|
40
|
+
});
|
|
41
|
+
for (const row of exact.items) {
|
|
42
|
+
push(fqnOfRow(row), "exact-simple-name");
|
|
43
|
+
}
|
|
44
|
+
// One index-backed prefix query (lower(symbol_name) LIKE 'pref%') feeds
|
|
45
|
+
// both the case-insensitive and the edit-distance tiers: a case variant
|
|
46
|
+
// shares the lowercased prefix, and a leading-wildcard `contains` scan
|
|
47
|
+
// would defeat the symbol-name index on every not-found error.
|
|
48
|
+
const prefixPool = svc.symbolsRepo.findScopedSymbols({
|
|
49
|
+
artifactId,
|
|
50
|
+
query: simpleName.slice(0, EDIT_DISTANCE_POOL_PREFIX),
|
|
51
|
+
match: "prefix",
|
|
52
|
+
symbolKinds: TYPE_SYMBOL_KINDS,
|
|
53
|
+
limit: 200
|
|
54
|
+
});
|
|
55
|
+
for (const row of prefixPool.items) {
|
|
56
|
+
if (row.symbolName === simpleName)
|
|
57
|
+
continue;
|
|
58
|
+
if (row.symbolName.toLowerCase() !== simpleName.toLowerCase())
|
|
59
|
+
continue;
|
|
60
|
+
push(fqnOfRow(row), "case-insensitive");
|
|
61
|
+
}
|
|
62
|
+
const poolNames = [...new Set(prefixPool.items.map((row) => row.symbolName))];
|
|
63
|
+
for (const suggestion of suggestSimilar(simpleName, poolNames)) {
|
|
64
|
+
const distance = levenshteinDistance(simpleName.toLowerCase(), suggestion.toLowerCase());
|
|
65
|
+
for (const row of prefixPool.items) {
|
|
66
|
+
if (row.symbolName !== suggestion)
|
|
67
|
+
continue;
|
|
68
|
+
push(fqnOfRow(row), `edit-distance:${distance}`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
// Candidate collection must never turn the not-found error into a
|
|
75
|
+
// different failure; a broken index simply yields no suggestions.
|
|
76
|
+
return [];
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
//# sourceMappingURL=did-you-mean.js.map
|
|
@@ -1,6 +1,64 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { basename } from "node:path";
|
|
2
|
+
import { ERROR_CODES, createError, isAppError } from "../errors.js";
|
|
2
3
|
import { log } from "../logger.js";
|
|
4
|
+
import { decodeJarEntryUtf8OrThrow, listJarEntries, readJarEntryCapped } from "../source-jar-reader.js";
|
|
3
5
|
import { normalizeOptionalString, normalizePathStyle } from "./shared-utils.js";
|
|
6
|
+
// Read-through delivery for non-indexed jar resources: text files under these
|
|
7
|
+
// prefixes are served directly from the backing jar when the source index has
|
|
8
|
+
// no row for them. The per-file cap bounds response size; binary entries
|
|
9
|
+
// answer with metadata only.
|
|
10
|
+
const READ_THROUGH_PREFIXES = ["assets/", "data/"];
|
|
11
|
+
const READ_THROUGH_MAX_BYTES = 512 * 1024;
|
|
12
|
+
const READ_THROUGH_TEXT_EXTENSIONS = new Set([
|
|
13
|
+
".json",
|
|
14
|
+
".mcmeta",
|
|
15
|
+
".txt",
|
|
16
|
+
".properties",
|
|
17
|
+
".lang",
|
|
18
|
+
".cfg",
|
|
19
|
+
".toml",
|
|
20
|
+
".snbt",
|
|
21
|
+
".yml",
|
|
22
|
+
".yaml",
|
|
23
|
+
".csv",
|
|
24
|
+
".md",
|
|
25
|
+
".fsh",
|
|
26
|
+
".vsh",
|
|
27
|
+
".glsl"
|
|
28
|
+
]);
|
|
29
|
+
const NEARBY_PATH_HINT_LIMIT = 5;
|
|
30
|
+
function isTraversalShapedPath(filePath) {
|
|
31
|
+
return (filePath.startsWith("/") ||
|
|
32
|
+
filePath.includes("\u0000") ||
|
|
33
|
+
filePath.split(/[\\/]/).includes(".."));
|
|
34
|
+
}
|
|
35
|
+
function hasReadThroughPrefix(filePath) {
|
|
36
|
+
return READ_THROUGH_PREFIXES.some((prefix) => filePath.startsWith(prefix));
|
|
37
|
+
}
|
|
38
|
+
function readThroughTextExtension(filePath) {
|
|
39
|
+
const dot = filePath.lastIndexOf(".");
|
|
40
|
+
if (dot < 0) {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
return READ_THROUGH_TEXT_EXTENSIONS.has(filePath.slice(dot).toLowerCase());
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Same-basename entries elsewhere in the jar, for not-found hints. Covers
|
|
47
|
+
* directory relocations across versions (e.g. assets/minecraft/models/item/*
|
|
48
|
+
* moving to assets/minecraft/items/*).
|
|
49
|
+
*/
|
|
50
|
+
async function collectNearbyPaths(binaryJarPath, missingPath) {
|
|
51
|
+
try {
|
|
52
|
+
const wanted = basename(missingPath);
|
|
53
|
+
const entries = await listJarEntries(binaryJarPath);
|
|
54
|
+
return entries
|
|
55
|
+
.filter((entry) => hasReadThroughPrefix(entry) && basename(entry) === wanted)
|
|
56
|
+
.slice(0, NEARBY_PATH_HINT_LIMIT);
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return [];
|
|
60
|
+
}
|
|
61
|
+
}
|
|
4
62
|
function clampLimit(limit, fallback, max) {
|
|
5
63
|
if (limit === undefined || limit === null) {
|
|
6
64
|
return fallback;
|
|
@@ -24,8 +82,28 @@ function truncateUtf8ToMaxBytes(content, maxBytes) {
|
|
|
24
82
|
export async function getArtifactFile(svc, input) {
|
|
25
83
|
const startedAt = Date.now();
|
|
26
84
|
try {
|
|
85
|
+
if (isTraversalShapedPath(input.filePath)) {
|
|
86
|
+
throw createError({
|
|
87
|
+
code: ERROR_CODES.INVALID_INPUT,
|
|
88
|
+
message: `filePath "${input.filePath}" must be a plain in-archive path (no absolute paths or ".." segments).`,
|
|
89
|
+
details: {
|
|
90
|
+
filePath: input.filePath,
|
|
91
|
+
nextAction: "Pass the entry path exactly as it appears inside the jar, e.g. assets/minecraft/models/block/stone.json."
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
}
|
|
27
95
|
const artifact = svc.getArtifact(input.artifactId);
|
|
28
|
-
const
|
|
96
|
+
const normalizedPath = normalizePathStyle(input.filePath);
|
|
97
|
+
const row = svc.filesRepo.getFileContent(artifact.artifactId, normalizedPath);
|
|
98
|
+
if (!row && hasReadThroughPrefix(normalizedPath) && artifact.binaryJarPath) {
|
|
99
|
+
return await readFileThroughJar(svc, {
|
|
100
|
+
artifact,
|
|
101
|
+
binaryJarPath: artifact.binaryJarPath,
|
|
102
|
+
filePath: normalizedPath,
|
|
103
|
+
maxBytes: input.maxBytes,
|
|
104
|
+
artifactId: input.artifactId
|
|
105
|
+
});
|
|
106
|
+
}
|
|
29
107
|
if (!row) {
|
|
30
108
|
throw createError({
|
|
31
109
|
code: ERROR_CODES.FILE_NOT_FOUND,
|
|
@@ -65,6 +143,84 @@ export async function getArtifactFile(svc, input) {
|
|
|
65
143
|
svc.metrics.recordDuration("get_file_duration_ms", Date.now() - startedAt);
|
|
66
144
|
}
|
|
67
145
|
}
|
|
146
|
+
async function readFileThroughJar(svc, args) {
|
|
147
|
+
const { artifact, binaryJarPath, filePath, artifactId } = args;
|
|
148
|
+
const isText = readThroughTextExtension(filePath);
|
|
149
|
+
const cap = isText
|
|
150
|
+
? Math.min(clampLimit(args.maxBytes, svc.config.maxContentBytes, Number.MAX_SAFE_INTEGER), READ_THROUGH_MAX_BYTES)
|
|
151
|
+
: 0;
|
|
152
|
+
let capped;
|
|
153
|
+
try {
|
|
154
|
+
// Read at most the cap (+ slack to trim back to a UTF-8 boundary); an
|
|
155
|
+
// oversized entry is never fully materialized in memory. Binary entries
|
|
156
|
+
// are metadata-only probes (no content read at all).
|
|
157
|
+
capped = await readJarEntryCapped(binaryJarPath, filePath, isText ? cap + 4 : 0);
|
|
158
|
+
}
|
|
159
|
+
catch (error) {
|
|
160
|
+
if (!isAppError(error) || error.code !== ERROR_CODES.SOURCE_NOT_FOUND) {
|
|
161
|
+
// Unsafe paths and unreadable/corrupt jars are their own failures;
|
|
162
|
+
// only a genuinely-missing entry becomes file-not-found with hints.
|
|
163
|
+
throw error;
|
|
164
|
+
}
|
|
165
|
+
const nearbyPaths = await collectNearbyPaths(binaryJarPath, filePath);
|
|
166
|
+
throw createError({
|
|
167
|
+
code: ERROR_CODES.FILE_NOT_FOUND,
|
|
168
|
+
message: `File "${filePath}" was not found in the source index or the backing jar.`,
|
|
169
|
+
details: {
|
|
170
|
+
artifactId,
|
|
171
|
+
filePath,
|
|
172
|
+
...(nearbyPaths.length > 0
|
|
173
|
+
? {
|
|
174
|
+
nearbyPaths,
|
|
175
|
+
nextAction: `Same-named entries exist at: ${nearbyPaths.join(", ")}. Directory layouts move between versions; retry with one of those paths.`
|
|
176
|
+
}
|
|
177
|
+
: {})
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
const base = {
|
|
182
|
+
filePath,
|
|
183
|
+
mappingApplied: artifact.mappingApplied ?? "obfuscated",
|
|
184
|
+
returnedNamespace: artifact.mappingApplied ?? "obfuscated",
|
|
185
|
+
artifactContents: svc.buildArtifactContentsSummary({
|
|
186
|
+
origin: artifact.origin,
|
|
187
|
+
sourceJarPath: artifact.sourceJarPath,
|
|
188
|
+
isDecompiled: artifact.isDecompiled,
|
|
189
|
+
qualityFlags: artifact.qualityFlags
|
|
190
|
+
}),
|
|
191
|
+
deliveryMode: "jar-read-through"
|
|
192
|
+
};
|
|
193
|
+
if (!isText) {
|
|
194
|
+
return {
|
|
195
|
+
...base,
|
|
196
|
+
content: "",
|
|
197
|
+
contentBytes: capped.entrySize,
|
|
198
|
+
truncated: false,
|
|
199
|
+
contentOmittedReason: "Entry is not a known text format; binary content is not delivered. Size and existence are reported instead."
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
const truncated = capped.entrySize > cap;
|
|
203
|
+
let content;
|
|
204
|
+
if (truncated) {
|
|
205
|
+
// Trim the capped prefix back to a UTF-8 character boundary before
|
|
206
|
+
// decoding; the tail past the cap is dropped by design.
|
|
207
|
+
const buffer = capped.buffer;
|
|
208
|
+
let cut = Math.min(cap, buffer.length);
|
|
209
|
+
while (cut > 0 && ((buffer[cut] ?? 0) & 0xc0) === 0x80) {
|
|
210
|
+
cut -= 1;
|
|
211
|
+
}
|
|
212
|
+
content = buffer.slice(0, cut).toString("utf8");
|
|
213
|
+
}
|
|
214
|
+
else {
|
|
215
|
+
content = decodeJarEntryUtf8OrThrow(capped.buffer, binaryJarPath, filePath);
|
|
216
|
+
}
|
|
217
|
+
return {
|
|
218
|
+
...base,
|
|
219
|
+
content,
|
|
220
|
+
contentBytes: capped.entrySize,
|
|
221
|
+
truncated
|
|
222
|
+
};
|
|
223
|
+
}
|
|
68
224
|
export async function listArtifactFiles(svc, input) {
|
|
69
225
|
const startedAt = Date.now();
|
|
70
226
|
try {
|
|
@@ -81,7 +237,7 @@ export async function listArtifactFiles(svc, input) {
|
|
|
81
237
|
if (normalizedPrefix &&
|
|
82
238
|
page.items.length === 0 &&
|
|
83
239
|
(normalizedPrefix.startsWith("assets/") || normalizedPrefix.startsWith("data/"))) {
|
|
84
|
-
warnings.push("Indexed artifacts currently include Java source only; non-Java resources are not indexed.
|
|
240
|
+
warnings.push("Indexed artifacts currently include Java source only; non-Java resources are not indexed. Text files under assets/ and data/ are served directly from the backing jar — request them by exact path with get-artifact-file (read-through delivery).");
|
|
85
241
|
}
|
|
86
242
|
return {
|
|
87
243
|
items: page.items,
|