gdharness 1.1.46 → 1.1.47

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/build/cli.js CHANGED
@@ -17588,7 +17588,7 @@ var init_tool_definitions = __esm(() => {
17588
17588
  },
17589
17589
  {
17590
17590
  name: "project_test",
17591
- description: "Runs the project's gdUnit4 tests headless and answers with every case that did not pass: where it is, and what the assertion said. Suites where everything passed are counted rather than listed, and an engine message keeps the frames above gdUnit4 rather than the twenty inside it, so a clean tier answers in a few lines. The class list is rebuilt first, so a suite written a moment ago is found, and the rebuild's answer is under classes, with the classes each addon declares counted under addedInAddons and changedInAddons rather than named: a class it puts back that the cache had after the last rebuild is named under classes.lostSinceLastRebuild, which is the editor writing its shorter list over the file on every save until restarted. On Windows and Linux the run gets a user:// of its own, so a suite that saves a game writes nowhere near the saves of the copy somebody plays. On macOS the engine reads user:// off HOME and the run writes where the player keeps theirs, which the answer says under savesNote rather than leaving to be found in a save list. A run that found nothing to run is never called a pass: gdUnit4 exits cleanly for one, so the answer says so and names the path it looked in. A script that does not load stops gdUnit4 before any suite runs: its errors come back in full under scriptErrors, and the scripts that failed only because one they depend on did are listed by path under failedThroughDependency. A run where nothing failed but nodes were left in the tree comes back under warnings, with the count per suite: gdUnit4 decides its verdict on those and keeps them out of its report, so they are read off what it printed. Needs gdUnit4 under addons/gdUnit4.",
17591
+ description: "Runs the project's gdUnit4 tests headless and answers with every case that did not pass: where it is, and what the assertion said. Suites where everything passed are counted rather than listed, and an engine message keeps the frames above gdUnit4 rather than the twenty inside it, so a clean tier answers in a few lines. The class list is rebuilt first, so a suite written a moment ago is found, and the rebuild's answer is under classes, with the classes each addon declares counted under addedInAddons and changedInAddons rather than named: a class it puts back that the cache had after the last rebuild is named under classes.lostSinceLastRebuild, which is the editor writing its shorter list over the file on every save until restarted. On Windows and Linux the run gets a user:// of its own, so a suite that saves a game writes nowhere near the saves of the copy somebody plays. On macOS the engine reads user:// off HOME and the run writes where the player keeps theirs, which the answer says under savesNote rather than leaving to be found in a save list. A run that found nothing to run is never called a pass: gdUnit4 exits cleanly for one, so the answer says so and names the path it looked in. A script that does not load stops gdUnit4 before any suite runs: its errors come back in full under scriptErrors, and the scripts that failed only because one they depend on did are listed by path under failedThroughDependency. When they failed on a class whose own script has a parser error, which gdUnit4 never names, that script is checked by the engine and its error comes first under scriptErrors, with the class it declares under declares. A run where nothing failed but nodes were left in the tree comes back under warnings, with the count per suite: gdUnit4 decides its verdict on those and keeps them out of its report, so they are read off what it printed. Needs gdUnit4 under addons/gdUnit4.",
17592
17592
  parameters: {
17593
17593
  projectPath: PROJECT_PATH,
17594
17594
  path: {
@@ -18158,7 +18158,7 @@ var init_tool_definitions = __esm(() => {
18158
18158
  says: {
18159
18159
  type: "string",
18160
18160
  ops: ["find"],
18161
- description: 'find: part of what the node has written on it as drawn, the way the text op reads it, case-insensitively, which is how a button is reached by the word on it rather than by a generated path. Its own text, so a row is found by the label in it, and hidden nodes match. A bare word is a contains; write a glob and it is one, matched against the whole of what the node says, the same as namePattern, and a glob that finds nothing says under note how many nodes it would find open at both ends. A label with a line break is matched with the break in the words, and a backslash followed by n counts as one. Alternatives separated by | match when any one of them does, each by these rules ("*won*|*lost*"); write \\| for a bar that is part of the words.'
18161
+ description: 'find: part of what the node has written on it as drawn, the way the text op reads it, case-insensitively and with a typed space matching any space the game draws (a non-breaking one included), which is how a button is reached by the word on it rather than by a generated path. Its own text, so a row is found by the label in it, and hidden nodes match. A bare word is a contains; write a glob and it is one, matched against the whole of what the node says, the same as namePattern, and a glob that finds nothing says under note how many nodes it would find open at both ends. A label with a line break is matched with the break in the words, and a backslash followed by n counts as one. Alternatives separated by | match when any one of them does, each by these rules ("*won*|*lost*"); write \\| for a bar that is part of the words.'
18162
18162
  },
18163
18163
  limit: {
18164
18164
  type: "integer",
@@ -18336,7 +18336,7 @@ var init_tool_definitions = __esm(() => {
18336
18336
  says: {
18337
18337
  type: "string",
18338
18338
  ops: ["click"],
18339
- description: "click: the words on the control to click, matched the way runtime_inspect find matches says (a plain word is contained in the text, a pattern with * or ? is a glob over it, and | separates alternatives), among the controls on screen, and pressed in the same frame it was found in, so a panel rebuilding between a find and a click cannot free it first. Words on a label inside a button press the button. A control with something drawn over its centre that would take the pointer, such as a screen shown over it or a dialog stacked above its own, is not on screen; nor is one on a hidden canvas layer, one outside the viewport, or one a container clips away that a click does not scroll. The refusal counts those apart. The matches are ranked the way a person picks what to press: an enabled button before anything else, a disabled one included, then a control whose whole text, or one whole line of it such as the title on a card, is the words before one saying them as part of more, and the best one is pressed; found.picked says why. Several matches at the best rank are refused with each one listed, and index picks one. A match saying the words only as part of more is named under found.partOf, and is refused when a control saying exactly the words is hidden, off the screen or covered, since it would stand in for that one; index presses it anyway. An empty field says its placeholder. An item of an open menu is drawn by the menu rather than held as a control, so a click finds none, and the refusal names the menu and the choose call that picks it."
18339
+ description: "click: the words on the control to click, matched the way runtime_inspect find matches says (a plain word is contained in the text, a pattern with * or ? is a glob over it, and | separates alternatives), among the controls on screen, and pressed in the same frame it was found in, so a panel rebuilding between a find and a click cannot free it first. Words on a label inside a button press the button. A control with something drawn over its centre that would take the pointer, such as a screen shown over it or a dialog stacked above its own, is not on screen; nor is one on a hidden canvas layer, one outside the viewport, or one a container clips away that a click does not scroll. The refusal counts those apart. The matches are ranked the way a person picks what to press: an enabled button before anything else, a disabled one included, then a control whose whole text, or one whole line of it such as the title on a card, is the words before one saying them as part of more, and the best one is pressed; found.picked says why. Several matches at the best rank are refused with each one listed, and index picks one. A match saying the words only as part of more is named under found.partOf, and is refused when another control says exactly the words (hidden, off the screen, covered, or on screen taking clicks without being a button), since it would stand in for that one, and when it lets the pointer through to what is under it; index presses it anyway. Words found only inside longer words (\"out\" in \"Scout\") rank after every other match and are refused unless index is given; words starting a longer word (\"Recruit\" in \"Recruits\") count. An empty field says its placeholder, and a control with no text of its own, a picture on a button, says its accessibility name. An item of an open menu is drawn by the menu rather than held as a control, so a click finds none, and the refusal names the menu and the choose call that picks it."
18340
18340
  },
18341
18341
  action: {
18342
18342
  type: "string",
@@ -38708,12 +38708,16 @@ function whyNoReport(printed, asked) {
38708
38708
  return printed.some((line) => NOTHING_RAN.test(line)) ? `no test cases found at ${asked}` : null;
38709
38709
  }
38710
38710
  function scriptErrorsPrinted(printed) {
38711
+ return discoveryListing(printed)?.errors ?? [];
38712
+ }
38713
+ function discoveryListing(printed) {
38711
38714
  const start = printed.findIndex((line) => line.includes(DISCOVERY_FAILED));
38712
38715
  if (start < 0) {
38713
- return [];
38716
+ return null;
38714
38717
  }
38715
38718
  const found = [];
38716
38719
  let message = null;
38720
+ let to = start + 1;
38717
38721
  for (const line of printed.slice(start + 1)) {
38718
38722
  const at = /^\s*at (res:\/\/.+):(\d+)\s*$/.exec(line);
38719
38723
  if (at !== null && message !== null) {
@@ -38724,8 +38728,10 @@ function scriptErrorsPrinted(printed) {
38724
38728
  } else {
38725
38729
  break;
38726
38730
  }
38731
+ to += 1;
38727
38732
  }
38728
- return found.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : (a.line ?? 0) - (b.line ?? 0));
38733
+ const errors = found.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : (a.line ?? 0) - (b.line ?? 0));
38734
+ return { errors, from: start, to };
38729
38735
  }
38730
38736
  function unspelled(text) {
38731
38737
  return text.replace(/<(?:LF|CR|TAB|BS|FF|VT|BEL|ESC|DEL|0x[0-9A-F]{2})>/g, (spelling) => spelling.startsWith("<0x") ? String.fromCharCode(Number.parseInt(spelling.slice(3, 5), 16)) : SPELLED[spelling] ?? spelling);
@@ -41562,6 +41568,104 @@ var init_run_record = __esm(() => {
41562
41568
  execFileAsync = promisify4(execFile4);
41563
41569
  });
41564
41570
 
41571
+ // src/script-origins.ts
41572
+ import { join as join24 } from "node:path";
41573
+ function unresolvedClassIn(message) {
41574
+ return UNRESOLVED.exec(message)?.[1] ?? null;
41575
+ }
41576
+ async function originsOf(errors, declared, check) {
41577
+ const listed = new Set(errors.map((error) => error.path));
41578
+ const origins = [];
41579
+ const unconfirmed = [];
41580
+ const asked = new Set;
41581
+ const waiting = errors.map((error) => unresolvedClassIn(error.message)).filter((name) => name !== null);
41582
+ while (waiting.length > 0 && asked.size < MOST_CHECKED) {
41583
+ const name = waiting.shift() ?? "";
41584
+ const path = declared.get(name);
41585
+ if (path === undefined || listed.has(path) || asked.has(path)) {
41586
+ continue;
41587
+ }
41588
+ asked.add(path);
41589
+ const checked = await check(path);
41590
+ const own = checked.errors.filter((error) => error.path === path);
41591
+ if (own.length === 0) {
41592
+ unconfirmed.push({
41593
+ path,
41594
+ declares: name,
41595
+ why: checked.failure === null ? "the engine's check of it found no error" : `the engine's check of it did not finish (${checked.failure})`
41596
+ });
41597
+ continue;
41598
+ }
41599
+ for (const error of own) {
41600
+ origins.push({ ...error, declares: name });
41601
+ const deeper = unresolvedClassIn(error.message);
41602
+ if (deeper !== null) {
41603
+ waiting.push(deeper);
41604
+ }
41605
+ }
41606
+ }
41607
+ return { origins, unconfirmed };
41608
+ }
41609
+ function reloadedAt(entry) {
41610
+ for (const line of entry.detail) {
41611
+ const at = RELOADED_AT.exec(line.trim());
41612
+ if (at !== null) {
41613
+ return { path: at[1] ?? "", line: Number(at[2]) === 0 ? null : Number(at[2]) };
41614
+ }
41615
+ }
41616
+ return null;
41617
+ }
41618
+ function checkWithEngine(godotPath, projectPath) {
41619
+ return async (path) => {
41620
+ const logDir = scratchDirectory("tests");
41621
+ try {
41622
+ const ran = await runEngine(godotPath, [
41623
+ "--headless",
41624
+ "--log-file",
41625
+ join24(logDir, "engine.log"),
41626
+ "--path",
41627
+ projectPath,
41628
+ "--check-only",
41629
+ "-s",
41630
+ path
41631
+ ], { timeout: { ms: 60000, said: "minute" } });
41632
+ const errors = ran.log.select({ severity: "error", sinceLastCall: false, limit: 50 }).entries.flatMap((entry) => {
41633
+ const at = reloadedAt(entry);
41634
+ return at === null ? [] : [{ ...at, message: entry.text }];
41635
+ });
41636
+ return { errors, failure: ran.failure === null ? null : howItEnded(ran) };
41637
+ } finally {
41638
+ discard(logDir);
41639
+ }
41640
+ };
41641
+ }
41642
+ function withoutListed(entries, errors) {
41643
+ if (errors.length === 0) {
41644
+ return [...entries];
41645
+ }
41646
+ const listing = discoveryListing(entries.map((entry) => entry.text));
41647
+ const paths = new Set(errors.map((error) => error.path));
41648
+ return entries.filter((entry, at) => {
41649
+ if (listing !== null && at >= listing.from && at < listing.to) {
41650
+ return false;
41651
+ }
41652
+ const reloaded = reloadedAt(entry);
41653
+ if (reloaded !== null && errors.some((error) => error.path === reloaded.path && error.line === reloaded.line && error.message === entry.text)) {
41654
+ return false;
41655
+ }
41656
+ const failedToLoad = /^Failed to load script "(res:\/\/[^"]+)" with error "[^"]+"\.$/.exec(entry.text);
41657
+ return failedToLoad === null || !paths.has(failedToLoad[1] ?? "");
41658
+ });
41659
+ }
41660
+ var UNRESOLVED, MOST_CHECKED = 8, RELOADED_AT;
41661
+ var init_script_origins = __esm(() => {
41662
+ init_engine_run();
41663
+ init_junit();
41664
+ init_scratch();
41665
+ UNRESOLVED = /Could not resolve class "([A-Za-z_][A-Za-z0-9_]*)", because of a parser error/;
41666
+ RELOADED_AT = /GDScript::reload \((res:\/\/.+):(\d+)\)$/;
41667
+ });
41668
+
41565
41669
  // src/server.ts
41566
41670
  import { execFile as execFile5, spawn as spawn4 } from "node:child_process";
41567
41671
  import { randomUUID as randomUUID2 } from "node:crypto";
@@ -41580,7 +41684,7 @@ import {
41580
41684
  rmdirSync as rmdirSync2,
41581
41685
  statSync as statSync9
41582
41686
  } from "node:fs";
41583
- import { basename as basename7, dirname as dirname18, extname as extname2, isAbsolute as isAbsolute3, join as join24, normalize as normalize2, relative as relative5 } from "node:path";
41687
+ import { basename as basename7, dirname as dirname18, extname as extname2, isAbsolute as isAbsolute3, join as join25, normalize as normalize2, relative as relative5 } from "node:path";
41584
41688
  import { setTimeout as delay3 } from "node:timers/promises";
41585
41689
  import { fileURLToPath as fileURLToPath5 } from "node:url";
41586
41690
  import { promisify as promisify5 } from "node:util";
@@ -41605,7 +41709,7 @@ function captureDestinationRefusal(outputPath, projects) {
41605
41709
  if (!existsSync19(directory)) {
41606
41710
  return `outputPath's directory ${dirname18(outputPath)} does not exist; a capture creates no directories.`;
41607
41711
  }
41608
- const where = join24(directory, basename7(outputPath));
41712
+ const where = join25(directory, basename7(outputPath));
41609
41713
  const inside = projects.find((project) => project !== "" && isWithinRoot(realPathOr(project), where));
41610
41714
  if (inside !== undefined) {
41611
41715
  return `outputPath ${outputPath} is inside the project at ${inside}; a capture is saved outside it, so it never becomes part of the game.`;
@@ -42093,7 +42197,13 @@ function foldedScriptErrors(errors) {
42093
42197
  function nonEmpty(lists) {
42094
42198
  return Object.fromEntries(Object.entries(lists).filter(([, list]) => list.length > 0));
42095
42199
  }
42096
- function scriptErrorsNote(errors) {
42200
+ function scriptErrorsNote(errors, {
42201
+ origins,
42202
+ unconfirmed
42203
+ } = {
42204
+ origins: [],
42205
+ unconfirmed: []
42206
+ }) {
42097
42207
  const { scriptErrors: own, failedThroughDependency: dependents } = foldedScriptErrors(errors);
42098
42208
  const causes = new Map;
42099
42209
  for (const error of own) {
@@ -42102,7 +42212,22 @@ function scriptErrorsNote(errors) {
42102
42212
  }
42103
42213
  }
42104
42214
  const scripts = causes.size + dependents.length;
42105
- const named = [...causes.values()].map((error) => `${error.line === null ? error.path : `${error.path}:${error.line}`} ${error.message}${/[.!?]\)?$/.test(error.message) ? "" : "."}`).join(" ");
42215
+ const at = (error) => error.line === null ? error.path : `${error.path}:${error.line}`;
42216
+ const sentence = (text) => `${text}${/[.!?]\)?$/.test(text) ? "" : "."}`;
42217
+ const traced = new Set;
42218
+ const failedOn = (declares) => {
42219
+ const through = [...causes.values()].filter((error) => unresolvedClassIn(error.message) === declares);
42220
+ for (const error of through) {
42221
+ traced.add(error.path);
42222
+ }
42223
+ return through.length === 0 ? "" : ` ${through.map(at).join(", ")} could not resolve ${declares} because of it.`;
42224
+ };
42225
+ const first = [...new Map(origins.map((origin) => [origin.path, origin])).values()].sort((a, b) => Number(unresolvedClassIn(a.message) !== null) - Number(unresolvedClassIn(b.message) !== null));
42226
+ const named = [
42227
+ ...first.map((origin) => `${at(origin)} ${sentence(origin.message)} gdUnit4 did not name this script, which declares ${origin.declares}; the engine's own check of it found the error.${failedOn(origin.declares)}`),
42228
+ ...unconfirmed.map((origin) => `${origin.path} declares ${origin.declares}, which gdUnit4 did not name, and ${origin.why}.${failedOn(origin.declares)}`),
42229
+ ...[...causes.values()].filter((error) => !traced.has(error.path)).map((error) => `${at(error)} ${sentence(error.message)}`)
42230
+ ].join(" ");
42106
42231
  const following = dependents.length === 0 ? "" : causes.size === 0 ? `${dependents.join(", ")} ${dependents.length === 1 ? "depends" : "depend"} on a script that did not compile, and gdUnit4 named none with an error of its own: script_diagnostics on one of them finds it.` : `${dependents.length === 1 ? "One more" : `${dependents.length} more`} failed only because a script ${dependents.length === 1 ? "it depends" : "they depend"} on did: ${dependents.join(", ")}.`;
42107
42232
  const more = own.length - causes.size;
42108
42233
  const rest = more > 0 ? `${more === 1 ? "One more error" : `${more} more errors`} in ${causes.size === 1 ? "that script" : "those scripts"} ${more === 1 ? "is" : "are"} under scriptErrors.` : "";
@@ -42169,7 +42294,7 @@ function aboveTheRunner(entry) {
42169
42294
  }
42170
42295
  function elsewhereIn(projectPath, asked) {
42171
42296
  const wanted = asked.replace(/^res:\/\//, "").replace(/\/+$/, "");
42172
- if (wanted === "" || existsSync19(join24(projectPath, wanted))) {
42297
+ if (wanted === "" || existsSync19(join25(projectPath, wanted))) {
42173
42298
  return "";
42174
42299
  }
42175
42300
  const root = (wanted.split("/")[0] ?? "").toLowerCase();
@@ -42221,7 +42346,7 @@ function runtimeAddonAnnouncesEditors(projectPath) {
42221
42346
  return false;
42222
42347
  }
42223
42348
  try {
42224
- return readFileSync21(join24(projectPath, RUNTIME_AUTOLOAD.path), "utf8").includes('"editor_pid"');
42349
+ return readFileSync21(join25(projectPath, RUNTIME_AUTOLOAD.path), "utf8").includes('"editor_pid"');
42225
42350
  } catch {
42226
42351
  return false;
42227
42352
  }
@@ -42246,7 +42371,7 @@ function needsItsOwnProcess(asked) {
42246
42371
  class GodotServer {
42247
42372
  mcp;
42248
42373
  locator = new GodotLocator;
42249
- operationsScript = join24(__dirname2, "godot", "operations", "godot_operations.gd");
42374
+ operationsScript = join25(__dirname2, "godot", "operations", "godot_operations.gd");
42250
42375
  godotBridge;
42251
42376
  tools = buildToolDefinitions();
42252
42377
  activeProcess = null;
@@ -42549,7 +42674,7 @@ class GodotServer {
42549
42674
  return checked.response;
42550
42675
  }
42551
42676
  const named = args["projectPath"];
42552
- if (typeof named === "string" && existsSync19(join24(named, "project.godot"))) {
42677
+ if (typeof named === "string" && existsSync19(join25(named, "project.godot"))) {
42553
42678
  this.lastProjectPath = named;
42554
42679
  }
42555
42680
  this.updates.refresh();
@@ -43023,7 +43148,7 @@ class GodotServer {
43023
43148
  if (path === undefined) {
43024
43149
  return { ok: false, response: this.createErrorResponse("projectPath is required.") };
43025
43150
  }
43026
- const file = join24(path, "project.godot");
43151
+ const file = join25(path, "project.godot");
43027
43152
  if (!existsSync19(file)) {
43028
43153
  return {
43029
43154
  ok: false,
@@ -43702,7 +43827,7 @@ class GodotServer {
43702
43827
  const exportArgs = [
43703
43828
  "--headless",
43704
43829
  "--log-file",
43705
- join24(exportLogs, "engine.log"),
43830
+ join25(exportLogs, "engine.log"),
43706
43831
  "--path",
43707
43832
  project.value.path,
43708
43833
  debug ? "--export-debug" : "--export-release",
@@ -43732,7 +43857,7 @@ class GodotServer {
43732
43857
  if (!paths.ok) {
43733
43858
  return this.createErrorResponse(paths.reason, PATH_SOLUTIONS);
43734
43859
  }
43735
- const missing = paths.value.filter((path) => !existsSync19(join24(project.value.path, path.slice("res://".length))));
43860
+ const missing = paths.value.filter((path) => !existsSync19(join25(project.value.path, path.slice("res://".length))));
43736
43861
  const asked = paths.value.filter((path) => !missing.includes(path));
43737
43862
  if (asked.length === 0) {
43738
43863
  const verdict = `nothing at ${missing.join(" and ")}`;
@@ -43748,7 +43873,7 @@ class GodotServer {
43748
43873
  };
43749
43874
  }
43750
43875
  const runner = "addons/gdUnit4/bin/GdUnitCmdTool.gd";
43751
- if (!existsSync19(join24(project.value.path, runner))) {
43876
+ if (!existsSync19(join25(project.value.path, runner))) {
43752
43877
  return this.createErrorResponse(`gdUnit4 is not installed in this project: no ${runner}.`, [
43753
43878
  "Install gdUnit4 under addons/gdUnit4, from https://github.com/godot-gdunit-labs/gdUnit4"
43754
43879
  ]);
@@ -43813,14 +43938,14 @@ class GodotServer {
43813
43938
  await untilGone([run.process.pid]);
43814
43939
  }
43815
43940
  await run.settled;
43816
- const reportsDir = join24(project.value.path, ".godot", ...ours.split("/"));
43941
+ const reportsDir = join25(project.value.path, ".godot", ...ours.split("/"));
43817
43942
  let report = null;
43818
43943
  let reportProblem = null;
43819
43944
  try {
43820
43945
  const written = existsSync19(reportsDir) ? readdirSync10(reportsDir).filter((name) => name.startsWith("report_")).sort((a, b) => Number(a.slice("report_".length)) - Number(b.slice("report_".length))) : [];
43821
43946
  const newest = written.at(-1);
43822
43947
  if (newest !== undefined) {
43823
- report = parseJUnit(readFileSync21(join24(reportsDir, newest, "results.xml"), "utf8"));
43948
+ report = parseJUnit(readFileSync21(join25(reportsDir, newest, "results.xml"), "utf8"));
43824
43949
  }
43825
43950
  } catch (error) {
43826
43951
  reportProblem = errorMessage(error);
@@ -43849,7 +43974,9 @@ class GodotServer {
43849
43974
  const nothingRan = timedOut ? null : whyNoReport(said, asked.join(" and "));
43850
43975
  const cutShort = timedOut ? timedOutVerdict(timeoutMs, silentForMs) : null;
43851
43976
  const hung = cutShort?.hung ?? false;
43852
- const scriptErrors = timedOut || exitCode !== 105 ? [] : scriptErrorsPrinted(said);
43977
+ const printedErrors = timedOut || exitCode !== 105 ? [] : scriptErrorsPrinted(said);
43978
+ const declaring = printedErrors.some((error) => unresolvedClassIn(error.message) !== null) ? await originsOf(printedErrors, declaredClasses(project.value.path), checkWithEngine(engine.value, project.value.path)) : { origins: [], unconfirmed: [] };
43979
+ const scriptErrors = [...declaring.origins, ...printedErrors];
43853
43980
  const brokenScripts = new Set(scriptErrors.map((error) => error.path)).size;
43854
43981
  const ranAs = cutShort !== null ? cutShort.verdict : nothingRan ?? (brokenScripts > 0 ? `script errors in ${brokenScripts === 1 ? "one script" : `${brokenScripts} scripts`}, so no suite ran` : undefined) ?? (exitCode === null ? undefined : verdicts[exitCode]) ?? (run.exitSignal === null ? `exit ${exitCode ?? "unknown"}` : howItExited(run));
43855
43982
  const nothingAt = missing.join(" and ");
@@ -43863,7 +43990,7 @@ class GodotServer {
43863
43990
  ...echoed.omitted > 0 ? { printedOmitted: echoed.omitted } : {}
43864
43991
  };
43865
43992
  if (report === null) {
43866
- const said = nothingRan !== null ? `No tests ran: ${ranAs}.` : scriptErrors.length > 0 ? scriptErrorsNote(scriptErrors) : reportProblem !== null ? `The test run's report could not be read (${reportProblem}); the engine's own verdict was ${ranAs}.` : `The test run wrote no report (${ranAs}).`;
43993
+ const said = nothingRan !== null ? `No tests ran: ${ranAs}.` : scriptErrors.length > 0 ? scriptErrorsNote(printedErrors, declaring) : reportProblem !== null ? `The test run's report could not be read (${reportProblem}); the engine's own verdict was ${ranAs}.` : `The test run wrote no report (${ranAs}).`;
43867
43994
  const note = missingNote === undefined ? said : `${said} ${missingNote}`;
43868
43995
  return {
43869
43996
  content: [
@@ -43881,7 +44008,7 @@ class GodotServer {
43881
44008
  ...missing.length > 0 ? { missing } : {},
43882
44009
  ...nonEmpty(foldedScriptErrors(scriptErrors)),
43883
44010
  arguments: cmdArgs,
43884
- entries: forAnswer(printed.slice(0, 60).map(aboveTheRunner)),
44011
+ entries: forAnswer(withoutListed(printed, scriptErrors).slice(0, 60).map(aboveTheRunner)),
43885
44012
  ...printedLines,
43886
44013
  savesNote,
43887
44014
  ...scanned
@@ -44389,7 +44516,7 @@ class GodotServer {
44389
44516
  return null;
44390
44517
  }
44391
44518
  try {
44392
- return settingKeys(readFileSync21(join24(projectPath, "project.godot"), "utf8"));
44519
+ return settingKeys(readFileSync21(join25(projectPath, "project.godot"), "utf8"));
44393
44520
  } catch {
44394
44521
  return null;
44395
44522
  }
@@ -44711,7 +44838,7 @@ class GodotServer {
44711
44838
  }
44712
44839
  async runtimeUp(projectPath, before, budget, withArgs) {
44713
44840
  const budgetMs = budget.ms;
44714
- if (!existsSync19(join24(projectPath, RUNTIME_AUTOLOAD.path))) {
44841
+ if (!existsSync19(join25(projectPath, RUNTIME_AUTOLOAD.path))) {
44715
44842
  return runtimeVerdict(null, {
44716
44843
  addon: false,
44717
44844
  budgetMs,
@@ -45728,7 +45855,7 @@ class GodotServer {
45728
45855
  notes.push(endedWithoutACode({
45729
45856
  throughEditor: run.throughEditor,
45730
45857
  announced: announced !== undefined || run.announcedPid !== undefined,
45731
- couldAnnounce: run.projectPath !== null && existsSync19(join24(run.projectPath, RUNTIME_AUTOLOAD.path)),
45858
+ couldAnnounce: run.projectPath !== null && existsSync19(join25(run.projectPath, RUNTIME_AUTOLOAD.path)),
45732
45859
  endedHere: typeof run.endedHere === "string",
45733
45860
  pickedUp: run.pickedUp === true
45734
45861
  }));
@@ -46577,7 +46704,7 @@ class GodotServer {
46577
46704
  async ownRunOnItsWay(projectPath, budgetMs) {
46578
46705
  const run = await this.currentRun();
46579
46706
  const project = projectPath ?? run?.projectPath ?? null;
46580
- if (run === null || project === null || run.announcedPid !== undefined || run.projectPath !== null && !isSameDirectory(run.projectPath, project) || !existsSync19(join24(project, RUNTIME_AUTOLOAD.path)) || !await this.runStillGoing(run)) {
46707
+ if (run === null || project === null || run.announcedPid !== undefined || run.projectPath !== null && !isSameDirectory(run.projectPath, project) || !existsSync19(join25(project, RUNTIME_AUTOLOAD.path)) || !await this.runStillGoing(run)) {
46581
46708
  return null;
46582
46709
  }
46583
46710
  const already = runtimesAnnounced().running;
@@ -46661,7 +46788,7 @@ class GodotServer {
46661
46788
  }
46662
46789
  }
46663
46790
  const screenshotDir = expectsScreenshot ? scratchDirectory("runtime-screenshot") : null;
46664
- const screenshotPath = screenshotDir ? join24(screenshotDir, "capture.png") : null;
46791
+ const screenshotPath = screenshotDir ? join25(screenshotDir, "capture.png") : null;
46665
46792
  const staleNote = this.runtimeStaleNote(choice.endpoint);
46666
46793
  const stale = staleNote === undefined ? {} : { addonIsStale: true, staleNote };
46667
46794
  const refused = (message) => this.createErrorResponse(staleNote === undefined ? message : `${message}
@@ -46852,6 +46979,7 @@ var init_server3 = __esm(() => {
46852
46979
  init_run_record();
46853
46980
  init_runtime_client();
46854
46981
  init_scratch();
46982
+ init_script_origins();
46855
46983
  init_server_version();
46856
46984
  init_setup();
46857
46985
  init_tool_definitions();
@@ -46908,7 +47036,7 @@ init_bridge_announce();
46908
47036
  init_errors();
46909
47037
  init_godot_path();
46910
47038
  import { existsSync as existsSync20 } from "node:fs";
46911
- import { join as join25, resolve as resolve8 } from "node:path";
47039
+ import { join as join26, resolve as resolve8 } from "node:path";
46912
47040
 
46913
47041
  // src/harnesses.ts
46914
47042
  import { existsSync as existsSync4, mkdirSync as mkdirSync2, readFileSync as readFileSync2, rmSync as rmSync2, statSync as statSync2, writeFileSync as writeFileSync2 } from "node:fs";
@@ -48239,7 +48367,7 @@ class UsageError extends Refusal {
48239
48367
  function projectArgument(at) {
48240
48368
  const named = args.slice(at).find((value) => !value.startsWith("--"));
48241
48369
  const projectPath = resolve8(named ?? ".");
48242
- if (!existsSync20(join25(projectPath, "project.godot"))) {
48370
+ if (!existsSync20(join26(projectPath, "project.godot"))) {
48243
48371
  throw new UsageError(`Not a Godot project: ${projectPath} holds no project.godot.`);
48244
48372
  }
48245
48373
  return projectPath;
@@ -48284,7 +48412,7 @@ async function registerRuntime(godot, projectPath) {
48284
48412
  return;
48285
48413
  }
48286
48414
  console.log(`${RUNTIME_AUTOLOAD.name} autoload left at ${named}, which this project registers itself`);
48287
- if (!existsSync20(join25(projectPath, (named ?? "").replace(/^res:\/\//, "")))) {
48415
+ if (!existsSync20(join26(projectPath, (named ?? "").replace(/^res:\/\//, "")))) {
48288
48416
  console.log(` that file is not in this project, so it boots with a missing script: restore it, or point the entry at res://${RUNTIME_AUTOLOAD.path}`);
48289
48417
  }
48290
48418
  }
@@ -61,7 +61,7 @@ static func wanted_items(menu: PopupMenu, params: Dictionary) -> Array[int]:
61
61
  if words[at] == wanted:
62
62
  return _items([order[at]])
63
63
  for at: int in words.size():
64
- if words[at].nocasecmp_to(wanted) == 0:
64
+ if Says.spaced(words[at]).nocasecmp_to(Says.spaced(wanted)) == 0:
65
65
  return _items([order[at]])
66
66
  # Separators are left out here: a heading is refused when named in full, and a pattern that
67
67
  # matched one beside the item it heads would tie the two.
@@ -16,6 +16,9 @@ const SPLITS_KEPT: int = 8
16
16
  ## Each `says` split into alternatives, by what was written; see [method alternatives].
17
17
  static var _splits: Dictionary[String, Array] = {}
18
18
 
19
+ ## Every space separator Unicode has besides the plain space, built once; see [method spaced].
20
+ static var _spaces: RegEx = null
21
+
19
22
 
20
23
  ## Whether [param node]'s own words are what [param wanted] asks for.
21
24
  static func says(node: Node, wanted: String) -> bool:
@@ -35,12 +38,33 @@ static func says(node: Node, wanted: String) -> bool:
35
38
  ## first, and read as one glob with the bars in it, it waited out its whole timeout for words no
36
39
  ## screen says.
37
40
  static func matches(said: String, wanted: String) -> bool:
41
+ var shown: String = spaced(said)
38
42
  for words: String in alternatives(wanted):
39
- if said.containsn(words) if is_plain(words) else said.matchn(words):
43
+ if shown.containsn(words) if is_plain(words) else shown.matchn(words):
40
44
  return true
41
45
  return false
42
46
 
43
47
 
48
+ ## [param text] with every Unicode space separator written as a plain space, which is what a caller
49
+ ## types for any of them. A game keeping a name on one line writes it with a non-breaking space, and
50
+ ## "Deskanem Bonituk" typed with a plain one found nothing while the screen showed exactly those
51
+ ## words. One character for one, so a place found in the result is the same place in [param text].
52
+ static func spaced(text: String) -> String:
53
+ if _spaces == null:
54
+ var separators: String = (
55
+ String.chr(0x00A0)
56
+ + String.chr(0x1680)
57
+ + String.chr(0x2000)
58
+ + "-"
59
+ + String.chr(0x200A)
60
+ + String.chr(0x202F)
61
+ + String.chr(0x205F)
62
+ + String.chr(0x3000)
63
+ )
64
+ _spaces = RegEx.create_from_string("[%s]" % separators)
65
+ return _spaces.sub(text, " ", true)
66
+
67
+
44
68
  ## The alternatives [param wanted] names, each as words on a screen: split at every `|` not written
45
69
  ## as `\|`, which stays a bar in the words. An empty alternative, a stray bar at either end, is
46
70
  ## dropped: it names no words, and a find that came back empty widened it to `**` and suggested a
@@ -110,4 +134,4 @@ static func widened(wanted: String) -> String:
110
134
  ## which reads as a control that is not on the screen. Nothing on a screen says a backslash and
111
135
  ## an n, so the two characters mean the break to everybody who writes them.
112
136
  static func as_said(wanted: String) -> String:
113
- return wanted.replace("\\n", "\n")
137
+ return spaced(wanted.replace("\\n", "\n"))
@@ -41,6 +41,8 @@ static func control_saying(root: Node, root_path: String, wanted: String, which:
41
41
  # The words of the control that matched, which a button pressed on a label's behalf has none of.
42
42
  var said: Array[String] = []
43
43
  var exact: Array[bool] = []
44
+ # Whether the words are only inside longer words there, "out" in "Scout".
45
+ var inside: Array[bool] = []
44
46
  var hidden: int = 0
45
47
  # Controls saying exactly the words that a click cannot reach, kept to say what a match on screen
46
48
  # that only says them as part of more stands in for.
@@ -60,14 +62,17 @@ static func control_saying(root: Node, root_path: String, wanted: String, which:
60
62
  var target: Control = _pressed_through(control)
61
63
  var words: String = Words.said_by(node)
62
64
  var whole: bool = _says_exactly(words, wanted)
65
+ var buried: bool = not whole and _inside_a_word(words, wanted)
63
66
  var at: int = matched.find(target)
64
67
  if at == -1:
65
68
  matched.append(target)
66
69
  said.append(words)
67
70
  exact.append(whole)
68
- elif whole and not exact[at]:
71
+ inside.append(buried)
72
+ elif (whole and not exact[at]) or (inside[at] and not buried):
69
73
  said[at] = words
70
- exact[at] = true
74
+ exact[at] = whole
75
+ inside[at] = buried
71
76
  var children: Array[Node] = node.get_children(true)
72
77
  for index: int in range(children.size() - 1, -1, -1):
73
78
  pending.push_front(children[index])
@@ -95,9 +100,13 @@ static func control_saying(root: Node, root_path: String, wanted: String, which:
95
100
  out_of_reach_as.append("under %s, which is drawn over it" % over.get_path())
96
101
  if cover == null:
97
102
  cover = over
98
- # Stable, so matches of one rank keep the order they are drawn in.
103
+ # Stable, so matches of one rank keep the order they are drawn in. Words only inside longer words
104
+ # come after everything else: ranked as a button saying them as part of more, "Scout, 90" was
105
+ # pressed for "Out" over a control saying exactly "Out", and spent a game's silver.
99
106
  open.sort_custom(
100
107
  func(a: int, b: int) -> bool:
108
+ if inside[a] != inside[b]:
109
+ return inside[b]
101
110
  var first: int = _rank(matched[a], exact[a])
102
111
  var second: int = _rank(matched[b], exact[b])
103
112
  return first > second or (first == second and a < b)
@@ -106,11 +115,13 @@ static func control_saying(root: Node, root_path: String, wanted: String, which:
106
115
  var ranks: Array[int] = []
107
116
  var lines: Array[String] = []
108
117
  var wholes: Array[bool] = []
118
+ var insides: Array[bool] = []
109
119
  for index: int in open:
110
120
  targets.append(matched[index])
111
121
  ranks.append(_rank(matched[index], exact[index]))
112
122
  lines.append(said[index])
113
123
  wholes.append(exact[index])
124
+ insides.append(inside[index])
114
125
 
115
126
  var under: String = "" if root_path == "/root" else " under %s" % root_path
116
127
  if targets.is_empty():
@@ -129,7 +140,10 @@ static func control_saying(root: Node, root_path: String, wanted: String, which:
129
140
  ]
130
141
  )
131
142
  }
132
- var best: int = ranks.count(ranks[0])
143
+ var best: int = 0
144
+ for at: int in targets.size():
145
+ if ranks[at] == ranks[0] and insides[at] == insides[0]:
146
+ best += 1
133
147
  if which == null and best > 1:
134
148
  var order: String = "" if best == targets.size() else ", best matches first"
135
149
  return {
@@ -151,30 +165,74 @@ static func control_saying(root: Node, root_path: String, wanted: String, which:
151
165
  % [str(which), targets.size(), plural, under, wanted, _candidates(targets, lines)]
152
166
  )
153
167
  }
154
- # A word inside a sentence on screen, while a control saying exactly that word is out of reach: the
155
- # sentence is a stand-in, not the control meant. A tab reading "Out" sat in a collapsed drawer, and
156
- # a click on it pressed a dispatch saying somebody "fell out in the hall". Pressed only when asked
157
- # for by index, which is the caller saying the sentence is what they meant.
158
- if which == null and not wholes[index] and not exact_out_of_reach.is_empty():
168
+ # A match saying the words as part of more, while another control says exactly them, is a stand-in
169
+ # for that one, not the control meant: a tab reading "Out" sat in a collapsed drawer and a dispatch
170
+ # saying somebody "fell out in the hall" was pressed, and with the tab's own words on screen in a
171
+ # control that is not a button, a card reading "Scout, 90" was. Pressed only when asked for by
172
+ # index, which is the caller saying the match is what they meant.
173
+ var exact_elsewhere: Array[Node] = exact_out_of_reach.duplicate()
174
+ var elsewhere_as: Array[String] = out_of_reach_as.duplicate()
175
+ # On screen only when it takes clicks itself: a heading saying exactly "Onward" beside a button
176
+ # saying "Onward now" is not what a click on "Onward" is for, and the button is pressed.
177
+ for other: int in targets.size():
178
+ var taking: bool = targets[other].get_mouse_filter_with_override() != Control.MOUSE_FILTER_IGNORE
179
+ if other != index and wholes[other] and taking:
180
+ exact_elsewhere.append(targets[other])
181
+ elsewhere_as.append("on screen, taking clicks but not an enabled button")
182
+ var best_one: String = "the one on screen," if targets.size() == 1 else "the best match on screen,"
183
+ if which == null and not wholes[index] and not exact_elsewhere.is_empty():
159
184
  return {
160
185
  "type": "error",
161
186
  "message":
162
187
  (
163
188
  (
164
- 'no control on screen%s says exactly "%s", so nothing was clicked: %s %s says it as part of'
165
- + ' "%s", while %s; pass index %d to press that one anyway, or bring the other into view'
189
+ 'no %s on screen%s says exactly "%s", so nothing was clicked: %s %s says it as part of'
190
+ + ' "%s", while %s; pass index %d to press that one anyway'
166
191
  )
167
192
  % [
193
+ "button" if exact_elsewhere.size() > exact_out_of_reach.size() else "control",
168
194
  under,
169
195
  wanted,
170
- "the one on screen," if targets.size() == 1 else "the best match on screen,",
196
+ best_one,
171
197
  targets[index].get_path(),
172
198
  _shortened(lines[index]),
173
- _out_of_reach_exactly(exact_out_of_reach, out_of_reach_as),
199
+ _out_of_reach_exactly(exact_elsewhere, elsewhere_as),
174
200
  index,
175
201
  ]
176
202
  )
177
203
  }
204
+ if which == null and insides[index]:
205
+ return {
206
+ "type": "error",
207
+ "message":
208
+ (
209
+ (
210
+ 'no control on screen%s says "%s" as a word of its own, so nothing was clicked: %s %s has'
211
+ + ' it only inside a longer word, "%s"; pass index %d to press that one anyway'
212
+ )
213
+ % [under, wanted, best_one, targets[index].get_path(), _shortened(lines[index]), index]
214
+ )
215
+ }
216
+ # Words in a sentence on a control that lets the pointer through: the press lands on whatever is
217
+ # under it, which is nothing the words name. A line saying what "is out there" was the best match
218
+ # left for "Out" once the cards saying "Scout" were passed over.
219
+ if (
220
+ which == null
221
+ and not wholes[index]
222
+ and targets[index].get_mouse_filter_with_override() == Control.MOUSE_FILTER_IGNORE
223
+ ):
224
+ return {
225
+ "type": "error",
226
+ "message":
227
+ (
228
+ (
229
+ 'no control on screen%s that takes a click says "%s", so nothing was clicked: %s %s says it'
230
+ + ' as part of "%s" and lets the pointer through to what is under it; pass index %d to'
231
+ + " click there anyway"
232
+ )
233
+ % [under, wanted, best_one, targets[index].get_path(), _shortened(lines[index]), index]
234
+ )
235
+ }
178
236
  var found: Dictionary = {"says": wanted, "index": index, "of": targets.size()}
179
237
  if not wholes[index]:
180
238
  found["partOf"] = _shortened(lines[index])
@@ -205,7 +263,8 @@ static func _rank(target: Control, whole: bool) -> int:
205
263
  ## A line counts because a card is a title over a description: "WARD" over "wards 3" is the card
206
264
  ## named WARD, and read as its words run together it said WARD only as part of more, level with
207
265
  ## "WARDSPITE" over its own description, and a click on WARD was refused as not clear.
208
- static func _says_exactly(said: String, wanted: String) -> bool:
266
+ static func _says_exactly(written: String, wanted: String) -> bool:
267
+ var said: String = Says.spaced(written)
209
268
  for words: String in Says.alternatives(wanted):
210
269
  if not Says.is_plain(words):
211
270
  if said.matchn(words):
@@ -220,6 +279,32 @@ static func _says_exactly(said: String, wanted: String) -> bool:
220
279
  return false
221
280
 
222
281
 
282
+ ## Whether [param written] has the plain words [param wanted] asks for only inside longer words,
283
+ ## never starting where a word starts: "out" in "Scout". Words starting a longer one count as said,
284
+ ## "Recruit" in "Recruits", and so does anything matched by a pattern.
285
+ static func _inside_a_word(written: String, wanted: String) -> bool:
286
+ var said: String = Says.spaced(written).to_lower()
287
+ var found: bool = false
288
+ for words: String in Says.alternatives(wanted):
289
+ var looked_for: String = words.to_lower()
290
+ if not Says.is_plain(words) or not _in_a_word(looked_for.unicode_at(0)):
291
+ return false
292
+ var at: int = said.find(looked_for)
293
+ while at >= 0:
294
+ found = true
295
+ if at == 0 or not _in_a_word(said.unicode_at(at - 1)):
296
+ return false
297
+ at = said.find(looked_for, at + 1)
298
+ return found
299
+
300
+
301
+ ## Whether the character [param code] is part of a word: a letter, a digit or an underscore. A letter
302
+ ## is told by having a case, which leaves scripts without one counted as breaks between words.
303
+ static func _in_a_word(code: int) -> bool:
304
+ var character: String = String.chr(code)
305
+ return character.to_lower() != character.to_upper() or character.is_valid_int() or character == "_"
306
+
307
+
223
308
  ## [param count] matches of [param rank], as the start of a sentence.
224
309
  static func _described(count: int, rank: int, wanted: String, under: String) -> String:
225
310
  var what: String = "button" if rank >= 2 else "control"
@@ -509,7 +594,7 @@ static func _clipped_away(
509
594
  ## words. A line in another font or holding an image measures off from what is drawn, which a wide
510
595
  ## line with nothing between the words and its start hardly does.
511
596
  static func point_on_words(label: RichTextLabel, wanted: String) -> Dictionary:
512
- var text: String = Words.said_by(label)
597
+ var text: String = Says.spaced(Words.said_by(label))
513
598
  var start: int = -1
514
599
  var length: int = 0
515
600
  for words: String in Says.alternatives(wanted):
@@ -25,7 +25,14 @@ static func said_by(node: Node) -> String:
25
25
  return _label_says(label)
26
26
  if _draws_a_list(node):
27
27
  return "\n".join(lines_said_by(node))
28
- return _text_of(node)
28
+ var own: String = _text_of(node)
29
+ # A control showing a picture rather than words is named for a player who cannot see it, and that
30
+ # name is the one the words a caller asks by are meant for: the rail's "Out" tab is a square with
31
+ # an icon on it, and a click on "Out" found only the words around it.
32
+ if own.is_empty() and node is Control:
33
+ var control: Control = node
34
+ return control.atr(control.accessibility_name).strip_edges()
35
+ return own
29
36
 
30
37
 
31
38
  ## Every line [param node] draws for a player to read, one to an entry: a label's one line, or each
package/build/index.js CHANGED
@@ -10259,7 +10259,7 @@ import {
10259
10259
  rmdirSync as rmdirSync2,
10260
10260
  statSync as statSync8
10261
10261
  } from "node:fs";
10262
- import { basename as basename5, dirname as dirname14, extname as extname2, isAbsolute as isAbsolute3, join as join20, normalize as normalize2, relative as relative4 } from "node:path";
10262
+ import { basename as basename5, dirname as dirname14, extname as extname2, isAbsolute as isAbsolute3, join as join21, normalize as normalize2, relative as relative4 } from "node:path";
10263
10263
  import { setTimeout as delay3 } from "node:timers/promises";
10264
10264
  import { fileURLToPath as fileURLToPath4 } from "node:url";
10265
10265
  import { promisify as promisify5 } from "node:util";
@@ -29420,12 +29420,16 @@ function whyNoReport(printed, asked) {
29420
29420
  }
29421
29421
  var DISCOVERY_FAILED = "Script errors were detected during test discovery!";
29422
29422
  function scriptErrorsPrinted(printed) {
29423
+ return discoveryListing(printed)?.errors ?? [];
29424
+ }
29425
+ function discoveryListing(printed) {
29423
29426
  const start = printed.findIndex((line) => line.includes(DISCOVERY_FAILED));
29424
29427
  if (start < 0) {
29425
- return [];
29428
+ return null;
29426
29429
  }
29427
29430
  const found = [];
29428
29431
  let message = null;
29432
+ let to = start + 1;
29429
29433
  for (const line of printed.slice(start + 1)) {
29430
29434
  const at = /^\s*at (res:\/\/.+):(\d+)\s*$/.exec(line);
29431
29435
  if (at !== null && message !== null) {
@@ -29436,8 +29440,10 @@ function scriptErrorsPrinted(printed) {
29436
29440
  } else {
29437
29441
  break;
29438
29442
  }
29443
+ to += 1;
29439
29444
  }
29440
- return found.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : (a.line ?? 0) - (b.line ?? 0));
29445
+ const errors = found.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : (a.line ?? 0) - (b.line ?? 0));
29446
+ return { errors, from: start, to };
29441
29447
  }
29442
29448
  var ESCAPE = String.fromCharCode(27);
29443
29449
  var LOCATION = `'[^'\\n]*' in \\S+:\\d+`;
@@ -32498,6 +32504,99 @@ function sweepTranscripts(keepMs = 24 * 60 * 60 * 1000, now = Date.now()) {
32498
32504
  }
32499
32505
  }
32500
32506
 
32507
+ // src/script-origins.ts
32508
+ import { join as join19 } from "node:path";
32509
+ var UNRESOLVED = /Could not resolve class "([A-Za-z_][A-Za-z0-9_]*)", because of a parser error/;
32510
+ function unresolvedClassIn(message) {
32511
+ return UNRESOLVED.exec(message)?.[1] ?? null;
32512
+ }
32513
+ var MOST_CHECKED = 8;
32514
+ async function originsOf(errors, declared, check) {
32515
+ const listed = new Set(errors.map((error) => error.path));
32516
+ const origins = [];
32517
+ const unconfirmed = [];
32518
+ const asked = new Set;
32519
+ const waiting = errors.map((error) => unresolvedClassIn(error.message)).filter((name) => name !== null);
32520
+ while (waiting.length > 0 && asked.size < MOST_CHECKED) {
32521
+ const name = waiting.shift() ?? "";
32522
+ const path = declared.get(name);
32523
+ if (path === undefined || listed.has(path) || asked.has(path)) {
32524
+ continue;
32525
+ }
32526
+ asked.add(path);
32527
+ const checked = await check(path);
32528
+ const own = checked.errors.filter((error) => error.path === path);
32529
+ if (own.length === 0) {
32530
+ unconfirmed.push({
32531
+ path,
32532
+ declares: name,
32533
+ why: checked.failure === null ? "the engine's check of it found no error" : `the engine's check of it did not finish (${checked.failure})`
32534
+ });
32535
+ continue;
32536
+ }
32537
+ for (const error of own) {
32538
+ origins.push({ ...error, declares: name });
32539
+ const deeper = unresolvedClassIn(error.message);
32540
+ if (deeper !== null) {
32541
+ waiting.push(deeper);
32542
+ }
32543
+ }
32544
+ }
32545
+ return { origins, unconfirmed };
32546
+ }
32547
+ var RELOADED_AT = /GDScript::reload \((res:\/\/.+):(\d+)\)$/;
32548
+ function reloadedAt(entry) {
32549
+ for (const line of entry.detail) {
32550
+ const at = RELOADED_AT.exec(line.trim());
32551
+ if (at !== null) {
32552
+ return { path: at[1] ?? "", line: Number(at[2]) === 0 ? null : Number(at[2]) };
32553
+ }
32554
+ }
32555
+ return null;
32556
+ }
32557
+ function checkWithEngine(godotPath, projectPath) {
32558
+ return async (path) => {
32559
+ const logDir = scratchDirectory("tests");
32560
+ try {
32561
+ const ran = await runEngine(godotPath, [
32562
+ "--headless",
32563
+ "--log-file",
32564
+ join19(logDir, "engine.log"),
32565
+ "--path",
32566
+ projectPath,
32567
+ "--check-only",
32568
+ "-s",
32569
+ path
32570
+ ], { timeout: { ms: 60000, said: "minute" } });
32571
+ const errors = ran.log.select({ severity: "error", sinceLastCall: false, limit: 50 }).entries.flatMap((entry) => {
32572
+ const at = reloadedAt(entry);
32573
+ return at === null ? [] : [{ ...at, message: entry.text }];
32574
+ });
32575
+ return { errors, failure: ran.failure === null ? null : howItEnded(ran) };
32576
+ } finally {
32577
+ discard(logDir);
32578
+ }
32579
+ };
32580
+ }
32581
+ function withoutListed(entries, errors) {
32582
+ if (errors.length === 0) {
32583
+ return [...entries];
32584
+ }
32585
+ const listing = discoveryListing(entries.map((entry) => entry.text));
32586
+ const paths = new Set(errors.map((error) => error.path));
32587
+ return entries.filter((entry, at) => {
32588
+ if (listing !== null && at >= listing.from && at < listing.to) {
32589
+ return false;
32590
+ }
32591
+ const reloaded = reloadedAt(entry);
32592
+ if (reloaded !== null && errors.some((error) => error.path === reloaded.path && error.line === reloaded.line && error.message === entry.text)) {
32593
+ return false;
32594
+ }
32595
+ const failedToLoad = /^Failed to load script "(res:\/\/[^"]+)" with error "[^"]+"\.$/.exec(entry.text);
32596
+ return failedToLoad === null || !paths.has(failedToLoad[1] ?? "");
32597
+ });
32598
+ }
32599
+
32501
32600
  // src/setup.ts
32502
32601
  import { spawnSync } from "node:child_process";
32503
32602
  import { createHash as createHash4 } from "node:crypto";
@@ -32512,7 +32611,7 @@ import {
32512
32611
  statSync as statSync7,
32513
32612
  writeFileSync as writeFileSync13
32514
32613
  } from "node:fs";
32515
- import { dirname as dirname13, join as join19 } from "node:path";
32614
+ import { dirname as dirname13, join as join20 } from "node:path";
32516
32615
  import { fileURLToPath as fileURLToPath3 } from "node:url";
32517
32616
  var ADDONS = ["gdharness_editor", "gdharness_runtime", "auto_reload"];
32518
32617
  var EDITOR_PLUGINS = ["gdharness_editor", "auto_reload"];
@@ -32536,7 +32635,7 @@ function addonsDigest(names, from) {
32536
32635
  if (name === VERSION_MARKER || name === DIGEST_MARKER) {
32537
32636
  continue;
32538
32637
  }
32539
- const path = join19(directory, name);
32638
+ const path = join20(directory, name);
32540
32639
  const inside = `${relative}/${name}`;
32541
32640
  if (statSync7(path).isDirectory()) {
32542
32641
  walk(path, inside);
@@ -32550,7 +32649,7 @@ function addonsDigest(names, from) {
32550
32649
  }
32551
32650
  };
32552
32651
  for (const name of names) {
32553
- walk(join19(from, name), name);
32652
+ walk(join20(from, name), name);
32554
32653
  }
32555
32654
  return hash.digest("hex");
32556
32655
  }
@@ -32583,7 +32682,7 @@ function installedRuntimeDigest(projectPath) {
32583
32682
  return digestMarker(projectPath, RUNTIME_ADDON);
32584
32683
  }
32585
32684
  function digestMarker(projectPath, addon) {
32586
- const marker = join19(projectPath, "addons", addon, DIGEST_MARKER);
32685
+ const marker = join20(projectPath, "addons", addon, DIGEST_MARKER);
32587
32686
  if (!existsSync15(marker)) {
32588
32687
  return null;
32589
32688
  }
@@ -32591,10 +32690,10 @@ function digestMarker(projectPath, addon) {
32591
32690
  return said === "" ? null : said;
32592
32691
  }
32593
32692
  function shippedAddonsDirectory() {
32594
- return join19(dirname13(fileURLToPath3(import.meta.url)), "godot", "addons");
32693
+ return join20(dirname13(fileURLToPath3(import.meta.url)), "godot", "addons");
32595
32694
  }
32596
32695
  function installedAddonVersion(projectPath) {
32597
- const marker = join19(projectPath, "addons", ADDONS[0], VERSION_MARKER);
32696
+ const marker = join20(projectPath, "addons", ADDONS[0], VERSION_MARKER);
32598
32697
  if (!existsSync15(marker)) {
32599
32698
  return null;
32600
32699
  }
@@ -32609,7 +32708,7 @@ function loaderAmong(projectPath, named) {
32609
32708
  }
32610
32709
  let source = null;
32611
32710
  try {
32612
- source = readFileSync18(join19(projectPath, path), "utf8");
32711
+ source = readFileSync18(join20(projectPath, path), "utf8");
32613
32712
  } catch {}
32614
32713
  if (source?.includes(RUNTIME_AUTOLOAD.path)) {
32615
32714
  return { name: entry, path, exists: true };
@@ -32651,8 +32750,8 @@ function enabledPlugins(settings) {
32651
32750
  function inspectProject(projectPath) {
32652
32751
  const problems = [];
32653
32752
  const addons = ADDONS.map((name) => {
32654
- const directory = join19(projectPath, "addons", name);
32655
- const marker = join19(directory, VERSION_MARKER);
32753
+ const directory = join20(projectPath, "addons", name);
32754
+ const marker = join20(directory, VERSION_MARKER);
32656
32755
  const installed = existsSync15(directory);
32657
32756
  const version = existsSync15(marker) ? readFileSync18(marker, "utf8").trim() : null;
32658
32757
  const current = version === SERVER_VERSION;
@@ -32663,7 +32762,7 @@ function inspectProject(projectPath) {
32663
32762
  }
32664
32763
  return { name, installed, version, current };
32665
32764
  });
32666
- const settings = parseProjectGodot(readFileSync18(join19(projectPath, "project.godot"), "utf8"));
32765
+ const settings = parseProjectGodot(readFileSync18(join20(projectPath, "project.godot"), "utf8"));
32667
32766
  const pluginsEnabled = enabledPlugins(settings);
32668
32767
  for (const name of EDITOR_PLUGINS) {
32669
32768
  if (!pluginsEnabled.includes(name)) {
@@ -32679,7 +32778,7 @@ function inspectProject(projectPath) {
32679
32778
  const tracked = trackedByGit(projectPath, [...named.values()]);
32680
32779
  const addonMissing = (path) => addons.some((addon) => !addon.installed && path.startsWith(`addons/${addon.name}/`));
32681
32780
  for (const [name, path] of named) {
32682
- if (!existsSync15(join19(projectPath, path))) {
32781
+ if (!existsSync15(join20(projectPath, path))) {
32683
32782
  if (!addonMissing(path)) {
32684
32783
  problems.push(`the ${name} autoload names res://${path}, which is not in this project, so it boots with a missing script; restore that file, or point the autoload at one that is here`);
32685
32784
  }
@@ -33085,7 +33184,7 @@ var TOOL_SPECS = [
33085
33184
  },
33086
33185
  {
33087
33186
  name: "project_test",
33088
- description: "Runs the project's gdUnit4 tests headless and answers with every case that did not pass: where it is, and what the assertion said. Suites where everything passed are counted rather than listed, and an engine message keeps the frames above gdUnit4 rather than the twenty inside it, so a clean tier answers in a few lines. The class list is rebuilt first, so a suite written a moment ago is found, and the rebuild's answer is under classes, with the classes each addon declares counted under addedInAddons and changedInAddons rather than named: a class it puts back that the cache had after the last rebuild is named under classes.lostSinceLastRebuild, which is the editor writing its shorter list over the file on every save until restarted. On Windows and Linux the run gets a user:// of its own, so a suite that saves a game writes nowhere near the saves of the copy somebody plays. On macOS the engine reads user:// off HOME and the run writes where the player keeps theirs, which the answer says under savesNote rather than leaving to be found in a save list. A run that found nothing to run is never called a pass: gdUnit4 exits cleanly for one, so the answer says so and names the path it looked in. A script that does not load stops gdUnit4 before any suite runs: its errors come back in full under scriptErrors, and the scripts that failed only because one they depend on did are listed by path under failedThroughDependency. A run where nothing failed but nodes were left in the tree comes back under warnings, with the count per suite: gdUnit4 decides its verdict on those and keeps them out of its report, so they are read off what it printed. Needs gdUnit4 under addons/gdUnit4.",
33187
+ description: "Runs the project's gdUnit4 tests headless and answers with every case that did not pass: where it is, and what the assertion said. Suites where everything passed are counted rather than listed, and an engine message keeps the frames above gdUnit4 rather than the twenty inside it, so a clean tier answers in a few lines. The class list is rebuilt first, so a suite written a moment ago is found, and the rebuild's answer is under classes, with the classes each addon declares counted under addedInAddons and changedInAddons rather than named: a class it puts back that the cache had after the last rebuild is named under classes.lostSinceLastRebuild, which is the editor writing its shorter list over the file on every save until restarted. On Windows and Linux the run gets a user:// of its own, so a suite that saves a game writes nowhere near the saves of the copy somebody plays. On macOS the engine reads user:// off HOME and the run writes where the player keeps theirs, which the answer says under savesNote rather than leaving to be found in a save list. A run that found nothing to run is never called a pass: gdUnit4 exits cleanly for one, so the answer says so and names the path it looked in. A script that does not load stops gdUnit4 before any suite runs: its errors come back in full under scriptErrors, and the scripts that failed only because one they depend on did are listed by path under failedThroughDependency. When they failed on a class whose own script has a parser error, which gdUnit4 never names, that script is checked by the engine and its error comes first under scriptErrors, with the class it declares under declares. A run where nothing failed but nodes were left in the tree comes back under warnings, with the count per suite: gdUnit4 decides its verdict on those and keeps them out of its report, so they are read off what it printed. Needs gdUnit4 under addons/gdUnit4.",
33089
33188
  parameters: {
33090
33189
  projectPath: PROJECT_PATH,
33091
33190
  path: {
@@ -33655,7 +33754,7 @@ var TOOL_SPECS = [
33655
33754
  says: {
33656
33755
  type: "string",
33657
33756
  ops: ["find"],
33658
- description: 'find: part of what the node has written on it as drawn, the way the text op reads it, case-insensitively, which is how a button is reached by the word on it rather than by a generated path. Its own text, so a row is found by the label in it, and hidden nodes match. A bare word is a contains; write a glob and it is one, matched against the whole of what the node says, the same as namePattern, and a glob that finds nothing says under note how many nodes it would find open at both ends. A label with a line break is matched with the break in the words, and a backslash followed by n counts as one. Alternatives separated by | match when any one of them does, each by these rules ("*won*|*lost*"); write \\| for a bar that is part of the words.'
33757
+ description: 'find: part of what the node has written on it as drawn, the way the text op reads it, case-insensitively and with a typed space matching any space the game draws (a non-breaking one included), which is how a button is reached by the word on it rather than by a generated path. Its own text, so a row is found by the label in it, and hidden nodes match. A bare word is a contains; write a glob and it is one, matched against the whole of what the node says, the same as namePattern, and a glob that finds nothing says under note how many nodes it would find open at both ends. A label with a line break is matched with the break in the words, and a backslash followed by n counts as one. Alternatives separated by | match when any one of them does, each by these rules ("*won*|*lost*"); write \\| for a bar that is part of the words.'
33659
33758
  },
33660
33759
  limit: {
33661
33760
  type: "integer",
@@ -33833,7 +33932,7 @@ var TOOL_SPECS = [
33833
33932
  says: {
33834
33933
  type: "string",
33835
33934
  ops: ["click"],
33836
- description: "click: the words on the control to click, matched the way runtime_inspect find matches says (a plain word is contained in the text, a pattern with * or ? is a glob over it, and | separates alternatives), among the controls on screen, and pressed in the same frame it was found in, so a panel rebuilding between a find and a click cannot free it first. Words on a label inside a button press the button. A control with something drawn over its centre that would take the pointer, such as a screen shown over it or a dialog stacked above its own, is not on screen; nor is one on a hidden canvas layer, one outside the viewport, or one a container clips away that a click does not scroll. The refusal counts those apart. The matches are ranked the way a person picks what to press: an enabled button before anything else, a disabled one included, then a control whose whole text, or one whole line of it such as the title on a card, is the words before one saying them as part of more, and the best one is pressed; found.picked says why. Several matches at the best rank are refused with each one listed, and index picks one. A match saying the words only as part of more is named under found.partOf, and is refused when a control saying exactly the words is hidden, off the screen or covered, since it would stand in for that one; index presses it anyway. An empty field says its placeholder. An item of an open menu is drawn by the menu rather than held as a control, so a click finds none, and the refusal names the menu and the choose call that picks it."
33935
+ description: "click: the words on the control to click, matched the way runtime_inspect find matches says (a plain word is contained in the text, a pattern with * or ? is a glob over it, and | separates alternatives), among the controls on screen, and pressed in the same frame it was found in, so a panel rebuilding between a find and a click cannot free it first. Words on a label inside a button press the button. A control with something drawn over its centre that would take the pointer, such as a screen shown over it or a dialog stacked above its own, is not on screen; nor is one on a hidden canvas layer, one outside the viewport, or one a container clips away that a click does not scroll. The refusal counts those apart. The matches are ranked the way a person picks what to press: an enabled button before anything else, a disabled one included, then a control whose whole text, or one whole line of it such as the title on a card, is the words before one saying them as part of more, and the best one is pressed; found.picked says why. Several matches at the best rank are refused with each one listed, and index picks one. A match saying the words only as part of more is named under found.partOf, and is refused when another control says exactly the words (hidden, off the screen, covered, or on screen taking clicks without being a button), since it would stand in for that one, and when it lets the pointer through to what is under it; index presses it anyway. Words found only inside longer words (\"out\" in \"Scout\") rank after every other match and are refused unless index is given; words starting a longer word (\"Recruit\" in \"Recruits\") count. An empty field says its placeholder, and a control with no text of its own, a picture on a button, says its accessibility name. An item of an open menu is drawn by the menu rather than held as a control, so a click finds none, and the refusal names the menu and the choose call that picks it."
33837
33936
  },
33838
33937
  action: {
33839
33938
  type: "string",
@@ -34114,7 +34213,7 @@ function captureDestinationRefusal(outputPath, projects) {
34114
34213
  if (!existsSync16(directory)) {
34115
34214
  return `outputPath's directory ${dirname14(outputPath)} does not exist; a capture creates no directories.`;
34116
34215
  }
34117
- const where = join20(directory, basename5(outputPath));
34216
+ const where = join21(directory, basename5(outputPath));
34118
34217
  const inside = projects.find((project) => project !== "" && isWithinRoot(realPathOr(project), where));
34119
34218
  if (inside !== undefined) {
34120
34219
  return `outputPath ${outputPath} is inside the project at ${inside}; a capture is saved outside it, so it never becomes part of the game.`;
@@ -34635,7 +34734,13 @@ function foldedScriptErrors(errors) {
34635
34734
  function nonEmpty(lists) {
34636
34735
  return Object.fromEntries(Object.entries(lists).filter(([, list]) => list.length > 0));
34637
34736
  }
34638
- function scriptErrorsNote(errors) {
34737
+ function scriptErrorsNote(errors, {
34738
+ origins,
34739
+ unconfirmed
34740
+ } = {
34741
+ origins: [],
34742
+ unconfirmed: []
34743
+ }) {
34639
34744
  const { scriptErrors: own, failedThroughDependency: dependents } = foldedScriptErrors(errors);
34640
34745
  const causes = new Map;
34641
34746
  for (const error of own) {
@@ -34644,7 +34749,22 @@ function scriptErrorsNote(errors) {
34644
34749
  }
34645
34750
  }
34646
34751
  const scripts = causes.size + dependents.length;
34647
- const named = [...causes.values()].map((error) => `${error.line === null ? error.path : `${error.path}:${error.line}`} ${error.message}${/[.!?]\)?$/.test(error.message) ? "" : "."}`).join(" ");
34752
+ const at = (error) => error.line === null ? error.path : `${error.path}:${error.line}`;
34753
+ const sentence = (text) => `${text}${/[.!?]\)?$/.test(text) ? "" : "."}`;
34754
+ const traced = new Set;
34755
+ const failedOn = (declares) => {
34756
+ const through = [...causes.values()].filter((error) => unresolvedClassIn(error.message) === declares);
34757
+ for (const error of through) {
34758
+ traced.add(error.path);
34759
+ }
34760
+ return through.length === 0 ? "" : ` ${through.map(at).join(", ")} could not resolve ${declares} because of it.`;
34761
+ };
34762
+ const first = [...new Map(origins.map((origin) => [origin.path, origin])).values()].sort((a, b) => Number(unresolvedClassIn(a.message) !== null) - Number(unresolvedClassIn(b.message) !== null));
34763
+ const named = [
34764
+ ...first.map((origin) => `${at(origin)} ${sentence(origin.message)} gdUnit4 did not name this script, which declares ${origin.declares}; the engine's own check of it found the error.${failedOn(origin.declares)}`),
34765
+ ...unconfirmed.map((origin) => `${origin.path} declares ${origin.declares}, which gdUnit4 did not name, and ${origin.why}.${failedOn(origin.declares)}`),
34766
+ ...[...causes.values()].filter((error) => !traced.has(error.path)).map((error) => `${at(error)} ${sentence(error.message)}`)
34767
+ ].join(" ");
34648
34768
  const following = dependents.length === 0 ? "" : causes.size === 0 ? `${dependents.join(", ")} ${dependents.length === 1 ? "depends" : "depend"} on a script that did not compile, and gdUnit4 named none with an error of its own: script_diagnostics on one of them finds it.` : `${dependents.length === 1 ? "One more" : `${dependents.length} more`} failed only because a script ${dependents.length === 1 ? "it depends" : "they depend"} on did: ${dependents.join(", ")}.`;
34649
34769
  const more = own.length - causes.size;
34650
34770
  const rest = more > 0 ? `${more === 1 ? "One more error" : `${more} more errors`} in ${causes.size === 1 ? "that script" : "those scripts"} ${more === 1 ? "is" : "are"} under scriptErrors.` : "";
@@ -34743,7 +34863,7 @@ function aboveTheRunner(entry) {
34743
34863
  }
34744
34864
  function elsewhereIn(projectPath, asked) {
34745
34865
  const wanted = asked.replace(/^res:\/\//, "").replace(/\/+$/, "");
34746
- if (wanted === "" || existsSync16(join20(projectPath, wanted))) {
34866
+ if (wanted === "" || existsSync16(join21(projectPath, wanted))) {
34747
34867
  return "";
34748
34868
  }
34749
34869
  const root = (wanted.split("/")[0] ?? "").toLowerCase();
@@ -34795,7 +34915,7 @@ function runtimeAddonAnnouncesEditors(projectPath) {
34795
34915
  return false;
34796
34916
  }
34797
34917
  try {
34798
- return readFileSync19(join20(projectPath, RUNTIME_AUTOLOAD.path), "utf8").includes('"editor_pid"');
34918
+ return readFileSync19(join21(projectPath, RUNTIME_AUTOLOAD.path), "utf8").includes('"editor_pid"');
34799
34919
  } catch {
34800
34920
  return false;
34801
34921
  }
@@ -34820,7 +34940,7 @@ function needsItsOwnProcess(asked) {
34820
34940
  class GodotServer {
34821
34941
  mcp;
34822
34942
  locator = new GodotLocator;
34823
- operationsScript = join20(__dirname2, "godot", "operations", "godot_operations.gd");
34943
+ operationsScript = join21(__dirname2, "godot", "operations", "godot_operations.gd");
34824
34944
  godotBridge;
34825
34945
  tools = buildToolDefinitions();
34826
34946
  activeProcess = null;
@@ -35123,7 +35243,7 @@ class GodotServer {
35123
35243
  return checked.response;
35124
35244
  }
35125
35245
  const named = args["projectPath"];
35126
- if (typeof named === "string" && existsSync16(join20(named, "project.godot"))) {
35246
+ if (typeof named === "string" && existsSync16(join21(named, "project.godot"))) {
35127
35247
  this.lastProjectPath = named;
35128
35248
  }
35129
35249
  this.updates.refresh();
@@ -35597,7 +35717,7 @@ class GodotServer {
35597
35717
  if (path === undefined) {
35598
35718
  return { ok: false, response: this.createErrorResponse("projectPath is required.") };
35599
35719
  }
35600
- const file = join20(path, "project.godot");
35720
+ const file = join21(path, "project.godot");
35601
35721
  if (!existsSync16(file)) {
35602
35722
  return {
35603
35723
  ok: false,
@@ -36276,7 +36396,7 @@ class GodotServer {
36276
36396
  const exportArgs = [
36277
36397
  "--headless",
36278
36398
  "--log-file",
36279
- join20(exportLogs, "engine.log"),
36399
+ join21(exportLogs, "engine.log"),
36280
36400
  "--path",
36281
36401
  project.value.path,
36282
36402
  debug ? "--export-debug" : "--export-release",
@@ -36306,7 +36426,7 @@ class GodotServer {
36306
36426
  if (!paths.ok) {
36307
36427
  return this.createErrorResponse(paths.reason, PATH_SOLUTIONS);
36308
36428
  }
36309
- const missing = paths.value.filter((path) => !existsSync16(join20(project.value.path, path.slice("res://".length))));
36429
+ const missing = paths.value.filter((path) => !existsSync16(join21(project.value.path, path.slice("res://".length))));
36310
36430
  const asked = paths.value.filter((path) => !missing.includes(path));
36311
36431
  if (asked.length === 0) {
36312
36432
  const verdict = `nothing at ${missing.join(" and ")}`;
@@ -36322,7 +36442,7 @@ class GodotServer {
36322
36442
  };
36323
36443
  }
36324
36444
  const runner = "addons/gdUnit4/bin/GdUnitCmdTool.gd";
36325
- if (!existsSync16(join20(project.value.path, runner))) {
36445
+ if (!existsSync16(join21(project.value.path, runner))) {
36326
36446
  return this.createErrorResponse(`gdUnit4 is not installed in this project: no ${runner}.`, [
36327
36447
  "Install gdUnit4 under addons/gdUnit4, from https://github.com/godot-gdunit-labs/gdUnit4"
36328
36448
  ]);
@@ -36387,14 +36507,14 @@ class GodotServer {
36387
36507
  await untilGone([run.process.pid]);
36388
36508
  }
36389
36509
  await run.settled;
36390
- const reportsDir = join20(project.value.path, ".godot", ...ours.split("/"));
36510
+ const reportsDir = join21(project.value.path, ".godot", ...ours.split("/"));
36391
36511
  let report = null;
36392
36512
  let reportProblem = null;
36393
36513
  try {
36394
36514
  const written = existsSync16(reportsDir) ? readdirSync9(reportsDir).filter((name) => name.startsWith("report_")).sort((a, b) => Number(a.slice("report_".length)) - Number(b.slice("report_".length))) : [];
36395
36515
  const newest = written.at(-1);
36396
36516
  if (newest !== undefined) {
36397
- report = parseJUnit(readFileSync19(join20(reportsDir, newest, "results.xml"), "utf8"));
36517
+ report = parseJUnit(readFileSync19(join21(reportsDir, newest, "results.xml"), "utf8"));
36398
36518
  }
36399
36519
  } catch (error) {
36400
36520
  reportProblem = errorMessage(error);
@@ -36423,7 +36543,9 @@ class GodotServer {
36423
36543
  const nothingRan = timedOut ? null : whyNoReport(said, asked.join(" and "));
36424
36544
  const cutShort = timedOut ? timedOutVerdict(timeoutMs, silentForMs) : null;
36425
36545
  const hung = cutShort?.hung ?? false;
36426
- const scriptErrors = timedOut || exitCode !== 105 ? [] : scriptErrorsPrinted(said);
36546
+ const printedErrors = timedOut || exitCode !== 105 ? [] : scriptErrorsPrinted(said);
36547
+ const declaring = printedErrors.some((error) => unresolvedClassIn(error.message) !== null) ? await originsOf(printedErrors, declaredClasses(project.value.path), checkWithEngine(engine.value, project.value.path)) : { origins: [], unconfirmed: [] };
36548
+ const scriptErrors = [...declaring.origins, ...printedErrors];
36427
36549
  const brokenScripts = new Set(scriptErrors.map((error) => error.path)).size;
36428
36550
  const ranAs = cutShort !== null ? cutShort.verdict : nothingRan ?? (brokenScripts > 0 ? `script errors in ${brokenScripts === 1 ? "one script" : `${brokenScripts} scripts`}, so no suite ran` : undefined) ?? (exitCode === null ? undefined : verdicts[exitCode]) ?? (run.exitSignal === null ? `exit ${exitCode ?? "unknown"}` : howItExited(run));
36429
36551
  const nothingAt = missing.join(" and ");
@@ -36437,7 +36559,7 @@ class GodotServer {
36437
36559
  ...echoed.omitted > 0 ? { printedOmitted: echoed.omitted } : {}
36438
36560
  };
36439
36561
  if (report === null) {
36440
- const said = nothingRan !== null ? `No tests ran: ${ranAs}.` : scriptErrors.length > 0 ? scriptErrorsNote(scriptErrors) : reportProblem !== null ? `The test run's report could not be read (${reportProblem}); the engine's own verdict was ${ranAs}.` : `The test run wrote no report (${ranAs}).`;
36562
+ const said = nothingRan !== null ? `No tests ran: ${ranAs}.` : scriptErrors.length > 0 ? scriptErrorsNote(printedErrors, declaring) : reportProblem !== null ? `The test run's report could not be read (${reportProblem}); the engine's own verdict was ${ranAs}.` : `The test run wrote no report (${ranAs}).`;
36441
36563
  const note = missingNote === undefined ? said : `${said} ${missingNote}`;
36442
36564
  return {
36443
36565
  content: [
@@ -36455,7 +36577,7 @@ class GodotServer {
36455
36577
  ...missing.length > 0 ? { missing } : {},
36456
36578
  ...nonEmpty(foldedScriptErrors(scriptErrors)),
36457
36579
  arguments: cmdArgs,
36458
- entries: forAnswer(printed.slice(0, 60).map(aboveTheRunner)),
36580
+ entries: forAnswer(withoutListed(printed, scriptErrors).slice(0, 60).map(aboveTheRunner)),
36459
36581
  ...printedLines,
36460
36582
  savesNote,
36461
36583
  ...scanned
@@ -36963,7 +37085,7 @@ class GodotServer {
36963
37085
  return null;
36964
37086
  }
36965
37087
  try {
36966
- return settingKeys(readFileSync19(join20(projectPath, "project.godot"), "utf8"));
37088
+ return settingKeys(readFileSync19(join21(projectPath, "project.godot"), "utf8"));
36967
37089
  } catch {
36968
37090
  return null;
36969
37091
  }
@@ -37285,7 +37407,7 @@ class GodotServer {
37285
37407
  }
37286
37408
  async runtimeUp(projectPath, before, budget, withArgs) {
37287
37409
  const budgetMs = budget.ms;
37288
- if (!existsSync16(join20(projectPath, RUNTIME_AUTOLOAD.path))) {
37410
+ if (!existsSync16(join21(projectPath, RUNTIME_AUTOLOAD.path))) {
37289
37411
  return runtimeVerdict(null, {
37290
37412
  addon: false,
37291
37413
  budgetMs,
@@ -38302,7 +38424,7 @@ class GodotServer {
38302
38424
  notes.push(endedWithoutACode({
38303
38425
  throughEditor: run.throughEditor,
38304
38426
  announced: announced !== undefined || run.announcedPid !== undefined,
38305
- couldAnnounce: run.projectPath !== null && existsSync16(join20(run.projectPath, RUNTIME_AUTOLOAD.path)),
38427
+ couldAnnounce: run.projectPath !== null && existsSync16(join21(run.projectPath, RUNTIME_AUTOLOAD.path)),
38306
38428
  endedHere: typeof run.endedHere === "string",
38307
38429
  pickedUp: run.pickedUp === true
38308
38430
  }));
@@ -39151,7 +39273,7 @@ class GodotServer {
39151
39273
  async ownRunOnItsWay(projectPath, budgetMs) {
39152
39274
  const run = await this.currentRun();
39153
39275
  const project = projectPath ?? run?.projectPath ?? null;
39154
- if (run === null || project === null || run.announcedPid !== undefined || run.projectPath !== null && !isSameDirectory(run.projectPath, project) || !existsSync16(join20(project, RUNTIME_AUTOLOAD.path)) || !await this.runStillGoing(run)) {
39276
+ if (run === null || project === null || run.announcedPid !== undefined || run.projectPath !== null && !isSameDirectory(run.projectPath, project) || !existsSync16(join21(project, RUNTIME_AUTOLOAD.path)) || !await this.runStillGoing(run)) {
39155
39277
  return null;
39156
39278
  }
39157
39279
  const already = runtimesAnnounced().running;
@@ -39235,7 +39357,7 @@ class GodotServer {
39235
39357
  }
39236
39358
  }
39237
39359
  const screenshotDir = expectsScreenshot ? scratchDirectory("runtime-screenshot") : null;
39238
- const screenshotPath = screenshotDir ? join20(screenshotDir, "capture.png") : null;
39360
+ const screenshotPath = screenshotDir ? join21(screenshotDir, "capture.png") : null;
39239
39361
  const staleNote = this.runtimeStaleNote(choice.endpoint);
39240
39362
  const stale = staleNote === undefined ? {} : { addonIsStale: true, staleNote };
39241
39363
  const refused = (message) => this.createErrorResponse(staleNote === undefined ? message : `${message}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gdharness",
3
- "version": "1.1.46",
3
+ "version": "1.1.47",
4
4
  "mcpName": "io.github.Aureliolo/gdharness",
5
5
  "description": "A harness for driving a Godot 4 project from an agent: editor addons, a runtime bridge into the running game, an MCP server in front of them, and a CLI that installs and diagnoses the Godot side.",
6
6
  "type": "module",