@adhisang/minecraft-modding-mcp 7.0.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 +72 -0
- package/README.md +4 -3
- 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/analyze-mod-service.d.ts +2 -2
- package/dist/entry-tools/analyze-symbol-service.d.ts +2 -2
- package/dist/entry-tools/batch-class-members-service.d.ts +3 -2
- package/dist/entry-tools/batch-class-members-service.js +20 -6
- package/dist/entry-tools/batch-class-source-service.d.ts +3 -2
- package/dist/entry-tools/batch-class-source-service.js +10 -0
- package/dist/entry-tools/compare-minecraft-service.d.ts +27 -4
- package/dist/entry-tools/compare-minecraft-service.js +65 -4
- package/dist/entry-tools/entry-tool-schema.d.ts +2 -2
- package/dist/entry-tools/inspect-minecraft/handlers/versions.js +15 -9
- package/dist/entry-tools/inspect-minecraft-service.d.ts +2 -2
- package/dist/entry-tools/manage-cache-service.d.ts +2 -2
- 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 +94 -16
- package/dist/entry-tools/validate-project-service.d.ts +2 -2
- package/dist/entry-tools/verify-mixin-target-service.js +19 -8
- package/dist/index.js +38 -15
- 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 +27 -3
- package/dist/source/artifact-resolver.js +235 -30
- package/dist/source/class-source/members-builder.d.ts +7 -0
- package/dist/source/class-source/members-builder.js +4 -1
- package/dist/source/class-source.d.ts +9 -2
- package/dist/source/class-source.js +186 -27
- package/dist/source/indexer.js +69 -1
- package/dist/source/lifecycle/mapping-helpers.d.ts +20 -1
- package/dist/source/lifecycle/mapping-helpers.js +29 -3
- package/dist/source/lifecycle/runtime-check.d.ts +25 -0
- package/dist/source/lifecycle/runtime-check.js +68 -39
- 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/symbol-resolver.js +88 -0
- 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 +48 -1
- package/dist/source-jar-reader.js +93 -3
- package/dist/source-resolver.d.ts +7 -0
- package/dist/source-resolver.js +22 -14
- package/dist/source-service.d.ts +5 -0
- package/dist/source-service.js +7 -0
- package/dist/stdio-supervisor.d.ts +35 -1
- package/dist/stdio-supervisor.js +77 -2
- package/dist/storage/db.d.ts +62 -2
- package/dist/storage/db.js +181 -20
- package/dist/storage/files-repo.d.ts +7 -0
- package/dist/storage/files-repo.js +17 -4
- package/dist/storage/sqlite.d.ts +31 -1
- package/dist/storage/sqlite.js +125 -16
- package/dist/tool-contract-manifest.js +2 -2
- package/dist/tool-execution-gate.js +2 -1
- package/dist/tool-guidance.js +4 -1
- package/dist/tool-schemas.d.ts +64 -52
- package/dist/tool-schemas.js +9 -7
- package/dist/types.d.ts +9 -0
- package/dist/v1-parity-schemas.js +36 -2
- package/dist/version-diff-service.d.ts +23 -0
- package/dist/version-diff-service.js +101 -0
- package/dist/version-service.d.ts +14 -0
- package/dist/version-service.js +52 -3
- package/dist/workspace-context-cache.d.ts +25 -0
- package/dist/workspace-context-cache.js +52 -2
- package/dist/workspace-mapping-service.d.ts +8 -0
- package/dist/workspace-mapping-service.js +151 -21
- package/docs/README-ja.md +3 -1
- package/docs/tool-reference.md +69 -22
- package/package.json +1 -1
|
@@ -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,
|
|
@@ -53,6 +53,39 @@ export declare class EntryTooLargeError extends Error {
|
|
|
53
53
|
export declare function listJarEntries(jarPath: string): Promise<string[]>;
|
|
54
54
|
export declare function listJavaEntries(jarPath: string): Promise<string[]>;
|
|
55
55
|
export declare function hasAnyJarEntry(jarPath: string, predicate: (entryPath: string) => boolean): Promise<boolean>;
|
|
56
|
+
/** Root entry in which a Minecraft runtime jar names its own release (`"id": "26.2"`). */
|
|
57
|
+
export declare const MINECRAFT_VERSION_JSON_ENTRY = "version.json";
|
|
58
|
+
/** Class every Minecraft runtime jar ships, under its Mojang name, from 26.1 on. */
|
|
59
|
+
export declare const MINECRAFT_SHARED_CONSTANTS_ENTRY = "net/minecraft/SharedConstants.class";
|
|
60
|
+
/**
|
|
61
|
+
* What an archive walk saw that bears on "is this the Minecraft runtime jar?".
|
|
62
|
+
* Raw observations only: judging them is the resolver's job.
|
|
63
|
+
*/
|
|
64
|
+
export interface MinecraftRuntimeJarSignals {
|
|
65
|
+
hasSharedConstantsClass: boolean;
|
|
66
|
+
/**
|
|
67
|
+
* Text of the root `version.json`; absent when the entry is missing, larger
|
|
68
|
+
* than the bound, unreadable, or not UTF-8.
|
|
69
|
+
*/
|
|
70
|
+
versionJsonText?: string;
|
|
71
|
+
}
|
|
72
|
+
export interface JavaSourceScan {
|
|
73
|
+
hasJavaSources: boolean;
|
|
74
|
+
/** Set only when the walk reached the end without meeting a `.java` entry. */
|
|
75
|
+
minecraftRuntimeSignals?: MinecraftRuntimeJarSignals;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* `hasAnyJarEntry(jarPath, hasJavaSourceExtension)` that also collects the
|
|
79
|
+
* Minecraft runtime signals on the same walk.
|
|
80
|
+
*
|
|
81
|
+
* A jar without sources is already walked to its last entry to prove it has none,
|
|
82
|
+
* so noting two entry names on the way costs no extra open and no extra pass. The
|
|
83
|
+
* one entry read, `version.json`, uses the handle that is already open. A failure
|
|
84
|
+
* to read it only withholds the signal: it is evidence for a mapping decision, not
|
|
85
|
+
* the archive's verdict, so it never fails the scan. Errors opening or walking the
|
|
86
|
+
* archive propagate exactly as they do from `hasAnyJarEntry`.
|
|
87
|
+
*/
|
|
88
|
+
export declare function scanJarForJavaSources(jarPath: string): Promise<JavaSourceScan>;
|
|
56
89
|
export declare function readJarEntryAsUtf8(jarPath: string, entryPath: string): Promise<string>;
|
|
57
90
|
/**
|
|
58
91
|
* Reads one entry fully into memory. `maxBytes` bounds that materialization and
|
|
@@ -109,6 +142,20 @@ export interface JarEntryBuffer {
|
|
|
109
142
|
export interface JarReaderOpenDeps {
|
|
110
143
|
openZipFile?: (jarPath: string) => Promise<ZipFile>;
|
|
111
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
|
+
}
|
|
112
159
|
/**
|
|
113
160
|
* Raw-buffer counterpart of {@link collectMatchedJarEntriesAsUtf8}: opens the jar
|
|
114
161
|
* ONCE and returns the matched entries as raw Buffers (no UTF-8 decode), so binary
|
|
@@ -116,7 +163,7 @@ export interface JarReaderOpenDeps {
|
|
|
116
163
|
* sampled entry. Mirrors the sibling's maxBytes/maxEntries/continueOnError semantics
|
|
117
164
|
* and its finally-close, but takes an injectable open seam for open-count assertions.
|
|
118
165
|
*/
|
|
119
|
-
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[]>;
|
|
120
167
|
export declare function iterateJavaEntriesAsUtf8(jarPath: string, maxBytes?: number): AsyncGenerator<JavaEntryText>;
|
|
121
168
|
export declare function readAllJavaEntriesAsUtf8(jarPath: string, maxBytes?: number): Promise<JavaEntryText[]>;
|
|
122
169
|
export declare function detectFabricLikeInputNamespace(inputJar: string, deps?: JarReaderOpenDeps): Promise<{
|
|
@@ -183,6 +183,64 @@ export async function hasAnyJarEntry(jarPath, predicate) {
|
|
|
183
183
|
}
|
|
184
184
|
});
|
|
185
185
|
}
|
|
186
|
+
/** Root entry in which a Minecraft runtime jar names its own release (`"id": "26.2"`). */
|
|
187
|
+
export const MINECRAFT_VERSION_JSON_ENTRY = "version.json";
|
|
188
|
+
/** Class every Minecraft runtime jar ships, under its Mojang name, from 26.1 on. */
|
|
189
|
+
export const MINECRAFT_SHARED_CONSTANTS_ENTRY = "net/minecraft/SharedConstants.class";
|
|
190
|
+
/** The real file is under 1 KiB; anything far larger is not the file this looks for. */
|
|
191
|
+
const MAX_VERSION_JSON_BYTES = 64 * 1024;
|
|
192
|
+
/**
|
|
193
|
+
* `hasAnyJarEntry(jarPath, hasJavaSourceExtension)` that also collects the
|
|
194
|
+
* Minecraft runtime signals on the same walk.
|
|
195
|
+
*
|
|
196
|
+
* A jar without sources is already walked to its last entry to prove it has none,
|
|
197
|
+
* so noting two entry names on the way costs no extra open and no extra pass. The
|
|
198
|
+
* one entry read, `version.json`, uses the handle that is already open. A failure
|
|
199
|
+
* to read it only withholds the signal: it is evidence for a mapping decision, not
|
|
200
|
+
* the archive's verdict, so it never fails the scan. Errors opening or walking the
|
|
201
|
+
* archive propagate exactly as they do from `hasAnyJarEntry`.
|
|
202
|
+
*/
|
|
203
|
+
export async function scanJarForJavaSources(jarPath) {
|
|
204
|
+
return withZipFile(jarPath, async (zipFile) => {
|
|
205
|
+
let hasSharedConstantsClass = false;
|
|
206
|
+
let versionJsonEntry;
|
|
207
|
+
while (true) {
|
|
208
|
+
const entry = await readNextEntry(zipFile);
|
|
209
|
+
if (!entry) {
|
|
210
|
+
break;
|
|
211
|
+
}
|
|
212
|
+
if (!isSecureJarEntryPath(entry.fileName)) {
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
if (hasJavaSourceExtension(entry.fileName)) {
|
|
216
|
+
return { hasJavaSources: true };
|
|
217
|
+
}
|
|
218
|
+
if (entry.fileName === MINECRAFT_SHARED_CONSTANTS_ENTRY) {
|
|
219
|
+
hasSharedConstantsClass = true;
|
|
220
|
+
}
|
|
221
|
+
else if (entry.fileName === MINECRAFT_VERSION_JSON_ENTRY) {
|
|
222
|
+
versionJsonEntry = entry;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
let versionJsonText;
|
|
226
|
+
if (versionJsonEntry && versionJsonEntry.uncompressedSize <= MAX_VERSION_JSON_BYTES) {
|
|
227
|
+
try {
|
|
228
|
+
const buffer = await readEntryStream(zipFile, versionJsonEntry, jarPath, MAX_VERSION_JSON_BYTES);
|
|
229
|
+
versionJsonText = UTF8_DECODER.decode(buffer);
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
versionJsonText = undefined;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return {
|
|
236
|
+
hasJavaSources: false,
|
|
237
|
+
minecraftRuntimeSignals: {
|
|
238
|
+
hasSharedConstantsClass,
|
|
239
|
+
...(versionJsonText !== undefined ? { versionJsonText } : {})
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
});
|
|
243
|
+
}
|
|
186
244
|
export async function readJarEntryAsUtf8(jarPath, entryPath) {
|
|
187
245
|
const contentBuffer = await readJarEntryAsBuffer(jarPath, entryPath);
|
|
188
246
|
return decodeUtf8OrThrow(contentBuffer, jarPath, entryPath);
|
|
@@ -439,6 +497,9 @@ export async function collectMatchedJarEntriesAsBuffers(jarPath, predicate, opti
|
|
|
439
497
|
if (maxEntries != null && entries.length >= maxEntries) {
|
|
440
498
|
return entries;
|
|
441
499
|
}
|
|
500
|
+
if (options.stopWhen?.(entries)) {
|
|
501
|
+
return entries;
|
|
502
|
+
}
|
|
442
503
|
}
|
|
443
504
|
catch (error) {
|
|
444
505
|
if (!options.continueOnError) {
|
|
@@ -524,12 +585,41 @@ export async function detectFabricLikeInputNamespace(inputJar, deps = {}) {
|
|
|
524
585
|
}
|
|
525
586
|
// Read all sampled class entries in a SINGLE jar open instead of re-opening the
|
|
526
587
|
// jar once per entry. The sample set and latin1 decode are unchanged, so scores —
|
|
527
|
-
// 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.
|
|
528
602
|
const sampleSet = new Set(classEntries);
|
|
529
|
-
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();
|
|
530
616
|
let mojangScore = 0;
|
|
531
617
|
let intermediaryScore = 0;
|
|
532
|
-
for (const { content } of matched) {
|
|
618
|
+
for (const { filePath, content } of matched) {
|
|
619
|
+
if (seenForScoring.has(filePath)) {
|
|
620
|
+
continue;
|
|
621
|
+
}
|
|
622
|
+
seenForScoring.add(filePath);
|
|
533
623
|
const text = content.toString("latin1");
|
|
534
624
|
mojangScore += countMatches(text, /net\/minecraft\/(?:advancements|client|commands|core|data|gametest|nbt|network|recipe|resources|server|sounds|stats|tags|util|world)\//g) * 3;
|
|
535
625
|
intermediaryScore += countMatches(text, /net\/minecraft\/class_\d+/g) * 3;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Config, MappingVariant, ResolvedSourceArtifact, SourceTargetInput } from "./types.js";
|
|
2
2
|
import { type MavenCoordinate } from "./maven-resolver.js";
|
|
3
|
+
import { type JavaSourceScan } from "./source-jar-reader.js";
|
|
3
4
|
/**
|
|
4
5
|
* Every `~/.m2` path a coordinate could name, before any of them is checked
|
|
5
6
|
* against the filesystem.
|
|
@@ -59,6 +60,12 @@ export interface ResolveSourceTargetOptions {
|
|
|
59
60
|
* legacy hash for obfuscated and source-backed artifacts.
|
|
60
61
|
*/
|
|
61
62
|
mappingVariant?: MappingVariant;
|
|
63
|
+
/**
|
|
64
|
+
* Receives the result of walking the jar a `kind: "jar"` request names, once,
|
|
65
|
+
* when that walk ran. The resolver uses it to recognise a Minecraft runtime jar
|
|
66
|
+
* from its contents without walking the archive a second time.
|
|
67
|
+
*/
|
|
68
|
+
onSubjectJarScanned?: (scan: JavaSourceScan) => void;
|
|
62
69
|
onRepoFailover?: (event: {
|
|
63
70
|
stage: "source" | "binary";
|
|
64
71
|
repoUrl: string;
|