breakpoint-mcp 1.28.0 โ 1.72.7
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/README.md +1 -1
- package/addon/breakpoint_mcp/operations.gd +248 -13
- package/addon/breakpoint_mcp/plugin.cfg +1 -1
- package/addon/breakpoint_mcp/runtime_bridge.gd +54 -1
- package/dist/annotations.js +3 -3
- package/dist/cli/doctor.js +1 -1
- package/dist/cli/init.js +1 -1
- package/dist/cli/tools.js +2 -2
- package/dist/csdap.js +88 -6
- package/dist/dap.js +172 -10
- package/dist/index.js +4 -4
- package/dist/path-cohort.js +177 -0
- package/dist/paths.js +111 -0
- package/dist/recipes.js +1 -1
- package/dist/schemas.js +49 -5
- package/dist/tools/assetgen.js +17 -1
- package/dist/tools/backend.js +18 -1
- package/dist/tools/cli.js +41 -4
- package/dist/tools/csdap.js +132 -8
- package/dist/tools/cslsp.js +110 -5
- package/dist/tools/dap.js +316 -59
- package/dist/tools/editor/audio.js +16 -2
- package/dist/tools/editor/common.js +39 -1
- package/dist/tools/editor/filesystem.js +23 -3
- package/dist/tools/editor/introspection.js +72 -1
- package/dist/tools/editor/node.js +7 -2
- package/dist/tools/editor/particles.js +7 -2
- package/dist/tools/editor/project_input_test.js +16 -2
- package/dist/tools/editor/resource.js +54 -7
- package/dist/tools/editor/scene.js +31 -4
- package/dist/tools/editor/shader.js +19 -3
- package/dist/tools/editor/spatial.js +16 -1
- package/dist/tools/editor/tiles.js +16 -1
- package/dist/tools/editor/ui.js +29 -2
- package/dist/tools/editor.js +20 -13
- package/dist/tools/knowledge.js +14 -1
- package/dist/tools/lsp-common.js +29 -0
- package/dist/tools/lsp.js +87 -8
- package/dist/tools/netcode.js +47 -4
- package/dist/tools/processes.js +14 -2
- package/dist/tools/runtime.js +49 -9
- package/dist/tools/tabletop.js +173 -7
- package/dist/tools/vcs.js +138 -22
- package/dist/toolsets.js +2 -2
- package/package.json +8 -2
package/dist/tools/dap.js
CHANGED
|
@@ -1,18 +1,130 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { DapError } from "../dap.js";
|
|
3
|
-
import {
|
|
3
|
+
import { resolveSourceFile } from "../paths.js";
|
|
4
4
|
import { gate } from "../confirm.js";
|
|
5
5
|
import { ok } from "./lsp-common.js";
|
|
6
6
|
import { portFree, portConflictMessage } from "../ports.js";
|
|
7
|
+
/**
|
|
8
|
+
* How long `stop_on_entry: true` waits for the entry `stopped` event before
|
|
9
|
+
* reporting that the adapter did not honour it. Short on purpose: Godot 4.7 ignores
|
|
10
|
+
* `stopOnEntry` outright (measured โ the game ran to completion and printed its
|
|
11
|
+
* `_ready()` line), so this is the price of the honest answer on every launch, not a
|
|
12
|
+
* rare timeout. An adapter that DOES honour it stops well inside the window.
|
|
13
|
+
*/
|
|
14
|
+
const ENTRY_STOP_WAIT_MS = 3000;
|
|
15
|
+
/** A refusal raised by a guard here, rendered verbatim rather than as a DAP error. */
|
|
16
|
+
class DapRefusal extends Error {
|
|
17
|
+
reason;
|
|
18
|
+
/**
|
|
19
|
+
* ๐ด 162: the SAME marker `paths.ts`, `lsp-common.fail()` and `csdap.ts` already use.
|
|
20
|
+
* Before this, dap.ts recognised a refusal only by `instanceof DapRefusal`, so a
|
|
21
|
+
* refusal raised anywhere else โ including by the shared guard this file now calls โ
|
|
22
|
+
* would have been rendered as `DAP error [error]: Refusing "โฆ"`, sending the caller
|
|
23
|
+
* to debug an adapter that was never asked. One refusal contract, four planes.
|
|
24
|
+
*/
|
|
25
|
+
refusal = true;
|
|
26
|
+
constructor(message, reason) {
|
|
27
|
+
super(message);
|
|
28
|
+
this.reason = reason;
|
|
29
|
+
this.name = "DapRefusal";
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* This plane's shipped refusal wording, kept verbatim while the guard moved to
|
|
34
|
+
* `paths.ts` (161 ยง8 item 5). `gdscript-dap-plane.integration.mjs` pins every string
|
|
35
|
+
* here BY REASON โ /outside the Godot project root/, /no such file/, /is not a file/,
|
|
36
|
+
* and for the empty path additionally /project root/.
|
|
37
|
+
*
|
|
38
|
+
* ๐ด The escape check is deliberately WIDER than the `cs_dbg_*` plane's, and 162
|
|
39
|
+
* measured both to confirm the difference is real rather than drift. `cs_dbg_launch`
|
|
40
|
+
* documents overriding `program` to debug a DIFFERENT .NET program, whose sources
|
|
41
|
+
* legitimately live outside the Godot C# project. `dbg_launch` has no such mainline:
|
|
42
|
+
* its `scene` is `'main'`, `'current'` or a `res://` path, and Godot binds breakpoints
|
|
43
|
+
* only to scripts in the project it runs โ so an absolute path outside the root can
|
|
44
|
+
* never bind here whichever way it is spelled, and all three forms stay anchored.
|
|
45
|
+
* Absolute paths INSIDE the project stay legal; the probe uses that form and two
|
|
46
|
+
* over-eager mutations exist to keep it working.
|
|
47
|
+
*/
|
|
48
|
+
const GD_DAP_PATHS = {
|
|
49
|
+
root: "the Godot project root",
|
|
50
|
+
escapeHint: "Godot binds breakpoints only to scripts in the project it runs, so a breakpoint " +
|
|
51
|
+
"there can never bind.",
|
|
52
|
+
missingHint: "A breakpoint there can never bind, but it was previously answered exactly like " +
|
|
53
|
+
"one on a real script.",
|
|
54
|
+
emptyNote: " (an empty path resolves to the project root)",
|
|
55
|
+
};
|
|
56
|
+
/**
|
|
57
|
+
* `dbg_launch` / `dbg_restart`'s `scene`, which is a path parameter that is not
|
|
58
|
+
* called `path` โ which is exactly why 162's sweep never reached it (163 ยง2).
|
|
59
|
+
*
|
|
60
|
+
* ๐ด MEASURED against a real 4.7 adapter before a line of this was written, because
|
|
61
|
+
* the obvious rule would have broken two spellings that work. Every row below is a
|
|
62
|
+
* launch that was actually performed and whose game console was read back over the
|
|
63
|
+
* DAP `output` event, so "did it run" is the engine's answer, not an inference:
|
|
64
|
+
*
|
|
65
|
+
* main ยท current ยท res://demo/demo.tscn ยท <abs inside>/demo/demo.tscn ยท uid://<known>
|
|
66
|
+
* -> the REQUESTED scene ran. The last three are the load-bearing ones: each
|
|
67
|
+
* ran a scene that is NOT the main scene, so they prove the spelling is
|
|
68
|
+
* honoured rather than silently falling back.
|
|
69
|
+
* res://../example_evil/โฆ ยท ../example_evil/โฆ ยท <root>_evil/โฆ ยท /elsewhere/โฆ
|
|
70
|
+
* -> ๐ด NOTHING ran, and the session stayed `running` with a live, sceneless
|
|
71
|
+
* game process. `dbg_stack_trace` then answered `{"frames":[]}` โ BYTE
|
|
72
|
+
* IDENTICAL to a healthy running session. The caller could not find out.
|
|
73
|
+
* "" -> ๐ด nothing ran, NO game process existed at all, session still `running`.
|
|
74
|
+
* /etc/passwd ยท res://NoSuchFile.tscn ยท res://tests (a directory)
|
|
75
|
+
* -> nothing ran; the session terminated a moment later, so those at least
|
|
76
|
+
* announced themselves.
|
|
77
|
+
*
|
|
78
|
+
* So NOTHING ESCAPES โ Godot never ran an out-of-project scene, whichever way it was
|
|
79
|
+
* spelled. The defect is not containment, it is that `dbg_launch` answered
|
|
80
|
+
* `{"state":"running"}` for every one of them. That is the same shape as the
|
|
81
|
+
* `dbg_set_breakpoints` defect above it in this file โ an answer that cannot
|
|
82
|
+
* distinguish "armed" from "can never bind" โ one tool over on the same plane.
|
|
83
|
+
*
|
|
84
|
+
* ๐ด uid:// IS EXEMPT AND THAT EXEMPTION IS MEASURED, NOT ASSUMED. Godot 4 writes a
|
|
85
|
+
* UID into every scene header, and `uid://bq3k7x2yv8n1a` RAN the scene it names. A
|
|
86
|
+
* uid names a project resource through the engine's own map, never a filesystem
|
|
87
|
+
* location, so it cannot express an escape โ and requiring it to exist on disk would
|
|
88
|
+
* refuse a working spelling to fix a broken one. That is precisely the mistake 162
|
|
89
|
+
* ยง5 D2 caught itself about to make on `csdap`.
|
|
90
|
+
*
|
|
91
|
+
* ๐ด WHAT THIS DELIBERATELY DOES NOT FIX, because it was measured and is a different
|
|
92
|
+
* defect: an UNKNOWN uid (`uid://zzzzzzzzzzzzz`) silently ran the MAIN scene while
|
|
93
|
+
* the tool echoed the uid back. Resolving that needs the engine's UID map, which the
|
|
94
|
+
* host does not have. Recorded in the description and the handoff rather than guessed
|
|
95
|
+
* at. Likewise `res://player.gd` โ a real file inside the root that is not a scene โ
|
|
96
|
+
* stays legal here and terminates the session, which at least announces itself.
|
|
97
|
+
*/
|
|
98
|
+
const GD_DAP_SCENE = {
|
|
99
|
+
root: "the Godot project root",
|
|
100
|
+
escapeHint: "Godot runs scenes from the project it launched, so a scene outside the root never " +
|
|
101
|
+
"loads: measured, the game starts with no scene and the session reports `running` " +
|
|
102
|
+
"forever. Pass 'main', 'current', a scene inside the project, or a uid:// reference.",
|
|
103
|
+
missingHint: "The adapter accepts the launch and nothing runs, so this was previously reported " +
|
|
104
|
+
"as a started session.",
|
|
105
|
+
emptyNote: " (an empty scene resolves to the project root)",
|
|
106
|
+
};
|
|
107
|
+
/** Scene spellings the adapter resolves itself; neither names a file. */
|
|
108
|
+
const SCENE_SENTINELS = new Set(["main", "current"]);
|
|
7
109
|
// How long step/continue wait for the program to settle (hit a breakpoint,
|
|
8
110
|
// finish a step, or terminate) before returning. On timeout the tool reports
|
|
9
111
|
// the current state โ e.g. `continue` with no further breakpoint stays running.
|
|
10
112
|
const RESUME_WAIT_MS = 15000;
|
|
11
113
|
function fail(err) {
|
|
114
|
+
// A refusal this file raised is the host's own answer โ rendering it as
|
|
115
|
+
// `DAP error [...]` would send the caller off to debug an adapter that was never
|
|
116
|
+
// asked. Same distinction lsp-common.fail() already draws for the LSP planes.
|
|
12
117
|
const e = err;
|
|
118
|
+
if (err instanceof DapRefusal || e?.refusal) {
|
|
119
|
+
return { isError: true, content: [{ type: "text", text: e.message ?? String(err) }] };
|
|
120
|
+
}
|
|
121
|
+
// An adapter can answer a failure with an EMPTY message (netcoredbg did, on
|
|
122
|
+
// setVariable). `?? String(err)` only covers an ABSENT one, so the whole answer
|
|
123
|
+
// became a label and a colon.
|
|
124
|
+
const detail = e.message && e.message.trim() !== "" ? e.message : String(err);
|
|
13
125
|
return {
|
|
14
126
|
isError: true,
|
|
15
|
-
content: [{ type: "text", text: `DAP error [${e.command ?? "error"}]: ${
|
|
127
|
+
content: [{ type: "text", text: `DAP error [${e.command ?? "error"}]: ${detail}` }],
|
|
16
128
|
};
|
|
17
129
|
}
|
|
18
130
|
/**
|
|
@@ -23,46 +135,125 @@ function fail(err) {
|
|
|
23
135
|
function isDapTimeout(err) {
|
|
24
136
|
return err instanceof DapError && /timed out after/.test(err.message);
|
|
25
137
|
}
|
|
26
|
-
// Per-line breakpoint modifier fields, each gated by an adapter capability. Godot 4.3
|
|
27
|
-
// advertises all three false AND ignores them (a "conditional" breakpoint would halt
|
|
28
|
-
// unconditionally โ verified live in the dap-plane's editor-dap-breakpoints probe), so we
|
|
29
|
-
// feature-detect: drop an unsupported modifier and warn, mirroring the
|
|
30
|
-
// dbg_set_exception_breakpoints / dbg_goto / dbg_data_breakpoints discipline.
|
|
31
|
-
const BREAKPOINT_MODIFIER_CAPS = {
|
|
32
|
-
condition: "supportsConditionalBreakpoints",
|
|
33
|
-
hitCondition: "supportsHitConditionalBreakpoints",
|
|
34
|
-
logMessage: "supportsLogPoints",
|
|
35
|
-
};
|
|
36
138
|
/**
|
|
37
|
-
*
|
|
38
|
-
* the
|
|
39
|
-
*
|
|
139
|
+
* ๐ด The modifier feature-detection lives in `dap.ts` (the client), NOT here, and that
|
|
140
|
+
* is the fix this release carries. It was here, at SET time, where `dap.capabilities`
|
|
141
|
+
* is null for every breakpoint buffered before launch โ which is the documented and
|
|
142
|
+
* ordinary way to arm one. The guard was correct and unreachable: measured live, a
|
|
143
|
+
* pre-launch `conditions: ["counter < 0"]` was sent verbatim to an adapter that ignores
|
|
144
|
+
* it, the breakpoint halted every frame, and the result carried no warning at all.
|
|
145
|
+
* Detection now happens where the modifiers go on the wire, so every path gets it.
|
|
40
146
|
*/
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
const out = [];
|
|
45
|
-
for (const field of ["condition", "hitCondition", "logMessage"]) {
|
|
46
|
-
if (requested[field] && caps[BREAKPOINT_MODIFIER_CAPS[field]] !== true)
|
|
47
|
-
out.push(field);
|
|
48
|
-
}
|
|
49
|
-
return out;
|
|
50
|
-
}
|
|
51
|
-
/** True when a per-line modifier array carries at least one non-null, non-empty entry. */
|
|
52
|
-
function hasModifier(arr) {
|
|
53
|
-
return Array.isArray(arr) && arr.some((v) => v != null && v !== "");
|
|
54
|
-
}
|
|
147
|
+
const warnDropped = (dropped) => `The connected Godot debug adapter does not support ${dropped.join(", ")} on breakpoints (it advertises ` +
|
|
148
|
+
`${dropped.length > 1 ? "them" : "it"} unsupported), so ${dropped.length > 1 ? "they were" : "it was"} dropped โ ` +
|
|
149
|
+
`the affected breakpoint(s) will halt unconditionally.`;
|
|
55
150
|
export function registerDapTools(server, dap, cfg) {
|
|
151
|
+
/**
|
|
152
|
+
* Refuse when no launch/attach the adapter accepted is in force.
|
|
153
|
+
*
|
|
154
|
+
* ๐ด Measured on a client that had never launched anything: `dbg_continue` and
|
|
155
|
+
* `dbg_step` answered `isError:false {"state":"running"}`, `dbg_stack_trace`
|
|
156
|
+
* answered `{"frames":[]}` and `dbg_scopes` `{"scopes":[]}` โ all four
|
|
157
|
+
* indistinguishable from the same calls against a real session. Worse,
|
|
158
|
+
* `resume()`'s optimistic `state = "running"` MEANT THE FIRST SUCH CALL LEFT THE
|
|
159
|
+
* CLIENT LOOKING LIVE, so everything after it read as a genuine session. Only
|
|
160
|
+
* `dbg_restart` refused, and only because it happens to read `lastStartMode`.
|
|
161
|
+
*/
|
|
162
|
+
const requireSession = (tool) => {
|
|
163
|
+
if (dap.hasSession)
|
|
164
|
+
return;
|
|
165
|
+
throw new DapRefusal(`${tool} needs a debug session: call dbg_launch or dbg_attach first. ` +
|
|
166
|
+
`There is none, so any state, frame or scope reported here would be invented โ ` +
|
|
167
|
+
`this call previously answered as if the program were running.`, "no_session");
|
|
168
|
+
};
|
|
169
|
+
/**
|
|
170
|
+
* Report, on a launch/attach result, any breakpoint modifier the handshake dropped
|
|
171
|
+
* when it applied the buffered breakpoints.
|
|
172
|
+
*
|
|
173
|
+
* ๐ด This is the other half of the buffered-modifier fix. `dbg_set_breakpoints` can
|
|
174
|
+
* only say "deferred" before a session exists; this is where the caller finds out what
|
|
175
|
+
* actually happened. Without it the honest "deferred" answer would have no follow-up
|
|
176
|
+
* and the caller would still be left guessing.
|
|
177
|
+
*/
|
|
178
|
+
const reportDroppedModifiers = (result) => {
|
|
179
|
+
const dropped = dap.droppedBreakpointModifiers();
|
|
180
|
+
if (!dropped.length)
|
|
181
|
+
return;
|
|
182
|
+
result.unsupported_modifiers = dropped;
|
|
183
|
+
// Never clobber a warning the caller needs more (stop_on_entry's); append.
|
|
184
|
+
const note = `Buffered breakpoint modifiers were applied during this handshake: ${warnDropped(dropped)}`;
|
|
185
|
+
result.warning = typeof result.warning === "string" && result.warning.length > 0 ? `${result.warning} ${note}` : note;
|
|
186
|
+
};
|
|
187
|
+
/**
|
|
188
|
+
* Resolve a breakpoint source, refusing one that can never bind.
|
|
189
|
+
*
|
|
190
|
+
* ๐ด Measured: `res://NoSuchFile.gd`, `res://tests` (a DIRECTORY) and `""` โ which
|
|
191
|
+
* resolves to the project root โ each answered `{"buffered":true,"breakpoints":[]}`
|
|
192
|
+
* with `isError:false`, so a caller could not tell an armed breakpoint from one
|
|
193
|
+
* that can never bind. `res://../../../etc/passwd` resolved to `/private/etc/passwd`
|
|
194
|
+
* and was accepted too.
|
|
195
|
+
*
|
|
196
|
+
* ๐ด THE ESCAPE CHECK IS DELIBERATELY WIDER THAN THE `cs_dbg_*` PLANE'S, and the
|
|
197
|
+
* difference is the point rather than an inconsistency. `cs_dbg_launch` documents
|
|
198
|
+
* overriding `program` to debug a DIFFERENT .NET program, whose sources
|
|
199
|
+
* legitimately live outside the Godot project, so #166 kept an absolute path
|
|
200
|
+
* elsewhere legal. `dbg_launch` has no such mainline: its `scene` is `'main'`,
|
|
201
|
+
* `'current'` or a `res://` path, and Godot binds breakpoints only to scripts in
|
|
202
|
+
* the project it is running. An absolute path outside the root can never bind here
|
|
203
|
+
* whichever way it is spelled, so ALL THREE FORMS are anchored to the root.
|
|
204
|
+
* Absolute paths INSIDE the project stay legal โ that form is documented, the probe
|
|
205
|
+
* uses it, and two over-eager mutations exist to keep it working.
|
|
206
|
+
*
|
|
207
|
+
* The comparison is against `root + path.sep`, never a bare `startsWith(root)`:
|
|
208
|
+
* the latter accepts a sibling directory that merely shares the root's name prefix.
|
|
209
|
+
*/
|
|
210
|
+
const guardSource = (p) => resolveSourceFile(p, cfg.projectPath, GD_DAP_PATHS);
|
|
211
|
+
/**
|
|
212
|
+
* `scene` for `dbg_launch` / `dbg_restart`. See GD_DAP_SCENE for the measurement.
|
|
213
|
+
*
|
|
214
|
+
* ๐ด Returns the ORIGINAL SPELLING, never the resolved path. `res://demo/demo.tscn`
|
|
215
|
+
* and `<abs>/demo/demo.tscn` were both measured running the demo scene, so what
|
|
216
|
+
* reaches the adapter must not change โ this guard only ever ADDS a refusal, which
|
|
217
|
+
* is what makes it behaviour-preserving on every spelling that already worked.
|
|
218
|
+
*
|
|
219
|
+
* ๐ด It also refuses BEFORE the port check and before the transport, deliberately:
|
|
220
|
+
* that is the property 162 ยง2 identified as making a guard measurable with no
|
|
221
|
+
* backend at all, and it is why the unit tests below need no editor. The one
|
|
222
|
+
* behaviour change is a call that has BOTH a bad scene and a bound bridge port โ
|
|
223
|
+
* it now names the scene instead of the port. That is the more actionable of the
|
|
224
|
+
* two answers, and it is pinned by a test rather than left to chance.
|
|
225
|
+
*/
|
|
226
|
+
const guardScene = (s) => {
|
|
227
|
+
if (s === undefined)
|
|
228
|
+
return;
|
|
229
|
+
if (SCENE_SENTINELS.has(s))
|
|
230
|
+
return;
|
|
231
|
+
if (s.startsWith("uid://"))
|
|
232
|
+
return;
|
|
233
|
+
resolveSourceFile(s, cfg.projectPath, GD_DAP_SCENE);
|
|
234
|
+
};
|
|
56
235
|
server.registerTool("dbg_launch", {
|
|
57
236
|
title: "Launch debug session",
|
|
58
|
-
description: "Start the game under the debugger. scene may be 'main', 'current',
|
|
237
|
+
description: "Start the game under the debugger. scene may be 'main', 'current', a scene inside the project " +
|
|
238
|
+
"(res://, or an absolute path inside the root), or a uid:// reference. " +
|
|
239
|
+
"Refuses a scene outside the project root, one that does not exist, and an empty scene: measured on 4.7, " +
|
|
240
|
+
"the adapter ACCEPTS all of those and nothing runs โ an out-of-project scene leaves a live sceneless game " +
|
|
241
|
+
"and a session that reports 'running' forever. A uid:// the project does not know is the one case still " +
|
|
242
|
+
"not caught: Godot silently runs the main scene instead, and resolving it needs the engine's UID map. " +
|
|
59
243
|
"Any breakpoints set beforehand are applied during the handshake. " +
|
|
244
|
+
"Refuses if the debug adapter rejects the launch (e.g. Godot answers 'wrong_path' when the configured " +
|
|
245
|
+
"project path is not the one the editor has open) rather than reporting a session that never started. " +
|
|
246
|
+
"With stop_on_entry the result carries stop_on_entry_honored: Godot's adapter does not implement " +
|
|
247
|
+
"stopOnEntry, so it reports false plus a warning instead of a bare 'running'. " +
|
|
60
248
|
"Refuses if the runtime bridge port is already bound โ the new game could not host the bridge, so " +
|
|
61
249
|
"runtime_* would address the process already holding the port. Clear the holder (godot_stop, or quit " +
|
|
62
250
|
"it), or dbg_attach onto it if it is already under the debugger, or pass allow_port_conflict " +
|
|
63
251
|
"(dbg_* is unaffected either way; only runtime_* is).",
|
|
64
252
|
inputSchema: {
|
|
65
|
-
scene: z
|
|
253
|
+
scene: z
|
|
254
|
+
.string()
|
|
255
|
+
.optional()
|
|
256
|
+
.describe("'main' (default), 'current', res://scene.tscn, or uid://โฆ โ must be inside the project"),
|
|
66
257
|
stop_on_entry: z.boolean().optional().describe("Break at entry (default false)"),
|
|
67
258
|
allow_port_conflict: z
|
|
68
259
|
.boolean()
|
|
@@ -72,6 +263,13 @@ export function registerDapTools(server, dap, cfg) {
|
|
|
72
263
|
"tools would talk to the process holding the port, not to this game."),
|
|
73
264
|
},
|
|
74
265
|
}, async ({ scene, stop_on_entry, allow_port_conflict }) => {
|
|
266
|
+
// Before the port check and before the transport โ see guardScene.
|
|
267
|
+
try {
|
|
268
|
+
guardScene(scene);
|
|
269
|
+
}
|
|
270
|
+
catch (err) {
|
|
271
|
+
return fail(err);
|
|
272
|
+
}
|
|
75
273
|
if (!allow_port_conflict && !(await portFree(cfg.runtimeHost, cfg.runtimePort))) {
|
|
76
274
|
return {
|
|
77
275
|
isError: true,
|
|
@@ -79,12 +277,23 @@ export function registerDapTools(server, dap, cfg) {
|
|
|
79
277
|
};
|
|
80
278
|
}
|
|
81
279
|
try {
|
|
82
|
-
await dap.start("launch", {
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
280
|
+
const { entryStopSeen } = await dap.start("launch", { project: cfg.projectPath, scene: scene ?? "main", stopOnEntry: stop_on_entry ?? false }, stop_on_entry ? ENTRY_STOP_WAIT_MS : 0);
|
|
281
|
+
const result = { session_id: "godot", state: dap.state, scene: scene ?? "main" };
|
|
282
|
+
if (stop_on_entry) {
|
|
283
|
+
// ๐ด Measured on Godot 4.7: stopOnEntry is ignored outright โ the game ran to
|
|
284
|
+
// completion and printed its _ready() line while the tool answered a bare
|
|
285
|
+
// `running`, which reads exactly like a launch that simply had not stopped
|
|
286
|
+
// YET. Say which one it is.
|
|
287
|
+
result.stop_on_entry_honored = entryStopSeen;
|
|
288
|
+
if (!entryStopSeen) {
|
|
289
|
+
result.warning =
|
|
290
|
+
`The connected Godot debug adapter did not stop at entry within ${ENTRY_STOP_WAIT_MS}ms ` +
|
|
291
|
+
`and the program is running. Godot's adapter does not implement stopOnEntry โ set a ` +
|
|
292
|
+
`breakpoint with dbg_set_breakpoints before dbg_launch to land a stop.`;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
reportDroppedModifiers(result);
|
|
296
|
+
return ok(result);
|
|
88
297
|
}
|
|
89
298
|
catch (err) {
|
|
90
299
|
return fail(err);
|
|
@@ -95,7 +304,9 @@ export function registerDapTools(server, dap, cfg) {
|
|
|
95
304
|
// exit and leave the caller with nowhere to go.
|
|
96
305
|
server.registerTool("dbg_attach", {
|
|
97
306
|
title: "Attach debug session",
|
|
98
|
-
description: "Attach to an already-running Godot debug session."
|
|
307
|
+
description: "Attach to an already-running Godot debug session. Refuses if the adapter rejects the attach โ " +
|
|
308
|
+
"Godot answers 'not_running' when nothing is running โ instead of reporting state 'running' for a " +
|
|
309
|
+
"session that never started.",
|
|
99
310
|
inputSchema: {
|
|
100
311
|
address: z.string().optional().describe("Address of the running game (default 127.0.0.1)"),
|
|
101
312
|
port: z.number().int().optional().describe("Remote debug port"),
|
|
@@ -103,7 +314,9 @@ export function registerDapTools(server, dap, cfg) {
|
|
|
103
314
|
}, async ({ address, port }) => {
|
|
104
315
|
try {
|
|
105
316
|
await dap.start("attach", { address: address ?? "127.0.0.1", port });
|
|
106
|
-
|
|
317
|
+
const result = { session_id: "godot", state: dap.state };
|
|
318
|
+
reportDroppedModifiers(result);
|
|
319
|
+
return ok(result);
|
|
107
320
|
}
|
|
108
321
|
catch (err) {
|
|
109
322
|
return fail(err);
|
|
@@ -112,10 +325,15 @@ export function registerDapTools(server, dap, cfg) {
|
|
|
112
325
|
server.registerTool("dbg_set_breakpoints", {
|
|
113
326
|
title: "Set breakpoints",
|
|
114
327
|
description: "Set (replace) the breakpoints for a source file. Applied immediately if a session is running, else buffered until launch. " +
|
|
328
|
+
"Refuses a source that can never bind: a missing file, a directory, an empty path (which resolves to the project root), " +
|
|
329
|
+
"or a path resolving outside the Godot project root โ Godot binds breakpoints only to scripts in the project it runs, so " +
|
|
330
|
+
"unlike cs_dbg_set_breakpoints an absolute path outside the root is refused here too. " +
|
|
115
331
|
"Feature-detected: the per-line conditions / hit_conditions / log_messages modifiers are only sent when the connected adapter " +
|
|
116
332
|
"advertises support (supportsConditionalBreakpoints / supportsHitConditionalBreakpoints / supportsLogPoints). On an adapter that " +
|
|
117
|
-
"advertises them unsupported (
|
|
118
|
-
"and the result includes `unsupported_modifiers` plus a `warning`. Detection
|
|
333
|
+
"advertises them unsupported (Godot advertises all three false and IGNORES them, so the breakpoint would halt unconditionally) the " +
|
|
334
|
+
"modifier is dropped and the result includes `unsupported_modifiers` plus a `warning`. Detection happens when the breakpoints are " +
|
|
335
|
+
"applied, so it covers buffered ones too: setting modifiers before a session exists returns `modifier_detection: \"deferred\"` with a " +
|
|
336
|
+
"warning, and dbg_launch then reports the `unsupported_modifiers` actually dropped during the handshake.",
|
|
119
337
|
inputSchema: {
|
|
120
338
|
path: z.string().describe("Script path (res://..., absolute, or project-relative)"),
|
|
121
339
|
lines: z.array(z.number().int().positive()).describe("1-based line numbers"),
|
|
@@ -125,28 +343,29 @@ export function registerDapTools(server, dap, cfg) {
|
|
|
125
343
|
},
|
|
126
344
|
}, async ({ path, lines, conditions, hit_conditions, log_messages }) => {
|
|
127
345
|
try {
|
|
128
|
-
const fsPath =
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
const dropped = unsupportedBreakpointModifiers(dap.capabilities, {
|
|
134
|
-
condition: hasModifier(conditions),
|
|
135
|
-
hitCondition: hasModifier(hit_conditions),
|
|
136
|
-
logMessage: hasModifier(log_messages),
|
|
137
|
-
});
|
|
138
|
-
const drop = new Set(dropped);
|
|
139
|
-
const body = await dap.setBreakpoints(fsPath, lines, drop.has("condition") ? undefined : conditions, drop.has("hitCondition") ? undefined : hit_conditions, drop.has("logMessage") ? undefined : log_messages);
|
|
346
|
+
const fsPath = guardSource(path);
|
|
347
|
+
// The client feature-detects and drops at apply time โ on this call when a session
|
|
348
|
+
// is live, during the handshake when the breakpoint is buffered.
|
|
349
|
+
const before = new Set(dap.droppedBreakpointModifiers());
|
|
350
|
+
const body = await dap.setBreakpoints(fsPath, lines, conditions, hit_conditions, log_messages);
|
|
140
351
|
const verified = Array.isArray(body["breakpoints"])
|
|
141
352
|
? body["breakpoints"].map((b) => ({ line: b.line ?? 0, verified: Boolean(b.verified) }))
|
|
142
353
|
: [];
|
|
143
354
|
const result = { path: fsPath, buffered: body["buffered"] === true, breakpoints: verified };
|
|
355
|
+
const dropped = dap.droppedBreakpointModifiers().filter((d) => !before.has(d));
|
|
144
356
|
if (dropped.length) {
|
|
145
357
|
result.unsupported_modifiers = dropped;
|
|
358
|
+
result.warning = warnDropped(dropped);
|
|
359
|
+
}
|
|
360
|
+
else if (body["modifier_detection_deferred"] === true) {
|
|
361
|
+
// ๐ด Buffered with modifiers and no session yet: the honest answer is "not yet
|
|
362
|
+
// known", never silence. dbg_launch reports what was actually dropped.
|
|
363
|
+
result.modifier_detection = "deferred";
|
|
146
364
|
result.warning =
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
365
|
+
"These breakpoints are buffered until launch, so the adapter has not advertised its capabilities yet and the " +
|
|
366
|
+
"conditions / hit_conditions / log_messages modifiers COULD NOT be feature-detected. Godot ignores modifiers it " +
|
|
367
|
+
"does not support, which makes such a breakpoint halt unconditionally. dbg_launch reports `unsupported_modifiers` " +
|
|
368
|
+
"for whatever is dropped when these are applied.";
|
|
150
369
|
}
|
|
151
370
|
return ok(result);
|
|
152
371
|
}
|
|
@@ -161,6 +380,7 @@ export function registerDapTools(server, dap, cfg) {
|
|
|
161
380
|
inputSchema: {},
|
|
162
381
|
}, async () => {
|
|
163
382
|
try {
|
|
383
|
+
requireSession("dbg_continue");
|
|
164
384
|
const r = await dap.resume("continue", { threadId: dap.threadId() }, RESUME_WAIT_MS);
|
|
165
385
|
return ok({ state: r.state, stopped_reason: r.reason });
|
|
166
386
|
}
|
|
@@ -175,6 +395,7 @@ export function registerDapTools(server, dap, cfg) {
|
|
|
175
395
|
inputSchema: { kind: z.enum(["in", "over", "out"]).describe("Step kind") },
|
|
176
396
|
}, async ({ kind }) => {
|
|
177
397
|
try {
|
|
398
|
+
requireSession("dbg_step");
|
|
178
399
|
const command = kind === "in" ? "stepIn" : kind === "out" ? "stepOut" : "next";
|
|
179
400
|
const r = await dap.resume(command, { threadId: dap.threadId() }, RESUME_WAIT_MS);
|
|
180
401
|
return ok({ state: r.state, stopped_reason: r.reason });
|
|
@@ -189,6 +410,7 @@ export function registerDapTools(server, dap, cfg) {
|
|
|
189
410
|
inputSchema: { levels: z.number().int().positive().optional().describe("Max frames (default 20)") },
|
|
190
411
|
}, async ({ levels }) => {
|
|
191
412
|
try {
|
|
413
|
+
requireSession("dbg_stack_trace");
|
|
192
414
|
const body = await dap.request("stackTrace", { threadId: dap.threadId(), startFrame: 0, levels: levels ?? 20 });
|
|
193
415
|
const frames = Array.isArray(body["stackFrames"])
|
|
194
416
|
? body["stackFrames"].map((f) => ({
|
|
@@ -207,6 +429,7 @@ export function registerDapTools(server, dap, cfg) {
|
|
|
207
429
|
inputSchema: { frame_id: z.number().int().describe("Frame id from dbg_stack_trace") },
|
|
208
430
|
}, async ({ frame_id }) => {
|
|
209
431
|
try {
|
|
432
|
+
requireSession("dbg_scopes");
|
|
210
433
|
const body = await dap.request("scopes", { frameId: frame_id });
|
|
211
434
|
const scopes = Array.isArray(body["scopes"])
|
|
212
435
|
? body["scopes"].map((s) => ({ name: s.name ?? "", variables_ref: s.variablesReference ?? 0 }))
|
|
@@ -223,6 +446,7 @@ export function registerDapTools(server, dap, cfg) {
|
|
|
223
446
|
inputSchema: { variables_ref: z.number().int().describe("variablesReference from dbg_scopes or a parent variable") },
|
|
224
447
|
}, async ({ variables_ref }) => {
|
|
225
448
|
try {
|
|
449
|
+
requireSession("dbg_variables");
|
|
226
450
|
const body = await dap.request("variables", { variablesReference: variables_ref });
|
|
227
451
|
const variables = Array.isArray(body["variables"])
|
|
228
452
|
? body["variables"].map((v) => ({
|
|
@@ -245,6 +469,9 @@ export function registerDapTools(server, dap, cfg) {
|
|
|
245
469
|
},
|
|
246
470
|
}, async ({ expression, frame_id, confirm }) => {
|
|
247
471
|
try {
|
|
472
|
+
// Before the gate on purpose: the old order prompted the operator to approve
|
|
473
|
+
// arbitrary code execution against a session that had never been started.
|
|
474
|
+
requireSession("dbg_evaluate");
|
|
248
475
|
const blocked = await gate(server, confirm, `Evaluate expression in the running game: ${expression}`);
|
|
249
476
|
if (blocked)
|
|
250
477
|
return blocked;
|
|
@@ -285,6 +512,7 @@ export function registerDapTools(server, dap, cfg) {
|
|
|
285
512
|
},
|
|
286
513
|
}, async ({ add, remove, clear, frame_id }) => {
|
|
287
514
|
try {
|
|
515
|
+
requireSession("dbg_watch");
|
|
288
516
|
if (clear)
|
|
289
517
|
dap.clearWatches();
|
|
290
518
|
if (remove && remove.length)
|
|
@@ -352,6 +580,8 @@ export function registerDapTools(server, dap, cfg) {
|
|
|
352
580
|
},
|
|
353
581
|
}, async ({ variables_ref, name, value, confirm }) => {
|
|
354
582
|
try {
|
|
583
|
+
// Before the gate and the caps check on purpose โ see dbg_evaluate.
|
|
584
|
+
requireSession("dbg_set_variable");
|
|
355
585
|
// Feature-detect: some debug adapters don't implement setVariable. If the
|
|
356
586
|
// adapter explicitly advertised it as unsupported, say so plainly instead
|
|
357
587
|
// of prompting for a confirmation and then failing.
|
|
@@ -400,13 +630,25 @@ export function registerDapTools(server, dap, cfg) {
|
|
|
400
630
|
description: "Restart the current debug session. Uses the DAP `restart` request when the adapter advertises `supportsRestartRequest`, " +
|
|
401
631
|
"otherwise falls back to terminate + relaunch โ so it works on every adapter. Reuses the last dbg_launch/dbg_attach parameters; " +
|
|
402
632
|
"pass `scene` / `stop_on_entry` to override them for a launched session. `method` in the result reports which path ran " +
|
|
403
|
-
"('restart' = native DAP restart, 'relaunch' = terminate + fresh handshake). Requires a session started with dbg_launch/dbg_attach."
|
|
633
|
+
"('restart' = native DAP restart, 'relaunch' = terminate + fresh handshake). Requires a session started with dbg_launch/dbg_attach. " +
|
|
634
|
+
"`scene` is held to the same rule as dbg_launch's โ inside the project root, existing, non-empty.",
|
|
404
635
|
inputSchema: {
|
|
405
|
-
scene: z
|
|
636
|
+
scene: z
|
|
637
|
+
.string()
|
|
638
|
+
.optional()
|
|
639
|
+
.describe("Override the scene for a launched session: 'main', 'current', res://scene.tscn, or uid://โฆ"),
|
|
406
640
|
stop_on_entry: z.boolean().optional().describe("Override stop-at-entry for the restart (launched sessions)"),
|
|
407
641
|
},
|
|
408
642
|
}, async ({ scene, stop_on_entry }) => {
|
|
409
643
|
try {
|
|
644
|
+
// ๐ด THE SECOND CALL SITE, and the reason it is here. 162 ยง8 item 5 named
|
|
645
|
+
// `dbg_launch` alone; `dbg_restart` takes the same `scene` and hands it to the
|
|
646
|
+
// same adapter, so guarding only the first would have left the plane guarded
|
|
647
|
+
// in name only. Measured before it was written: an unguarded `dbg_restart`
|
|
648
|
+
// onto `res://../example_evil/outside.tscn` answered
|
|
649
|
+
// `ok {"method":"restart","state":"running"}`, exactly like `dbg_launch`.
|
|
650
|
+
// ยง7's standing rule โ a guard is not wired until EVERY call site is wired.
|
|
651
|
+
guardScene(scene);
|
|
410
652
|
const override = {};
|
|
411
653
|
if (scene !== undefined)
|
|
412
654
|
override.scene = scene;
|
|
@@ -425,6 +667,8 @@ export function registerDapTools(server, dap, cfg) {
|
|
|
425
667
|
"list the valid goto targets on that line; when the line has exactly one target (or you pass `target_id`) it jumps there. " +
|
|
426
668
|
"DESTRUCTIVE: skips or repeats code by moving execution โ confirm with the user and keep this gated. " +
|
|
427
669
|
"Feature-detected: on an adapter that does not advertise `supportsGotoTargetsRequest` it returns a clear \"unsupported\" message WITHOUT prompting. " +
|
|
670
|
+
"Refuses a source that can never carry a target โ a missing file, a directory, an empty path (which resolves to the project root), " +
|
|
671
|
+
"or a path resolving outside the Godot project root โ the same guard dbg_set_breakpoints applies. " +
|
|
428
672
|
"Only meaningful while stopped at a breakpoint.",
|
|
429
673
|
inputSchema: {
|
|
430
674
|
path: z.string().describe("Script path (res://..., absolute, or project-relative)"),
|
|
@@ -440,7 +684,20 @@ export function registerDapTools(server, dap, cfg) {
|
|
|
440
684
|
content: [{ type: "text", text: "dbg_goto is unsupported by the connected Godot build's debug adapter (it does not advertise supportsGotoTargetsRequest)." }],
|
|
441
685
|
};
|
|
442
686
|
}
|
|
443
|
-
|
|
687
|
+
// ๐ด 162: THIS GUARD WAS MISSING, and only a measurement found it. 161 ยง8 item 5
|
|
688
|
+
// described dap.ts as carrying "its own inline copy of the escape guard" โ true
|
|
689
|
+
// of ONE of its two path-taking tools. `dbg_goto` resolved with a bare
|
|
690
|
+
// `toFsPath` and handed the result to the adapter as `source.path`: no
|
|
691
|
+
// containment check, no existence check. The same shape as 161 ยง4's "only
|
|
692
|
+
// board_create's stale-tab close was asserted, and one of four creators was
|
|
693
|
+
// covered" โ a guard is not wired until every call site is wired.
|
|
694
|
+
//
|
|
695
|
+
// ๐ด It is not currently REACHABLE on Godot: the capability gate above returns
|
|
696
|
+
// first because no Godot build advertises supportsGotoTargetsRequest. That is a
|
|
697
|
+
// capability, not a boundary โ it can change under us, and `dbg_goto` is the
|
|
698
|
+
// DESTRUCTIVE tool on this plane. Guarded on its own merits, not on the engine's
|
|
699
|
+
// current answer.
|
|
700
|
+
const fsPath = guardSource(path);
|
|
444
701
|
const body = await dap.request("gotoTargets", { source: { path: fsPath }, line });
|
|
445
702
|
const targets = Array.isArray(body["targets"])
|
|
446
703
|
? body["targets"].map((t) => ({ id: t.id ?? 0, label: t.label ?? "", line: t.line ?? 0 }))
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { gate } from "../../confirm.js";
|
|
3
3
|
/** AudioStreamPlayer nodes + audio bus layout / effects. */
|
|
4
|
-
export function registerAudioTools(server, call) {
|
|
4
|
+
export function registerAudioTools(server, call, guard) {
|
|
5
5
|
server.registerTool("audio_player_create", {
|
|
6
6
|
title: "Create audio player",
|
|
7
7
|
description: "Add an AudioStreamPlayer / AudioStreamPlayer2D / AudioStreamPlayer3D node under a parent in the edited scene (undoable). `dim` selects \"none\" (default, non-positional AudioStreamPlayer), \"2d\", or \"3d\". Optional initial stream_path (res:// AudioStream), autoplay, volume_db, bus. In-scene and undoable โ not gated.",
|
|
@@ -15,6 +15,9 @@ export function registerAudioTools(server, call) {
|
|
|
15
15
|
bus: z.string().optional().describe("Target audio bus name (default \"Master\")"),
|
|
16
16
|
},
|
|
17
17
|
}, async ({ parent_path, dim, name, stream_path, autoplay, volume_db, bus }) => {
|
|
18
|
+
const escaped = guard(stream_path, "stream_path");
|
|
19
|
+
if (escaped)
|
|
20
|
+
return escaped;
|
|
18
21
|
const params = { parent_path };
|
|
19
22
|
if (dim !== undefined)
|
|
20
23
|
params.dim = dim;
|
|
@@ -37,7 +40,12 @@ export function registerAudioTools(server, call) {
|
|
|
37
40
|
path: z.string().describe("AudioStreamPlayer/2D/3D node path relative to the scene root"),
|
|
38
41
|
stream_path: z.string().describe("res:// path to an AudioStream resource"),
|
|
39
42
|
},
|
|
40
|
-
}, async ({ path, stream_path }) =>
|
|
43
|
+
}, async ({ path, stream_path }) => {
|
|
44
|
+
const escaped = guard(stream_path, "stream_path");
|
|
45
|
+
if (escaped)
|
|
46
|
+
return escaped;
|
|
47
|
+
return call("audio.set_stream", { path, stream_path });
|
|
48
|
+
});
|
|
41
49
|
server.registerTool("audio_bus_add", {
|
|
42
50
|
title: "Add audio bus",
|
|
43
51
|
description: "Add a bus to the project's global AudioServer layout, optionally naming it, positioning it (at_position), and setting its send bus. DESTRUCTIVE (project-wide audio state) โ gated by confirmation.",
|
|
@@ -100,6 +108,12 @@ export function registerAudioTools(server, call) {
|
|
|
100
108
|
confirm: z.boolean().optional().describe("Auto-approve this destructive action (skip the confirmation prompt)"),
|
|
101
109
|
},
|
|
102
110
|
}, async ({ to_path, confirm }) => {
|
|
111
|
+
// `to_path` is OPTIONAL here โ an omitted one takes the addon's documented
|
|
112
|
+
// default and has nothing to escape, which is why the guard passes `undefined`
|
|
113
|
+
// through untouched rather than refusing it.
|
|
114
|
+
const escaped = guard(to_path, "to_path");
|
|
115
|
+
if (escaped)
|
|
116
|
+
return escaped;
|
|
103
117
|
const blocked = await gate(server, confirm, `Save audio bus layout to ${to_path ?? "res://default_bus_layout.tres"}`);
|
|
104
118
|
if (blocked)
|
|
105
119
|
return blocked;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { ok } from "../lsp-common.js";
|
|
1
|
+
import { ok, failPath } from "../lsp-common.js";
|
|
2
|
+
import { resolveInsideProject } from "../../paths.js";
|
|
2
3
|
/**
|
|
3
4
|
* MCP error envelope for a failed editor-bridge call (never throws to the
|
|
4
5
|
* caller). Distinct from lsp-common's `fail` (which labels errors "LSP error");
|
|
@@ -28,4 +29,41 @@ export function makeCall(bridge) {
|
|
|
28
29
|
}
|
|
29
30
|
};
|
|
30
31
|
}
|
|
32
|
+
// ------------------------------------------------------------ path guard ----
|
|
33
|
+
//
|
|
34
|
+
// MEASURED, session 164, against a real 4.7 editor with the addon live, on a temp
|
|
35
|
+
// project copy at /private/tmp/g164 with a prefix-sharing sibling `example_evil/`:
|
|
36
|
+
// ELEVEN editor writers created files OUTSIDE the project root through `res://../`,
|
|
37
|
+
// and `filesystem_move` MOVED one out. Every one answered `ok` and echoed the
|
|
38
|
+
// escaping path straight back. The verdict came from `stat`, not from the reply โ
|
|
39
|
+
// 163 ยง1's lesson, which is that an ACCEPTANCE is only measured by asking a channel
|
|
40
|
+
// the tool does not control.
|
|
41
|
+
//
|
|
42
|
+
// ๐ด THE ADDON'S ONLY CHECK IS `to_path.begins_with("res://")`, at seventeen call
|
|
43
|
+
// sites in operations.gd, and `res://../` satisfies it. That is why a bare relative
|
|
44
|
+
// and an absolute path are already refused (self-announcing) while `res://../` was
|
|
45
|
+
// silent. This guard closes the silent one; it does not touch the other two.
|
|
46
|
+
/**
|
|
47
|
+
* Build the containment guard the editor writers share. Returns `null` when the
|
|
48
|
+
* path is legal and a ready-to-return error envelope when it escapes, so a call
|
|
49
|
+
* site reads as one line and cannot forget the `return`.
|
|
50
|
+
*
|
|
51
|
+
* ๐ด IT RETURNS NOTHING AND REWRITES NOTHING. The caller's ORIGINAL spelling still
|
|
52
|
+
* goes on the wire, so `res://foo.tres` reaches the addon exactly as before and the
|
|
53
|
+
* guard is provably inert on every path that already worked โ the same property
|
|
54
|
+
* 163's `guardScene` was built for, and an over-eager mutation keeps it that way.
|
|
55
|
+
*/
|
|
56
|
+
export function makePathGuard(projectPath) {
|
|
57
|
+
return (p, label) => {
|
|
58
|
+
if (p === undefined)
|
|
59
|
+
return null; // an absent optional param has nothing to escape
|
|
60
|
+
try {
|
|
61
|
+
resolveInsideProject(p, projectPath, label);
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
catch (err) {
|
|
65
|
+
return failPath(err);
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
}
|
|
31
69
|
//# sourceMappingURL=common.js.map
|