@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.
Files changed (95) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/README.md +4 -3
  3. package/dist/access-transformer-parser.d.ts +8 -0
  4. package/dist/access-transformer-parser.js +8 -1
  5. package/dist/access-widener-parser.d.ts +17 -0
  6. package/dist/access-widener-parser.js +12 -1
  7. package/dist/cache-registry.js +19 -6
  8. package/dist/entry-tools/analyze-mod-service.d.ts +2 -2
  9. package/dist/entry-tools/analyze-symbol-service.d.ts +2 -2
  10. package/dist/entry-tools/batch-class-members-service.d.ts +3 -2
  11. package/dist/entry-tools/batch-class-members-service.js +20 -6
  12. package/dist/entry-tools/batch-class-source-service.d.ts +3 -2
  13. package/dist/entry-tools/batch-class-source-service.js +10 -0
  14. package/dist/entry-tools/compare-minecraft-service.d.ts +27 -4
  15. package/dist/entry-tools/compare-minecraft-service.js +65 -4
  16. package/dist/entry-tools/entry-tool-schema.d.ts +2 -2
  17. package/dist/entry-tools/inspect-minecraft/handlers/versions.js +15 -9
  18. package/dist/entry-tools/inspect-minecraft-service.d.ts +2 -2
  19. package/dist/entry-tools/manage-cache-service.d.ts +2 -2
  20. package/dist/entry-tools/manage-cache-service.js +10 -14
  21. package/dist/entry-tools/validate-project/cases/access-transformer.js +22 -3
  22. package/dist/entry-tools/validate-project/cases/access-widener.js +31 -4
  23. package/dist/entry-tools/validate-project/cases/mixin.js +11 -3
  24. package/dist/entry-tools/validate-project/cases/project-summary.js +94 -16
  25. package/dist/entry-tools/validate-project-service.d.ts +2 -2
  26. package/dist/entry-tools/verify-mixin-target-service.js +19 -8
  27. package/dist/index.js +38 -15
  28. package/dist/java-process.d.ts +1 -0
  29. package/dist/java-process.js +14 -0
  30. package/dist/mapping/lookup.js +16 -1
  31. package/dist/mapping-service.d.ts +14 -0
  32. package/dist/mapping-service.js +35 -15
  33. package/dist/minecraft-explorer-service.js +70 -8
  34. package/dist/mixin/access-validators.js +38 -2
  35. package/dist/mixin/annotation-validators.js +137 -43
  36. package/dist/mixin/parsed-validator.js +21 -7
  37. package/dist/mixin-parser.d.ts +52 -0
  38. package/dist/mixin-parser.js +709 -130
  39. package/dist/mod-decompile-service.js +11 -1
  40. package/dist/mod-remap-service.js +6 -6
  41. package/dist/nbt/java-nbt-codec.js +7 -1
  42. package/dist/source/access-validate.js +10 -0
  43. package/dist/source/artifact-resolver.d.ts +27 -3
  44. package/dist/source/artifact-resolver.js +235 -30
  45. package/dist/source/class-source/members-builder.d.ts +7 -0
  46. package/dist/source/class-source/members-builder.js +4 -1
  47. package/dist/source/class-source.d.ts +9 -2
  48. package/dist/source/class-source.js +186 -27
  49. package/dist/source/indexer.js +69 -1
  50. package/dist/source/lifecycle/mapping-helpers.d.ts +20 -1
  51. package/dist/source/lifecycle/mapping-helpers.js +29 -3
  52. package/dist/source/lifecycle/runtime-check.d.ts +25 -0
  53. package/dist/source/lifecycle/runtime-check.js +68 -39
  54. package/dist/source/nested-jars.d.ts +15 -1
  55. package/dist/source/nested-jars.js +14 -5
  56. package/dist/source/search.d.ts +10 -2
  57. package/dist/source/search.js +60 -13
  58. package/dist/source/symbol-resolver.js +88 -0
  59. package/dist/source/validate-mixin/pipeline/mapping-health.js +20 -1
  60. package/dist/source/validate-mixin/pipeline/target-lookup.js +16 -7
  61. package/dist/source/validate-mixin.d.ts +5 -0
  62. package/dist/source/validate-mixin.js +136 -21
  63. package/dist/source/workspace-target.js +75 -7
  64. package/dist/source-jar-reader.d.ts +48 -1
  65. package/dist/source-jar-reader.js +93 -3
  66. package/dist/source-resolver.d.ts +7 -0
  67. package/dist/source-resolver.js +22 -14
  68. package/dist/source-service.d.ts +5 -0
  69. package/dist/source-service.js +7 -0
  70. package/dist/stdio-supervisor.d.ts +35 -1
  71. package/dist/stdio-supervisor.js +77 -2
  72. package/dist/storage/db.d.ts +62 -2
  73. package/dist/storage/db.js +181 -20
  74. package/dist/storage/files-repo.d.ts +7 -0
  75. package/dist/storage/files-repo.js +17 -4
  76. package/dist/storage/sqlite.d.ts +31 -1
  77. package/dist/storage/sqlite.js +125 -16
  78. package/dist/tool-contract-manifest.js +2 -2
  79. package/dist/tool-execution-gate.js +2 -1
  80. package/dist/tool-guidance.js +4 -1
  81. package/dist/tool-schemas.d.ts +64 -52
  82. package/dist/tool-schemas.js +9 -7
  83. package/dist/types.d.ts +9 -0
  84. package/dist/v1-parity-schemas.js +36 -2
  85. package/dist/version-diff-service.d.ts +23 -0
  86. package/dist/version-diff-service.js +101 -0
  87. package/dist/version-service.d.ts +14 -0
  88. package/dist/version-service.js +52 -3
  89. package/dist/workspace-context-cache.d.ts +25 -0
  90. package/dist/workspace-context-cache.js +52 -2
  91. package/dist/workspace-mapping-service.d.ts +8 -0
  92. package/dist/workspace-mapping-service.js +151 -21
  93. package/docs/README-ja.md +3 -1
  94. package/docs/tool-reference.md +69 -22
  95. package/package.json +1 -1
