Skip to content

Commit 4d8eb4c

Browse files
fix(mcp): return correct lines on typescript errors
1 parent e4ad4ae commit 4d8eb4c

File tree

2 files changed

+379
-0
lines changed

2 files changed

+379
-0
lines changed

.devcontainer/Dockerfile

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# syntax=docker/dockerfile:1
2+
FROM debian:bookworm-slim AS stainless
3+
4+
RUN apt-get update && apt-get install -y \
5+
nodejs \
6+
npm \
7+
yarnpkg \
8+
&& apt-get clean autoclean
9+
10+
# Ensure UTF-8 encoding
11+
ENV LANG=C.UTF-8
12+
ENV LC_ALL=C.UTF-8
13+
14+
# Yarn
15+
RUN ln -sf /usr/bin/yarnpkg /usr/bin/yarn
16+
17+
WORKDIR /workspace
18+
19+
COPY package.json yarn.lock /workspace/
20+
21+
RUN yarn install
22+
23+
COPY . /workspace
Lines changed: 356 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,356 @@
1+
import Fuse from 'fuse.js';
2+
3+
const allKinds = ['string', 'number', 'boolean', 'object', 'array', 'method', 'constructor'] as const;
4+
type Kind = (typeof allKinds)[number];
5+
6+
const nodeInspect = Symbol.for('nodejs.util.inspect.custom');
7+
const denoInspect = Symbol.for('Deno.customInspect');
8+
9+
const stringMethods = new Set(Reflect.ownKeys(String.prototype).filter((k) => typeof k === 'string'));
10+
const numberMethods = new Set(Reflect.ownKeys(Number.prototype).filter((k) => typeof k === 'string'));
11+
const arrayMethods = new Set(Reflect.ownKeys(Array.prototype).filter((k) => typeof k === 'string'));
12+
13+
function getApplyKinds(name?: string | symbol): readonly Kind[] {
14+
if (!name || name === nodeInspect || name === denoInspect) {
15+
return allKinds;
16+
}
17+
18+
if (name === Symbol.toPrimitive || name === 'toString' || name === 'valueOf') {
19+
return ['string', 'number', 'boolean'];
20+
}
21+
22+
if (name === Symbol.iterator) {
23+
return ['array'];
24+
}
25+
26+
if (typeof name !== 'string') {
27+
return ['method'];
28+
}
29+
30+
const kinds: Kind[] = [];
31+
if (stringMethods.has(name)) {
32+
kinds.push('string');
33+
} else if (numberMethods.has(name)) {
34+
kinds.push('number');
35+
} else if (arrayMethods.has(name)) {
36+
kinds.push('array');
37+
}
38+
39+
return kinds.length > 0 ? kinds : ['method'];
40+
}
41+
42+
type KindPaths = Record<Kind, string[]>;
43+
44+
function traverseKinds(
45+
obj: object,
46+
path: string = '',
47+
result: KindPaths = {
48+
string: [],
49+
number: [],
50+
boolean: [],
51+
object: [],
52+
array: [],
53+
method: [],
54+
constructor: [],
55+
},
56+
): KindPaths {
57+
while (obj !== null) {
58+
for (const key of Reflect.ownKeys(obj)) {
59+
if (typeof key !== 'string') {
60+
continue;
61+
}
62+
63+
if (key === 'constructor') {
64+
continue;
65+
}
66+
67+
if (!/^[a-zA-Z]/.test(key)) {
68+
continue;
69+
}
70+
71+
const value = Reflect.get(obj, key);
72+
let kind: Kind;
73+
74+
switch (typeof value) {
75+
case 'string': {
76+
kind = 'string';
77+
break;
78+
}
79+
case 'number':
80+
case 'bigint': {
81+
kind = 'number';
82+
break;
83+
}
84+
case 'boolean': {
85+
kind = 'boolean';
86+
break;
87+
}
88+
case 'object': {
89+
if (value === null) {
90+
continue;
91+
}
92+
kind = Array.isArray(value) ? 'array' : 'object';
93+
break;
94+
}
95+
case 'function': {
96+
kind =
97+
key === value.name && value.name === value.prototype?.constructor?.name ?
98+
'constructor'
99+
: 'method';
100+
break;
101+
}
102+
default: {
103+
continue;
104+
}
105+
}
106+
107+
const fullKey = path ? `${path}.${key}` : key;
108+
result[kind].push(fullKey);
109+
110+
if (kind === 'object') {
111+
traverseKinds(value, fullKey, result);
112+
} else if (kind === 'array' && value.length > 0) {
113+
traverseKinds(value[0], `${fullKey}[]`, result);
114+
}
115+
}
116+
117+
obj = Object.getPrototypeOf(obj);
118+
if (obj === Object.prototype || obj === Array.prototype) {
119+
break;
120+
}
121+
}
122+
123+
return result;
124+
}
125+
126+
export type MakeError = (props: {
127+
expected: readonly Kind[];
128+
rootPath: (string | symbol)[];
129+
path: (string | symbol)[];
130+
suggestions: { item: string; score: number }[];
131+
}) => string;
132+
133+
export type ProxyConfig = {
134+
/**
135+
* Whether to also proxy the return values. They will be proxied with
136+
* the same config, except the root path will be blank. Defaults to true.
137+
*/
138+
proxyReturn?: boolean;
139+
/**
140+
* The path to the root object, prepended to path suggestions. For example,
141+
* if this is set to ['client'], then suggestions will be 'client.repos.list',
142+
* 'client.users.list', etc.
143+
*/
144+
rootPath?: (string | symbol)[];
145+
/**
146+
* Customize the error message to be thrown. The root path will not be
147+
* prepended to either the path or the suggestions.
148+
*/
149+
makeSuggestionError?: MakeError;
150+
};
151+
152+
function shouldProxy(value: unknown): value is NonNullable<object> {
153+
return value !== null && (typeof value === 'object' || typeof value === 'function');
154+
}
155+
156+
const emptyTargetSymbol = Symbol.for('did-you-mean-proxy.emptyTargetPath');
157+
158+
type EmptyTarget = {
159+
[emptyTargetSymbol]: {
160+
getError: () => string;
161+
};
162+
};
163+
type EmptyTargetInfo = EmptyTarget[typeof emptyTargetSymbol];
164+
165+
/**
166+
* We use a special empty target so we can catch calls and constructions.
167+
* Also useful for de-proxying in the end; if we get an empty target, we know
168+
* we can throw an error.
169+
*/
170+
function createEmptyTarget(info: EmptyTargetInfo): EmptyTarget {
171+
const emptyTarget = function () {} as any;
172+
emptyTarget[nodeInspect] = () => {
173+
throw info.getError();
174+
};
175+
emptyTarget[denoInspect] = () => {
176+
throw info.getError();
177+
};
178+
emptyTarget[emptyTargetSymbol] = info;
179+
return emptyTarget;
180+
}
181+
182+
function isEmptyTarget(value: unknown): value is EmptyTarget {
183+
return typeof value === 'function' && (value as any)[emptyTargetSymbol] !== undefined;
184+
}
185+
186+
export const defaultMakeError: MakeError = function ({ expected, rootPath, path, suggestions }) {
187+
const rootPathString =
188+
rootPath.length > 0 ? `${rootPath.filter((p) => typeof p === 'string').join('.')}.` : '';
189+
const pathString = `'${rootPathString}${path.filter((p) => typeof p === 'string').join('.')}'`;
190+
191+
let header = `${pathString} does not exist.`;
192+
if (expected.length === 1) {
193+
const expectedType =
194+
expected[0] === 'array' ? 'an array'
195+
: expected[0] === 'object' ? 'an object'
196+
: expected[0] === 'method' ? 'a function'
197+
: `a ${expected[0]}`;
198+
header = `${pathString} is not ${expectedType}.`;
199+
}
200+
201+
const suggestionStrings = suggestions
202+
// TODO(sometime): thresholding?
203+
.filter((suggestion) => suggestion.score < 1)
204+
.slice(0, 5)
205+
.map((suggestion) => `'${rootPathString}${suggestion.item}'`);
206+
207+
let body = '';
208+
if (suggestionStrings.length === 1) {
209+
body = `Did you mean ${suggestionStrings[0]}?`;
210+
} else if (suggestionStrings.length > 1) {
211+
const commas = suggestionStrings.slice(0, suggestionStrings.length - 1).join(', ');
212+
body = `Did you mean ${commas}, or ${suggestionStrings[suggestionStrings.length - 1]}?`;
213+
}
214+
215+
return body ? `${header} ${body}` : header;
216+
};
217+
218+
export const debugMakeError: MakeError = function ({ expected, path, suggestions }) {
219+
return `path ${path.filter((p) => typeof p === 'string').join('.')}; expected ${expected.join(', ')}
220+
${suggestions
221+
.slice(0, 10)
222+
.map((suggestion) => ` - [${suggestion.score.toFixed(2)}] ${suggestion.item}`)
223+
.join('\n')}
224+
`;
225+
};
226+
227+
const proxyToObj = new WeakMap<any, any>();
228+
229+
export function makeProxy<Root extends object>(root: Root, config: ProxyConfig = {}): Root {
230+
let kindPaths: KindPaths | null = null;
231+
232+
config.proxyReturn ??= true;
233+
config.rootPath ??= [''];
234+
config.makeSuggestionError ??= defaultMakeError;
235+
236+
const { proxyReturn, rootPath, makeSuggestionError } = config;
237+
const { rootPath: _, ...subconfig } = config;
238+
239+
function makeError(pathWithRoot: (string | symbol)[], expected: readonly Kind[]) {
240+
if (!kindPaths) {
241+
kindPaths = traverseKinds(root);
242+
}
243+
244+
const fuse = new Fuse(
245+
expected.flatMap((kind) => kindPaths![kind]),
246+
{ includeScore: true },
247+
);
248+
249+
const path = pathWithRoot.slice(rootPath.length);
250+
const searchKey: string[] = [];
251+
for (const key of path) {
252+
// Convert array keys to []:
253+
if (/^\d+$/.test(key.toString())) {
254+
searchKey.push('[]');
255+
} else if (typeof key === 'string') {
256+
searchKey.push('.');
257+
searchKey.push(key);
258+
}
259+
}
260+
261+
const key = searchKey.join('');
262+
const suggestions = fuse.search(key.slice(1)) as { item: string; score: number }[];
263+
264+
return makeSuggestionError({ expected, rootPath, path, suggestions });
265+
}
266+
267+
function subproxy<T extends object>(obj: T, path: (string | symbol)[]): T {
268+
const handlers: ProxyHandler<T> = {
269+
get(target, prop, receiver) {
270+
const newPath = [...path, prop];
271+
const value = Reflect.get(target, prop, receiver);
272+
273+
if (value === undefined && !Reflect.has(target, prop)) {
274+
// Some common special cases:
275+
// - 'then' is called on a non-thenable.
276+
// - 'toJSON' is called when it's not defined.
277+
// In these cases, we actually want to return undefined, so we
278+
// resolve to the top-level thing.
279+
if (prop === 'then' || prop === 'toJSON') {
280+
return undefined;
281+
}
282+
283+
return subproxy(
284+
createEmptyTarget({
285+
getError: () => makeError(newPath, allKinds),
286+
}),
287+
newPath,
288+
);
289+
}
290+
291+
return shouldProxy(value) ? subproxy(value, newPath) : value;
292+
},
293+
construct(target, args, newTarget) {
294+
if (isEmptyTarget(target) || typeof target !== 'function') {
295+
throw new Error(makeError(path, ['constructor']));
296+
}
297+
298+
const result = Reflect.construct(target, args, newTarget);
299+
300+
return proxyReturn && shouldProxy(result) ? makeProxy(result, subconfig) : result;
301+
},
302+
apply(target, thisArg, args) {
303+
if (isEmptyTarget(target) || typeof target !== 'function') {
304+
throw new Error(makeError(path, getApplyKinds(path[path.length - 1])));
305+
}
306+
307+
const correctThisArg = proxyToObj.get(thisArg) ?? thisArg;
308+
const proxiedArgs =
309+
proxyReturn ? args.map((arg) => (shouldProxy(arg) ? makeProxy(arg, subconfig) : arg)) : args;
310+
const result = Reflect.apply(target, correctThisArg, proxiedArgs);
311+
312+
return proxyReturn && shouldProxy(result) ? makeProxy(result, subconfig) : result;
313+
},
314+
};
315+
316+
// All other traps demand a non-empty target:
317+
for (const trap of [
318+
'defineProperty',
319+
'has',
320+
'set',
321+
'deleteProperty',
322+
'ownKeys',
323+
'getPrototypeOf',
324+
'setPrototypeOf',
325+
'isExtensible',
326+
'preventExtensions',
327+
'getOwnPropertyDescriptor',
328+
] as const) {
329+
handlers[trap] = function (target: any, ...args: any[]) {
330+
if (isEmptyTarget(target)) {
331+
throw new Error(makeError(path, allKinds));
332+
}
333+
334+
return (Reflect[trap] as any)(target, ...args);
335+
};
336+
}
337+
338+
const proxy = new Proxy(obj, handlers);
339+
proxyToObj.set(proxy, obj);
340+
341+
return proxy;
342+
}
343+
344+
return subproxy(root, rootPath);
345+
}
346+
347+
export function deproxy<T>(value: T): T {
348+
// Primitives never get proxied, so these are safe:
349+
if (typeof value !== 'object' && typeof value !== 'function') {
350+
return value;
351+
}
352+
if (isEmptyTarget(value)) {
353+
throw new Error(value[emptyTargetSymbol].getError());
354+
}
355+
return proxyToObj.get(value) ?? value;
356+
}

0 commit comments

Comments
 (0)