@adhisang/minecraft-modding-mcp 7.1.0 → 7.1.1
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 +42 -0
- package/README.md +1 -1
- package/dist/access-transformer-parser.d.ts +8 -0
- package/dist/access-transformer-parser.js +8 -1
- package/dist/access-widener-parser.d.ts +17 -0
- package/dist/access-widener-parser.js +12 -1
- package/dist/cache-registry.js +19 -6
- package/dist/entry-tools/inspect-minecraft/handlers/versions.js +15 -9
- package/dist/entry-tools/manage-cache-service.js +10 -14
- package/dist/entry-tools/validate-project/cases/access-transformer.js +22 -3
- package/dist/entry-tools/validate-project/cases/access-widener.js +31 -4
- package/dist/entry-tools/validate-project/cases/mixin.js +11 -3
- package/dist/entry-tools/validate-project/cases/project-summary.js +24 -5
- package/dist/entry-tools/verify-mixin-target-service.js +19 -8
- package/dist/index.js +1 -0
- package/dist/java-process.d.ts +1 -0
- package/dist/java-process.js +14 -0
- package/dist/mapping/lookup.js +16 -1
- package/dist/mapping-service.d.ts +14 -0
- package/dist/mapping-service.js +35 -15
- package/dist/minecraft-explorer-service.js +70 -8
- package/dist/mixin/access-validators.js +38 -2
- package/dist/mixin/annotation-validators.js +137 -43
- package/dist/mixin/parsed-validator.js +21 -7
- package/dist/mixin-parser.d.ts +52 -0
- package/dist/mixin-parser.js +709 -130
- package/dist/mod-decompile-service.js +11 -1
- package/dist/mod-remap-service.js +6 -6
- package/dist/nbt/java-nbt-codec.js +7 -1
- package/dist/source/access-validate.js +10 -0
- package/dist/source/artifact-resolver.d.ts +13 -3
- package/dist/source/artifact-resolver.js +129 -18
- package/dist/source/class-source.js +13 -2
- package/dist/source/nested-jars.d.ts +15 -1
- package/dist/source/nested-jars.js +14 -5
- package/dist/source/search.d.ts +10 -2
- package/dist/source/search.js +60 -13
- package/dist/source/validate-mixin/pipeline/mapping-health.js +20 -1
- package/dist/source/validate-mixin/pipeline/target-lookup.js +16 -7
- package/dist/source/validate-mixin.d.ts +5 -0
- package/dist/source/validate-mixin.js +136 -21
- package/dist/source/workspace-target.js +75 -7
- package/dist/source-jar-reader.d.ts +15 -1
- package/dist/source-jar-reader.js +35 -3
- package/dist/source-resolver.js +2 -9
- package/dist/stdio-supervisor.d.ts +35 -1
- package/dist/stdio-supervisor.js +77 -2
- package/dist/storage/files-repo.d.ts +7 -0
- package/dist/storage/files-repo.js +17 -4
- package/dist/tool-contract-manifest.js +2 -2
- package/dist/tool-execution-gate.js +2 -1
- package/dist/version-service.js +7 -0
- package/dist/workspace-context-cache.d.ts +25 -0
- package/dist/workspace-context-cache.js +52 -2
- package/dist/workspace-mapping-service.js +116 -14
- package/docs/README-ja.md +1 -1
- package/docs/tool-reference.md +14 -11
- package/package.json +1 -1
|
@@ -1,4 +1,23 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
|
+
/**
|
|
3
|
+
* Whether the mapping data required by `requestedMapping` is present.
|
|
4
|
+
*
|
|
5
|
+
* Mojang mappings are only on the resolution path for `"mojang"` requests.
|
|
6
|
+
* `"yarn"`/`"intermediary"` requests resolve through Tiny mappings instead
|
|
7
|
+
* and never touch Mojang mappings. `"obfuscated"` requests need neither —
|
|
8
|
+
* the jar itself (checked separately via `jarAvailable`) is sufficient.
|
|
9
|
+
*/
|
|
10
|
+
function requiredMappingHealthy(requestedMapping, health) {
|
|
11
|
+
switch (requestedMapping) {
|
|
12
|
+
case "mojang":
|
|
13
|
+
return health.mojangMappingsAvailable;
|
|
14
|
+
case "yarn":
|
|
15
|
+
case "intermediary":
|
|
16
|
+
return health.tinyMappingsAvailable;
|
|
17
|
+
case "obfuscated":
|
|
18
|
+
return true;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
2
21
|
export async function runMappingHealthStage(svc, ctx) {
|
|
3
22
|
const mappingHealthStartedAt = await ctx.enterStage("mapping-health");
|
|
4
23
|
try {
|
|
@@ -15,7 +34,7 @@ export async function runMappingHealthStage(svc, ctx) {
|
|
|
15
34
|
mojangMappingsAvailable: health.mojangMappingsAvailable,
|
|
16
35
|
tinyMappingsAvailable: health.tinyMappingsAvailable,
|
|
17
36
|
memberRemapAvailable: health.memberRemapAvailable,
|
|
18
|
-
overallHealthy: jarAvailable &&
|
|
37
|
+
overallHealthy: jarAvailable && requiredMappingHealthy(ctx.requestedMapping, health),
|
|
19
38
|
degradations: [
|
|
20
39
|
...(jarAvailable ? [] : ["Game jar not found."]),
|
|
21
40
|
...health.degradations
|
|
@@ -101,10 +101,13 @@ export async function processSingleMixinTarget(svc, ctx, target, targetIndex) {
|
|
|
101
101
|
}
|
|
102
102
|
}
|
|
103
103
|
try {
|
|
104
|
+
// Mixin can target synthetic members (lambda bodies, bridges, $VALUES, this$0) and counts
|
|
105
|
+
// synthetic methods in quantifiers.
|
|
104
106
|
const sig = await svc.explorerService.getSignature({
|
|
105
107
|
fqn: obfuscatedName,
|
|
106
108
|
jarPath: ctx.jarPath,
|
|
107
|
-
access: "all"
|
|
109
|
+
access: "all",
|
|
110
|
+
includeSynthetic: true
|
|
108
111
|
});
|
|
109
112
|
ctx.warnings.push(...sig.warnings);
|
|
110
113
|
ctx.resolutionTrace?.push({ target: target.className, step: "signature", input: obfuscatedName, output: `${sig.methods.length} methods, ${sig.fields.length} fields`, success: true });
|
|
@@ -112,15 +115,21 @@ export async function processSingleMixinTarget(svc, ctx, target, targetIndex) {
|
|
|
112
115
|
let methods = sig.methods;
|
|
113
116
|
let fields = sig.fields;
|
|
114
117
|
if (ctx.requestedMapping !== ctx.signatureLookupMapping) {
|
|
118
|
+
const remap = (members, kind, warnings) => svc.remapSignatureMembers(members, kind, ctx.version, ctx.signatureLookupMapping, ctx.requestedMapping, ctx.currentSourcePriority, warnings, ctx.input.projectPath, ctx.input.gradleUserHome);
|
|
119
|
+
// Unnamed lambdas routinely have no mapping; a synthetic member that fails to remap
|
|
120
|
+
// stays listed under its source name without degrading the target or the result.
|
|
121
|
+
const syntheticWarnings = [];
|
|
115
122
|
try {
|
|
116
|
-
const [ctorResult, methodResult, fieldResult] = await Promise.all([
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
123
|
+
const [ctorResult, methodResult, fieldResult, syntheticMethodResult, syntheticFieldResult] = await Promise.all([
|
|
124
|
+
remap(sig.constructors, "method", ctx.warnings),
|
|
125
|
+
remap(sig.methods.filter((member) => !member.isSynthetic), "method", ctx.warnings),
|
|
126
|
+
remap(sig.fields.filter((member) => !member.isSynthetic), "field", ctx.warnings),
|
|
127
|
+
remap(sig.methods.filter((member) => member.isSynthetic), "method", syntheticWarnings),
|
|
128
|
+
remap(sig.fields.filter((member) => member.isSynthetic), "field", syntheticWarnings)
|
|
120
129
|
]);
|
|
121
130
|
constructors = ctorResult.members;
|
|
122
|
-
methods = methodResult.members;
|
|
123
|
-
fields = fieldResult.members;
|
|
131
|
+
methods = [...methodResult.members, ...syntheticMethodResult.members];
|
|
132
|
+
fields = [...fieldResult.members, ...syntheticFieldResult.members];
|
|
124
133
|
const targetFailed = new Set();
|
|
125
134
|
for (const n of ctorResult.failedNames)
|
|
126
135
|
targetFailed.add(n);
|
|
@@ -8,6 +8,11 @@ export type ValidateMixinSingleInput = Omit<ValidateMixinInput, "input" | "versi
|
|
|
8
8
|
version: string;
|
|
9
9
|
source?: string;
|
|
10
10
|
sourcePath?: string;
|
|
11
|
+
/**
|
|
12
|
+
* Validate only the `@Mixin` class at this simple-name path from the source's top-level class
|
|
13
|
+
* down (`["Outer"]` for a config entry `Outer`, `["Outer", "Inner"]` for `Outer$Inner`).
|
|
14
|
+
*/
|
|
15
|
+
nestedClassPath?: string[];
|
|
11
16
|
batchCaches?: {
|
|
12
17
|
classMappings: Map<string, Promise<MappingFindMappingOutput>>;
|
|
13
18
|
};
|
|
@@ -4,6 +4,7 @@ import { performance } from "node:perf_hooks";
|
|
|
4
4
|
import fastGlob from "fast-glob";
|
|
5
5
|
import { ERROR_CODES, createError, isAppError } from "../errors.js";
|
|
6
6
|
import { loadMixinStageBudgets, refreshMixinValidationOutcome, validateParsedMixin } from "../mixin-validator.js";
|
|
7
|
+
import { selectNestedMixin } from "../mixin-parser.js";
|
|
7
8
|
import { normalizePathForHost } from "../path-converter.js";
|
|
8
9
|
import { NOOP_STAGE_EMITTER } from "../stage-emitter.js";
|
|
9
10
|
import { createMixinPipelineContext } from "./validate-mixin/pipeline-context.js";
|
|
@@ -262,11 +263,12 @@ async function runValidateMixinDispatcher(svc, rawInput, options = {}) {
|
|
|
262
263
|
return validateMixinMany(svc, mode, configSources.map((entry) => ({
|
|
263
264
|
source: {
|
|
264
265
|
kind: "config",
|
|
265
|
-
label: entry.sourcePath,
|
|
266
|
+
label: (entry.nestedClassPath?.length ?? 0) > 1 ? `${entry.sourcePath} (${entry.entry})` : entry.sourcePath,
|
|
266
267
|
path: entry.sourcePath,
|
|
267
268
|
configPath: entry.configPath
|
|
268
269
|
},
|
|
269
|
-
sourcePath: entry.sourcePath
|
|
270
|
+
sourcePath: entry.sourcePath,
|
|
271
|
+
nestedClassPath: entry.nestedClassPath
|
|
270
272
|
})), resolvedInput, configWarnings, sharedSingleOptions);
|
|
271
273
|
}
|
|
272
274
|
async function createProjectValidateMixinConfigInput(input) {
|
|
@@ -299,7 +301,7 @@ async function createProjectValidateMixinConfigInput(input) {
|
|
|
299
301
|
}
|
|
300
302
|
};
|
|
301
303
|
}
|
|
302
|
-
function shouldRetryValidateMixinWithMavenFirst(svc, input, result) {
|
|
304
|
+
function shouldRetryValidateMixinWithMavenFirst(svc, input, result, eligibilityIssues) {
|
|
303
305
|
const initialPriority = input.retryState?.initialSourcePriority ?? input.sourcePriority ?? svc.config.mappingSourcePriority;
|
|
304
306
|
if (input.retryState?.attempted || initialPriority !== "loom-first") {
|
|
305
307
|
return false;
|
|
@@ -316,7 +318,9 @@ function shouldRetryValidateMixinWithMavenFirst(svc, input, result) {
|
|
|
316
318
|
if (result.summary.membersSkipped > 0) {
|
|
317
319
|
return true;
|
|
318
320
|
}
|
|
319
|
-
|
|
321
|
+
// eligibilityIssues is the pre-category-filter issue list: a display
|
|
322
|
+
// filter (warningCategoryFilter) must not change whether a retry happens.
|
|
323
|
+
return eligibilityIssues.some((issue) => issue.resolutionPath === "source-signature-unavailable" ||
|
|
320
324
|
issue.resolutionPath === "target-mapping-failed" ||
|
|
321
325
|
issue.resolutionPath === "member-remap-failed");
|
|
322
326
|
}
|
|
@@ -401,6 +405,20 @@ async function runValidateMixinPipeline(svc, seed) {
|
|
|
401
405
|
await runResolveStage(svc, ctx);
|
|
402
406
|
await runMappingHealthStage(svc, ctx);
|
|
403
407
|
await runParseStage(ctx);
|
|
408
|
+
const nestedClassPath = ctx.input.nestedClassPath;
|
|
409
|
+
if (nestedClassPath) {
|
|
410
|
+
const nested = selectNestedMixin(ctx.parsed, nestedClassPath);
|
|
411
|
+
// A top-level entry whose file has no located @Mixin class keeps the whole-file result.
|
|
412
|
+
const wholeFile = nestedClassPath.length === 1 && (ctx.parsed.mixins ?? []).length === 0;
|
|
413
|
+
if (!nested && !wholeFile) {
|
|
414
|
+
throw createError({
|
|
415
|
+
code: ERROR_CODES.INVALID_INPUT,
|
|
416
|
+
message: `No @Mixin class "${nestedClassPath.join("$")}" is declared in "${ctx.input.sourcePath ?? "<source>"}".`,
|
|
417
|
+
details: { failedStage: "parse" }
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
ctx.parsed = nested ?? ctx.parsed;
|
|
421
|
+
}
|
|
404
422
|
await runTargetLookupStage(svc, ctx);
|
|
405
423
|
return finalizeValidateMixinPipeline(svc, ctx);
|
|
406
424
|
}
|
|
@@ -498,7 +516,25 @@ async function finalizeValidateMixinPipeline(svc, ctx) {
|
|
|
498
516
|
};
|
|
499
517
|
result.unfilteredSummary = unfilteredSummary;
|
|
500
518
|
}
|
|
501
|
-
|
|
519
|
+
// Recompute the outcome as the severity/uncertainty filters above leave it
|
|
520
|
+
// (a no-op when neither minSeverity nor hideUncertain was set), BEFORE any
|
|
521
|
+
// category filtering narrows things further. This is the baseline a
|
|
522
|
+
// warningCategoryFilter must reproduce exactly: valid, validationStatus and
|
|
523
|
+
// quickSummary must come out identical with or without the category
|
|
524
|
+
// filter, and retry eligibility must be decided from this same
|
|
525
|
+
// pre-category-filter issue list (see work items: warningCategoryFilter
|
|
526
|
+
// must not report a target-not-found mixin as valid:true, and must not
|
|
527
|
+
// change maven-first retry eligibility).
|
|
528
|
+
refreshMixinValidationOutcome(result);
|
|
529
|
+
const issuesForRetryEligibility = result.issues;
|
|
530
|
+
const preCategoryFilterValid = result.valid;
|
|
531
|
+
const preCategoryFilterValidationStatus = result.validationStatus;
|
|
532
|
+
const preCategoryFilterQuickSummary = result.quickSummary;
|
|
533
|
+
const categoryFilterApplied = !!(input.warningCategoryFilter && input.warningCategoryFilter.length > 0);
|
|
534
|
+
if (categoryFilterApplied) {
|
|
535
|
+
if (!result.unfilteredSummary) {
|
|
536
|
+
result.unfilteredSummary = { ...result.summary };
|
|
537
|
+
}
|
|
502
538
|
const allowedCategories = new Set(input.warningCategoryFilter);
|
|
503
539
|
result.issues = result.issues.filter((i) => i.category && allowedCategories.has(i.category));
|
|
504
540
|
if (result.structuredWarnings) {
|
|
@@ -540,7 +576,15 @@ async function finalizeValidateMixinPipeline(svc, ctx) {
|
|
|
540
576
|
else {
|
|
541
577
|
refreshMixinValidationOutcome(result);
|
|
542
578
|
}
|
|
543
|
-
if (
|
|
579
|
+
if (categoryFilterApplied) {
|
|
580
|
+
// refreshMixinValidationOutcome above recomputed valid/validationStatus/
|
|
581
|
+
// quickSummary from the category-filtered summary; restore the true
|
|
582
|
+
// (pre-category-filter) verdict now that member-count bookkeeping is done.
|
|
583
|
+
result.valid = preCategoryFilterValid;
|
|
584
|
+
result.validationStatus = preCategoryFilterValidationStatus;
|
|
585
|
+
result.quickSummary = preCategoryFilterQuickSummary;
|
|
586
|
+
}
|
|
587
|
+
if (shouldRetryValidateMixinWithMavenFirst(svc, input, result, issuesForRetryEligibility)) {
|
|
544
588
|
const retryWarning = `Retrying validate-mixin with sourcePriority="maven-first" after partial validation using "${currentSourcePriority}".`;
|
|
545
589
|
try {
|
|
546
590
|
const retried = await svc.validateMixinSingle({
|
|
@@ -588,10 +632,10 @@ async function resolveMixinConfigSources(input) {
|
|
|
588
632
|
const warnings = [];
|
|
589
633
|
for (const rawConfigPath of input.input.configPaths) {
|
|
590
634
|
const resolvedConfigPath = resolveMixinInputPath(rawConfigPath, "configPath");
|
|
591
|
-
let
|
|
635
|
+
let parsedConfig;
|
|
592
636
|
try {
|
|
593
637
|
const raw = await readFile(resolvedConfigPath, "utf-8");
|
|
594
|
-
|
|
638
|
+
parsedConfig = JSON.parse(raw);
|
|
595
639
|
}
|
|
596
640
|
catch (err) {
|
|
597
641
|
throw createError({
|
|
@@ -600,6 +644,44 @@ async function resolveMixinConfigSources(input) {
|
|
|
600
644
|
details: { failedStage: "input-validation" }
|
|
601
645
|
});
|
|
602
646
|
}
|
|
647
|
+
if (parsedConfig === null || typeof parsedConfig !== "object" || Array.isArray(parsedConfig)) {
|
|
648
|
+
throw createError({
|
|
649
|
+
code: ERROR_CODES.INVALID_INPUT,
|
|
650
|
+
message: `Mixin config "${rawConfigPath}" must contain a JSON object.`,
|
|
651
|
+
details: { failedStage: "input-validation" }
|
|
652
|
+
});
|
|
653
|
+
}
|
|
654
|
+
const configRecord = parsedConfig;
|
|
655
|
+
for (const key of ["mixins", "client", "server"]) {
|
|
656
|
+
const fieldValue = configRecord[key];
|
|
657
|
+
if (fieldValue === undefined) {
|
|
658
|
+
continue;
|
|
659
|
+
}
|
|
660
|
+
if (!Array.isArray(fieldValue)) {
|
|
661
|
+
throw createError({
|
|
662
|
+
code: ERROR_CODES.INVALID_INPUT,
|
|
663
|
+
message: `Mixin config "${rawConfigPath}" field "${key}" must be an array of class names.`,
|
|
664
|
+
details: { failedStage: "input-validation" }
|
|
665
|
+
});
|
|
666
|
+
}
|
|
667
|
+
for (let i = 0; i < fieldValue.length; i++) {
|
|
668
|
+
if (typeof fieldValue[i] !== "string") {
|
|
669
|
+
throw createError({
|
|
670
|
+
code: ERROR_CODES.INVALID_INPUT,
|
|
671
|
+
message: `Mixin config "${rawConfigPath}" field "${key}" element at index ${i} must be a string.`,
|
|
672
|
+
details: { failedStage: "input-validation" }
|
|
673
|
+
});
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
if (configRecord.package !== undefined && typeof configRecord.package !== "string") {
|
|
678
|
+
throw createError({
|
|
679
|
+
code: ERROR_CODES.INVALID_INPUT,
|
|
680
|
+
message: `Mixin config "${rawConfigPath}" field "package" must be a string.`,
|
|
681
|
+
details: { failedStage: "input-validation" }
|
|
682
|
+
});
|
|
683
|
+
}
|
|
684
|
+
const configJson = configRecord;
|
|
603
685
|
const pkg = configJson.package ?? "";
|
|
604
686
|
const classNames = [
|
|
605
687
|
...(configJson.mixins ?? []),
|
|
@@ -622,10 +704,13 @@ async function resolveMixinConfigSources(input) {
|
|
|
622
704
|
for (const candidateRoot of COMMON_SOURCE_ROOTS) {
|
|
623
705
|
let foundInRoot = false;
|
|
624
706
|
for (const className of classNames) {
|
|
625
|
-
const
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
707
|
+
for (const candidate of configEntrySourceCandidates(pkg, className)) {
|
|
708
|
+
if (await pathExists(resolvePath(projectBase, candidateRoot, candidate.relativePath))) {
|
|
709
|
+
foundInRoot = true;
|
|
710
|
+
break;
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
if (foundInRoot) {
|
|
629
714
|
break;
|
|
630
715
|
}
|
|
631
716
|
}
|
|
@@ -636,19 +721,27 @@ async function resolveMixinConfigSources(input) {
|
|
|
636
721
|
sourceRootCandidates = detected.length > 0 ? detected : ["src/main/java"];
|
|
637
722
|
}
|
|
638
723
|
for (const cls of classNames) {
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
sourcePath = candidate;
|
|
724
|
+
// The entry's own file wins; a `$`-qualified entry without one is a nested class
|
|
725
|
+
// validated inside its outer file. An entry found nowhere keeps its own path.
|
|
726
|
+
const candidates = configEntrySourceCandidates(pkg, cls);
|
|
727
|
+
let resolved;
|
|
728
|
+
for (const candidate of candidates) {
|
|
729
|
+
for (const root of sourceRootCandidates) {
|
|
730
|
+
const sourcePath = resolvePath(projectBase, root, candidate.relativePath);
|
|
731
|
+
if (await pathExists(sourcePath)) {
|
|
732
|
+
resolved = { sourcePath, nestedClassPath: candidate.nestedClassPath };
|
|
733
|
+
break;
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
if (resolved) {
|
|
646
737
|
break;
|
|
647
738
|
}
|
|
648
739
|
}
|
|
649
740
|
results.push({
|
|
650
|
-
sourcePath,
|
|
651
|
-
configPath: resolvedConfigPath
|
|
741
|
+
sourcePath: resolved?.sourcePath ?? resolvePath(projectBase, sourceRootCandidates[0], candidates[0].relativePath),
|
|
742
|
+
configPath: resolvedConfigPath,
|
|
743
|
+
nestedClassPath: resolved?.nestedClassPath,
|
|
744
|
+
entry: cls
|
|
652
745
|
});
|
|
653
746
|
}
|
|
654
747
|
}
|
|
@@ -657,6 +750,27 @@ async function resolveMixinConfigSources(input) {
|
|
|
657
750
|
warnings
|
|
658
751
|
};
|
|
659
752
|
}
|
|
753
|
+
/**
|
|
754
|
+
* Source files a mixins-config entry may live in, in lookup order, each with the simple-name path
|
|
755
|
+
* of the entry's class inside it: the entry's own file (a one-element path, so nested mixins in
|
|
756
|
+
* that file are left to their own entries), then for a nested entry (`Outer$Inner`,
|
|
757
|
+
* `Outer$Mid$Inner`) the outer class's file.
|
|
758
|
+
*/
|
|
759
|
+
function configEntrySourceCandidates(pkg, cls) {
|
|
760
|
+
const fqcn = pkg ? `${pkg}.${cls}` : cls;
|
|
761
|
+
const lastDot = fqcn.lastIndexOf(".");
|
|
762
|
+
const candidates = [
|
|
763
|
+
{ relativePath: fqcn.replace(/\./g, "/") + ".java", nestedClassPath: [fqcn.slice(lastDot + 1)] }
|
|
764
|
+
];
|
|
765
|
+
const classPath = fqcn.slice(lastDot + 1).split("$");
|
|
766
|
+
if (classPath.length > 1 && classPath.every((name) => name !== "")) {
|
|
767
|
+
candidates.push({
|
|
768
|
+
relativePath: `${fqcn.slice(0, lastDot + 1).replace(/\./g, "/")}${classPath[0]}.java`,
|
|
769
|
+
nestedClassPath: classPath
|
|
770
|
+
});
|
|
771
|
+
}
|
|
772
|
+
return candidates;
|
|
773
|
+
}
|
|
660
774
|
async function validateMixinMany(svc, mode, entries, input, additionalWarnings, extras = {}) {
|
|
661
775
|
const results = [];
|
|
662
776
|
const batchWarningMode = input.warningMode ?? "aggregated";
|
|
@@ -670,6 +784,7 @@ async function validateMixinMany(svc, mode, entries, input, additionalWarnings,
|
|
|
670
784
|
const singleResult = await svc.validateMixinSingle({
|
|
671
785
|
...sharedInput,
|
|
672
786
|
sourcePath: entry.sourcePath,
|
|
787
|
+
nestedClassPath: entry.nestedClassPath,
|
|
673
788
|
warningMode: batchWarningMode,
|
|
674
789
|
batchCaches,
|
|
675
790
|
stageEmitter,
|
|
@@ -1,15 +1,39 @@
|
|
|
1
|
+
import { resolve as resolvePath } from "node:path";
|
|
1
2
|
import { buildSuggestedCall } from "../build-suggested-call.js";
|
|
2
3
|
import { ERROR_CODES, createError } from "../errors.js";
|
|
4
|
+
import { normalizeOptionalProjectPath } from "../gradle-paths.js";
|
|
3
5
|
import { describeSafeMavenSegmentRule, isSafeMavenSegment } from "../maven-token.js";
|
|
6
|
+
import { computeWorkspaceContextFingerprint } from "../workspace-context-cache.js";
|
|
4
7
|
import { normalizeMapping } from "./shared-utils.js";
|
|
5
8
|
// Env toggles are read at call time, not at module load: tests dynamically
|
|
6
9
|
// re-import source-service.ts with a cache-busting query to flip the flag,
|
|
7
10
|
// but cannot bust this module's cache through that path.
|
|
11
|
+
/**
|
|
12
|
+
* The root-level files `detectProjectMinecraftVersion`/`detectCompileMapping`/
|
|
13
|
+
* `detectProjectLoader` (src/workspace-mapping-service.ts) are guaranteed to
|
|
14
|
+
* read (or attempt to read) for every project, independent of what detection
|
|
15
|
+
* finds: `gradle.properties` by exact path, and `build.gradle`/`build.gradle.kts`
|
|
16
|
+
* by exact path as the first two entries of the build-script glob those two
|
|
17
|
+
* methods both run before descending into subdirectories. `settings.gradle(.kts)`
|
|
18
|
+
* and `libs.versions.toml` are not read by any of the three, so they are not
|
|
19
|
+
* listed here - see the coverage note on `fingerprintPaths` below.
|
|
20
|
+
*/
|
|
21
|
+
function fixedDetectionFilenames() {
|
|
22
|
+
return ["gradle.properties", "build.gradle", "build.gradle.kts"];
|
|
23
|
+
}
|
|
8
24
|
export async function loadOrDetectWorkspaceContext(svc, projectPath) {
|
|
9
25
|
const cached = svc.workspaceContextCache.read(projectPath);
|
|
10
26
|
if (cached && !cached.partial) {
|
|
11
27
|
return cached;
|
|
12
28
|
}
|
|
29
|
+
const projectRoot = resolvePath(projectPath);
|
|
30
|
+
// Snapshotted BEFORE detection runs, not after: an edit that lands while
|
|
31
|
+
// detection is mid-read (racing the very call below) must still show up as
|
|
32
|
+
// a stat difference on the next read. Fingerprinting after detection would
|
|
33
|
+
// record whatever is on disk at that later point - which could already be
|
|
34
|
+
// the edit that should have invalidated the entry, silently absorbing the
|
|
35
|
+
// race into a "the cache is fresh" fingerprint instead of catching it.
|
|
36
|
+
const fixedFingerprint = computeWorkspaceContextFingerprint(fixedDetectionFilenames().map((filename) => resolvePath(projectRoot, filename)));
|
|
13
37
|
const [minecraftVersion, mappingResult, loaderResult] = await Promise.all([
|
|
14
38
|
svc.workspaceMappingService.detectProjectMinecraftVersion(projectPath),
|
|
15
39
|
svc.workspaceMappingService.detectCompileMapping({ projectPath }).catch(() => undefined),
|
|
@@ -38,6 +62,39 @@ export async function loadOrDetectWorkspaceContext(svc, projectPath) {
|
|
|
38
62
|
});
|
|
39
63
|
}
|
|
40
64
|
const latest = svc.workspaceContextCache.read(projectPath);
|
|
65
|
+
// Fingerprint the files this detection actually read (or, for the fixed
|
|
66
|
+
// root set above, attempted to read regardless of outcome), so a later
|
|
67
|
+
// cache hit can catch an edit made within the TTL instead of serving a
|
|
68
|
+
// stale minecraftVersion/mapping/loader for up to five more minutes.
|
|
69
|
+
//
|
|
70
|
+
// Coverage: `fixedFingerprint` above (taken pre-detection) covers
|
|
71
|
+
// gradle.properties and a root-level build.gradle/build.gradle.kts,
|
|
72
|
+
// present or not - editing, creating, or deleting any of those three
|
|
73
|
+
// always invalidates. mapping/loader detection also globs subproject
|
|
74
|
+
// build scripts (`**/build.gradle(.kts)`) and, for the loader, mod
|
|
75
|
+
// descriptor files (`fabric.mod.json`, `quilt.mod.json`,
|
|
76
|
+
// `META-INF/{mods,neoforge.mods}.toml`) anywhere under the project; only
|
|
77
|
+
// the ones that actually produced a mapping/loader declaration are known
|
|
78
|
+
// here (as `evidence[].filePath`) and fingerprinted below. A subproject
|
|
79
|
+
// build script or descriptor that was scanned but declared nothing is NOT
|
|
80
|
+
// covered - editing one to newly declare a mapping/loader is not caught
|
|
81
|
+
// until the TTL expires, same as before this fingerprint existed.
|
|
82
|
+
// settings.gradle(.kts) and libs.versions.toml are not read by detection
|
|
83
|
+
// at all, so they are never fingerprinted.
|
|
84
|
+
const fixedPaths = new Set(fixedFingerprint.map((entry) => entry.path));
|
|
85
|
+
const evidencePaths = [
|
|
86
|
+
...(mappingResult?.evidence.map((entry) => entry.filePath) ?? []),
|
|
87
|
+
...(loaderResult?.evidence.map((entry) => entry.filePath) ?? [])
|
|
88
|
+
].filter((path) => !fixedPaths.has(resolvePath(path)));
|
|
89
|
+
const fingerprint = [
|
|
90
|
+
...fixedFingerprint,
|
|
91
|
+
// A path already covered by fixedFingerprint above (taken pre-detection)
|
|
92
|
+
// is skipped here rather than re-stat-ed post-detection: keeping only
|
|
93
|
+
// the earlier snapshot is what makes that entry catch a mid-detection
|
|
94
|
+
// race at all, and a second, later entry for the same path would just
|
|
95
|
+
// add a redundant stat on every read without changing the result.
|
|
96
|
+
...computeWorkspaceContextFingerprint(evidencePaths)
|
|
97
|
+
];
|
|
41
98
|
const ctx = {
|
|
42
99
|
projectPath,
|
|
43
100
|
minecraftVersion,
|
|
@@ -46,7 +103,8 @@ export async function loadOrDetectWorkspaceContext(svc, projectPath) {
|
|
|
46
103
|
detectedAt: Date.now(),
|
|
47
104
|
evidence,
|
|
48
105
|
dependencyVersions: latest?.dependencyVersions ?? cached?.dependencyVersions ?? new Map(),
|
|
49
|
-
partial: false
|
|
106
|
+
partial: false,
|
|
107
|
+
fingerprint
|
|
50
108
|
};
|
|
51
109
|
svc.workspaceContextCache.write(ctx);
|
|
52
110
|
return ctx;
|
|
@@ -61,7 +119,11 @@ export async function synthesizeWorkspaceTarget(svc, input, workspace) {
|
|
|
61
119
|
}
|
|
62
120
|
});
|
|
63
121
|
}
|
|
64
|
-
|
|
122
|
+
// Normalized once, here, and used for everything below: the workspace-context
|
|
123
|
+
// cache key, the detection reads and the provenance all have to name the same
|
|
124
|
+
// path. Trimming alone left a Windows-spelled path unreadable on a WSL host
|
|
125
|
+
// for this target kind while the sibling routes resolved it.
|
|
126
|
+
const projectPath = normalizeOptionalProjectPath(input.projectPath);
|
|
65
127
|
if (!projectPath) {
|
|
66
128
|
throw createError({
|
|
67
129
|
code: ERROR_CODES.INVALID_INPUT,
|
|
@@ -86,10 +148,16 @@ export async function synthesizeWorkspaceTarget(svc, input, workspace) {
|
|
|
86
148
|
nextAction: "Set minecraft_version in gradle.properties or pass target.kind=\"version\" with an explicit Minecraft version.",
|
|
87
149
|
...buildSuggestedCall({
|
|
88
150
|
tool: "resolve-artifact",
|
|
89
|
-
params:
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
151
|
+
params: undefined,
|
|
152
|
+
examples: [
|
|
153
|
+
{
|
|
154
|
+
params: {
|
|
155
|
+
target: { kind: "version", value: "<your-mc-version>" },
|
|
156
|
+
projectPath
|
|
157
|
+
},
|
|
158
|
+
reason: "Replace <your-mc-version> with the Minecraft version, or set minecraft_version in gradle.properties."
|
|
159
|
+
}
|
|
160
|
+
]
|
|
93
161
|
})
|
|
94
162
|
}
|
|
95
163
|
});
|
|
@@ -226,7 +294,7 @@ export async function synthesizeDependencyTarget(svc, input, dep) {
|
|
|
226
294
|
}
|
|
227
295
|
});
|
|
228
296
|
}
|
|
229
|
-
const projectPath = input.projectPath
|
|
297
|
+
const projectPath = normalizeOptionalProjectPath(input.projectPath);
|
|
230
298
|
if (!projectPath) {
|
|
231
299
|
throw createError({
|
|
232
300
|
code: ERROR_CODES.INVALID_INPUT,
|
|
@@ -142,6 +142,20 @@ export interface JarEntryBuffer {
|
|
|
142
142
|
export interface JarReaderOpenDeps {
|
|
143
143
|
openZipFile?: (jarPath: string) => Promise<ZipFile>;
|
|
144
144
|
}
|
|
145
|
+
export interface CollectMatchedJarEntriesAsBuffersOptions extends CollectMatchedJarEntriesOptions {
|
|
146
|
+
/**
|
|
147
|
+
* Checked only after an entry has been SUCCESSFULLY read and pushed onto the
|
|
148
|
+
* result (the success path), with the result array so far; returning true
|
|
149
|
+
* stops the walk immediately, alongside `maxEntries` (whichever triggers
|
|
150
|
+
* first). Unlike a predicate — which runs before a read is even attempted —
|
|
151
|
+
* this lets a caller implement a stop condition that depends on which reads
|
|
152
|
+
* actually succeeded, e.g. "stop once every distinct wanted name has at
|
|
153
|
+
* least one successful read", so one unreadable copy of a duplicate-named
|
|
154
|
+
* entry (with `continueOnError: true`) does not block a later readable copy
|
|
155
|
+
* of the same name from ever being tried.
|
|
156
|
+
*/
|
|
157
|
+
stopWhen?: (collected: readonly JarEntryBuffer[]) => boolean;
|
|
158
|
+
}
|
|
145
159
|
/**
|
|
146
160
|
* Raw-buffer counterpart of {@link collectMatchedJarEntriesAsUtf8}: opens the jar
|
|
147
161
|
* ONCE and returns the matched entries as raw Buffers (no UTF-8 decode), so binary
|
|
@@ -149,7 +163,7 @@ export interface JarReaderOpenDeps {
|
|
|
149
163
|
* sampled entry. Mirrors the sibling's maxBytes/maxEntries/continueOnError semantics
|
|
150
164
|
* and its finally-close, but takes an injectable open seam for open-count assertions.
|
|
151
165
|
*/
|
|
152
|
-
export declare function collectMatchedJarEntriesAsBuffers(jarPath: string, predicate: (entryPath: string) => boolean, options?:
|
|
166
|
+
export declare function collectMatchedJarEntriesAsBuffers(jarPath: string, predicate: (entryPath: string) => boolean, options?: CollectMatchedJarEntriesAsBuffersOptions, deps?: JarReaderOpenDeps): Promise<JarEntryBuffer[]>;
|
|
153
167
|
export declare function iterateJavaEntriesAsUtf8(jarPath: string, maxBytes?: number): AsyncGenerator<JavaEntryText>;
|
|
154
168
|
export declare function readAllJavaEntriesAsUtf8(jarPath: string, maxBytes?: number): Promise<JavaEntryText[]>;
|
|
155
169
|
export declare function detectFabricLikeInputNamespace(inputJar: string, deps?: JarReaderOpenDeps): Promise<{
|
|
@@ -497,6 +497,9 @@ export async function collectMatchedJarEntriesAsBuffers(jarPath, predicate, opti
|
|
|
497
497
|
if (maxEntries != null && entries.length >= maxEntries) {
|
|
498
498
|
return entries;
|
|
499
499
|
}
|
|
500
|
+
if (options.stopWhen?.(entries)) {
|
|
501
|
+
return entries;
|
|
502
|
+
}
|
|
500
503
|
}
|
|
501
504
|
catch (error) {
|
|
502
505
|
if (!options.continueOnError) {
|
|
@@ -582,12 +585,41 @@ export async function detectFabricLikeInputNamespace(inputJar, deps = {}) {
|
|
|
582
585
|
}
|
|
583
586
|
// Read all sampled class entries in a SINGLE jar open instead of re-opening the
|
|
584
587
|
// jar once per entry. The sample set and latin1 decode are unchanged, so scores —
|
|
585
|
-
// and therefore the detected namespace — are identical.
|
|
588
|
+
// and therefore the detected namespace — are identical. The walk stops as soon
|
|
589
|
+
// as every sampled entry has been found instead of always scanning the rest of
|
|
590
|
+
// the jar.
|
|
591
|
+
//
|
|
592
|
+
// The predicate only checks name membership (not distinctness), so a readable
|
|
593
|
+
// duplicate-named entry can still be pushed. Stopping is instead driven by
|
|
594
|
+
// `stopWhen`, which runs only in the SUCCESS path (after a read has actually
|
|
595
|
+
// completed) and tracks distinct COLLECTED names there — not in the predicate,
|
|
596
|
+
// which runs before a read is attempted. A predicate-based dedup would mark a
|
|
597
|
+
// name "collected" optimistically before knowing whether its read succeeds; if
|
|
598
|
+
// the first copy of a duplicate name is unreadable (with `continueOnError:
|
|
599
|
+
// true`), that would permanently block a later, readable copy of the same name
|
|
600
|
+
// from ever being tried. The walk still stops once every distinct sampled name
|
|
601
|
+
// has at least one successful read, or the jar is exhausted.
|
|
586
602
|
const sampleSet = new Set(classEntries);
|
|
587
|
-
const
|
|
603
|
+
const collectedNames = new Set();
|
|
604
|
+
const matched = await collectMatchedJarEntriesAsBuffers(inputJar, (name) => sampleSet.has(name), {
|
|
605
|
+
continueOnError: true,
|
|
606
|
+
stopWhen: (collected) => {
|
|
607
|
+
collectedNames.add(collected[collected.length - 1].filePath);
|
|
608
|
+
return collectedNames.size >= sampleSet.size;
|
|
609
|
+
}
|
|
610
|
+
}, deps);
|
|
611
|
+
// A readable duplicate of an already-collected name can still have been
|
|
612
|
+
// pushed above (the predicate does not dedupe); keep only the first
|
|
613
|
+
// successfully-read copy of each distinct name so each sampled class
|
|
614
|
+
// contributes to the score exactly once.
|
|
615
|
+
const seenForScoring = new Set();
|
|
588
616
|
let mojangScore = 0;
|
|
589
617
|
let intermediaryScore = 0;
|
|
590
|
-
for (const { content } of matched) {
|
|
618
|
+
for (const { filePath, content } of matched) {
|
|
619
|
+
if (seenForScoring.has(filePath)) {
|
|
620
|
+
continue;
|
|
621
|
+
}
|
|
622
|
+
seenForScoring.add(filePath);
|
|
591
623
|
const text = content.toString("latin1");
|
|
592
624
|
mojangScore += countMatches(text, /net\/minecraft\/(?:advancements|client|commands|core|data|gametest|nbt|network|recipe|resources|server|sounds|stats|tags|util|world)\//g) * 3;
|
|
593
625
|
intermediaryScore += countMatches(text, /net\/minecraft\/class_\d+/g) * 3;
|
package/dist/source-resolver.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { readdir } from "node:fs/promises";
|
|
2
2
|
import { basename, dirname, join, resolve as resolvePath, sep } from "node:path";
|
|
3
|
-
import { homedir } from "node:os";
|
|
4
3
|
import fastGlob from "fast-glob";
|
|
5
4
|
import { createError, ERROR_CODES, isAppError } from "./errors.js";
|
|
5
|
+
import { resolveGradleUserHomePath } from "./gradle-paths.js";
|
|
6
6
|
import { buildRemoteBinaryUrls, buildRemoteSourceUrls, groupToPath, hasExistingJar, isMutableMavenCoordinate, parseCoordinate, normalizedCoordinateValue } from "./maven-resolver.js";
|
|
7
7
|
import { defaultDownloadPath, discardCachedDownload, resolveCachedDownload } from "./repo-downloader.js";
|
|
8
8
|
import { normalizeJarPath } from "./path-resolver.js";
|
|
@@ -344,16 +344,9 @@ async function firstReadableJarArchive(candidates) {
|
|
|
344
344
|
}
|
|
345
345
|
return undefined;
|
|
346
346
|
}
|
|
347
|
-
function resolveGradleUserHome() {
|
|
348
|
-
const configured = process.env.GRADLE_USER_HOME?.trim();
|
|
349
|
-
if (configured) {
|
|
350
|
-
return configured;
|
|
351
|
-
}
|
|
352
|
-
return resolvePath(homedir(), ".gradle");
|
|
353
|
-
}
|
|
354
347
|
async function resolveGradleCacheCoordinateCandidate(coordinate) {
|
|
355
348
|
const parsed = parseCoordinate(coordinate);
|
|
356
|
-
const baseDir = resolvePath(
|
|
349
|
+
const baseDir = resolvePath(resolveGradleUserHomePath(), "caches", "modules-2", "files-2.1", parsed.groupId, parsed.artifactId, parsed.version);
|
|
357
350
|
const base = `${parsed.artifactId}-${parsed.version}`;
|
|
358
351
|
const classifierSuffix = parsed.classifier ? `-${parsed.classifier}` : "";
|
|
359
352
|
const preferredSourceNames = [
|
|
@@ -204,6 +204,15 @@ export declare class StdioSupervisor {
|
|
|
204
204
|
private readonly unresolvedTreeTokens;
|
|
205
205
|
private readonly restartBackoff;
|
|
206
206
|
private readonly terminalChildren;
|
|
207
|
+
/**
|
|
208
|
+
* Workers started by the DEFAULT spawner on POSIX, which spawns them
|
|
209
|
+
* detached — so each one leads its own process group and `-pid` names
|
|
210
|
+
* exactly that group. Only these are group-signalled after an unexpected
|
|
211
|
+
* exit: a child handed in by an injected spawner carries no such guarantee,
|
|
212
|
+
* and its pid may not be a group id this supervisor owns.
|
|
213
|
+
*/
|
|
214
|
+
private readonly processGroupLeaders;
|
|
215
|
+
private readonly spawnsProcessGroupLeaders;
|
|
207
216
|
/**
|
|
208
217
|
* Monotonic worker-generation counter (incremented per successful spawn).
|
|
209
218
|
* Recorded into finality tombstones so retention can be bounded per
|
|
@@ -310,7 +319,8 @@ export declare class StdioSupervisor {
|
|
|
310
319
|
* Number of queued requests that arrived before an initial initialize while
|
|
311
320
|
* the worker was unavailable. Only era-neutral modern discovers can occupy
|
|
312
321
|
* this prefix; readiness forwards it before initialize, then keeps the queue
|
|
313
|
-
* suffix gated until the initialization response.
|
|
322
|
+
* suffix gated until the initialization response. A RE-initialize (the era
|
|
323
|
+
* is already legacy) has no prefix at all: nothing queued may overtake it.
|
|
314
324
|
*/
|
|
315
325
|
private initializePredecessorCount;
|
|
316
326
|
private clientInitialized;
|
|
@@ -739,6 +749,30 @@ export declare class StdioSupervisor {
|
|
|
739
749
|
private adoptActiveChild;
|
|
740
750
|
private invalidateCurrentChild;
|
|
741
751
|
private detachCurrentChild;
|
|
752
|
+
/**
|
|
753
|
+
* Signals the process group of a worker that exited UNEXPECTEDLY, so the
|
|
754
|
+
* descendants it leaves behind (a Java grandchild above all) are not
|
|
755
|
+
* orphaned.
|
|
756
|
+
*
|
|
757
|
+
* PID reuse: this runs from the worker's `exit` event, after the worker
|
|
758
|
+
* itself was reaped, so its pid is free again. The kernel still refuses to
|
|
759
|
+
* hand that number out as a new pid or pgid while ANY member of the old
|
|
760
|
+
* group is alive — so while there is something left to reap, `-pid` names
|
|
761
|
+
* exactly that group. The residual race (the group already empty AND the
|
|
762
|
+
* number reused by a new group leader in between) is accepted as negligible.
|
|
763
|
+
* pidfd-based group signalling would close it but needs Linux 6.9+.
|
|
764
|
+
*
|
|
765
|
+
* Fire-and-forget by design: it is not recorded as an in-flight cleanup, so
|
|
766
|
+
* it never occupies a live-generation slot or delays the replacement.
|
|
767
|
+
*
|
|
768
|
+
* Windows: `taskkill /T` walks the tree from its root, and the root has
|
|
769
|
+
* already exited here, so there is nothing it can find. Descendants of a
|
|
770
|
+
* crashed worker are not reaped on Windows (known limitation).
|
|
771
|
+
*
|
|
772
|
+
* Logged `result`: "signalled" (the group had members and was signalled),
|
|
773
|
+
* "group-empty" (ESRCH — nothing was left), or "failed".
|
|
774
|
+
*/
|
|
775
|
+
private reapExitedWorkerGroup;
|
|
742
776
|
private recoverTimedOutWorker;
|
|
743
777
|
private beginTreeTermination;
|
|
744
778
|
private finishTreeTermination;
|