@adhisang/minecraft-modding-mcp 6.1.1 → 6.3.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 +40 -1
- package/README.md +12 -4
- 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 +6 -4
- package/dist/entry-tools/analyze-symbol-service.js +37 -2
- package/dist/entry-tools/inspect-minecraft/internal.d.ts +7 -3
- package/dist/entry-tools/inspect-minecraft/internal.js +43 -15
- package/dist/entry-tools/inspect-minecraft-service.d.ts +12 -12
- package/dist/entry-tools/inspect-minecraft-service.js +1 -1
- 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 +39 -20
- 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/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 +3 -1
- package/dist/source/class-source.js +192 -19
- 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 +9 -5
- package/dist/source/nested-jars.d.ts +78 -0
- package/dist/source/nested-jars.js +267 -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 +37 -0
- package/dist/source-service.js +52 -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 +2 -2
- package/dist/tool-guidance.js +115 -7
- package/dist/tool-schemas.d.ts +1343 -149
- package/dist/tool-schemas.js +39 -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/README-ja.md +4 -0
- package/docs/tool-reference.md +92 -6
- package/package.json +5 -5
|
@@ -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 { findNestedJarClasses, 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";
|
|
@@ -80,6 +82,14 @@ function obfuscatedNamespaceHint(className) {
|
|
|
80
82
|
function hasPartialNetMinecraftCoverage(qualityFlags) {
|
|
81
83
|
return qualityFlags.includes("partial-source-no-net-minecraft");
|
|
82
84
|
}
|
|
85
|
+
function shouldSuggestObfuscatedMapping(artifact, className) {
|
|
86
|
+
const nativeDependency = artifact.provenance?.dependencyResolution != null;
|
|
87
|
+
const shellArtifact = artifact.qualityFlags.includes("shell-jar");
|
|
88
|
+
return (artifact.mappingApplied === "obfuscated" &&
|
|
89
|
+
!nativeDependency &&
|
|
90
|
+
!shellArtifact &&
|
|
91
|
+
looksLikeDeobfuscatedClassName(className));
|
|
92
|
+
}
|
|
83
93
|
function classNameToClassPath(className) {
|
|
84
94
|
const normalized = normalizePathStyle(className.trim()).replace(/\//g, ".");
|
|
85
95
|
const segments = normalized.split(".").filter((segment) => segment.length > 0);
|
|
@@ -165,7 +175,7 @@ export function buildFallbackProvenance(svc, input) {
|
|
|
165
175
|
transformChain
|
|
166
176
|
};
|
|
167
177
|
}
|
|
168
|
-
export function buildClassSourceNotFoundError(
|
|
178
|
+
export function buildClassSourceNotFoundError(svc, input) {
|
|
169
179
|
const simpleName = input.className.split(/[.$]/).at(-1) ?? input.className;
|
|
170
180
|
const details = {
|
|
171
181
|
artifactId: input.artifactId,
|
|
@@ -177,7 +187,11 @@ export function buildClassSourceNotFoundError(_svc, input) {
|
|
|
177
187
|
...(input.scope ? { scope: input.scope } : {}),
|
|
178
188
|
...(input.targetKind ? { targetKind: input.targetKind } : {}),
|
|
179
189
|
...(input.targetValue ? { targetValue: input.targetValue } : {}),
|
|
180
|
-
...(input.attemptedBinaryFallback ? { binaryFallbackAttempted: true } : {})
|
|
190
|
+
...(input.attemptedBinaryFallback ? { binaryFallbackAttempted: true } : {}),
|
|
191
|
+
...(input.nestedJars && input.nestedJars.length > 0 ? { nestedJars: input.nestedJars } : {}),
|
|
192
|
+
// Candidates are hints from the symbol index, never assertions that the
|
|
193
|
+
// class exists at the suggested location; empty when nothing usable.
|
|
194
|
+
didYouMean: collectDidYouMeanCandidates(svc, input.artifactId, input.className)
|
|
181
195
|
};
|
|
182
196
|
let nextAction = `Use find-class to resolve the correct fully-qualified name for "${simpleName}".`;
|
|
183
197
|
let suggestionSpec = {
|
|
@@ -220,6 +234,29 @@ export function buildClassSourceNotFoundError(_svc, input) {
|
|
|
220
234
|
}
|
|
221
235
|
details.nextAction = nextAction;
|
|
222
236
|
Object.assign(details, buildSuggestedCall(suggestionSpec));
|
|
237
|
+
// Split-source workspaces can omit client-only classes from merged indexes;
|
|
238
|
+
// the vanilla scope decompiles the client jar, which contains them. Offer
|
|
239
|
+
// the retry as an example using existing scope enum values only.
|
|
240
|
+
if (input.targetKind === "version" && input.version && input.scope !== "vanilla") {
|
|
241
|
+
const scopeRetry = buildSuggestedCall({
|
|
242
|
+
tool: "get-class-source",
|
|
243
|
+
params: undefined,
|
|
244
|
+
examples: [
|
|
245
|
+
{
|
|
246
|
+
params: {
|
|
247
|
+
className: input.className,
|
|
248
|
+
target: { kind: "version", value: input.version },
|
|
249
|
+
scope: "vanilla"
|
|
250
|
+
},
|
|
251
|
+
reason: "Client-only classes can be missing from merged split-source indexes; scope \"vanilla\" decompiles the client jar, which contains them."
|
|
252
|
+
}
|
|
253
|
+
]
|
|
254
|
+
});
|
|
255
|
+
if (scopeRetry.exampleCalls?.length) {
|
|
256
|
+
const existing = Array.isArray(details.exampleCalls) ? details.exampleCalls : [];
|
|
257
|
+
details.exampleCalls = [...existing, ...scopeRetry.exampleCalls];
|
|
258
|
+
}
|
|
259
|
+
}
|
|
223
260
|
return createError({
|
|
224
261
|
code: ERROR_CODES.CLASS_NOT_FOUND,
|
|
225
262
|
message: `Source for class "${input.className}" was not found.`,
|
|
@@ -369,7 +406,7 @@ export function findClass(svc, input) {
|
|
|
369
406
|
if (filteredMatches.length === 0 && partialVanillaLookup) {
|
|
370
407
|
warnings.push(`Artifact source coverage is partial and excludes net.minecraft; returning non-vanilla matches for "${className}" would be misleading. Use get-class-source/get-class-members for binary fallback or get-class-api-matrix for mapped API inspection.`);
|
|
371
408
|
}
|
|
372
|
-
if (filteredMatches.length === 0 && artifact
|
|
409
|
+
if (filteredMatches.length === 0 && shouldSuggestObfuscatedMapping(artifact, className)) {
|
|
373
410
|
warnings.push(`No exact class symbol matched "${className}". ${obfuscatedNamespaceHint(className)}`);
|
|
374
411
|
}
|
|
375
412
|
return { matches: filteredMatches, total: filteredMatches.length, warnings };
|
|
@@ -403,11 +440,39 @@ export function findClass(svc, input) {
|
|
|
403
440
|
if (filteredMatches.length === 0 && partialVanillaLookup) {
|
|
404
441
|
warnings.push(`Artifact source coverage is partial and excludes net.minecraft; returning non-vanilla matches for "${className}" would be misleading. Use get-class-source/get-class-members for binary fallback or get-class-api-matrix for mapped API inspection.`);
|
|
405
442
|
}
|
|
406
|
-
if (filteredMatches.length === 0 && artifact
|
|
443
|
+
if (filteredMatches.length === 0 && shouldSuggestObfuscatedMapping(artifact, className)) {
|
|
407
444
|
warnings.push(`No exact class symbol matched "${className}". ${obfuscatedNamespaceHint(className)}`);
|
|
408
445
|
}
|
|
409
446
|
return { matches: filteredMatches, total: filteredMatches.length, warnings };
|
|
410
447
|
}
|
|
448
|
+
export async function findClassIncludingNested(svc, input) {
|
|
449
|
+
const indexed = findClass(svc, input);
|
|
450
|
+
if (indexed.total > 0) {
|
|
451
|
+
return indexed;
|
|
452
|
+
}
|
|
453
|
+
const artifact = svc.getArtifact(input.artifactId.trim());
|
|
454
|
+
const inventory = artifact.provenance?.nestedJars;
|
|
455
|
+
if (!artifact.qualityFlags.includes("shell-jar") ||
|
|
456
|
+
!artifact.binaryJarPath ||
|
|
457
|
+
!inventory ||
|
|
458
|
+
inventory.length === 0) {
|
|
459
|
+
return indexed;
|
|
460
|
+
}
|
|
461
|
+
const limit = Math.max(1, Math.min(input.limit ?? 20, 200));
|
|
462
|
+
const matches = await findNestedJarClasses({
|
|
463
|
+
cacheDir: svc.config.cacheDir,
|
|
464
|
+
outerJarPath: artifact.binaryJarPath,
|
|
465
|
+
outerSignature: artifact.artifactId,
|
|
466
|
+
inventory,
|
|
467
|
+
className: input.className,
|
|
468
|
+
limit
|
|
469
|
+
});
|
|
470
|
+
return {
|
|
471
|
+
matches,
|
|
472
|
+
total: matches.length,
|
|
473
|
+
warnings: indexed.warnings
|
|
474
|
+
};
|
|
475
|
+
}
|
|
411
476
|
export async function getClassSource(svc, input) {
|
|
412
477
|
const className = input.className.trim();
|
|
413
478
|
if (!className) {
|
|
@@ -547,6 +612,53 @@ export async function getClassSource(svc, input) {
|
|
|
547
612
|
}
|
|
548
613
|
return true;
|
|
549
614
|
};
|
|
615
|
+
let attemptedNestedJarRedirect = false;
|
|
616
|
+
const tryNestedJarRedirect = async (lookupClassName) => {
|
|
617
|
+
if (attemptedNestedJarRedirect) {
|
|
618
|
+
return false;
|
|
619
|
+
}
|
|
620
|
+
const inventory = activeProvenance?.nestedJars;
|
|
621
|
+
const outerJarPath = normalizeOptionalString(binaryJarPath);
|
|
622
|
+
if (!inventory || inventory.length === 0 || !outerJarPath) {
|
|
623
|
+
return false;
|
|
624
|
+
}
|
|
625
|
+
attemptedNestedJarRedirect = true;
|
|
626
|
+
const shellArtifactId = activeArtifactId;
|
|
627
|
+
const match = await resolveUniqueNestedJarForClass({
|
|
628
|
+
cacheDir: svc.config.cacheDir,
|
|
629
|
+
outerJarPath,
|
|
630
|
+
outerArtifactId: shellArtifactId,
|
|
631
|
+
inventory,
|
|
632
|
+
className: lookupClassName
|
|
633
|
+
});
|
|
634
|
+
if (!match) {
|
|
635
|
+
return false;
|
|
636
|
+
}
|
|
637
|
+
const redirectResolved = await svc.resolveArtifact({
|
|
638
|
+
target: { kind: "jar", value: match.extractedPath },
|
|
639
|
+
mapping: input.mapping,
|
|
640
|
+
sourcePriority: input.sourcePriority,
|
|
641
|
+
allowDecompile: input.allowDecompile,
|
|
642
|
+
projectPath: input.projectPath,
|
|
643
|
+
gradleUserHome: input.gradleUserHome
|
|
644
|
+
});
|
|
645
|
+
activeArtifactId = redirectResolved.artifactId;
|
|
646
|
+
activeOrigin = redirectResolved.origin;
|
|
647
|
+
activeMappingApplied = redirectResolved.mappingApplied ?? activeMappingApplied;
|
|
648
|
+
activeProvenance = redirectResolved.provenance
|
|
649
|
+
? {
|
|
650
|
+
...redirectResolved.provenance,
|
|
651
|
+
nestedJar: { entryName: match.entryName, shellArtifactId }
|
|
652
|
+
}
|
|
653
|
+
: activeProvenance;
|
|
654
|
+
activeQualityFlags = dedupeQualityFlags([
|
|
655
|
+
...redirectResolved.qualityFlags,
|
|
656
|
+
"nested-jar-redirect"
|
|
657
|
+
]);
|
|
658
|
+
activeSourceJarPath = redirectResolved.resolvedSourceJarPath;
|
|
659
|
+
warnings.push(`Class "${className}" lives in nested jar "${match.entryName}" bundled by the shell jar; the lookup was redirected there automatically.`);
|
|
660
|
+
return true;
|
|
661
|
+
};
|
|
550
662
|
let activeLookupClassName = await svc.resolveClassNameForLookup({
|
|
551
663
|
className,
|
|
552
664
|
version,
|
|
@@ -558,6 +670,9 @@ export async function getClassSource(svc, input) {
|
|
|
558
670
|
context: "source lookup"
|
|
559
671
|
});
|
|
560
672
|
let filePath = resolveClassFilePath(svc, activeArtifactId, activeLookupClassName);
|
|
673
|
+
if (!filePath && (await tryNestedJarRedirect(activeLookupClassName))) {
|
|
674
|
+
filePath = resolveClassFilePath(svc, activeArtifactId, activeLookupClassName);
|
|
675
|
+
}
|
|
561
676
|
if (!filePath && (await tryBinaryFallback())) {
|
|
562
677
|
activeLookupClassName = await svc.resolveClassNameForLookup({
|
|
563
678
|
className,
|
|
@@ -584,10 +699,18 @@ export async function getClassSource(svc, input) {
|
|
|
584
699
|
targetValue: input.target && "value" in input.target ? input.target.value : undefined,
|
|
585
700
|
scope: input.scope,
|
|
586
701
|
projectPath: input.projectPath,
|
|
587
|
-
version
|
|
702
|
+
version,
|
|
703
|
+
nestedJars: activeProvenance?.nestedJars
|
|
588
704
|
});
|
|
589
705
|
}
|
|
590
706
|
let row = svc.filesRepo.getFileContent(activeArtifactId, filePath);
|
|
707
|
+
if (!row && (await tryNestedJarRedirect(activeLookupClassName))) {
|
|
708
|
+
const redirectedFilePath = resolveClassFilePath(svc, activeArtifactId, activeLookupClassName);
|
|
709
|
+
if (redirectedFilePath) {
|
|
710
|
+
filePath = redirectedFilePath;
|
|
711
|
+
row = svc.filesRepo.getFileContent(activeArtifactId, filePath);
|
|
712
|
+
}
|
|
713
|
+
}
|
|
591
714
|
if (!row && (await tryBinaryFallback())) {
|
|
592
715
|
activeLookupClassName = await svc.resolveClassNameForLookup({
|
|
593
716
|
className,
|
|
@@ -616,7 +739,8 @@ export async function getClassSource(svc, input) {
|
|
|
616
739
|
targetValue: input.target && "value" in input.target ? input.target.value : undefined,
|
|
617
740
|
scope: input.scope,
|
|
618
741
|
projectPath: input.projectPath,
|
|
619
|
-
version
|
|
742
|
+
version,
|
|
743
|
+
nestedJars: activeProvenance?.nestedJars
|
|
620
744
|
});
|
|
621
745
|
}
|
|
622
746
|
const snippet = buildClassSourceSnippet({
|
|
@@ -841,22 +965,67 @@ export async function getClassMembers(svc, input) {
|
|
|
841
965
|
let signatureMethods;
|
|
842
966
|
let binaryExtractionFailed = false;
|
|
843
967
|
let binaryExtractionFailureReason;
|
|
968
|
+
let nestedJarRedirect;
|
|
969
|
+
const fetchSignature = (jarPath) => svc.explorerService.getSignature({
|
|
970
|
+
fqn: lookupClassName,
|
|
971
|
+
jarPath,
|
|
972
|
+
access,
|
|
973
|
+
includeSynthetic,
|
|
974
|
+
includeInherited,
|
|
975
|
+
memberPattern: requestedMapping === mappingApplied ? memberPattern : undefined
|
|
976
|
+
});
|
|
844
977
|
try {
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
978
|
+
let signature;
|
|
979
|
+
try {
|
|
980
|
+
signature = await fetchSignature(binaryJarPath);
|
|
981
|
+
}
|
|
982
|
+
catch (missError) {
|
|
983
|
+
const inventory = provenance?.nestedJars;
|
|
984
|
+
if (!isAppError(missError) ||
|
|
985
|
+
missError.code !== ERROR_CODES.CLASS_NOT_FOUND ||
|
|
986
|
+
!inventory ||
|
|
987
|
+
inventory.length === 0) {
|
|
988
|
+
throw missError;
|
|
989
|
+
}
|
|
990
|
+
const match = await resolveUniqueNestedJarForClass({
|
|
991
|
+
cacheDir: svc.config.cacheDir,
|
|
992
|
+
outerJarPath: binaryJarPath,
|
|
993
|
+
outerArtifactId: artifactId,
|
|
994
|
+
inventory,
|
|
995
|
+
className: lookupClassName
|
|
996
|
+
});
|
|
997
|
+
if (!match) {
|
|
998
|
+
throw missError;
|
|
999
|
+
}
|
|
1000
|
+
nestedJarRedirect = { entryName: match.entryName, shellArtifactId: artifactId };
|
|
1001
|
+
warnings.push(`Class "${className}" lives in nested jar "${match.entryName}" bundled by the shell jar; members were read from it.`);
|
|
1002
|
+
signature = await fetchSignature(match.extractedPath);
|
|
1003
|
+
}
|
|
853
1004
|
warnings.push(...signature.warnings);
|
|
854
1005
|
signatureContext = signature.context;
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
1006
|
+
// Member annotations are opt-in: strip them unless requested. The
|
|
1007
|
+
// annotationDefault of annotation-type members is always kept. Cached
|
|
1008
|
+
// signature objects must not be mutated, so stripping copies.
|
|
1009
|
+
const stripAnnotations = (member) => {
|
|
1010
|
+
if (!member.annotations) {
|
|
1011
|
+
return member;
|
|
1012
|
+
}
|
|
1013
|
+
const { annotations: _omitted, ...rest } = member;
|
|
1014
|
+
return rest;
|
|
1015
|
+
};
|
|
1016
|
+
const includeAnnotations = input.includeAnnotations ?? false;
|
|
1017
|
+
signatureConstructors = includeAnnotations
|
|
1018
|
+
? signature.constructors
|
|
1019
|
+
: signature.constructors.map(stripAnnotations);
|
|
1020
|
+
signatureFields = includeAnnotations ? signature.fields : signature.fields.map(stripAnnotations);
|
|
1021
|
+
signatureMethods = includeAnnotations ? signature.methods : signature.methods.map(stripAnnotations);
|
|
858
1022
|
}
|
|
859
1023
|
catch (error) {
|
|
1024
|
+
if (isAppError(error) && error.code === ERROR_CODES.NESTED_JAR_AMBIGUOUS) {
|
|
1025
|
+
// A class living in several nested jars needs the caller's choice; the
|
|
1026
|
+
// candidates error must not degrade into a members_unavailable response.
|
|
1027
|
+
throw error;
|
|
1028
|
+
}
|
|
860
1029
|
if (isAppError(error) && error.code === ERROR_CODES.CLASS_NOT_FOUND) {
|
|
861
1030
|
// Re-raise with the shared recovery shape (find-class/api-matrix
|
|
862
1031
|
// suggestedCall, namespace + scope hints) instead of the sparse bytecode
|
|
@@ -873,7 +1042,8 @@ export async function getClassMembers(svc, input) {
|
|
|
873
1042
|
targetValue: input.target && "value" in input.target ? input.target.value : undefined,
|
|
874
1043
|
scope: input.scope,
|
|
875
1044
|
projectPath: input.projectPath,
|
|
876
|
-
version
|
|
1045
|
+
version,
|
|
1046
|
+
nestedJars: provenance?.nestedJars
|
|
877
1047
|
});
|
|
878
1048
|
}
|
|
879
1049
|
binaryExtractionFailed = true;
|
|
@@ -926,13 +1096,16 @@ export async function getClassMembers(svc, input) {
|
|
|
926
1096
|
const projectedMembers = projectMembersByLevel(projectMembersForWire({ constructors, fields, methods }, includeInherited, input.includeDescriptors ?? false), projection);
|
|
927
1097
|
const truncated = sliced.truncated;
|
|
928
1098
|
const nextCursor = sliced.nextOffset != null ? encodeOffsetCursor(sliced.nextOffset, memberCursorContext) : undefined;
|
|
929
|
-
const
|
|
1099
|
+
const baseProvenance = provenance ??
|
|
930
1100
|
buildFallbackProvenance(svc, {
|
|
931
1101
|
artifactId,
|
|
932
1102
|
origin,
|
|
933
1103
|
requestedMapping,
|
|
934
1104
|
mappingApplied
|
|
935
1105
|
});
|
|
1106
|
+
const normalizedProvenance = nestedJarRedirect
|
|
1107
|
+
? { ...baseProvenance, nestedJar: nestedJarRedirect }
|
|
1108
|
+
: baseProvenance;
|
|
936
1109
|
let decompiledFallback;
|
|
937
1110
|
let decompiledMemberCounts;
|
|
938
1111
|
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,
|