Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions changelog.d/595.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
`get_local_variables` no longer returns JavaScript Global bindings as locals
when a frame exposes no Local or block scope. It returns an empty list with an
`anchorNote` directing callers to `get_scopes` and `get_variables` instead.
2 changes: 1 addition & 1 deletion docs/tool-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -788,7 +788,7 @@ If the originally stopped thread has no frames, this tool uses the same user-fra

**Language-Specific Behavior:**
- **Python**: Looks for "Locals" scope, filters out `__builtins__`, special variables, and internal debugger variables
- **JavaScript**: Merges every block scope on the frame — js-debug names them `Block`, `Catch Block`, `With Block` (or the legacy `Block:<label>`) — ahead of the frame's `Local` scope, innermost block first, so a `let` declared in a `for` body or a `catch (e)` binding is returned alongside the function's own locals and shadows them in the order JavaScript does (#558). `scopeName` stays `Local` with no `note` whenever the frame has a Local scope and any block or local scope produced variables, even if `Local` itself contributed nothing. On an ESM top-level frame (blocks but no `Local`), the frame's `Module`/`Script` scope joins the merge as its base and `scopeName` is the block. When nothing local-like has anything to show, the response falls through to a `Closure` scope, then `Module`/`Script`, on the same frame, and `note` says which scope was used. `Global` is consulted only for frames that expose no local or block scope at all — never as a fall-through, so Node's globals are not reported as locals. Filters out `this`, `__proto__`, and V8 internals
- **JavaScript**: Merges every block scope on the frame — js-debug names them `Block`, `Catch Block`, `With Block` (or the legacy `Block:<label>`) — ahead of the frame's `Local` scope, innermost block first, so a `let` declared in a `for` body or a `catch (e)` binding is returned alongside the function's own locals and shadows them in the order JavaScript does (#558). `scopeName` stays `Local` with no `note` whenever the frame has a Local scope and any block or local scope produced variables, even if `Local` itself contributed nothing. On an ESM top-level frame (blocks but no `Local`), the frame's `Module`/`Script` scope joins the merge as its base and `scopeName` is the block. An empty Local/block frame may fall through to Closure or Module on that same frame. A frame with no Local or block scope returns empty variables and an `anchorNote` directing you to `get_scopes`/`get_variables`; Global is never mislabeled as locals. Filters out `this`, `__proto__`, and V8 internals.
- **Ruby**: Reads rdbg's "Local variables" scope, hiding the `%self` pseudo-variable unless `includeSpecial: true`; a frame whose only local is `%self` (a native `[C]` frame) counts as empty
- **Other Languages**: Falls back to generic behavior (first non-global scope)

Expand Down
27 changes: 11 additions & 16 deletions packages/shared/src/interfaces/adapter-policy-js.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,7 @@ export const JS_SCOPE_KINDS = {
local: ['Local', 'Locals', 'Local:'],
block: ['Block', 'Catch Block', 'With Block', 'Block:'],
closure: ['Closure', 'Closure:'],
module: ['Script', 'Module', 'module'],
global: ['Global']
module: ['Script', 'Module', 'module']
} as const;

/** Exact match, or prefix match for a name written with a trailing ':'. */
Expand Down Expand Up @@ -65,8 +64,8 @@ const isClosureScope = (scope: DebugProtocol.Scope): boolean =>
matchesScopeNames(scope, JS_SCOPE_KINDS.closure) || scope.name.startsWith('Closure ');
const isModuleScope = (scope: DebugProtocol.Scope): boolean =>
matchesScopeNames(scope, JS_SCOPE_KINDS.module) || scope.name.toLowerCase() === 'module';
const isGlobalScope = (scope: DebugProtocol.Scope): boolean =>
scope.name.toLowerCase().includes('global');
const JS_NO_LOCAL_SCOPE_NOTE =
'This JavaScript frame exposes no Local or block scope, so get_local_variables intentionally returned no variables; use get_scopes with this frame ID, then get_variables for an explicit scope (for example Global).';

/**
* JavaScript-specific adapter state
Expand Down Expand Up @@ -204,6 +203,13 @@ export const JsDebugAdapterPolicy: AdapterPolicy = {
if (!frameScopes || frameScopes.length === 0) {
return emptyLocalVariableExtraction();
}

// Script/Module/Global-only frames have no local-variable contract.
// Returning Global can dump hundreds of runtime bindings while labeling
// them as locals; leave scope selection explicit instead (#595).
if (!frameScopes.some(isLocalLikeScope)) {
return emptyLocalVariableExtraction(JS_NO_LOCAL_SCOPE_NOTE);
}

const variablesForScope = (scope: DebugProtocol.Scope): Variable[] => {
let scopeVariables = variables[scope.variablesReference] || [];
Expand Down Expand Up @@ -316,16 +322,6 @@ export const JsDebugAdapterPolicy: AdapterPolicy = {
isClosureScope,
isModuleScope
];
// Global is a last resort only for frames that expose no Local or block
// scope at all (top-level script frames). It must never be a fall-through
// past an empty Local: js-debug marks Global expensive but the session layer
// still fetches it, so Node's ~140 globals would be reported as locals
// and the non-empty result would keep the issue #468 walk-down to the
// caller frame from ever running.
if (!frameScopes.some(isLocalLikeScope)) {
fallbackGroups.push(isGlobalScope);
}

for (const matches of fallbackGroups) {
for (const scope of frameScopes.filter(matches)) {
const scopeVariables = variablesForScope(scope);
Expand Down Expand Up @@ -354,8 +350,7 @@ export const JsDebugAdapterPolicy: AdapterPolicy = {
...JS_SCOPE_KINDS.local,
...JS_SCOPE_KINDS.block,
...JS_SCOPE_KINDS.closure,
...JS_SCOPE_KINDS.module,
...JS_SCOPE_KINDS.global
...JS_SCOPE_KINDS.module
];
},

Expand Down
6 changes: 4 additions & 2 deletions packages/shared/src/interfaces/adapter-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,11 +90,13 @@ export interface LocalVariableExtraction {
* variable, in the same order. Empty when `variables` is empty.
*/
scopeRefs: number[];
/** Optional explanation for an intentionally empty policy result. */
note?: string;
}

/** The "no locals here" result: no variables, and therefore no scopes. */
export function emptyLocalVariableExtraction(): LocalVariableExtraction {
return { variables: [], scopeRefs: [] };
export function emptyLocalVariableExtraction(note?: string): LocalVariableExtraction {
return { variables: [], scopeRefs: [], ...(note ? { note } : {}) };
}

/**
Expand Down
8 changes: 4 additions & 4 deletions packages/shared/tests/unit/adapter-policy-js.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,7 @@ describe('JsDebugAdapterPolicy', () => {
expect(JsDebugAdapterPolicy.extractLocalVariables!([frame], scopes, vars)).toEqual({ variables: [], scopeRefs: [] });
});

it('uses Global only for a frame that exposes no Local scope at all', () => {
it('never substitutes Global for a frame that exposes no Local scope', () => {
const scopes: Record<number, DebugProtocol.Scope[]> = {
1: [
{ name: 'Script', variablesReference: 100, expensive: false },
Expand All @@ -235,7 +235,8 @@ describe('JsDebugAdapterPolicy', () => {

const result = JsDebugAdapterPolicy.extractLocalVariables!([frame], scopes, vars);

expect(result.variables.map(variable => variable.name)).toEqual(['globalValue']);
expect(result).toMatchObject({ variables: [], scopeRefs: [] });
expect(result.note).toMatch(/get_scopes.*get_variables/);
});

it('merges a for-body Block scope ahead of the function locals (issue #558)', () => {
Expand Down Expand Up @@ -531,8 +532,7 @@ describe('JsDebugAdapterPolicy', () => {
...JS_SCOPE_KINDS.local,
...JS_SCOPE_KINDS.block,
...JS_SCOPE_KINDS.closure,
...JS_SCOPE_KINDS.module,
...JS_SCOPE_KINDS.global
...JS_SCOPE_KINDS.module
]);
});

Expand Down
2 changes: 1 addition & 1 deletion src/server/tool-schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ export function buildToolDefinitions(options: BuildToolDefinitionsOptions): Tool
{ name: 'pause_execution', description: 'Pause a running program. Waits briefly for the stop; if the program cannot stop within ~5s (e.g. blocked in native code, or an idle server waiting for input), returns success with pending:true and the session reports "paused" the next time the program runs code. Fails with an actionable error if the session has no debuggable target to pause (e.g. a js attach whose target session was never adopted or has ended)', inputSchema: { type: 'object', properties: { sessionId: { type: 'string' }, threadId: { type: 'number', description: 'Thread ID to pause. If omitted or 0, pauses all threads.' } }, required: ['sessionId'] } },
{ name: 'list_threads', description: 'List all threads in the debugged process', inputSchema: { type: 'object', properties: { sessionId: { type: 'string' } }, required: ['sessionId'] } },
{ name: 'get_variables', description: 'Get variables (scope is variablesReference: number). Responses are size-guarded: oversized values are cut (truncated:true) and very large scopes return a capped list with a truncation notice — pass names:[...] to fetch specific variables in full', inputSchema: { type: 'object', properties: { sessionId: { type: 'string' }, scope: { type: 'number', description: "The variablesReference number from a StackFrame or Variable" }, names: namesProp }, required: getVariablesRequired } },
{ name: 'get_local_variables', description: 'Get local variables for the shared inspection anchor. If the stopped thread is frameless, a sibling with policy-recognized user frames is preferred, adopted, and disclosed in anchorNote. Responses are size-guarded: oversized values are cut (truncated:true) and very large scopes return a capped list with a truncation notice — pass names:[...] to fetch specific variables in full', inputSchema: { type: 'object', properties: { sessionId: { type: 'string' }, includeSpecial: { type: 'boolean', description: 'Include special/internal variables like this, __proto__, __builtins__, etc. Default: false' }, names: namesProp }, required: getLocalVariablesRequired } },
{ name: 'get_local_variables', description: 'Get local variables for the shared inspection anchor. If the stopped thread is frameless, a sibling with policy-recognized user frames is preferred, adopted, and disclosed in anchorNote. A JavaScript frame with no Local/block scope returns empty with guidance instead of mislabeling Global bindings. Responses are size-guarded; pass names:[...] to fetch specific variables in full', inputSchema: { type: 'object', properties: { sessionId: { type: 'string' }, includeSpecial: { type: 'boolean', description: 'Include special/internal variables like this, __proto__, __builtins__, etc. Default: false' }, names: namesProp }, required: getLocalVariablesRequired } },
{ name: 'get_stack_trace', description: 'Get stack trace. The response includes stopReason — why the session is paused. For an implicit frameless stopped thread, a sibling with policy-recognized user frames is preferred and adopted, with the switch disclosed in note. Internal/runtime frames are filtered by default; when any are hidden the response carries hiddenFrames and a note', inputSchema: { type: 'object', properties: { sessionId: { type: 'string' }, includeInternals: { type: 'boolean', description: 'Include internal/framework frames (e.g., Node.js internals). Default: false for cleaner output.' }, threadId: { type: 'number', description: 'Inspect this exact thread (ids from list_threads). Explicit selection is authoritative and is never silently switched' } }, required: ['sessionId'] } },
{ name: 'get_scopes', description: 'Get scopes for a stack frame', inputSchema: { type: 'object', properties: { sessionId: { type: 'string' }, frameId: { type: 'number', description: "The ID of the stack frame from a stackTrace response" } }, required: ['sessionId', 'frameId'] } },
{ name: 'evaluate_expression', description: 'Evaluate expression in the current debug context. Without frameId, uses the same adopted top frame as stack and locals and discloses an automatic thread switch in anchorNote. Expressions can read and modify program state. Waits up to 30s by default', inputSchema: { type: 'object', properties: { sessionId: { type: 'string' }, expression: { type: 'string' }, frameId: { type: 'number', description: 'Optional authoritative stack frame ID from get_stack_trace. When provided, no automatic frame/thread selection occurs' }, timeout: { type: 'number', description: 'Max time (ms) to wait for the evaluation to complete (default: 30000, max: 600000). On expiry the request fails but the expression may keep executing in the debuggee. Note: your MCP client may enforce its own overall request timeout' } }, required: ['sessionId', 'expression'] } },
Expand Down
8 changes: 8 additions & 0 deletions src/session/session-manager-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,14 @@ export abstract class SessionManagerData extends SessionManagerCore {
const anchor = frames[0];
if (policy.extractLocalVariables) {
const extraction = policy.extractLocalVariables(frames, scopesMap, variablesMap, includeSpecial);
if (extraction.variables.length === 0 && extraction.note) {
return {
localVars: [],
scopeRefs: [],
scopeName: null,
scopeNote: extraction.note
};
}

// Report the ACTUAL scope name the adapter returned, not the policy's
// canonical name — adapters may annotate it (e.g. Delve's "Locals
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -507,7 +507,7 @@
},
{
"name": "get_local_variables",
"description": "Get local variables for the shared inspection anchor. If the stopped thread is frameless, a sibling with policy-recognized user frames is preferred, adopted, and disclosed in anchorNote. Responses are size-guarded: oversized values are cut (truncated:true) and very large scopes return a capped list with a truncation notice — pass names:[...] to fetch specific variables in full",
"description": "Get local variables for the shared inspection anchor. If the stopped thread is frameless, a sibling with policy-recognized user frames is preferred, adopted, and disclosed in anchorNote. A JavaScript frame with no Local/block scope returns empty with guidance instead of mislabeling Global bindings. Responses are size-guarded; pass names:[...] to fetch specific variables in full",
"inputSchema": {
"type": "object",
"properties": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -507,7 +507,7 @@
},
{
"name": "get_local_variables",
"description": "Get local variables for the shared inspection anchor. If the stopped thread is frameless, a sibling with policy-recognized user frames is preferred, adopted, and disclosed in anchorNote. Responses are size-guarded: oversized values are cut (truncated:true) and very large scopes return a capped list with a truncation notice — pass names:[...] to fetch specific variables in full",
"description": "Get local variables for the shared inspection anchor. If the stopped thread is frameless, a sibling with policy-recognized user frames is preferred, adopted, and disclosed in anchorNote. A JavaScript frame with no Local/block scope returns empty with guidance instead of mislabeling Global bindings. Responses are size-guarded; pass names:[...] to fetch specific variables in full",
"inputSchema": {
"type": "object",
"properties": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -506,7 +506,7 @@
},
{
"name": "get_local_variables",
"description": "Get local variables for the shared inspection anchor. If the stopped thread is frameless, a sibling with policy-recognized user frames is preferred, adopted, and disclosed in anchorNote. Responses are size-guarded: oversized values are cut (truncated:true) and very large scopes return a capped list with a truncation notice — pass names:[...] to fetch specific variables in full",
"description": "Get local variables for the shared inspection anchor. If the stopped thread is frameless, a sibling with policy-recognized user frames is preferred, adopted, and disclosed in anchorNote. A JavaScript frame with no Local/block scope returns empty with guidance instead of mislabeling Global bindings. Responses are size-guarded; pass names:[...] to fetch specific variables in full",
"inputSchema": {
"type": "object",
"properties": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -506,7 +506,7 @@
},
{
"name": "get_local_variables",
"description": "Get local variables for the shared inspection anchor. If the stopped thread is frameless, a sibling with policy-recognized user frames is preferred, adopted, and disclosed in anchorNote. Responses are size-guarded: oversized values are cut (truncated:true) and very large scopes return a capped list with a truncation notice — pass names:[...] to fetch specific variables in full",
"description": "Get local variables for the shared inspection anchor. If the stopped thread is frameless, a sibling with policy-recognized user frames is preferred, adopted, and disclosed in anchorNote. A JavaScript frame with no Local/block scope returns empty with guidance instead of mislabeling Global bindings. Responses are size-guarded; pass names:[...] to fetch specific variables in full",
"inputSchema": {
"type": "object",
"properties": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -506,7 +506,7 @@
},
{
"name": "get_local_variables",
"description": "Get local variables for the shared inspection anchor. If the stopped thread is frameless, a sibling with policy-recognized user frames is preferred, adopted, and disclosed in anchorNote. Responses are size-guarded: oversized values are cut (truncated:true) and very large scopes return a capped list with a truncation notice — pass names:[...] to fetch specific variables in full",
"description": "Get local variables for the shared inspection anchor. If the stopped thread is frameless, a sibling with policy-recognized user frames is preferred, adopted, and disclosed in anchorNote. A JavaScript frame with no Local/block scope returns empty with guidance instead of mislabeling Global bindings. Responses are size-guarded; pass names:[...] to fetch specific variables in full",
"inputSchema": {
"type": "object",
"properties": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -506,7 +506,7 @@
},
{
"name": "get_local_variables",
"description": "Get local variables for the shared inspection anchor. If the stopped thread is frameless, a sibling with policy-recognized user frames is preferred, adopted, and disclosed in anchorNote. Responses are size-guarded: oversized values are cut (truncated:true) and very large scopes return a capped list with a truncation notice — pass names:[...] to fetch specific variables in full",
"description": "Get local variables for the shared inspection anchor. If the stopped thread is frameless, a sibling with policy-recognized user frames is preferred, adopted, and disclosed in anchorNote. A JavaScript frame with no Local/block scope returns empty with guidance instead of mislabeling Global bindings. Responses are size-guarded; pass names:[...] to fetch specific variables in full",
"inputSchema": {
"type": "object",
"properties": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -517,7 +517,7 @@
},
{
"name": "get_local_variables",
"description": "Get local variables for the shared inspection anchor. If the stopped thread is frameless, a sibling with policy-recognized user frames is preferred, adopted, and disclosed in anchorNote. Responses are size-guarded: oversized values are cut (truncated:true) and very large scopes return a capped list with a truncation notice — pass names:[...] to fetch specific variables in full",
"description": "Get local variables for the shared inspection anchor. If the stopped thread is frameless, a sibling with policy-recognized user frames is preferred, adopted, and disclosed in anchorNote. A JavaScript frame with no Local/block scope returns empty with guidance instead of mislabeling Global bindings. Responses are size-guarded; pass names:[...] to fetch specific variables in full",
"inputSchema": {
"type": "object",
"properties": {
Expand Down
Loading
Loading