@@ -1,21 +1,22 @@
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";
9
9
  import { composeArtifactId, contentDigestSignature, contentSignature, jarArtifactIdentity, DECOMPILE_SIGNATURE_QUALIFIER } from "./artifact-identity.js";
10
- import { hasAnyJarEntry, hasJavaSourceExtension } from "./source-jar-reader.js";
10
+ import { hasAnyJarEntry, hasJavaSourceExtension, scanJarForJavaSources } from "./source-jar-reader.js";
11
11
  /**
12
12
  * Whether a jar contains java sources, with archive errors deliberately
13
13
  * propagating.
14
14
  *
15
15
  * This is the check for a jar that IS the subject of the request - the
16
- * `input.kind === "jar"` branch of `resolveSourceTarget` calling it on
17
- * `resolvedJarPath`. What propagates is the reader's refusal to OPEN the
18
- * archive: a truncated file, a non-zip file, an unreadable central directory,
16
+ * `input.kind === "jar"` branch of `resolveSourceTarget` runs it on
17
+ * `resolvedJarPath` as `scanSubjectJarForJavaSources`, the same walk that also
18
+ * collects Minecraft runtime signals. What propagates is the reader's refusal
19
+ * to OPEN the archive: a truncated file, a non-zip file, an unreadable central directory,
19
20
  * an I/O failure. On the subject jar that refusal is the verdict itself, and
20
21
  * swallowing it would downgrade "this archive could not be read" into "this
21
22
  * archive has no sources" - handing back a sibling `-sources.jar` as if the
@@ -73,6 +74,18 @@ async function candidateHasJavaSources(jarPath) {
73
74
  return false;
74
75
  }
75
76
  }
77
+ /**
78
+ * `hasJavaSources` for the subject jar of a `kind: "jar"` request, returning the
79
+ * Minecraft runtime signals the same walk collected. Same contract on both counts
80
+ * that matter: an absent file answers "no sources" without opening anything, and
81
+ * an archive that cannot be opened propagates.
82
+ */
83
+ async function scanSubjectJarForJavaSources(jarPath) {
84
+ if (!hasExistingJar(jarPath)) {
85
+ return { hasJavaSources: false };
86
+ }
87
+ return await scanJarForJavaSources(jarPath);
88
+ }
76
89
  function resolveExactJarSourceCandidate(inputJarPath) {
77
90
  const directory = dirname(inputJarPath);
78
91
  const jarName = basename(inputJarPath);
@@ -331,16 +344,9 @@ async function firstReadableJarArchive(candidates) {
331
344
  }
332
345
  return undefined;
333
346
  }
