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.
Files changed (45) hide show
  1. package/README.md +1 -1
  2. package/addon/breakpoint_mcp/operations.gd +248 -13
  3. package/addon/breakpoint_mcp/plugin.cfg +1 -1
  4. package/addon/breakpoint_mcp/runtime_bridge.gd +54 -1
  5. package/dist/annotations.js +3 -3
  6. package/dist/cli/doctor.js +1 -1
  7. package/dist/cli/init.js +1 -1
  8. package/dist/cli/tools.js +2 -2
  9. package/dist/csdap.js +88 -6
  10. package/dist/dap.js +172 -10
  11. package/dist/index.js +4 -4
  12. package/dist/path-cohort.js +177 -0
  13. package/dist/paths.js +111 -0
  14. package/dist/recipes.js +1 -1
  15. package/dist/schemas.js +49 -5
  16. package/dist/tools/assetgen.js +17 -1
  17. package/dist/tools/backend.js +18 -1
  18. package/dist/tools/cli.js +41 -4
  19. package/dist/tools/csdap.js +132 -8
  20. package/dist/tools/cslsp.js +110 -5
  21. package/dist/tools/dap.js +316 -59
  22. package/dist/tools/editor/audio.js +16 -2
  23. package/dist/tools/editor/common.js +39 -1
  24. package/dist/tools/editor/filesystem.js +23 -3
  25. package/dist/tools/editor/introspection.js +72 -1
  26. package/dist/tools/editor/node.js +7 -2
  27. package/dist/tools/editor/particles.js +7 -2
  28. package/dist/tools/editor/project_input_test.js +16 -2
  29. package/dist/tools/editor/resource.js +54 -7
  30. package/dist/tools/editor/scene.js +31 -4
  31. package/dist/tools/editor/shader.js +19 -3
  32. package/dist/tools/editor/spatial.js +16 -1
  33. package/dist/tools/editor/tiles.js +16 -1
  34. package/dist/tools/editor/ui.js +29 -2
  35. package/dist/tools/editor.js +20 -13
  36. package/dist/tools/knowledge.js +14 -1
  37. package/dist/tools/lsp-common.js +29 -0
  38. package/dist/tools/lsp.js +87 -8
  39. package/dist/tools/netcode.js +47 -4
  40. package/dist/tools/processes.js +14 -2
  41. package/dist/tools/runtime.js +49 -9
  42. package/dist/tools/tabletop.js +173 -7
  43. package/dist/tools/vcs.js +138 -22
  44. package/dist/toolsets.js +2 -2
  45. 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
- return { buffered: true, path, lines };
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
- /** Full handshake: initialize → launch/attach → (breakpoints) → configurationDone. */
244
- async start(mode, args) {
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.state = "running";
268
- // Surface a launch/attach failure if one occurs, but don't hang on success.
269
- startReq.catch((err) => this.emit("error", err));
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.28.0" }, { capabilities: { ...TASK_CAPABILITIES, ...RESOURCE_CAPABILITIES }, taskStore });
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
- // 289 − 13 = 276 tools. Enable via BREAKPOINT_PRIVILEGED_GROUPS. Wraps
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 289-tool surface, byte-identical to before. A filter
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 289-tool count is unchanged — and drives
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 289-tool
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";