xcode-cli 1.0.5
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/LICENSE +21 -0
- package/README.md +151 -0
- package/bin/xcode-cli +19 -0
- package/bin/xcode-cli-ctl +19 -0
- package/package.json +41 -0
- package/skills/xcode-cli/SKILL.md +138 -0
- package/src/mcpbridge.ts +1624 -0
- package/src/xcode-ctl.ts +138 -0
- package/src/xcode-issues.ts +165 -0
- package/src/xcode-mcp.ts +483 -0
- package/src/xcode-output.ts +431 -0
- package/src/xcode-preview.ts +55 -0
- package/src/xcode-service.ts +278 -0
- package/src/xcode-skill.ts +52 -0
- package/src/xcode-test.ts +115 -0
- package/src/xcode-tree.ts +59 -0
- package/src/xcode-types.ts +28 -0
- package/src/xcode.ts +785 -0
package/src/xcode-mcp.ts
ADDED
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import { URL } from 'node:url';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
5
|
+
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
|
6
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
7
|
+
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
8
|
+
import {
|
|
9
|
+
CallToolRequestSchema,
|
|
10
|
+
isInitializeRequest,
|
|
11
|
+
ListToolsRequestSchema,
|
|
12
|
+
} from '@modelcontextprotocol/sdk/types.js';
|
|
13
|
+
import { parseTestSpecifier, type ParsedTestSpecifier } from './xcode-test.ts';
|
|
14
|
+
|
|
15
|
+
export type McpBridgeStartOptions = {
|
|
16
|
+
host: string;
|
|
17
|
+
port: number;
|
|
18
|
+
path: string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
type TransportSession = {
|
|
22
|
+
server: Server;
|
|
23
|
+
transport: StreamableHTTPServerTransport;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const BRIDGE_NAME = 'xcode-cli-service';
|
|
27
|
+
const BRIDGE_VERSION = '1.0.0';
|
|
28
|
+
|
|
29
|
+
export async function startMcpBridge(options: McpBridgeStartOptions): Promise<void> {
|
|
30
|
+
if (!Number.isInteger(options.port) || options.port < 1 || options.port > 65535) {
|
|
31
|
+
throw new Error(`Invalid port '${options.port}'. Use an integer between 1 and 65535.`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const endpoint = new URL(`http://${options.host}:${options.port}${normalizePath(options.path)}`);
|
|
35
|
+
const upstream = new Client(
|
|
36
|
+
{
|
|
37
|
+
name: BRIDGE_NAME,
|
|
38
|
+
version: BRIDGE_VERSION,
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
capabilities: {},
|
|
42
|
+
},
|
|
43
|
+
);
|
|
44
|
+
const upstreamTransport = new StdioClientTransport({
|
|
45
|
+
command: 'xcrun',
|
|
46
|
+
args: ['mcpbridge'],
|
|
47
|
+
env: buildEnv(),
|
|
48
|
+
stderr: 'inherit',
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
upstream.onerror = (error) => {
|
|
52
|
+
console.error(`Upstream stdio MCP error: ${error.message}`);
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
await upstream.connect(upstreamTransport);
|
|
57
|
+
} catch (error) {
|
|
58
|
+
await upstream.close().catch(() => undefined);
|
|
59
|
+
await upstreamTransport.close().catch(() => undefined);
|
|
60
|
+
const details = error instanceof Error ? error.message : String(error);
|
|
61
|
+
throw new Error(
|
|
62
|
+
[
|
|
63
|
+
'Unable to connect to Xcode via `xcrun mcpbridge`.',
|
|
64
|
+
'Check the following and try again:',
|
|
65
|
+
'1) Xcode 26.3 or later is installed.',
|
|
66
|
+
'2) Xcode is open.',
|
|
67
|
+
'3) `xcode-select -p` points to your Xcode developer directory.',
|
|
68
|
+
`Original error: ${details}`,
|
|
69
|
+
].join('\n'),
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const sessions = new Map<string, TransportSession>();
|
|
74
|
+
const server = http.createServer(async (req, res) => {
|
|
75
|
+
try {
|
|
76
|
+
if (!req.url) {
|
|
77
|
+
res.statusCode = 400;
|
|
78
|
+
res.end('Missing URL');
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const requestUrl = new URL(req.url, endpoint);
|
|
83
|
+
if (requestUrl.pathname === '/health') {
|
|
84
|
+
res.setHeader('content-type', 'application/json');
|
|
85
|
+
res.end(JSON.stringify({ ok: true, endpoint: endpoint.toString() }));
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (requestUrl.pathname !== normalizePath(options.path)) {
|
|
90
|
+
res.statusCode = 404;
|
|
91
|
+
res.end('Not found');
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const method = req.method?.toUpperCase() ?? '';
|
|
96
|
+
const body = method === 'POST' ? await parseJsonBody(req) : undefined;
|
|
97
|
+
const sessionIdHeader = req.headers['mcp-session-id'];
|
|
98
|
+
const sessionId =
|
|
99
|
+
typeof sessionIdHeader === 'string'
|
|
100
|
+
? sessionIdHeader
|
|
101
|
+
: Array.isArray(sessionIdHeader)
|
|
102
|
+
? sessionIdHeader[0]
|
|
103
|
+
: undefined;
|
|
104
|
+
|
|
105
|
+
if (method === 'POST') {
|
|
106
|
+
if (sessionId && sessions.has(sessionId)) {
|
|
107
|
+
await sessions.get(sessionId)!.transport.handleRequest(req, res, body);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (!sessionId && isInitializeRequest(body)) {
|
|
112
|
+
let mcpServer: Server;
|
|
113
|
+
const transport = new StreamableHTTPServerTransport({
|
|
114
|
+
sessionIdGenerator: () => randomUUID(),
|
|
115
|
+
onsessioninitialized: (newSessionId) => {
|
|
116
|
+
sessions.set(newSessionId, { server: mcpServer, transport });
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
transport.onclose = () => {
|
|
120
|
+
const closedSessionId = transport.sessionId;
|
|
121
|
+
if (!closedSessionId) {
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
sessions.delete(closedSessionId);
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
mcpServer = createSessionServer(upstream);
|
|
128
|
+
await mcpServer.connect(transport);
|
|
129
|
+
await transport.handleRequest(req, res, body);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
res.statusCode = 400;
|
|
134
|
+
res.setHeader('content-type', 'application/json');
|
|
135
|
+
res.end(
|
|
136
|
+
JSON.stringify({
|
|
137
|
+
jsonrpc: '2.0',
|
|
138
|
+
error: { code: -32000, message: 'Bad Request: missing valid MCP session' },
|
|
139
|
+
id: null,
|
|
140
|
+
}),
|
|
141
|
+
);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (method === 'GET' || method === 'DELETE') {
|
|
146
|
+
if (!sessionId || !sessions.has(sessionId)) {
|
|
147
|
+
res.statusCode = 400;
|
|
148
|
+
res.end('Invalid or missing MCP session ID');
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
await sessions.get(sessionId)!.transport.handleRequest(req, res);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
res.statusCode = 405;
|
|
156
|
+
res.end('Method not allowed');
|
|
157
|
+
} catch (error) {
|
|
158
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
159
|
+
if (!res.headersSent) {
|
|
160
|
+
res.statusCode = 500;
|
|
161
|
+
res.setHeader('content-type', 'application/json');
|
|
162
|
+
res.end(
|
|
163
|
+
JSON.stringify({
|
|
164
|
+
jsonrpc: '2.0',
|
|
165
|
+
error: { code: -32603, message },
|
|
166
|
+
id: null,
|
|
167
|
+
}),
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
const cleanup = async () => {
|
|
174
|
+
for (const { server: sessionServer } of sessions.values()) {
|
|
175
|
+
await sessionServer.close().catch(() => undefined);
|
|
176
|
+
}
|
|
177
|
+
sessions.clear();
|
|
178
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
179
|
+
await upstream.close().catch(() => undefined);
|
|
180
|
+
await upstreamTransport.close().catch(() => undefined);
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
for (const signal of ['SIGINT', 'SIGTERM'] as const) {
|
|
184
|
+
process.once(signal, () => {
|
|
185
|
+
cleanup()
|
|
186
|
+
.catch(() => undefined)
|
|
187
|
+
.finally(() => {
|
|
188
|
+
process.exit(0);
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
await new Promise<void>((resolve, reject) => {
|
|
194
|
+
server.once('error', reject);
|
|
195
|
+
server.listen(options.port, options.host, () => {
|
|
196
|
+
console.error(`MCP bridge listening on ${endpoint.toString()}`);
|
|
197
|
+
console.error('Upstream stdio: xcrun mcpbridge');
|
|
198
|
+
resolve();
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function createSessionServer(upstream: Client): Server {
|
|
204
|
+
const server = new Server(
|
|
205
|
+
{
|
|
206
|
+
name: BRIDGE_NAME,
|
|
207
|
+
version: BRIDGE_VERSION,
|
|
208
|
+
},
|
|
209
|
+
{
|
|
210
|
+
capabilities: {
|
|
211
|
+
tools: {
|
|
212
|
+
listChanged: true,
|
|
213
|
+
},
|
|
214
|
+
},
|
|
215
|
+
},
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
server.setRequestHandler(ListToolsRequestSchema, async (request) => {
|
|
219
|
+
return await upstream.listTools(request.params);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
223
|
+
const params = await normalizeRunSomeTestsCall(request.params, upstream);
|
|
224
|
+
return await upstream.callTool(params);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
return server;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function normalizePath(value: string): string {
|
|
231
|
+
if (!value.startsWith('/')) {
|
|
232
|
+
return `/${value}`;
|
|
233
|
+
}
|
|
234
|
+
return value;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function buildEnv(): Record<string, string> {
|
|
238
|
+
const env: Record<string, string> = {};
|
|
239
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
240
|
+
if (typeof value === 'string') {
|
|
241
|
+
env[key] = value;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return env;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async function parseJsonBody(req: http.IncomingMessage): Promise<unknown> {
|
|
248
|
+
const chunks: Buffer[] = [];
|
|
249
|
+
for await (const chunk of req) {
|
|
250
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
251
|
+
}
|
|
252
|
+
if (chunks.length === 0) {
|
|
253
|
+
return undefined;
|
|
254
|
+
}
|
|
255
|
+
const raw = Buffer.concat(chunks).toString('utf8').trim();
|
|
256
|
+
if (!raw) {
|
|
257
|
+
return undefined;
|
|
258
|
+
}
|
|
259
|
+
return JSON.parse(raw);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
type TestCatalogEntry = {
|
|
263
|
+
targetName: string;
|
|
264
|
+
identifier: string;
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
async function normalizeRunSomeTestsCall(
|
|
268
|
+
params: Record<string, unknown>,
|
|
269
|
+
upstream: Client,
|
|
270
|
+
): Promise<Record<string, unknown>> {
|
|
271
|
+
if (params.name !== 'RunSomeTests') {
|
|
272
|
+
return params;
|
|
273
|
+
}
|
|
274
|
+
if (!params.arguments || typeof params.arguments !== 'object' || Array.isArray(params.arguments)) {
|
|
275
|
+
return params;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const argumentsRecord = params.arguments as Record<string, unknown>;
|
|
279
|
+
const testsValue = argumentsRecord.tests;
|
|
280
|
+
if (!Array.isArray(testsValue)) {
|
|
281
|
+
return params;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const defaultTargetName = normalizeString(argumentsRecord.targetName);
|
|
285
|
+
const parsed = testsValue.map((value) => parseBridgeTestSpecifier(value, defaultTargetName));
|
|
286
|
+
const normalizedTests = await resolveBridgeTestSpecifiers(parsed, argumentsRecord, upstream);
|
|
287
|
+
|
|
288
|
+
return {
|
|
289
|
+
...params,
|
|
290
|
+
arguments: {
|
|
291
|
+
...argumentsRecord,
|
|
292
|
+
tests: normalizedTests,
|
|
293
|
+
},
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function parseBridgeTestSpecifier(
|
|
298
|
+
value: unknown,
|
|
299
|
+
defaultTargetName?: string,
|
|
300
|
+
): ParsedTestSpecifier {
|
|
301
|
+
if (typeof value === 'string') {
|
|
302
|
+
return parseTestSpecifier(value, defaultTargetName);
|
|
303
|
+
}
|
|
304
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
305
|
+
throw new Error(`Invalid RunSomeTests entry '${String(value)}'. Expected a string or object.`);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const record = value as Record<string, unknown>;
|
|
309
|
+
const targetName = normalizeString(record.targetName) ?? defaultTargetName;
|
|
310
|
+
const testIdentifier = normalizeString(record.testIdentifier);
|
|
311
|
+
|
|
312
|
+
if (testIdentifier) {
|
|
313
|
+
return {
|
|
314
|
+
source: testIdentifier,
|
|
315
|
+
targetName,
|
|
316
|
+
testIdentifier,
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const shorthand = normalizeString(record.identifier) ?? normalizeString(record.test);
|
|
321
|
+
if (shorthand) {
|
|
322
|
+
return parseTestSpecifier(shorthand, targetName);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
throw new Error('Invalid RunSomeTests entry. Missing testIdentifier/identifier/test field.');
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
async function resolveBridgeTestSpecifiers(
|
|
329
|
+
parsed: ParsedTestSpecifier[],
|
|
330
|
+
args: Record<string, unknown>,
|
|
331
|
+
upstream: Client,
|
|
332
|
+
): Promise<Array<{ targetName: string; testIdentifier: string }>> {
|
|
333
|
+
if (parsed.every((entry) => Boolean(entry.targetName))) {
|
|
334
|
+
return parsed.map((entry) => ({
|
|
335
|
+
targetName: entry.targetName!.trim(),
|
|
336
|
+
testIdentifier: entry.testIdentifier,
|
|
337
|
+
}));
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const tabIdentifier = normalizeString(args.tabIdentifier);
|
|
341
|
+
if (!tabIdentifier) {
|
|
342
|
+
throw new Error(
|
|
343
|
+
"RunSomeTests shorthand requires 'tabIdentifier' to resolve test target. Provide 'targetName' explicitly or use Target::Identifier.",
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const catalog = await fetchTestCatalog(upstream, tabIdentifier);
|
|
348
|
+
const availableTargets = [...new Set(catalog.map((entry) => entry.targetName))].sort();
|
|
349
|
+
const lookup = buildCatalogLookup(catalog);
|
|
350
|
+
|
|
351
|
+
return parsed.map((entry) => {
|
|
352
|
+
if (entry.targetName) {
|
|
353
|
+
return {
|
|
354
|
+
targetName: entry.targetName.trim(),
|
|
355
|
+
testIdentifier: entry.testIdentifier,
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const matches = resolveCatalogEntries(lookup, entry.testIdentifier);
|
|
360
|
+
if (matches.length === 0) {
|
|
361
|
+
const targetHint =
|
|
362
|
+
availableTargets.length > 0
|
|
363
|
+
? ` Active scheme targets: ${availableTargets.join(', ')}.`
|
|
364
|
+
: ' Active scheme has no discoverable test targets.';
|
|
365
|
+
throw new Error(
|
|
366
|
+
`Unable to resolve target for '${entry.source}'. Use Target::Identifier or provide targetName.${targetHint} If this test belongs to another scheme, switch the active scheme in Xcode first.`,
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const targets = [...new Set(matches.map((match) => match.targetName))].sort();
|
|
371
|
+
if (targets.length > 1) {
|
|
372
|
+
throw new Error(
|
|
373
|
+
`Ambiguous RunSomeTests shorthand '${entry.source}'. Matching targets: ${targets.join(', ')}. Use Target::Identifier.`,
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
return {
|
|
378
|
+
targetName: targets[0],
|
|
379
|
+
testIdentifier: matches[0].identifier,
|
|
380
|
+
};
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
async function fetchTestCatalog(upstream: Client, tabIdentifier: string): Promise<TestCatalogEntry[]> {
|
|
385
|
+
const response = await upstream.callTool({
|
|
386
|
+
name: 'GetTestList',
|
|
387
|
+
arguments: { tabIdentifier },
|
|
388
|
+
});
|
|
389
|
+
const value =
|
|
390
|
+
(response as { structuredContent?: unknown }).structuredContent ?? response;
|
|
391
|
+
return extractTestCatalog(value);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function extractTestCatalog(value: unknown): TestCatalogEntry[] {
|
|
395
|
+
const entries: TestCatalogEntry[] = [];
|
|
396
|
+
const queue: unknown[] = [value];
|
|
397
|
+
while (queue.length > 0) {
|
|
398
|
+
const current = queue.shift();
|
|
399
|
+
if (!current) {
|
|
400
|
+
continue;
|
|
401
|
+
}
|
|
402
|
+
if (Array.isArray(current)) {
|
|
403
|
+
queue.push(...current);
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
if (typeof current !== 'object') {
|
|
407
|
+
continue;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const record = current as Record<string, unknown>;
|
|
411
|
+
const targetName = normalizeString(record.targetName);
|
|
412
|
+
const identifier = normalizeString(record.identifier);
|
|
413
|
+
if (targetName && identifier) {
|
|
414
|
+
entries.push({ targetName, identifier });
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
for (const nested of Object.values(record)) {
|
|
418
|
+
if (!nested) {
|
|
419
|
+
continue;
|
|
420
|
+
}
|
|
421
|
+
if (Array.isArray(nested)) {
|
|
422
|
+
queue.push(...nested);
|
|
423
|
+
} else if (typeof nested === 'object') {
|
|
424
|
+
queue.push(nested);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
return entries;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function buildCatalogLookup(catalog: TestCatalogEntry[]): Map<string, TestCatalogEntry[]> {
|
|
432
|
+
const lookup = new Map<string, TestCatalogEntry[]>();
|
|
433
|
+
for (const entry of catalog) {
|
|
434
|
+
for (const key of identifierLookupKeys(entry.identifier)) {
|
|
435
|
+
const existing = lookup.get(key);
|
|
436
|
+
if (existing) {
|
|
437
|
+
existing.push(entry);
|
|
438
|
+
} else {
|
|
439
|
+
lookup.set(key, [entry]);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
return lookup;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function resolveCatalogEntries(
|
|
447
|
+
lookup: Map<string, TestCatalogEntry[]>,
|
|
448
|
+
testIdentifier: string,
|
|
449
|
+
): TestCatalogEntry[] {
|
|
450
|
+
const matches = new Map<string, TestCatalogEntry>();
|
|
451
|
+
for (const key of identifierLookupKeys(testIdentifier)) {
|
|
452
|
+
const entries = lookup.get(key);
|
|
453
|
+
if (!entries) {
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
456
|
+
for (const entry of entries) {
|
|
457
|
+
matches.set(`${entry.targetName}::${entry.identifier}`, entry);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
return [...matches.values()];
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function identifierLookupKeys(identifier: string): string[] {
|
|
464
|
+
const trimmed = identifier.trim();
|
|
465
|
+
if (!trimmed) {
|
|
466
|
+
return [];
|
|
467
|
+
}
|
|
468
|
+
const keys = new Set<string>([trimmed]);
|
|
469
|
+
if (trimmed.endsWith('()')) {
|
|
470
|
+
keys.add(trimmed.slice(0, -2));
|
|
471
|
+
} else if (!trimmed.endsWith(')')) {
|
|
472
|
+
keys.add(`${trimmed}()`);
|
|
473
|
+
}
|
|
474
|
+
return [...keys];
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function normalizeString(value: unknown): string | undefined {
|
|
478
|
+
if (typeof value !== 'string') {
|
|
479
|
+
return undefined;
|
|
480
|
+
}
|
|
481
|
+
const trimmed = value.trim();
|
|
482
|
+
return trimmed ? trimmed : undefined;
|
|
483
|
+
}
|