334
- function resolveGradleUserHome() {
335
- const configured = process.env.GRADLE_USER_HOME?.trim();
336
- if (configured) {
337
- return configured;
338
- }
339
- return resolvePath(homedir(), ".gradle");
340
- }
341
347
  async function resolveGradleCacheCoordinateCandidate(coordinate) {
342
348
  const parsed = parseCoordinate(coordinate);
343
- const baseDir = resolvePath(resolveGradleUserHome(), "caches", "modules-2", "files-2.1", parsed.groupId, parsed.artifactId, parsed.version);
349
+ const baseDir = resolvePath(resolveGradleUserHomePath(), "caches", "modules-2", "files-2.1", parsed.groupId, parsed.artifactId, parsed.version);
344
350
  const base = `${parsed.artifactId}-${parsed.version}`;
345
351
  const classifierSuffix = parsed.classifier ? `-${parsed.classifier}` : "";
346
352
  const preferredSourceNames = [
@@ -526,7 +532,9 @@ export async function resolveSourceTarget(input, options, explicitConfig) {
526
532
  const adjacentSourceCandidates = await listAdjacentJarSourceCandidates(resolvedJarPath);
527
533
  const maybeAdjacentSourceCandidates = adjacentSourceCandidates.length > 0 ? adjacentSourceCandidates : undefined;
528
534
  const preferBinaryOnly = options.preferBinaryOnly ?? false;
529
- if (await hasJavaSources(resolvedJarPath)) {
535
+ const subjectScan = await scanSubjectJarForJavaSources(resolvedJarPath);
536
+ options.onSubjectJarScanned?.(subjectScan);
537
+ if (subjectScan.hasJavaSources) {
530
538
  const siblingBinaryJarPath = resolveSiblingBinaryJarCandidate(resolvedJarPath);
531
539
  const binaryJarPath = siblingBinaryJarPath ??
532
540
  (basename(resolvedJarPath).endsWith("-sources.jar") ? undefined : resolvedJarPath);
@@ -671,6 +671,11 @@ export declare class SourceService {
671
671
  listVersions(input?: ListVersionsInput): Promise<ListVersionsOutput>;
672
672
  getRegistryData(input: GetRegistryDataInput): Promise<GetRegistryDataOutput>;
673
673
  compareVersions(input: CompareVersionsInput): Promise<CompareVersionsOutput>;
674
+ /**
675
+ * Thin passthrough so entry tools (migration-overview) can diff a version
676
+ * pair's `libraries` without reaching into VersionService directly.
677
+ */
678
+ getVersionLibraries(version: string): Promise<string[]>;
674
679
  decompileModJar(input: DecompileModJarInput): Promise<DecompileModJarOutput>;
675
680
  /**
676
681
  * Member-level view of a third-party mod jar class, read from bytecode
@@ -123,6 +123,13 @@ export class SourceService {
123
123
  async compareVersions(input) {
124
124
  return this.versionDiffService.compareVersions(input);
125
125
  }
126
+ /**
127
+ * Thin passthrough so entry tools (migration-overview) can diff a version
128
+ * pair's `libraries` without reaching into VersionService directly.
129
+ */
130
+ async getVersionLibraries(version) {
131
+ return this.versionService.getVersionLibraries(version);
132
+ }
126
133
  async decompileModJar(input) {
127
134
  return this.modDecompileService.decompileModJar(input);
128
135
  }
@@ -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;
@@ -670,6 +670,15 @@ export class StdioSupervisor {
670
670
  unresolvedTreeTokens = new Set();
671
671
  restartBackoff = new RestartBackoffState();
672
672
  terminalChildren = new WeakSet();
673
+ /**
674
+ * Workers started by the DEFAULT spawner on POSIX, which spawns them
675
+ * detached — so each one leads its own process group and `-pid` names
676
+ * exactly that group. Only these are group-signalled after an unexpected
677
+ * exit: a child handed in by an injected spawner carries no such guarantee,
678
+ * and its pid may not be a group id this supervisor owns.
679
+ */
680
+ processGroupLeaders = new WeakSet();
681
+ spawnsProcessGroupLeaders;
673
682
  /**
674
683
  * Monotonic worker-generation counter (incremented per successful spawn).
675
684
  * Recorded into finality tombstones so retention can be bounded per
@@ -776,7 +785,8 @@ export class StdioSupervisor {
776
785
  * Number of queued requests that arrived before an initial initialize while
777
786
  * the worker was unavailable. Only era-neutral modern discovers can occupy
778
787
  * this prefix; readiness forwards it before initialize, then keeps the queue
779
- * suffix gated until the initialization response.
788
+ * suffix gated until the initialization response. A RE-initialize (the era
789
+ * is already legacy) has no prefix at all: nothing queued may overtake it.
780
790
  */
781
791
  initializePredecessorCount = 0;
782
792
  clientInitialized = false;
@@ -818,6 +828,7 @@ export class StdioSupervisor {
818
828
  this.monotonicNow = options.monotonicNow ?? (() => performance.now());
819
829
  this.timerScheduler = options.timerScheduler ?? ((callback, delayMs) => setTimeout(callback, delayMs));
820
830
  this.timerClearer = options.timerClearer ?? ((timer) => clearTimeout(timer));
831
+ this.spawnsProcessGroupLeaders = options.workerSpawner === undefined && process.platform !== "win32";
821
832
  this.workerSpawner = options.workerSpawner ?? (() => spawn(process.execPath, [...process.execArgv, this.entryFile], {
822
833
  env: {
823
834
  ...process.env,
@@ -1246,6 +1257,7 @@ export class StdioSupervisor {
1246
1257
  this.writeToClient(buildInvalidInitializeRejection(message.id), this.modeForMessage(message));
1247
1258
  return;
1248
1259
  }
1260
+ const eraBeforeInitialize = this.era;
1249
1261
  this.era = "legacy";
1250
1262
  // The SDK's opening classifier treats an initialize that carries a
1251
1263
  // valid modern era claim as MODERN, diverging from this admission rule
@@ -1270,7 +1282,13 @@ export class StdioSupervisor {
1270
1282
  this.forwardRequest(message, this.createPendingRequest(message));
1271
1283
  }
1272
1284
  else {
1273
- this.initializePredecessorCount = this.queuedRequests.length;
1285
+ // Only an INITIAL initialize owns a releasable prefix: while the era
1286
+ // was unselected the sole request that can have queued is an
1287
+ // era-neutral discover. Once legacy is locked ordinary requests queue
1288
+ // too, and letting them overtake a RE-sent initialize would hand them
1289
+ // to an un-handshaken replacement worker.
1290
+ this.initializePredecessorCount =
1291
+ eraBeforeInitialize === "unselected" ? this.queuedRequests.length : 0;
1274
1292
  const existing = this.queuedNotifications.findIndex((entry) => isRequest(entry) && entry.method === "initialize");
1275
1293
  if (existing >= 0)
1276
1294
  this.queuedNotifications.splice(existing, 1);
@@ -2009,6 +2027,8 @@ export class StdioSupervisor {
2009
2027
  }
2010
2028
  this.child = child;
2011
2029
  this.liveChildren.add(child);
2030
+ if (this.spawnsProcessGroupLeaders)
2031
+ this.processGroupLeaders.add(child);
2012
2032
  this.workerReaders.set(child, new JsonRpcFrameReader());
2013
2033
  const generation = ++this.workerGeneration;
2014
2034
  // "close" fires only once the process has ended AND its stdio streams are
@@ -2407,6 +2427,10 @@ export class StdioSupervisor {
2407
2427
  const wasReady = this.childReady;
2408
2428
  const readyAt = this.childReadyAt;
2409
2429
  this.detachCurrentChild();
2430
+ // The worker ended on its own (crash, OOM, an external kill of the worker
2431
+ // alone): no supervisor-initiated termination ran, so any descendant it
2432
+ // forked is still alive in its process group with no other owner.
2433
+ this.reapExitedWorkerGroup(child, childPid);
2410
2434
  if (this.shuttingDown) {
2411
2435
  return;
2412
2436
  }
@@ -2925,6 +2949,57 @@ export class StdioSupervisor {
2925
2949
  child.stdin.removeAllListeners("error");
2926
2950
  }
2927
2951
  }
2952
+ /**
2953
+ * Signals the process group of a worker that exited UNEXPECTEDLY, so the
2954
+ * descendants it leaves behind (a Java grandchild above all) are not
2955
+ * orphaned.
2956
+ *
2957
+ * PID reuse: this runs from the worker's `exit` event, after the worker
2958
+ * itself was reaped, so its pid is free again. The kernel still refuses to
2959
+ * hand that number out as a new pid or pgid while ANY member of the old
2960
+ * group is alive — so while there is something left to reap, `-pid` names
2961
+ * exactly that group. The residual race (the group already empty AND the
2962
+ * number reused by a new group leader in between) is accepted as negligible.
2963
+ * pidfd-based group signalling would close it but needs Linux 6.9+.
2964
+ *
2965
+ * Fire-and-forget by design: it is not recorded as an in-flight cleanup, so
2966
+ * it never occupies a live-generation slot or delays the replacement.
2967
+ *
2968
+ * Windows: `taskkill /T` walks the tree from its root, and the root has
2969
+ * already exited here, so there is nothing it can find. Descendants of a
2970
+ * crashed worker are not reaped on Windows (known limitation).
2971
+ *
2972
+ * Logged `result`: "signalled" (the group had members and was signalled),
2973
+ * "group-empty" (ESRCH — nothing was left), or "failed".
2974
+ */
2975
+ reapExitedWorkerGroup(child, pid) {
2976
+ if (pid === undefined)
2977
+ return;
2978
+ const report = (result) => {
2979
+ this.eventWriter(result === "failed" ? "warn" : "info", "supervisor.worker_exit_group_cleanup", {
2980
+ pid,
2981
+ result
2982
+ });
2983
+ };
2984
+ if (process.platform === "win32") {
2985
+ debugSupervisor("worker_exit_group_unreachable", { pid });
2986
+ return;
2987
+ }
2988
+ if (!this.processGroupLeaders.has(child))
2989
+ return;
2990
+ let groupEmpty = false;
2991
+ const signalled = terminatePosixProcessGroup(pid, (target, signal) => {
2992
+ try {
2993
+ process.kill(target, signal);
2994
+ }
2995
+ catch (error) {
2996
+ if (error?.code === "ESRCH")
2997
+ groupEmpty = true;
2998
+ throw error;
2999
+ }
3000
+ });
3001
+ report(!signalled ? "failed" : groupEmpty ? "group-empty" : "signalled");
3002
+ }
2928
3003
  recoverTimedOutWorker() {
2929
3004
  const child = this.child;
2930
3005
  if (!child) {
@@ -1,5 +1,6 @@
1
- import Database from "./sqlite.js";
1
+ import Database, { isRawSqliteCorruptionError } from "./sqlite.js";
2
2
  import type { Config } from "../types.js";
3
+ export { isRawSqliteCorruptionError };
3
4
  type SqliteDatabase = InstanceType<typeof Database>;
4
5
  type Logger = {
5
6
  warn: (message: string, details?: Record<string, unknown>) => void;
@@ -11,5 +12,64 @@ export interface InitializedDatabase {
11
12
  schemaVersion: number;
12
13
  }
13
14
  type DatabaseConfig = Pick<Config, "sqlitePath"> & Partial<Pick<Config, "sqliteCacheKb" | "sqliteMmapSize">>;
15
+ /**
16
+ * Best-effort request that the NEXT `openDatabase` for `sqlitePath` run the
17
+ * full `PRAGMA integrity_check` instead of the default `quick_check`. Creates
18
+ * a new, uniquely-named request file (see the block comment above) with
19
+ * exclusive-create so it can never collide with or overwrite another
20
+ * pending request. Never throws: a failure to write the file only means the
21
+ * escalation is missed, which is logged rather than allowed to mask the
22
+ * caller's real error. Returns whether the file was actually written, so a
23
+ * caller that cannot schedule the follow-up check can say so instead of
24
+ * promising one.
25
+ */
26
+ export declare function requestFullIntegrityCheck(sqlitePath: string, logger?: Logger): boolean;
27
+ /**
28
+ * Lists the full paths of every currently pending full-integrity-check
29
+ * request file for `sqlitePath`. Best-effort: a missing or unreadable
30
+ * directory is treated as "no requests pending" rather than as an error,
31
+ * since the fallback (a quick_check) is always safe to run.
32
+ */
33
+ export declare function listIntegrityCheckRequestFiles(sqlitePath: string): string[];
34
+ /**
35
+ * Deletes EXACTLY the given request file paths - the ones an earlier
36
+ * `listIntegrityCheckRequestFiles` call returned, never a fresh listing - so
37
+ * a request file created after that decision point is never deleted by a
38
+ * clear that only ever meant to honor the requests it actually acted on.
39
+ * Best-effort per file: ENOENT (already gone) is ignored, anything else is
40
+ * logged, not thrown.
41
+ *
42
+ * Exported so a unit test can exercise the race guard directly:
43
+ * `openDatabase` lists-then-clears synchronously with no `await` in between,
44
+ * so there is no way to interleave a concurrent request file appearing
45
+ * between "decide" and "clear" from outside that call.
46
+ */
47
+ export declare function clearIntegrityCheckRequestFiles(requestFiles: string[], logger?: Logger): void;
48
+ /**
49
+ * Wires a Database instance's corruption observer (see sqlite.ts) so that a
50
+ * raw corruption error thrown by ANY later statement on this handle - no
51
+ * matter what catches and wraps it further up the call stack, including a
52
+ * tool-specific error wrapper that never reaches runTool's own catch block -
53
+ * still schedules the next open's full integrity_check. Exported so a unit
54
+ * test can attach the same wiring to a Database instance opened directly
55
+ * (bypassing openDatabase's own open-time check, which would otherwise catch
56
+ * a test's injected corruption before a "successfully opened" handle ever
57
+ * existed to query against).
58
+ */
59
+ export declare function attachRuntimeCorruptionObserver(db: SqliteDatabase, sqlitePath: string, logger?: Logger): void;
60
+ /**
61
+ * runTool catch-path helper: when `caughtError` is a raw SQLite corruption
62
+ * error observed while serving a tool call (not at open time - open-time
63
+ * corruption is already an AppError by the time it reaches here), this
64
+ * requests a full integrity check on the next open and returns a typed
65
+ * ERR_DB_FAILURE the caller should report instead of the raw error. Any other
66
+ * error is returned unchanged so non-corruption failures are unaffected.
67
+ *
68
+ * The marker request here is redundant with the Database corruption observer
69
+ * (attachRuntimeCorruptionObserver) for an UNWRAPPED raw error, since that
70
+ * observer already fired deeper in the call stack; it is kept because
71
+ * `convertRuntimeSqliteCorruption` is the only place with reliable access to
72
+ * whether scheduling succeeded, which decides which `nextAction` to publish.
73
+ */
74
+ export declare function convertRuntimeSqliteCorruption(error: unknown, sqlitePath: string, logger?: Logger): unknown;
14
75
  export declare function openDatabase(config: DatabaseConfig, logger?: Logger): InitializedDatabase;
15
- export {};
@@ -1,22 +1,131 @@
1
- import { existsSync, renameSync } from "node:fs";
2
- import { dirname } from "node:path";
1
+ import { existsSync, readdirSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { basename, dirname, join } from "node:path";
3
3
  import { mkdirSync } from "node:fs";
4
- import Database from "./sqlite.js";
4
+ import { randomUUID } from "node:crypto";
5
+ import Database, { isRawSqliteCorruptionError } from "./sqlite.js";
5
6
  import { runMigrations } from "./migrations.js";
6
7
  import { createError, ERROR_CODES, isAppError } from "../errors.js";
7
8
  import { log } from "../logger.js";
9
+ // Re-exported for callers that already import the predicate from this module
10
+ // (it now lives in ./sqlite.js so the Database wrapper can call it directly
11
+ // without a module cycle back to this file).
12
+ export { isRawSqliteCorruptionError };
8
13
  const DEFAULT_SQLITE_CACHE_KB = 8_000;
9
14
  const DEFAULT_SQLITE_MMAP_SIZE = 268_435_456;
15
+ // Per-request escalation files created by the runtime-corruption recovery
16
+ // path (see attachRuntimeCorruptionObserver / convertRuntimeSqliteCorruption
17
+ // below): a SQLite error surfaced while serving a tool call - not at open
18
+ // time - means quick_check already passed for this file, so the NEXT open
19
+ // must pay for the full integrity_check instead of trusting quick_check
20
+ // again.
21
+ //
22
+ // Each request is its OWN immutable, uniquely-named file (exclusive-create,
23
+ // never overwritten), rather than one shared marker whose content gets
24
+ // replaced. That is what makes two requests race-free even across processes
25
+ // sharing one cache directory: an open lists whatever request files exist at
26
+ // its OWN decision point and remembers those exact names; after honoring
27
+ // them, it deletes only those names. A request file created by someone else
28
+ // AFTER that decision (a concurrent process, or a second corruption event)
29
+ // is simply a name that open never saw, so it can never be swept up by a
30
+ // clear meant for a different, earlier decision - it waits for the NEXT
31
+ // open. (An earlier single shared marker file, `<sqlitePath>.integrity-check-
32
+ // requested`, was never released, so there is no old name to keep reading
33
+ // for compatibility; it is dropped outright rather than special-cased.)
34
+ const INTEGRITY_CHECK_REQUEST_INFIX = ".integrity-check-requested.";
10
35
  function ensureParentDirectory(path) {
11
36
  mkdirSync(dirname(path), { recursive: true });
12
37
  }
13
- function runIntegrityCheck(db) {
14
- const result = db.prepare("PRAGMA integrity_check").get();
15
- if (!result || result.integrity_check !== "ok") {
38
+ function integrityCheckRequestFileName(sqlitePath, token) {
39
+ return `${basename(sqlitePath)}${INTEGRITY_CHECK_REQUEST_INFIX}${token}`;
40
+ }
41
+ function integrityCheckRequestFileNamePrefix(sqlitePath) {
42
+ return `${basename(sqlitePath)}${INTEGRITY_CHECK_REQUEST_INFIX}`;
43
+ }
44
+ /**
45
+ * Best-effort request that the NEXT `openDatabase` for `sqlitePath` run the
46
+ * full `PRAGMA integrity_check` instead of the default `quick_check`. Creates
47
+ * a new, uniquely-named request file (see the block comment above) with
48
+ * exclusive-create so it can never collide with or overwrite another
49
+ * pending request. Never throws: a failure to write the file only means the
50
+ * escalation is missed, which is logged rather than allowed to mask the
51
+ * caller's real error. Returns whether the file was actually written, so a
52
+ * caller that cannot schedule the follow-up check can say so instead of
53
+ * promising one.
54
+ */
55
+ export function requestFullIntegrityCheck(sqlitePath, logger = buildDefaultLogger()) {
56
+ const token = `${Date.now()}-${process.pid}-${randomUUID()}`;
57
+ const requestPath = join(dirname(sqlitePath), integrityCheckRequestFileName(sqlitePath, token));
58
+ try {
59
+ ensureParentDirectory(sqlitePath);
60
+ writeFileSync(requestPath, "", { flag: "wx" });
61
+ return true;
62
+ }
63
+ catch (writeError) {
64
+ logger.warn("Failed to request a full SQLite integrity check", {
65
+ sqlitePath,
66
+ reason: writeError instanceof Error ? writeError.message : String(writeError)
67
+ });
68
+ return false;
69
+ }
70
+ }
71
+ /**
72
+ * Lists the full paths of every currently pending full-integrity-check
73
+ * request file for `sqlitePath`. Best-effort: a missing or unreadable
74
+ * directory is treated as "no requests pending" rather than as an error,
75
+ * since the fallback (a quick_check) is always safe to run.
76
+ */
77
+ export function listIntegrityCheckRequestFiles(sqlitePath) {
78
+ const dir = dirname(sqlitePath);
79
+ const prefix = integrityCheckRequestFileNamePrefix(sqlitePath);
80
+ try {
81
+ return readdirSync(dir)
82
+ .filter((name) => name.startsWith(prefix))
83
+ .map((name) => join(dir, name));
84
+ }
85
+ catch {
86
+ return [];
87
+ }
88
+ }
89
+ /**
90
+ * Deletes EXACTLY the given request file paths - the ones an earlier
91
+ * `listIntegrityCheckRequestFiles` call returned, never a fresh listing - so
92
+ * a request file created after that decision point is never deleted by a
93
+ * clear that only ever meant to honor the requests it actually acted on.
94
+ * Best-effort per file: ENOENT (already gone) is ignored, anything else is
95
+ * logged, not thrown.
96
+ *
97
+ * Exported so a unit test can exercise the race guard directly:
98
+ * `openDatabase` lists-then-clears synchronously with no `await` in between,
99
+ * so there is no way to interleave a concurrent request file appearing
100
+ * between "decide" and "clear" from outside that call.
101
+ */
102
+ export function clearIntegrityCheckRequestFiles(requestFiles, logger = buildDefaultLogger()) {
103
+ for (const requestFile of requestFiles) {
104
+ try {
105
+ unlinkSync(requestFile);
106
+ }
107
+ catch (error) {
108
+ if (error?.code === "ENOENT") {
109
+ continue;
110
+ }
111
+ logger.warn("Failed to clear a SQLite full-integrity-check request file", {
112
+ requestFile,
113
+ reason: error instanceof Error ? error.message : String(error)
114
+ });
115
+ }
116
+ }
117
+ }
118
+ function runIntegrityCheck(db, logger, mode = "quick") {
119
+ const resultKey = mode === "full" ? "integrity_check" : "quick_check";
120
+ const startedAt = Date.now();
121
+ const result = db.prepare(`PRAGMA ${resultKey}`).get();
122
+ const durationMs = Date.now() - startedAt;
123
+ logger.info("SQLite consistency check completed", { durationMs, check: resultKey });
124
+ if (!result || result[resultKey] !== "ok") {
16
125
  throw createError({
17
126
  code: ERROR_CODES.DB_FAILURE,
18
127
  message: "SQLite integrity check failed.",
19
- details: { reason: "integrity_check_failed", integrityCheck: result }
128
+ details: { reason: "integrity_check_failed", check: resultKey, checkResult: result }
20
129
  });
21
130
  }
22
131
  }
@@ -60,21 +169,59 @@ function isSchemaVersionMismatchError(error) {
60
169
  return (error.details?.reason === "schema_version_unsupported" ||
61
170
  error.details?.reason === "schema_version_invalid");
62
171
  }
63
- const SQLITE_CORRUPT_ERRCODE = 11;
64
- const SQLITE_NOTADB_ERRCODE = 26;
65
172
  function isCorruptionError(error) {
66
173
  if (isAppError(error)) {
67
174
  return (error.code === ERROR_CODES.DB_FAILURE && error.details?.reason === "integrity_check_failed");
68
175
  }
69
- const sqliteError = error;
70
- if (sqliteError?.code === "SQLITE_CORRUPT" || sqliteError?.code === "SQLITE_NOTADB") {
71
- return true;
72
- }
73
- if (typeof sqliteError?.errcode !== "number") {
74
- return false;
176
+ return isRawSqliteCorruptionError(error);
177
+ }
178
+ /**
179
+ * Wires a Database instance's corruption observer (see sqlite.ts) so that a
180
+ * raw corruption error thrown by ANY later statement on this handle - no
181
+ * matter what catches and wraps it further up the call stack, including a
182
+ * tool-specific error wrapper that never reaches runTool's own catch block -
183
+ * still schedules the next open's full integrity_check. Exported so a unit
184
+ * test can attach the same wiring to a Database instance opened directly
185
+ * (bypassing openDatabase's own open-time check, which would otherwise catch
186
+ * a test's injected corruption before a "successfully opened" handle ever
187
+ * existed to query against).
188
+ */
189
+ export function attachRuntimeCorruptionObserver(db, sqlitePath, logger = buildDefaultLogger()) {
190
+ db.setCorruptionObserver(() => {
191
+ requestFullIntegrityCheck(sqlitePath, logger);
192
+ });
193
+ }
194
+ /**
195
+ * runTool catch-path helper: when `caughtError` is a raw SQLite corruption
196
+ * error observed while serving a tool call (not at open time - open-time
197
+ * corruption is already an AppError by the time it reaches here), this
198
+ * requests a full integrity check on the next open and returns a typed
199
+ * ERR_DB_FAILURE the caller should report instead of the raw error. Any other
200
+ * error is returned unchanged so non-corruption failures are unaffected.
201
+ *
202
+ * The marker request here is redundant with the Database corruption observer
203
+ * (attachRuntimeCorruptionObserver) for an UNWRAPPED raw error, since that
204
+ * observer already fired deeper in the call stack; it is kept because
205
+ * `convertRuntimeSqliteCorruption` is the only place with reliable access to
206
+ * whether scheduling succeeded, which decides which `nextAction` to publish.
207
+ */
208
+ export function convertRuntimeSqliteCorruption(error, sqlitePath, logger = buildDefaultLogger()) {
209
+ if (!isRawSqliteCorruptionError(error)) {
210
+ return error;
75
211
  }
76
- const primaryErrcode = sqliteError.errcode & 0xff;
77
- return primaryErrcode === SQLITE_CORRUPT_ERRCODE || primaryErrcode === SQLITE_NOTADB_ERRCODE;
212
+ const scheduled = requestFullIntegrityCheck(sqlitePath, logger);
213
+ const nextAction = scheduled
214
+ ? "Restart the MCP server so the cache database is fully checked and rebuilt if necessary, then retry the request."
215
+ : `The automatic full check could not be scheduled. Stop every MCP server process that uses this cache, delete the cache database at ${sqlitePath} together with its -wal and -shm files, then restart; the cache is rebuilt on the next start.`;
216
+ return createError({
217
+ code: ERROR_CODES.DB_FAILURE,
218
+ message: "A SQLite consistency error occurred while serving this request.",
219
+ details: {
220
+ sqlitePath,
221
+ reason: "runtime_corruption",
222
+ nextAction
223
+ }
224
+ });
78
225
  }
79
226
  function buildDefaultLogger() {
80
227
  return {
@@ -100,12 +247,21 @@ function buildDefaultLogger() {
100
247
  }
101
248
  export function openDatabase(config, logger = buildDefaultLogger()) {
102
249
  let db;
250
+ const pendingRequestFiles = listIntegrityCheckRequestFiles(config.sqlitePath);
103
251
  try {
104
252
  ensureParentDirectory(config.sqlitePath);
105
253
  db = new Database(config.sqlitePath);
106
254
  applyPragmas(db, config);
107
255
  const schemaVersion = runMigrations(db);
108
- runIntegrityCheck(db);
256
+ runIntegrityCheck(db, logger, pendingRequestFiles.length > 0 ? "full" : "quick");
257
+ if (pendingRequestFiles.length > 0) {
258
+ clearIntegrityCheckRequestFiles(pendingRequestFiles, logger);
259
+ }
260
+ // Only attach the runtime-corruption observer once the open itself
261
+ // (migrations + consistency check) has fully succeeded: open-time
262
+ // failures must keep going through the backup/rebuild path below, not
263
+ // silently re-request a check that is about to happen anyway.
264
+ attachRuntimeCorruptionObserver(db, config.sqlitePath, logger);
109
265
  return { db, schemaVersion };
110
266
  }
111
267
  catch (caughtError) {
@@ -152,12 +308,17 @@ export function openDatabase(config, logger = buildDefaultLogger()) {
152
308
  const backupPath = backupCorruptedDb(config.sqlitePath);
153
309
  logger.warn("SQLite database integrity check failed. Recreated database after backup", {
154
310
  sqlitePath: config.sqlitePath,
155
- backupPath
311
+ backupPath,
312
+ reason: errorMessage
156
313
  });
157
314
  rebuilt = new Database(config.sqlitePath);
158
315
  applyPragmas(rebuilt, config);
159
316
  const schemaVersion = runMigrations(rebuilt);
160
- runIntegrityCheck(rebuilt);
317
+ runIntegrityCheck(rebuilt, logger);
318
+ if (pendingRequestFiles.length > 0) {
319
+ clearIntegrityCheckRequestFiles(pendingRequestFiles, logger);
320
+ }
321
+ attachRuntimeCorruptionObserver(rebuilt, config.sqlitePath, logger);
161
322
  return { db: rebuilt, schemaVersion };
162
323
  }
163
324
  catch (rebuildError) {
@@ -19,6 +19,13 @@ export interface SearchFilesOptions {
19
19
  cursor?: string;
20
20
  mode?: "mixed" | "text" | "path";
21
21
  fetchLimitOverride?: number;
22
+ /**
23
+ * Literal file_path prefix (e.g. `net/minecraft/`) that narrows the candidate
24
+ * queries through an escaped, ASCII case-insensitive `LIKE prefix%`. That match
25
+ * set is a superset of a case-sensitive startsWith, so callers keep their own
26
+ * exact scope check on the returned candidates.
27
+ */
28
+ pathPrefix?: string;
22
29
  }
23
30
  export interface SearchFilesResult {
24
31
  filePath: string;