breakpoint-mcp 1.29.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 +42 -3
- 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/dap.js
CHANGED
|
@@ -9,6 +9,52 @@ export class DapError extends Error {
|
|
|
9
9
|
this.name = "DapError";
|
|
10
10
|
}
|
|
11
11
|
}
|
|
12
|
+
/**
|
|
13
|
+
* Per-line breakpoint modifier fields, each gated by an adapter capability. Godot
|
|
14
|
+
* advertises all three false AND ignores them — a "conditional" breakpoint halts
|
|
15
|
+
* unconditionally (measured live on 4.3 and 4.7), so an undropped modifier is not a
|
|
16
|
+
* no-op, it is the opposite of what the caller asked for.
|
|
17
|
+
*/
|
|
18
|
+
export const BREAKPOINT_MODIFIER_CAPS = {
|
|
19
|
+
condition: "supportsConditionalBreakpoints",
|
|
20
|
+
hitCondition: "supportsHitConditionalBreakpoints",
|
|
21
|
+
logMessage: "supportsLogPoints",
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* Which of condition/hitCondition/logMessage the connected adapter does NOT support, out
|
|
25
|
+
* of the ones actually requested.
|
|
26
|
+
*
|
|
27
|
+
* `caps === null` returns [] — nothing can be feature-detected before the adapter has
|
|
28
|
+
* advertised anything. 🔴 That branch is DEFENSIVE TYPING, NOT A LIVE PATH, and the
|
|
29
|
+
* mutation sweep proved it by surviving a mutation that made it drop everything: the
|
|
30
|
+
* only caller is `applyBreakpoints`, a response body is normalised to `{}` before it is
|
|
31
|
+
* ever assigned, and the handshake sets `capabilities` before it applies any breakpoint.
|
|
32
|
+
* Recorded rather than deleted because the field's TYPE is nullable and a future caller
|
|
33
|
+
* could reach it; what IS reachable — an adapter answering `initialize` with an empty
|
|
34
|
+
* body, so `caps` is `{}` and advertises nothing — is pinned by a unit test instead.
|
|
35
|
+
*
|
|
36
|
+
* 🔴 CAPABILITIES ARE UNKNOWN UNTIL `initialize` ANSWERS, AND BREAKPOINTS ARE ORDINARILY
|
|
37
|
+
* BUFFERED BEFORE THAT. That is why this is called from `applyBreakpoints` — the moment
|
|
38
|
+
* the modifiers are actually put on the wire — and not only from the tool layer at set
|
|
39
|
+
* time. Detecting at set time alone left the buffered path, which is the documented and
|
|
40
|
+
* overwhelmingly common one, sending modifiers to an adapter that ignores them with no
|
|
41
|
+
* warning: the caller asked for "break when counter < 0" and got a breakpoint that halts
|
|
42
|
+
* every frame, reported as success. Measured, D1 of this release.
|
|
43
|
+
*/
|
|
44
|
+
export function unsupportedBreakpointModifiers(caps, requested) {
|
|
45
|
+
if (!caps)
|
|
46
|
+
return [];
|
|
47
|
+
const out = [];
|
|
48
|
+
for (const field of ["condition", "hitCondition", "logMessage"]) {
|
|
49
|
+
if (requested[field] && caps[BREAKPOINT_MODIFIER_CAPS[field]] !== true)
|
|
50
|
+
out.push(field);
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
/** True when a per-line modifier array carries at least one non-null, non-empty entry. */
|
|
55
|
+
export function hasModifier(arr) {
|
|
56
|
+
return Array.isArray(arr) && arr.some((v) => v != null && v !== "");
|
|
57
|
+
}
|
|
12
58
|
/**
|
|
13
59
|
* Minimal DAP client for Godot's Debug Adapter (raw TCP + DAP framing). Runs the
|
|
14
60
|
* initialize → (breakpoints) → configurationDone → launch/attach handshake, and
|
|
@@ -20,6 +66,8 @@ export class DapClient extends EventEmitter {
|
|
|
20
66
|
seq = 1;
|
|
21
67
|
pending = new Map();
|
|
22
68
|
breakpoints = new Map();
|
|
69
|
+
/** Modifier fields dropped at apply time this session; reset by `start()`. */
|
|
70
|
+
droppedModifiers = new Set();
|
|
23
71
|
/**
|
|
24
72
|
* Seqs whose deadline fired, so a response arriving later is recognised, not
|
|
25
73
|
* dropped. `seq` is monotonic and is never reset, so a seq is never reused and
|
|
@@ -29,6 +77,18 @@ export class DapClient extends EventEmitter {
|
|
|
29
77
|
configured = false;
|
|
30
78
|
/** Persistent watch expressions, re-evaluated at each stop (see evaluateWatches). */
|
|
31
79
|
watches = [];
|
|
80
|
+
/**
|
|
81
|
+
* True only between a launch/attach the adapter ACCEPTED and the session ending.
|
|
82
|
+
*
|
|
83
|
+
* 🔴 Before this existed, exactly one of the fifteen `dbg_*` tools (`dbg_restart`)
|
|
84
|
+
* knew whether a session had ever been started, and it knew by accident — it reads
|
|
85
|
+
* `lastStartMode`. Everything else fired its request at the adapter and reported the
|
|
86
|
+
* answer, so `dbg_continue` on a never-launched client returned
|
|
87
|
+
* `{"state":"running"}` with `isError:false` AND left `state = "running"` behind
|
|
88
|
+
* (see `resume()`'s optimistic assignment), which made every later answer read as if
|
|
89
|
+
* a session existed. Measured live against Godot 4.7.
|
|
90
|
+
*/
|
|
91
|
+
sessionStarted = false;
|
|
32
92
|
/** The mode + args of the last start(), so restart() can reuse/override them. */
|
|
33
93
|
lastStartMode = null;
|
|
34
94
|
lastStartArgs = null;
|
|
@@ -36,6 +96,23 @@ export class DapClient extends EventEmitter {
|
|
|
36
96
|
state = "disconnected";
|
|
37
97
|
lastStoppedThreadId = null;
|
|
38
98
|
lastStoppedReason = null;
|
|
99
|
+
/**
|
|
100
|
+
* Whether a launch/attach the adapter accepted is still in force. The `dbg_*` tools
|
|
101
|
+
* that can only answer from a live session consult this instead of asking the
|
|
102
|
+
* adapter and reporting whatever came back.
|
|
103
|
+
*/
|
|
104
|
+
get hasSession() {
|
|
105
|
+
return this.sessionStarted && this.state !== "terminated" && this.state !== "disconnected";
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Whether the adapter has reported a stop, read through a call so TypeScript's
|
|
109
|
+
* control-flow narrowing does not collapse it: `state` is mutated by the event
|
|
110
|
+
* handler across every `await` in `start()`, and a literal `this.state === "stopped"`
|
|
111
|
+
* after a `this.state = "running"` is (wrongly) an impossible comparison to tsc.
|
|
112
|
+
*/
|
|
113
|
+
stoppedNow() {
|
|
114
|
+
return this.state === "stopped";
|
|
115
|
+
}
|
|
39
116
|
constructor(host, port, timeoutMs) {
|
|
40
117
|
super();
|
|
41
118
|
this.timeoutMs = timeoutMs;
|
|
@@ -160,21 +237,46 @@ export class DapClient extends EventEmitter {
|
|
|
160
237
|
if (this.configured) {
|
|
161
238
|
return this.applyBreakpoints(path);
|
|
162
239
|
}
|
|
163
|
-
|
|
240
|
+
// 🔴 Buffered: capabilities are still unknown, so the modifiers CANNOT be
|
|
241
|
+
// feature-detected yet. Say so rather than let the caller read a bare
|
|
242
|
+
// `buffered: true` as "your condition was accepted" — detection happens in
|
|
243
|
+
// `applyBreakpoints` during the handshake, and `dbg_launch` reports the result.
|
|
244
|
+
const deferred = hasModifier(conditions) || hasModifier(hitConditions) || hasModifier(logMessages);
|
|
245
|
+
return deferred ? { buffered: true, path, lines, modifier_detection_deferred: true } : { buffered: true, path, lines };
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Modifier fields dropped because the connected adapter does not support them,
|
|
249
|
+
* accumulated across every `applyBreakpoints` since the last `start()`. Read by
|
|
250
|
+
* `dbg_launch` / `dbg_attach` so a buffered modifier's fate is reported to the
|
|
251
|
+
* caller that buffered it.
|
|
252
|
+
*/
|
|
253
|
+
droppedBreakpointModifiers() {
|
|
254
|
+
return [...this.droppedModifiers];
|
|
164
255
|
}
|
|
165
256
|
applyBreakpoints(path) {
|
|
166
257
|
const bp = this.breakpoints.get(path);
|
|
167
258
|
if (!bp)
|
|
168
259
|
return Promise.resolve({});
|
|
260
|
+
// 🔴 Feature-detect HERE, where the modifiers actually go on the wire. This is the
|
|
261
|
+
// only point that sees both the modifiers and the adapter's capabilities on every
|
|
262
|
+
// path — the buffered one included. See `unsupportedBreakpointModifiers`.
|
|
263
|
+
const dropped = unsupportedBreakpointModifiers(this.capabilities, {
|
|
264
|
+
condition: hasModifier(bp.conditions),
|
|
265
|
+
hitCondition: hasModifier(bp.hitConditions),
|
|
266
|
+
logMessage: hasModifier(bp.logMessages),
|
|
267
|
+
});
|
|
268
|
+
for (const d of dropped)
|
|
269
|
+
this.droppedModifiers.add(d);
|
|
270
|
+
const drop = new Set(dropped);
|
|
169
271
|
return this.request("setBreakpoints", {
|
|
170
272
|
source: { path },
|
|
171
273
|
// DAP SourceBreakpoint: line + optional condition / hitCondition / logMessage.
|
|
172
274
|
// A logMessage turns the breakpoint into a logpoint (adapter logs, doesn't halt).
|
|
173
275
|
breakpoints: bp.lines.map((line, i) => {
|
|
174
276
|
const b = { line };
|
|
175
|
-
const condition = bp.conditions?.[i];
|
|
176
|
-
const hit = bp.hitConditions?.[i];
|
|
177
|
-
const log = bp.logMessages?.[i];
|
|
277
|
+
const condition = drop.has("condition") ? null : bp.conditions?.[i];
|
|
278
|
+
const hit = drop.has("hitCondition") ? null : bp.hitConditions?.[i];
|
|
279
|
+
const log = drop.has("logMessage") ? null : bp.logMessages?.[i];
|
|
178
280
|
if (condition)
|
|
179
281
|
b.condition = condition;
|
|
180
282
|
if (hit)
|
|
@@ -240,11 +342,38 @@ export class DapClient extends EventEmitter {
|
|
|
240
342
|
await this.applyBreakpoints(path).catch(() => undefined);
|
|
241
343
|
}
|
|
242
344
|
}
|
|
243
|
-
/**
|
|
244
|
-
|
|
345
|
+
/**
|
|
346
|
+
* Full handshake: initialize → launch/attach → (breakpoints) → configurationDone.
|
|
347
|
+
*
|
|
348
|
+
* 🔴 THREE THINGS HERE WERE WRONG AT ONCE, and Godot's adapter reaches all three.
|
|
349
|
+
*
|
|
350
|
+
* 1. `startReq.catch(...)` was attached on the LAST line of the handshake. Godot
|
|
351
|
+
* rejects the `launch`/`attach` request ITSELF — `wrong_path` when `project`
|
|
352
|
+
* does not match the editor's open project (trivially reachable on macOS, where
|
|
353
|
+
* `/tmp` realpaths to `/private/tmp`), `not_running` when nothing is running to
|
|
354
|
+
* attach to. That rejection lands DURING the handshake, before the `.catch()`
|
|
355
|
+
* existed, so it was an UNHANDLED REJECTION — which Node terminates the process
|
|
356
|
+
* for. Measured twice, exit code 1. The handler is now attached at creation.
|
|
357
|
+
* 2. It emitted on `"error"`. An unlistened `error` emit on an EventEmitter THROWS
|
|
358
|
+
* (158 §1). The notification goes out on a distinct name, `start_failed`.
|
|
359
|
+
* 3. `state = "running"` was unconditional, so a session the adapter refused was
|
|
360
|
+
* reported as running. It is now only set from `initialized`, which also stops
|
|
361
|
+
* it clobbering a `stopped` that arrives mid-handshake (the race CI found in
|
|
362
|
+
* #166 and this Mac never did).
|
|
363
|
+
*
|
|
364
|
+
* `entryStopWaitMs > 0` additionally waits, bounded, for the entry `stopped` event
|
|
365
|
+
* before returning — see `dbg_launch`'s `stop_on_entry`. Godot 4.7 does not honour
|
|
366
|
+
* `stopOnEntry` at all, so this wait times out there and the caller is told so
|
|
367
|
+
* rather than being handed a bare `running` that reads like it worked.
|
|
368
|
+
*/
|
|
369
|
+
async start(mode, args, entryStopWaitMs = 0) {
|
|
245
370
|
// Remember how we started so restart() can reuse (or override) these params.
|
|
246
371
|
this.lastStartMode = mode;
|
|
247
372
|
this.lastStartArgs = args;
|
|
373
|
+
this.sessionStarted = false;
|
|
374
|
+
// A fresh session re-detects against whatever adapter answers this time — a
|
|
375
|
+
// restart may reach a different build than the one that dropped last time.
|
|
376
|
+
this.droppedModifiers.clear();
|
|
248
377
|
// Listen for `initialized` before we ask, so we cannot miss it.
|
|
249
378
|
const onInit = this.waitEvent("initialized", Math.min(this.timeoutMs, 5000));
|
|
250
379
|
this.capabilities = await this.request("initialize", {
|
|
@@ -258,15 +387,47 @@ export class DapClient extends EventEmitter {
|
|
|
258
387
|
});
|
|
259
388
|
this.state = "initialized";
|
|
260
389
|
// Send launch/attach but don't await it yet — many adapters only resolve it
|
|
261
|
-
// after configurationDone.
|
|
390
|
+
// after configurationDone. The rejection handler is attached HERE, on the same
|
|
391
|
+
// tick the promise is created: see (1) above. It records rather than throws, so
|
|
392
|
+
// a rejection arriving at any point is observable and never unhandled.
|
|
393
|
+
let startFailure = null;
|
|
262
394
|
const startReq = this.request(mode, args);
|
|
395
|
+
startReq.catch((err) => {
|
|
396
|
+
startFailure = err;
|
|
397
|
+
this.sessionStarted = false;
|
|
398
|
+
// Only the handshake's own window may report `running`; a failure arriving
|
|
399
|
+
// later ends the session so the tools' session guard refuses from then on.
|
|
400
|
+
if (!this.stoppedNow())
|
|
401
|
+
this.state = "terminated";
|
|
402
|
+
this.emit("start_failed", err);
|
|
403
|
+
});
|
|
404
|
+
// An entry stop can arrive before configurationDone answers — arm first.
|
|
405
|
+
const entryStop = entryStopWaitMs > 0 ? this.waitEvent("stopped", entryStopWaitMs) : null;
|
|
263
406
|
await onInit;
|
|
264
407
|
await this.applyAllBreakpoints();
|
|
265
408
|
await this.request("configurationDone", {}).catch(() => undefined);
|
|
409
|
+
// 🔴 By the time we get here an already-arrived rejection has ALREADY run its
|
|
410
|
+
// `.catch()`: the `.catch(() => undefined)` on the configurationDone request adds
|
|
411
|
+
// a microtask hop, so the recorder is queued ahead of this continuation even when
|
|
412
|
+
// both responses land in a single TCP read. A `setImmediate` here looked like
|
|
413
|
+
// prudence and was measurably dead code — a mutation deleting it survived every
|
|
414
|
+
// test in the suite, including one written specifically to catch it. The
|
|
415
|
+
// same-read ordering is pinned by a unit test instead, which is the thing that
|
|
416
|
+
// would actually notice if that hop were ever removed.
|
|
417
|
+
// A rejection arriving LATER than this ends the session via the handler above.
|
|
418
|
+
if (startFailure)
|
|
419
|
+
throw startFailure;
|
|
266
420
|
this.configured = true;
|
|
267
|
-
this.
|
|
268
|
-
//
|
|
269
|
-
|
|
421
|
+
this.sessionStarted = true;
|
|
422
|
+
// Guarded, not unconditional: a `stopped` that landed mid-handshake stays.
|
|
423
|
+
if (this.state === "initialized")
|
|
424
|
+
this.state = "running";
|
|
425
|
+
if (entryStop) {
|
|
426
|
+
await entryStop;
|
|
427
|
+
if (startFailure)
|
|
428
|
+
throw startFailure;
|
|
429
|
+
}
|
|
430
|
+
return { entryStopSeen: this.stoppedNow() };
|
|
270
431
|
}
|
|
271
432
|
threadId() {
|
|
272
433
|
return this.lastStoppedThreadId ?? 1;
|
|
@@ -361,6 +522,7 @@ export class DapClient extends EventEmitter {
|
|
|
361
522
|
this.conn.close();
|
|
362
523
|
this.state = "disconnected";
|
|
363
524
|
this.configured = false;
|
|
525
|
+
this.sessionStarted = false;
|
|
364
526
|
}
|
|
365
527
|
}
|
|
366
528
|
//# sourceMappingURL=dap.js.map
|
package/dist/index.js
CHANGED
|
@@ -41,7 +41,7 @@ async function main() {
|
|
|
41
41
|
// so long jobs (export/import/headless script) support poll/await/cancel.
|
|
42
42
|
// D3: also advertise resources.subscribe so clients can subscribe to
|
|
43
43
|
// godot://… resources and receive notifications/resources/updated.
|
|
44
|
-
const server = new McpServer({ name: "breakpoint-mcp", version: "1.
|
|
44
|
+
const server = new McpServer({ name: "breakpoint-mcp", version: "1.72.7" }, { capabilities: { ...TASK_CAPABILITIES, ...RESOURCE_CAPABILITIES }, taskStore });
|
|
45
45
|
// B1: enforce frozen output schemas on every structured tool. Must run before
|
|
46
46
|
// any register*Tools call — it wraps server.registerTool.
|
|
47
47
|
applyOutputSchemas(server);
|
|
@@ -63,14 +63,14 @@ async function main() {
|
|
|
63
63
|
// Capability groups — a risk-based axis over the toolsets. `code-execution` is
|
|
64
64
|
// OFF by default; a disabled group's tools are DROPPED at
|
|
65
65
|
// registration (omitted from tools/list), so the secure-default surface is
|
|
66
|
-
//
|
|
66
|
+
// 291 − 13 = 278 tools. Enable via BREAKPOINT_PRIVILEGED_GROUPS. Wraps
|
|
67
67
|
// server.registerTool AFTER applyOutputSchemas (schema wrapper stays innermost).
|
|
68
68
|
const privilegedGroups = selectPrivilegedGroups(config.privilegedGroups, (unknown) => log(`ignoring unknown BREAKPOINT_PRIVILEGED_GROUPS token(s): ${unknown.join(", ")}`));
|
|
69
69
|
applyCapabilities(server, privilegedGroups);
|
|
70
70
|
// The A/B/C/D planes ARE the grouping. Build the ordered toolset registry
|
|
71
71
|
// (the single source of truth, shared with the registration tests) and
|
|
72
72
|
// register only the selected groups. Default (BREAKPOINT_TOOLSETS unset) =
|
|
73
|
-
// every group → the full
|
|
73
|
+
// every group → the full 291-tool surface, byte-identical to before. A filter
|
|
74
74
|
// lets a client that can't defer tools, or a user who wants a smaller default
|
|
75
75
|
// menu, load only the planes a project needs (GitHub-MCP `--toolsets` style).
|
|
76
76
|
let processes;
|
|
@@ -110,7 +110,7 @@ async function main() {
|
|
|
110
110
|
log(`toolsets enabled: ${[...enabled].sort().join(", ")} (${enabled.size}/${toolsets.length} groups)`);
|
|
111
111
|
}
|
|
112
112
|
// Recipes: a free, curated task-recipe layer exposed as MCP prompts (discoverable
|
|
113
|
-
// via prompts/list). Adds NO tools — the
|
|
113
|
+
// via prompts/list). Adds NO tools — the 291-tool count is unchanged — and drives
|
|
114
114
|
// the enforced tools above, so it's a skill-pack layer over typed/undoable tools.
|
|
115
115
|
registerRecipes(server);
|
|
116
116
|
// Always-on capability affordance — never behind a toolset or a privileged
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE PATH-LIKE PARAMETER COHORT — the enumeration every containment session scopes
|
|
3
|
+
* its work with, promoted out of scratch and into the shipped surface.
|
|
4
|
+
*
|
|
5
|
+
* ─────────────────────────────────────────────────────────────────────────────────
|
|
6
|
+
* 🔴 WHY THIS FILE EXISTS AND WHY IT IS NOT A SCRATCH SCRIPT
|
|
7
|
+
*
|
|
8
|
+
* Sessions 163–165 scoped their path work against "78 path-like parameters". That
|
|
9
|
+
* number came from a twelve-line loop in `_to_delete/enum163.mjs` that nobody read.
|
|
10
|
+
* The loop was blind THREE ways, and each blindness hid real defects:
|
|
11
|
+
*
|
|
12
|
+
* 1. it walked TOP-LEVEL `inputSchema.properties` only, so a nested parameter
|
|
13
|
+
* appeared in no count at all;
|
|
14
|
+
* 2. 🔴 it DISCARDED every parameter literally named `path` —
|
|
15
|
+
* `if (prop === "path") { pathNamed++; continue; }` — because session 162 had
|
|
16
|
+
* concluded "I already swept those". That line is not a bug. It is a FINDING
|
|
17
|
+
* COMPILED INTO THE MEASURING INSTRUMENT, and it kept asserting itself for
|
|
18
|
+
* three sessions after 165 disproved it. It threw away 124 rows, 15 of which
|
|
19
|
+
* were escaping;
|
|
20
|
+
* 3. its name test was an ANCHORED EXACT-WORD list, so a compound name like
|
|
21
|
+
* `font_path` could only ever be found through its DESCRIPTION — and
|
|
22
|
+
* `card_template_create.theme.font_path` has no description, which made it
|
|
23
|
+
* invisible to both tests at once.
|
|
24
|
+
*
|
|
25
|
+
* Corrected, the enumeration is 258 rows, not 78. Session 167 re-implemented the
|
|
26
|
+
* corrected rules independently, with FIVE further candidate blindnesses closed
|
|
27
|
+
* (multi-branch unions, non-object array items, camelCase boundaries, identity-based
|
|
28
|
+
* dedupe, additionalProperties containers) and confirmed the same 258: a strict
|
|
29
|
+
* superset that loses nothing and adds one false positive. The instrument is sound.
|
|
30
|
+
*
|
|
31
|
+
* 🔴 THE RULE THIS FILE ENCODES: **a measurement's conclusion must never become the
|
|
32
|
+
* next measurement's filter.** So this enumerator EXCLUDES NOTHING. Over-inclusion is
|
|
33
|
+
* cheap — a row you ask about once and clear. Under-inclusion cost four releases.
|
|
34
|
+
* ─────────────────────────────────────────────────────────────────────────────────
|
|
35
|
+
*/
|
|
36
|
+
/**
|
|
37
|
+
* Name segments that make a parameter path-like. Deliberately broad: a name only has
|
|
38
|
+
* to CONTAIN one of these as a segment, so `font_path`, `to_path` and `theme_path`
|
|
39
|
+
* all match where an exact-word test saw nothing.
|
|
40
|
+
*/
|
|
41
|
+
const NAME_SEG = "(path|paths|file|files|scene|scenes|program|uri|url|source|src|dest|destination|target|to|from|dir|" +
|
|
42
|
+
"directory|out|output|input|script|scripts|asset|assets|resource|template|config|cwd|exe|binary|bin|art|" +
|
|
43
|
+
"texture|image|icon|font|sound|audio|mesh|material|shader|theme|tileset|tres|pck|glb|obj|sprite|atlas)";
|
|
44
|
+
const NAME_HINT = new RegExp(`(^|_)${NAME_SEG}(_|$)`, "i");
|
|
45
|
+
const SEG_EXACT = new RegExp(`^${NAME_SEG}$`, "i");
|
|
46
|
+
const DESC_HINT = /res:\/\/|user:\/\/|file path|path to|filesystem|absolute path|\.tscn|\.gd\b|\.cs\b|\.tres|\.png|directory/i;
|
|
47
|
+
/** Split a name on underscores AND camelCase/digit boundaries, so `toPath` segments. */
|
|
48
|
+
function segments(name) {
|
|
49
|
+
return name
|
|
50
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1_$2")
|
|
51
|
+
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1_$2")
|
|
52
|
+
.split(/[_\-.\s]+/)
|
|
53
|
+
.filter(Boolean);
|
|
54
|
+
}
|
|
55
|
+
function nameLooksPathLike(name) {
|
|
56
|
+
return NAME_HINT.test(name) || segments(name).some((s) => SEG_EXACT.test(s));
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Every non-null branch of a union, or the node itself. enum166 unwrapped ONLY when a
|
|
60
|
+
* single branch survived, so a `string | string[]` parameter fell off the end of its
|
|
61
|
+
* loop — matched neither the stringy test nor the container test.
|
|
62
|
+
*/
|
|
63
|
+
function branchesOf(node) {
|
|
64
|
+
const n = node;
|
|
65
|
+
const b = n?.anyOf ?? n?.oneOf;
|
|
66
|
+
if (!b)
|
|
67
|
+
return [node];
|
|
68
|
+
const nonNull = b.filter((x) => x && x.type !== "null");
|
|
69
|
+
return nonNull.length ? nonNull : [node];
|
|
70
|
+
}
|
|
71
|
+
/** A node is stringy if it, or any branch, is a string or an array of strings. */
|
|
72
|
+
function isStringy(node) {
|
|
73
|
+
for (const br of branchesOf(node)) {
|
|
74
|
+
const b = br;
|
|
75
|
+
if (b?.type === "string")
|
|
76
|
+
return true;
|
|
77
|
+
if (b?.type === "array") {
|
|
78
|
+
for (const it of branchesOf(b.items)) {
|
|
79
|
+
if (it?.type === "string")
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
/** Every child property-map reachable from a node: objects, array items, maps. */
|
|
87
|
+
function childMaps(node) {
|
|
88
|
+
const out = [];
|
|
89
|
+
for (const br of branchesOf(node)) {
|
|
90
|
+
const b = br;
|
|
91
|
+
if (!b || typeof b !== "object")
|
|
92
|
+
continue;
|
|
93
|
+
if (b.properties)
|
|
94
|
+
out.push(b.properties);
|
|
95
|
+
if (b.type === "array" || b.items) {
|
|
96
|
+
for (const it of branchesOf(b.items)) {
|
|
97
|
+
const i = it;
|
|
98
|
+
if (i?.properties)
|
|
99
|
+
out.push(i.properties);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (b.additionalProperties && typeof b.additionalProperties === "object" && b.additionalProperties.properties) {
|
|
103
|
+
out.push(b.additionalProperties.properties);
|
|
104
|
+
}
|
|
105
|
+
if (b.patternProperties) {
|
|
106
|
+
for (const sub of Object.values(b.patternProperties))
|
|
107
|
+
if (sub?.properties)
|
|
108
|
+
out.push(sub.properties);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return out;
|
|
112
|
+
}
|
|
113
|
+
function describe(schema) {
|
|
114
|
+
const own = schema?.description;
|
|
115
|
+
if (own)
|
|
116
|
+
return own;
|
|
117
|
+
for (const br of branchesOf(schema)) {
|
|
118
|
+
const d = br?.description;
|
|
119
|
+
if (d)
|
|
120
|
+
return d;
|
|
121
|
+
}
|
|
122
|
+
return "";
|
|
123
|
+
}
|
|
124
|
+
const MAX_DEPTH = 6;
|
|
125
|
+
function walk(tool, props, trail, depth, seen, sink) {
|
|
126
|
+
for (const [prop, schema] of Object.entries(props ?? {})) {
|
|
127
|
+
if (!schema || typeof schema !== "object")
|
|
128
|
+
continue;
|
|
129
|
+
const dotted = trail ? `${trail}.${prop}` : prop;
|
|
130
|
+
// dedupe by TRAIL, not by object identity: two parameters may share one schema
|
|
131
|
+
// object, and identity-dedupe silently drops the second.
|
|
132
|
+
if (seen.has(dotted))
|
|
133
|
+
continue;
|
|
134
|
+
seen.add(dotted);
|
|
135
|
+
if (isStringy(schema)) {
|
|
136
|
+
const desc = describe(schema);
|
|
137
|
+
const byName = nameLooksPathLike(prop);
|
|
138
|
+
const byDesc = DESC_HINT.test(desc);
|
|
139
|
+
if (byName || byDesc) {
|
|
140
|
+
sink.set(`${tool}\t${dotted}`, {
|
|
141
|
+
tool,
|
|
142
|
+
param: dotted,
|
|
143
|
+
depth,
|
|
144
|
+
why: `${byName ? "name" : ""}${byName && byDesc ? "+" : ""}${byDesc ? "desc" : ""}`,
|
|
145
|
+
desc: desc.replace(/\s+/g, " ").trim(),
|
|
146
|
+
named: prop.toLowerCase() === "path",
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
// deliberately NO `continue`: a union can be stringy AND a container at once.
|
|
150
|
+
}
|
|
151
|
+
if (depth < MAX_DEPTH) {
|
|
152
|
+
for (const child of childMaps(schema))
|
|
153
|
+
walk(tool, child, dotted, depth + 1, seen, sink);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Enumerate every path-like parameter across a tool list, nested ones included,
|
|
159
|
+
* sorted by tool then parameter so two runs are diffable.
|
|
160
|
+
*/
|
|
161
|
+
export function enumeratePathCohort(tools) {
|
|
162
|
+
const sink = new Map();
|
|
163
|
+
for (const t of [...tools].sort((a, b) => a.name.localeCompare(b.name))) {
|
|
164
|
+
walk(t.name, t.inputSchema?.properties, "", 0, new Set(), sink);
|
|
165
|
+
}
|
|
166
|
+
return [...sink.values()].sort((a, b) => a.tool.localeCompare(b.tool) || a.param.localeCompare(b.param));
|
|
167
|
+
}
|
|
168
|
+
/** Cohort sizes, kept comparable to the numbers the handoffs quote. */
|
|
169
|
+
export function summarisePathCohort(rows) {
|
|
170
|
+
return {
|
|
171
|
+
total: rows.length,
|
|
172
|
+
topLevelNamedPath: rows.filter((r) => r.depth === 0 && r.named).length,
|
|
173
|
+
topLevelOther: rows.filter((r) => r.depth === 0 && !r.named).length,
|
|
174
|
+
nested: rows.filter((r) => r.depth > 0).length,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
//# sourceMappingURL=path-cohort.js.map
|
package/dist/paths.js
CHANGED
|
@@ -25,4 +25,115 @@ export function readFileText(absPath) {
|
|
|
25
25
|
return "";
|
|
26
26
|
}
|
|
27
27
|
}
|
|
28
|
+
function refuse(code, message) {
|
|
29
|
+
throw Object.assign(new Error(message), { refusal: true, code });
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* THE containment test. One implementation, and every guard in the codebase calls it.
|
|
33
|
+
*
|
|
34
|
+
* 🔴 `root + path.sep`, never a bare `startsWith(root)`: a SIBLING directory named
|
|
35
|
+
* `<root>_evil` shares the prefix and would otherwise pass. That is the exact spelling
|
|
36
|
+
* every measurement uses, and the trap 160 §7 records as carried.
|
|
37
|
+
*
|
|
38
|
+
* Before 1.40.0 this comparison existed FIVE times — here, and hand-rolled in lsp.ts,
|
|
39
|
+
* cslsp.ts, dap.ts and csdap.ts. Four copies of a security-relevant check is three too
|
|
40
|
+
* many; 161 §8 item 5 said so, and said to measure the four before folding them.
|
|
41
|
+
*/
|
|
42
|
+
export function escapesProject(fsPath, root) {
|
|
43
|
+
return fsPath !== root && !fsPath.startsWith(root + path.sep);
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Resolve a caller-supplied path and REFUSE one that escapes the project root.
|
|
47
|
+
* Returns the resolved absolute path.
|
|
48
|
+
*
|
|
49
|
+
* An absolute path that lands INSIDE the root stays legal — several tools
|
|
50
|
+
* document `table_path` and friends as "res:// or absolute", and narrowing that
|
|
51
|
+
* would break callers who pass a full path to a file in their own project.
|
|
52
|
+
* What is refused is the resolved location, not the spelling.
|
|
53
|
+
*/
|
|
54
|
+
export function resolveInsideProject(p, projectPath, label = "path") {
|
|
55
|
+
const fsPath = path.resolve(toFsPath(p, projectPath));
|
|
56
|
+
const root = path.resolve(projectPath);
|
|
57
|
+
if (escapesProject(fsPath, root)) {
|
|
58
|
+
refuse("path_outside_project", `Refusing ${label} ${JSON.stringify(p)}: it resolves to ${fsPath}, which is outside ` +
|
|
59
|
+
`the Godot project root (${root}). Pass a res:// path, or a path inside the project.`);
|
|
60
|
+
}
|
|
61
|
+
return fsPath;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* `resolveInsideProject`, and the target must already exist AS A REGULAR FILE.
|
|
65
|
+
*
|
|
66
|
+
* This is what separates the four causes `readFileText`'s "" used to conflate.
|
|
67
|
+
* Measured: a missing file, a real but EMPTY file, a DIRECTORY, and `""` (which
|
|
68
|
+
* resolves to the project root itself) all produced the identical
|
|
69
|
+
* "…(does it exist?)" refusal, and three of those four DID exist. Existence and
|
|
70
|
+
* emptiness are different answers and the caller has to be able to tell them
|
|
71
|
+
* apart — an empty table is a data problem, a missing one is a path problem.
|
|
72
|
+
*/
|
|
73
|
+
export function resolveExistingFile(p, projectPath, label = "path") {
|
|
74
|
+
const fsPath = resolveInsideProject(p, projectPath, label);
|
|
75
|
+
let stat;
|
|
76
|
+
try {
|
|
77
|
+
stat = fs.statSync(fsPath);
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
refuse("not_found", `Refusing ${label} ${JSON.stringify(p)}: no such file (${fsPath}).`);
|
|
81
|
+
}
|
|
82
|
+
if (stat.isDirectory()) {
|
|
83
|
+
refuse("not_a_file", `Refusing ${label} ${JSON.stringify(p)}: ${fsPath} is a directory, not a file` +
|
|
84
|
+
`${p === "" ? " — an empty path resolves to the project root itself" : ""}.`);
|
|
85
|
+
}
|
|
86
|
+
if (!stat.isFile()) {
|
|
87
|
+
refuse("not_a_file", `Refusing ${label} ${JSON.stringify(p)}: ${fsPath} is not a regular file.`);
|
|
88
|
+
}
|
|
89
|
+
return fsPath;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* `resolveInsideProject` for a WRITE target, honouring an `overwrite` flag.
|
|
93
|
+
*
|
|
94
|
+
* Returns `{ fsPath, exists }` so the caller can decide what an existing target
|
|
95
|
+
* needs beyond permission to replace it — the tabletop scene creators have to
|
|
96
|
+
* close a stale editor tab first, because the editor reuses an already-open
|
|
97
|
+
* scene rather than re-reading the fresh root the addon just saved.
|
|
98
|
+
*/
|
|
99
|
+
export function resolveWriteTarget(p, projectPath, opts = {}) {
|
|
100
|
+
const label = opts.label ?? "path";
|
|
101
|
+
const fsPath = resolveInsideProject(p, projectPath, label);
|
|
102
|
+
const exists = fs.existsSync(fsPath);
|
|
103
|
+
if (exists && !opts.overwrite) {
|
|
104
|
+
refuse("exists", `Refusing to write ${label} ${JSON.stringify(p)}: ${fsPath} already exists. ` +
|
|
105
|
+
`Pass overwrite:true to replace it, or choose another path.`);
|
|
106
|
+
}
|
|
107
|
+
return { fsPath, exists };
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* The guard the four language/debug planes share: refuse a source that escapes the
|
|
111
|
+
* project root, names nothing, or names something that is not a regular file.
|
|
112
|
+
*
|
|
113
|
+
* Separate from `resolveExistingFile` on purpose. That one serves tabletop/netcode,
|
|
114
|
+
* whose refusals are worded around READING A TABLE and whose gate pins "is a
|
|
115
|
+
* directory, not a file"; these four are worded around A BREAKPOINT THAT CANNOT BIND
|
|
116
|
+
* and their gates pin "is not a file". Same check, two shipped vocabularies — folding
|
|
117
|
+
* the vocabularies together would change answers this session did not measure.
|
|
118
|
+
*/
|
|
119
|
+
export function resolveSourceFile(p, projectPath, w) {
|
|
120
|
+
const fsPath = path.resolve(toFsPath(p, projectPath));
|
|
121
|
+
const root = path.resolve(projectPath);
|
|
122
|
+
const anchored = !w.anchoredOnly || !path.isAbsolute(p);
|
|
123
|
+
if (anchored && escapesProject(fsPath, root)) {
|
|
124
|
+
refuse("path_outside_project", `Refusing ${JSON.stringify(p)}: it resolves to ${fsPath}, which is outside ${w.root} ` +
|
|
125
|
+
`(${root}). ${w.escapeHint}`);
|
|
126
|
+
}
|
|
127
|
+
let stat;
|
|
128
|
+
try {
|
|
129
|
+
stat = fs.statSync(fsPath);
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
refuse("file_not_found", `Refusing ${JSON.stringify(p)}: no such file (${fsPath}). ${w.missingHint}`);
|
|
133
|
+
}
|
|
134
|
+
if (!stat.isFile()) {
|
|
135
|
+
refuse("not_a_file", `Refusing ${JSON.stringify(p)}: ${fsPath} is not a file${p === "" ? w.emptyNote ?? "" : ""}.`);
|
|
136
|
+
}
|
|
137
|
+
return fsPath;
|
|
138
|
+
}
|
|
28
139
|
//# sourceMappingURL=paths.js.map
|
package/dist/recipes.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* it's **free** (MIT, shipped in the server), and it sits **over typed,
|
|
8
8
|
* schema-validated, undoable tools** — so the contract is executed by the
|
|
9
9
|
* server, not merely described in prose the model might misapply. Recipes are
|
|
10
|
-
* discoverable via MCP `prompts/list`; they add **no tools** (the
|
|
10
|
+
* discoverable via MCP `prompts/list`; they add **no tools** (the 291-tool
|
|
11
11
|
* count is unchanged) and cost nothing until a client pulls one.
|
|
12
12
|
*/
|
|
13
13
|
import { z } from "zod";
|