@gate-forge/pack-http 0.1.0

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.
@@ -0,0 +1,1069 @@
1
+ /**
2
+ * Bounded static dataflow for frontend API-client calls (ADR 0004 D6,
3
+ * plan phase 3).
4
+ *
5
+ * Real ASTs (the TypeScript compiler API, pure analysis — no evaluation,
6
+ * no I/O beyond the caller-provided file set) replace the old regex
7
+ * client scan. The model is deliberately bounded:
8
+ *
9
+ * - **Direct literal calls**: `fetch('/x')`, `axios.get('/x')`, instance
10
+ * verbs, `axios({url, method})` — method from verb name or a literal /
11
+ * const `method` property; never a silent GET default for computed
12
+ * methods (those become `HTTP_METHOD_DYNAMIC`).
13
+ * - **Module constants**: `const X = '/x'` / template / builder call,
14
+ * resolved across the scanned file set through relative imports with a
15
+ * cycle guard and memoization.
16
+ * - **Templates**: `${expr}` holes resolve through the same value table;
17
+ * rooted holes become positional `{}` slots without needing runtime
18
+ * values; an unresolvable hole before the path is rooted (host/base)
19
+ * makes the whole target `FRONTEND_CALL_TARGET_UNRESOLVED`.
20
+ * - **Configured client symbols** (`apiClient.get(...)`) and **pure URL
21
+ * builders** (`buildApiPath('/v1/x')` with an optional declared base)
22
+ * are configuration-declared resolvable APIs — never coverage
23
+ * exemptions: unresolved flows still block.
24
+ * - **Instance baseURL joining**: when an instance symbol's creation is
25
+ * modeled — a module-scope `const apiClient = axios.create({...})`
26
+ * whose config carries a proven LITERAL `baseURL` (a direct property,
27
+ * or one reaching it through a declared constant config object the
28
+ * creation is assigned from or spreads, resolved by the same bounded
29
+ * value table) — the base joins into the emitted call path:
30
+ * `normalizedPath = normalize(baseURL + callPath)` with exactly one
31
+ * slash seam. Empty/`/` bases, absolute call URLs, and unprovable
32
+ * (env-dependent) bases join nothing: the callsite behaves exactly as
33
+ * it would without the feature, and joining never turns a passing
34
+ * callsite into a blocker. The raw path stays exactly as written.
35
+ * - **Simple wrapper functions**: a configured wrapper whose declaration
36
+ * in the scanned set is a single `return <client call>(...)` arrow or
37
+ * function resolves its internal call with the callsite's first
38
+ * argument substituted for the wrapper's first parameter (one level,
39
+ * one parameter — anything deeper is typed unresolved).
40
+ */
41
+ import ts from 'typescript';
42
+ import { readFileSync } from 'node:fs';
43
+ import { posix } from 'node:path';
44
+ import { FRONTEND_CALL_TARGET_UNRESOLVED, HTTP_METHOD_DYNAMIC, HTTP_PATH_DYNAMIC, normalizeHttpPath, } from '@gate-forge/http-contract';
45
+ import { pathInScope } from '@gate-forge/core';
46
+ export const DEFAULT_CLIENT_SCAN_CONFIG = {};
47
+ /**
48
+ * Whether `file` (repo-root-relative posix) is inside the top-level
49
+ * client-call scan roots. Absent roots admit EVERY file — the
50
+ * back-compat contract: without the key, scanning is exactly as before.
51
+ */
52
+ export function fileInClientScanRoots(config, file) {
53
+ return config.clientScanRoots === undefined || pathInScope(file, config.clientScanRoots);
54
+ }
55
+ /**
56
+ * Whether `file` is inside the top-level server-route scan roots.
57
+ * Absent roots admit every file (back-compat, same rule as above).
58
+ */
59
+ export function fileInServerScanRoots(config, file) {
60
+ return config.serverScanRoots === undefined || pathInScope(file, config.serverScanRoots);
61
+ }
62
+ /** Whether one entry's own include/exclude scoping admits `file`. */
63
+ function entryInScope(file, entry) {
64
+ // Exclude wins over include: a file named by both is out of scope.
65
+ // Deterministic precedence, documented in the README.
66
+ if (entry.exclude !== undefined && pathInScope(file, entry.exclude))
67
+ return false;
68
+ if (entry.include !== undefined && !pathInScope(file, entry.include))
69
+ return false;
70
+ return true;
71
+ }
72
+ /** Whether ANY configured client-symbol entry named `name` admits `file`. */
73
+ function clientSymbolEntriesAdmit(config, name, file) {
74
+ return (config.clientSymbols?.some((entry) => typeof entry === 'string' ? entry === name : entry.name === name && entryInScope(file, entry)) ?? false);
75
+ }
76
+ /**
77
+ * Whether a callsite in `file` uses the configured client symbol
78
+ * `name`: the file must match the top-level clientScanRoots (if
79
+ * present) AND the symbol's own include/exclude (if present). Both
80
+ * gates must pass — per-symbol scoping composes with (never relaxes)
81
+ * the top-level roots.
82
+ */
83
+ export function clientSymbolActiveIn(config, name, file) {
84
+ if (!fileInClientScanRoots(config, file))
85
+ return false;
86
+ return clientSymbolEntriesAdmit(config, name, file);
87
+ }
88
+ /** The active configured wrapper named `name` for `file`, if any. */
89
+ function activeWrapperIn(config, name, file) {
90
+ if (!fileInClientScanRoots(config, file))
91
+ return undefined;
92
+ return config.wrapperFunctions?.find((entry) => entry.name === name && entryInScope(file, entry));
93
+ }
94
+ /** The active configured URL builder named `name` for `file`, if any. */
95
+ function activeUrlBuilderIn(config, name, file) {
96
+ if (!fileInClientScanRoots(config, file))
97
+ return undefined;
98
+ return config.urlBuilders?.find((entry) => entry.name === name && entryInScope(file, entry));
99
+ }
100
+ /**
101
+ * True when `name` is declared somewhere in the configuration but NO
102
+ * entry for it admits `file` — the e2e helper that happens to share a
103
+ * configured symbol's name. Such calls are IGNORED, neither resolved
104
+ * nor blocked: the configuration has explicitly spoken about the name
105
+ * and scoped it elsewhere, so interpreting out-of-scope calls (or
106
+ * firing the undeclared-wrapper evidence rule on them) would resurface
107
+ * exactly the harness false positives this scoping removes. Names with
108
+ * no configuration mention never take this path (the evidence rule
109
+ * still blocks undeclared wrappers).
110
+ */
111
+ function declaredElsewhere(config, name, file) {
112
+ const mentioned = (config.clientSymbols?.some((entry) => (typeof entry === 'string' ? entry : entry.name) === name) ?? false) ||
113
+ (config.wrapperFunctions?.some((entry) => entry.name === name) ?? false) ||
114
+ (config.urlBuilders?.some((entry) => entry.name === name) ?? false);
115
+ if (!mentioned)
116
+ return false;
117
+ return (!clientSymbolEntriesAdmit(config, name, file) &&
118
+ config.wrapperFunctions?.some((entry) => entry.name === name && entryInScope(file, entry)) !== true &&
119
+ config.urlBuilders?.some((entry) => entry.name === name && entryInScope(file, entry)) !== true);
120
+ }
121
+ /**
122
+ * The configured client-symbol names ACTIVE for `file` — used by the
123
+ * server-route scanner to disambiguate `api.get('/x')`-shaped client
124
+ * calls from router registrations. Scope-aware: a symbol scoped away
125
+ * from this file does not suppress route discovery in it (out of client
126
+ * scope, `<symbol>.<verb>(path, handler)` can only be a router).
127
+ */
128
+ export function activeClientSymbolNamesIn(config, file) {
129
+ const names = new Set();
130
+ for (const entry of config.clientSymbols ?? []) {
131
+ const name = typeof entry === 'string' ? entry : entry.name;
132
+ if (clientSymbolActiveIn(config, name, file))
133
+ names.add(name);
134
+ }
135
+ return [...names].sort();
136
+ }
137
+ const VERB_METHODS = new Map([
138
+ ['get', 'GET'],
139
+ ['post', 'POST'],
140
+ ['put', 'PUT'],
141
+ ['patch', 'PATCH'],
142
+ ['delete', 'DELETE'],
143
+ ['head', 'HEAD'],
144
+ ['options', 'OPTIONS'],
145
+ ]);
146
+ const UNRESOLVED_VALUE = { kind: 'unresolved', text: '' };
147
+ function languageKindFor(file) {
148
+ if (file.endsWith('.tsx'))
149
+ return ts.ScriptKind.TSX;
150
+ if (file.endsWith('.jsx'))
151
+ return ts.ScriptKind.JSX;
152
+ if (file.endsWith('.js') || file.endsWith('.mjs') || file.endsWith('.cjs')) {
153
+ return ts.ScriptKind.JS;
154
+ }
155
+ return ts.ScriptKind.TS;
156
+ }
157
+ function parseSource(sourceText, file) {
158
+ return ts.createSourceFile(file, sourceText, ts.ScriptTarget.Latest, true, languageKindFor(file));
159
+ }
160
+ /** Collects module-scope constants and configured wrapper declarations. */
161
+ function modelFile(source, config, file) {
162
+ const constants = new Map();
163
+ const clientFunctions = new Set();
164
+ const wrappers = new Map();
165
+ const isModuleScope = (node) => {
166
+ let current = node.parent;
167
+ while (current !== undefined) {
168
+ if (ts.isFunctionDeclaration(current) ||
169
+ ts.isFunctionExpression(current) ||
170
+ ts.isArrowFunction(current) ||
171
+ ts.isMethodDeclaration(current) ||
172
+ ts.isBlock(current)) {
173
+ return false;
174
+ }
175
+ current = current.parent;
176
+ }
177
+ return true;
178
+ };
179
+ const visit = (node) => {
180
+ if ((ts.isVariableStatement(node)) &&
181
+ isModuleScope(node)) {
182
+ for (const declaration of node.declarationList.declarations) {
183
+ if (!ts.isIdentifier(declaration.name) || declaration.initializer === undefined)
184
+ continue;
185
+ constants.set(declaration.name.text, declaration.initializer);
186
+ // One-declaration wrapper: `const apiGet = (path) => fetch(...)`.
187
+ const init = declaration.initializer;
188
+ if (ts.isArrowFunction(init) || ts.isFunctionExpression(init)) {
189
+ modelWrapper(declaration.name.text, init, wrappers);
190
+ if (containsClientCall(init, config, file))
191
+ clientFunctions.add(declaration.name.text);
192
+ }
193
+ }
194
+ }
195
+ if ((ts.isFunctionDeclaration(node) && node.name !== undefined && node.body !== undefined) ||
196
+ (ts.isVariableStatement(node))) {
197
+ // Named function declarations can be client wrappers too.
198
+ if (ts.isFunctionDeclaration(node) && node.name !== undefined && containsClientCall(node, config, file)) {
199
+ clientFunctions.add(node.name.text);
200
+ }
201
+ }
202
+ ts.forEachChild(node, visit);
203
+ };
204
+ visit(source);
205
+ return { source, constants, clientFunctions, wrappers };
206
+ }
207
+ /** True when the subtree contains a direct fetch/axios/client call. */
208
+ function containsClientCall(node, config, file) {
209
+ let found = false;
210
+ const visit = (current) => {
211
+ if (found || !ts.isCallExpression(current)) {
212
+ ts.forEachChild(current, visit);
213
+ return;
214
+ }
215
+ const expression = current.expression;
216
+ if (ts.isIdentifier(expression) && expression.text === 'fetch') {
217
+ found = true;
218
+ return;
219
+ }
220
+ if (ts.isPropertyAccessExpression(expression) && ts.isIdentifier(expression.expression)) {
221
+ const objectName = expression.expression.text;
222
+ if (objectName === 'axios' ||
223
+ expression.name.text === 'fetch' ||
224
+ clientSymbolActiveIn(config, objectName, file)) {
225
+ found = true;
226
+ return;
227
+ }
228
+ }
229
+ ts.forEachChild(current, visit);
230
+ };
231
+ visit(node);
232
+ return found;
233
+ }
234
+ function modelWrapper(name, fn, wrappers) {
235
+ const first = fn.parameters[0];
236
+ if (first === undefined || !ts.isIdentifier(first.name) || fn.parameters.length !== 1)
237
+ return;
238
+ let body;
239
+ if (ts.isArrowFunction(fn)) {
240
+ body = ts.isExpression(fn.body) ? fn.body : undefined;
241
+ }
242
+ else if (fn.body !== undefined && ts.isBlock(fn.body) && fn.body.statements.length === 1) {
243
+ const only = fn.body.statements[0];
244
+ if (only !== undefined && ts.isReturnStatement(only) && only.expression !== undefined) {
245
+ body = only.expression;
246
+ }
247
+ }
248
+ if (body === undefined || !ts.isCallExpression(body))
249
+ return;
250
+ const client = calleeClientName(body);
251
+ if (client !== null) {
252
+ wrappers.set(name, {
253
+ parameter: first.name.text,
254
+ call: body,
255
+ client,
256
+ start: fn.getStart(),
257
+ end: fn.getEnd(),
258
+ });
259
+ }
260
+ }
261
+ /** `fetch`, `axios`, or a configured client symbol — else null. */
262
+ function calleeClientName(call) {
263
+ const expression = call.expression;
264
+ if (ts.isIdentifier(expression))
265
+ return expression.text === 'fetch' ? 'fetch' : expression.text;
266
+ if (ts.isPropertyAccessExpression(expression)) {
267
+ const object = expression.expression;
268
+ if (ts.isIdentifier(object))
269
+ return object.text;
270
+ }
271
+ return null;
272
+ }
273
+ /**
274
+ * Scans one file for frontend API-client calls under the bounded model.
275
+ * `scannedFiles` maps repo-relative paths to source text for every file
276
+ * in the discovery request (import resolution stays inside the set).
277
+ *
278
+ * Scan scoping (phase 3): a file outside `clientScanRoots` (when the
279
+ * key is present) returns EMPTY — no calls AND no unresolved entries.
280
+ * The gate comes first on purpose: even bare `fetch` extraction must
281
+ * not run, because a scoped-out file (an e2e spec, a test harness) is
282
+ * not product frontend consumption and must be invisible to this
283
+ * channel, blockers included. Files outside the roots still participate
284
+ * in the value table as import targets — scoping narrows fact
285
+ * emission, not the dataflow's ability to resolve product code.
286
+ */
287
+ export function scanClientCalls(file, sourceText, config, scannedFiles) {
288
+ if (!fileInClientScanRoots(config, file))
289
+ return { calls: [], unresolved: [] };
290
+ const source = parseSource(sourceText, file);
291
+ const table = new ValueTable(config, scannedFiles);
292
+ const model = table.modelOf(file);
293
+ const calls = [];
294
+ const unresolved = [];
295
+ const wrapperSpans = [...(model?.wrappers.values() ?? [])].map((w) => [w.start, w.end]);
296
+ const insideWrapper = (node) => wrapperSpans.some(([start, end]) => node.getStart(source) >= start && node.getEnd() <= end);
297
+ const visit = (node) => {
298
+ if (ts.isCallExpression(node) && !insideWrapper(node)) {
299
+ extractCall(node, file, config, table, model, calls, unresolved);
300
+ }
301
+ ts.forEachChild(node, visit);
302
+ };
303
+ visit(source);
304
+ calls.sort(byLocation);
305
+ unresolved.sort((a, b) => (a.location.file < b.location.file ? -1 : a.location.file > b.location.file ? 1 : 0) ||
306
+ a.location.line - b.location.line ||
307
+ a.location.col - b.location.col ||
308
+ (a.code < b.code ? -1 : a.code > b.code ? 1 : 0));
309
+ return { calls, unresolved };
310
+ }
311
+ function byLocation(a, b) {
312
+ return ((a.location.file < b.location.file ? -1 : a.location.file > b.location.file ? 1 : 0) ||
313
+ a.location.line - b.location.line ||
314
+ a.location.col - b.location.col);
315
+ }
316
+ function locationOf(file, source, node) {
317
+ const start = node.getStart(source);
318
+ const { line, character } = source.getLineAndCharacterOfPosition(start);
319
+ return { file, line: line + 1, col: character };
320
+ }
321
+ function extractCall(call, file, config, table, model, calls, unresolved) {
322
+ const source = model?.source;
323
+ if (source === undefined)
324
+ return;
325
+ const location = locationOf(file, source, call);
326
+ const expression = call.expression;
327
+ // fetch(url[, {method}])
328
+ if (ts.isIdentifier(expression) && expression.text === 'fetch') {
329
+ extractClientCall(call, 'fetch', 'GET', file, config, table, calls, unresolved, location);
330
+ return;
331
+ }
332
+ // axios.get(url), apiClient.post(url), window.fetch(url)
333
+ if (ts.isPropertyAccessExpression(expression) && ts.isIdentifier(expression.expression)) {
334
+ const objectName = expression.expression.text;
335
+ const verb = expression.name.text.toLowerCase();
336
+ const isFetchObject = objectName === 'window' && expression.name.text === 'fetch';
337
+ if (isFetchObject) {
338
+ extractClientCall(call, 'fetch', 'GET', file, config, table, calls, unresolved, location);
339
+ return;
340
+ }
341
+ // Per-symbol scoping (phase 3): the symbol counts only where its
342
+ // include/exclude (and the top-level roots) admit this file.
343
+ const configured = clientSymbolActiveIn(config, objectName, file);
344
+ if ((objectName === 'axios' || configured) && VERB_METHODS.has(verb)) {
345
+ const method = VERB_METHODS.get(verb) ?? 'GET';
346
+ // Instance baseURL joining: a modeled `axios.create` creation with a
347
+ // proven literal baseURL joins into the emitted path (fetch has no
348
+ // instance and never joins).
349
+ const baseURL = table.instanceBaseURL(objectName, file);
350
+ extractClientCall(call, objectName, method, file, config, table, calls, unresolved, location, baseURL);
351
+ return;
352
+ }
353
+ }
354
+ // axios(url[, config]) / axios.request(config) / instance(config)
355
+ let configCall = null;
356
+ let framework = null;
357
+ if (ts.isIdentifier(expression) && (expression.text === 'axios' || clientSymbolActiveIn(config, expression.text, file))) {
358
+ configCall = call;
359
+ framework = expression.text;
360
+ }
361
+ else if (ts.isPropertyAccessExpression(expression) &&
362
+ ts.isIdentifier(expression.expression) &&
363
+ expression.name.text === 'request' &&
364
+ (expression.expression.text === 'axios' ||
365
+ clientSymbolActiveIn(config, expression.expression.text, file))) {
366
+ configCall = call;
367
+ framework = expression.expression.text;
368
+ }
369
+ if (configCall !== null && framework !== null) {
370
+ const baseURL = table.instanceBaseURL(framework, file);
371
+ extractConfiguredCall(configCall, framework, file, config, table, calls, unresolved, location, baseURL);
372
+ return;
373
+ }
374
+ // Configured wrapper: the declaration must exist in the scanned set as
375
+ // a simple single-return client call; URL comes from the internal call
376
+ // with the callsite's first argument substituted for the parameter,
377
+ // method from configuration. Anything else is typed unresolved — a
378
+ // first-arg-only guess could join the wrong route.
379
+ const wrapperConfig = ts.isIdentifier(expression) ? activeWrapperIn(config, expression.text, file) : undefined;
380
+ if (wrapperConfig !== undefined) {
381
+ const wrapperModel = table.modelOf(file)?.wrappers.get(wrapperConfig.name);
382
+ if (wrapperModel === undefined) {
383
+ unresolved.push({
384
+ code: FRONTEND_CALL_TARGET_UNRESOLVED,
385
+ detail: (`wrapper '${wrapperConfig.name}' has no simple single-return client call in the ` +
386
+ 'scanned set; its call targets cannot be resolved statically'),
387
+ location,
388
+ });
389
+ return;
390
+ }
391
+ const bound = new BoundTable(table, wrapperModel.parameter, call.arguments[0], file);
392
+ const internal = wrapperModel.call;
393
+ const internalExpression = internal.expression;
394
+ let urlNode;
395
+ if (ts.isIdentifier(internalExpression) && internalExpression.text === 'fetch' && internal.arguments.length > 0) {
396
+ urlNode = internal.arguments[0];
397
+ }
398
+ else if (ts.isPropertyAccessExpression(internalExpression) &&
399
+ VERB_METHODS.has(internalExpression.name.text.toLowerCase()) &&
400
+ internal.arguments.length > 0) {
401
+ urlNode = internal.arguments[0];
402
+ }
403
+ if (urlNode === undefined) {
404
+ unresolved.push({
405
+ code: FRONTEND_CALL_TARGET_UNRESOLVED,
406
+ detail: `wrapper '${wrapperConfig.name}' internal call target is outside the supported model`,
407
+ location,
408
+ });
409
+ return;
410
+ }
411
+ resolveAndRecord(urlNode, wrapperConfig.name, wrapperConfig.method, file, config, bound, calls, unresolved, location);
412
+ return;
413
+ }
414
+ // A module-scope function whose body issues client calls IS a client
415
+ // wrapper — evidence from the code itself. Calling it without
416
+ // declaring it in the configuration is a typed block, never silence.
417
+ // EXCEPTION (phase 3 scan-scoping): a name the configuration DOES
418
+ // declare but scopes to other files is the e2e-helper collision —
419
+ // declaredElsewhere ignores it instead of blocking (see there).
420
+ if (ts.isIdentifier(expression) &&
421
+ !declaredElsewhere(config, expression.text, file) &&
422
+ (model?.clientFunctions.has(expression.text) ?? false)) {
423
+ unresolved.push({
424
+ code: FRONTEND_CALL_TARGET_UNRESOLVED,
425
+ detail: (`call to '${expression.text}' resolves to an HTTP client wrapper that is not ` +
426
+ 'declared in the client-scan configuration; declare it to make its targets resolvable'),
427
+ location,
428
+ });
429
+ }
430
+ }
431
+ /**
432
+ * Evaluates expressions with the wrapper's first parameter bound to the
433
+ * callsite argument (one-level bounded dataflow).
434
+ */
435
+ class BoundTable {
436
+ inner;
437
+ parameter;
438
+ argument;
439
+ argumentFile;
440
+ constructor(inner, parameter, argument, argumentFile) {
441
+ this.inner = inner;
442
+ this.parameter = parameter;
443
+ this.argument = argument;
444
+ this.argumentFile = argumentFile;
445
+ }
446
+ evaluate(node, file) {
447
+ if (ts.isIdentifier(node) && node.text === this.parameter && this.argument !== undefined) {
448
+ return this.inner.evaluate(this.argument, this.argumentFile);
449
+ }
450
+ if (ts.isTemplateExpression(node)) {
451
+ let text = node.head.text;
452
+ let rooted = text.startsWith('/');
453
+ for (const span of node.templateSpans) {
454
+ const hole = this.evaluate(span.expression, file);
455
+ if (hole.kind === 'unresolved') {
456
+ if (!rooted)
457
+ return { kind: 'unresolved', text: '' };
458
+ text += '${}';
459
+ }
460
+ else {
461
+ text += hole.text;
462
+ rooted = rooted || hole.text.startsWith('/');
463
+ }
464
+ text += span.literal.text;
465
+ }
466
+ return { kind: 'literal', text };
467
+ }
468
+ return this.inner.evaluate(node, file);
469
+ }
470
+ }
471
+ function extractClientCall(call, framework, defaultMethod, file, config, table, calls, unresolved, location, baseURL) {
472
+ const urlNode = call.arguments[0];
473
+ if (urlNode === undefined) {
474
+ unresolved.push({
475
+ code: FRONTEND_CALL_TARGET_UNRESOLVED,
476
+ detail: `${framework} call has no target argument`,
477
+ location,
478
+ });
479
+ return;
480
+ }
481
+ // Method: explicit option first, else the verb default.
482
+ let method = defaultMethod === 'GET' ? 'GET' : defaultMethod;
483
+ const optionsNode = call.arguments[1];
484
+ if (optionsNode !== undefined && ts.isObjectLiteralExpression(optionsNode)) {
485
+ for (const property of optionsNode.properties) {
486
+ const isMethod = (ts.isPropertyAssignment(property) && ts.isIdentifier(property.name) && property.name.text === 'method') ||
487
+ (ts.isShorthandPropertyAssignment(property) && property.name.text === 'method');
488
+ if (isMethod) {
489
+ const initializer = ts.isPropertyAssignment(property)
490
+ ? property.initializer
491
+ : property.name;
492
+ const resolved = table.evaluate(initializer, file);
493
+ if (resolved.kind === 'literal') {
494
+ const normalized = normalizeHttpMethodValue(resolved.text);
495
+ if (normalized === null) {
496
+ unresolved.push({
497
+ code: HTTP_METHOD_DYNAMIC,
498
+ detail: `fetch call method '${resolved.text}' is not a concrete supported verb`,
499
+ location,
500
+ });
501
+ return;
502
+ }
503
+ method = normalized;
504
+ }
505
+ else {
506
+ unresolved.push({
507
+ code: HTTP_METHOD_DYNAMIC,
508
+ detail: 'fetch call method is computed and cannot be proven statically',
509
+ location,
510
+ });
511
+ return;
512
+ }
513
+ }
514
+ }
515
+ }
516
+ if (method === null)
517
+ return;
518
+ resolveAndRecord(urlNode, framework, method, file, config, table, calls, unresolved, location, baseURL);
519
+ }
520
+ function extractConfiguredCall(call, framework, file, config, table, calls, unresolved, location, baseURL) {
521
+ const configNode = call.arguments[0];
522
+ if (configNode === undefined || !ts.isObjectLiteralExpression(configNode)) {
523
+ unresolved.push({
524
+ code: FRONTEND_CALL_TARGET_UNRESOLVED,
525
+ detail: `${framework} call without an inline config object is outside the supported model`,
526
+ location,
527
+ });
528
+ return;
529
+ }
530
+ let method = 'GET';
531
+ let urlNode;
532
+ for (const property of configNode.properties) {
533
+ if (!ts.isPropertyAssignment(property) || !ts.isIdentifier(property.name))
534
+ continue;
535
+ if (property.name.text === 'url')
536
+ urlNode = property.initializer;
537
+ if (property.name.text === 'method') {
538
+ const resolved = table.evaluate(property.initializer, file);
539
+ if (resolved.kind !== 'literal') {
540
+ unresolved.push({
541
+ code: HTTP_METHOD_DYNAMIC,
542
+ detail: `${framework} call method is computed and cannot be proven statically`,
543
+ location,
544
+ });
545
+ return;
546
+ }
547
+ const normalized = normalizeHttpMethodValue(resolved.text);
548
+ if (normalized === null) {
549
+ unresolved.push({
550
+ code: HTTP_METHOD_DYNAMIC,
551
+ detail: `${framework} call method '${resolved.text}' is not a concrete supported verb`,
552
+ location,
553
+ });
554
+ return;
555
+ }
556
+ method = normalized;
557
+ }
558
+ }
559
+ if (urlNode === undefined) {
560
+ unresolved.push({
561
+ code: FRONTEND_CALL_TARGET_UNRESOLVED,
562
+ detail: `${framework} call config carries no literal-resolvable url property`,
563
+ location,
564
+ });
565
+ return;
566
+ }
567
+ resolveAndRecord(urlNode, framework, method, file, config, table, calls, unresolved, location, baseURL);
568
+ }
569
+ /**
570
+ * axios `isAbsoluteURL`: a call URL beginning `<scheme>://` or a
571
+ * protocol-relative `//` is absolute and IGNORES the configured
572
+ * instance baseURL at runtime — the static join must agree.
573
+ */
574
+ const ABSOLUTE_CALL_URL_RE = /^([a-z][a-z\d+\-.]*:)?\/\//i;
575
+ /**
576
+ * Joins a proven literal instance baseURL onto one call path — the
577
+ * instance-side mirror of the `urlBuilders[].base` join, with axios
578
+ * `combineURLs` seam semantics: trailing base slashes and leading path
579
+ * slashes collapse so exactly one slash separates base and path
580
+ * (normalization then treats the result like any other raw path —
581
+ * identical query/fragment stripping and `${}`/`{}` slotting). Empty
582
+ * and `/` bases add no prefix (byte-identical output), and an absolute
583
+ * call URL keeps its own base, exactly as axios resolves it at runtime.
584
+ * The raw written path is never mutated; the join affects only the
585
+ * canonical/emitted form.
586
+ */
587
+ function joinInstanceBaseURL(baseURL, callPath) {
588
+ if (baseURL === undefined || baseURL === '' || baseURL === '/') {
589
+ return { path: callPath, joined: undefined };
590
+ }
591
+ if (ABSOLUTE_CALL_URL_RE.test(callPath)) {
592
+ return { path: callPath, joined: undefined };
593
+ }
594
+ return {
595
+ path: `${baseURL.replace(/\/+$/, '')}/${callPath.replace(/^\/+/, '')}`,
596
+ joined: baseURL,
597
+ };
598
+ }
599
+ function resolveAndRecord(urlNode, framework, method, file, config, table, calls, unresolved, location, baseURL) {
600
+ const resolved = table.evaluate(urlNode, file);
601
+ if (resolved.kind === 'unresolved') {
602
+ unresolved.push({
603
+ code: FRONTEND_CALL_TARGET_UNRESOLVED,
604
+ detail: `call target cannot be resolved statically (${framework})`,
605
+ location,
606
+ });
607
+ return;
608
+ }
609
+ const joined = joinInstanceBaseURL(baseURL, resolved.text);
610
+ const canonical = normalizeHttpPath(joined.path, { sameOriginHosts: config.sameOriginHosts });
611
+ if (!canonical.ok) {
612
+ unresolved.push({
613
+ code: FRONTEND_CALL_TARGET_UNRESOLVED,
614
+ detail: `call target '${joined.path}': ${canonical.detail}`,
615
+ location,
616
+ });
617
+ return;
618
+ }
619
+ calls.push({
620
+ method,
621
+ rawPath: resolved.text,
622
+ canonicalPath: canonical.canonical,
623
+ ...(joined.joined !== undefined ? { joinedBaseURL: joined.joined } : {}),
624
+ framework,
625
+ location,
626
+ });
627
+ }
628
+ function normalizeHttpMethodValue(raw) {
629
+ const upper = raw.trim().toUpperCase();
630
+ if (upper === 'GET' || upper === 'HEAD' || upper === 'POST' || upper === 'PUT' || upper === 'PATCH' || upper === 'DELETE' || upper === 'OPTIONS') {
631
+ return upper;
632
+ }
633
+ return null;
634
+ }
635
+ /**
636
+ * The one modeled instance-creation shape: `axios.create(...)`. Chained
637
+ * or aliased factories (`getInstance().create`, `makeClient()`) are
638
+ * outside the bounded model — fail closed, no base, unchanged emission.
639
+ */
640
+ function isAxiosCreateCall(node) {
641
+ return (ts.isCallExpression(node) &&
642
+ ts.isPropertyAccessExpression(node.expression) &&
643
+ ts.isIdentifier(node.expression.expression) &&
644
+ node.expression.expression.text === 'axios' &&
645
+ node.expression.name.text === 'create');
646
+ }
647
+ const ABSENT_PROOF = { state: 'absent' };
648
+ const UNPROVEN_PROOF = { state: 'unproven' };
649
+ const PROVEN_EMPTY_PROOF = { state: 'proven', base: '' };
650
+ /** Bounded value resolution over the scanned file set. */
651
+ class ValueTable {
652
+ config;
653
+ scannedFiles;
654
+ models = new Map();
655
+ evaluating = new Set();
656
+ baseMemo = new Map();
657
+ constructor(config, scannedFiles) {
658
+ this.config = config;
659
+ this.scannedFiles = scannedFiles;
660
+ }
661
+ modelOf(file) {
662
+ const cached = this.models.get(file);
663
+ if (cached !== undefined)
664
+ return cached;
665
+ const text = this.scannedFiles.get(file);
666
+ if (text === undefined)
667
+ return undefined;
668
+ const model = modelFile(parseSource(text, file), this.config, file);
669
+ this.models.set(file, model);
670
+ return model;
671
+ }
672
+ /**
673
+ * Evaluates one expression to literal text (with `${}` slots kept for
674
+ * template holes) or `unresolved`. Rootedness matters only at the call
675
+ * site: a slot mid-path needs no runtime value, an unresolved prefix does.
676
+ */
677
+ evaluate(node, file) {
678
+ if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
679
+ return { kind: 'literal', text: node.text };
680
+ }
681
+ if (ts.isTemplateExpression(node)) {
682
+ let text = node.head.text;
683
+ let rooted = text.startsWith('/') || text.startsWith('${');
684
+ for (const span of node.templateSpans) {
685
+ const hole = this.evaluate(span.expression, file);
686
+ if (hole.kind === 'unresolved') {
687
+ // Before the path is rooted, an unknown hole poisons the target
688
+ // (host/base unknown). After it, the hole is a positional slot.
689
+ if (!rooted)
690
+ return UNRESOLVED_VALUE;
691
+ text += '${}';
692
+ }
693
+ else {
694
+ text += hole.text;
695
+ rooted = rooted || hole.text.startsWith('/');
696
+ }
697
+ text += span.literal.text;
698
+ }
699
+ return { kind: 'literal', text };
700
+ }
701
+ if (ts.isIdentifier(node)) {
702
+ const name = node.text;
703
+ const key = `${file}::${name}`;
704
+ const model = this.modelOf(file);
705
+ const local = model?.constants.get(name);
706
+ if (local !== undefined) {
707
+ if (this.evaluating.has(key))
708
+ return UNRESOLVED_VALUE; // cycle guard
709
+ this.evaluating.add(key);
710
+ const value = this.evaluate(local, file);
711
+ this.evaluating.delete(key);
712
+ return value;
713
+ }
714
+ // Cross-file: relative import binding resolved inside the scanned set.
715
+ const imported = this.importedFrom(file, name);
716
+ if (imported !== null) {
717
+ if (this.evaluating.has(`${imported}::${name}`))
718
+ return UNRESOLVED_VALUE;
719
+ this.evaluating.add(`${imported}::${name}`);
720
+ const value = this.evaluateImported(imported, name);
721
+ this.evaluating.delete(`${imported}::${name}`);
722
+ return value;
723
+ }
724
+ return UNRESOLVED_VALUE;
725
+ }
726
+ // URL builder calls: `buildApiPath('/v1/x')` → base + resolved arg.
727
+ // Per-symbol scoping (phase 3): a builder scoped away from `file`
728
+ // does not resolve here — an in-scope enclosing call whose target
729
+ // needs it fails closed (typed unresolved), never silently.
730
+ if (ts.isCallExpression(node) && ts.isIdentifier(node.expression)) {
731
+ const callee = node.expression;
732
+ const builder = activeUrlBuilderIn(this.config, callee.text, file);
733
+ if (builder !== undefined) {
734
+ const argument = node.arguments[0];
735
+ if (argument === undefined)
736
+ return UNRESOLVED_VALUE;
737
+ const inner = this.evaluate(argument, file);
738
+ if (inner.kind === 'unresolved')
739
+ return UNRESOLVED_VALUE;
740
+ const base = builder.base ?? '';
741
+ const joined = inner.text.startsWith('/') || base === ''
742
+ ? `${base}${inner.text}`
743
+ : `${base}/${inner.text}`;
744
+ return { kind: 'literal', text: joined };
745
+ }
746
+ }
747
+ return UNRESOLVED_VALUE;
748
+ }
749
+ /**
750
+ * The proven literal baseURL of the axios instance `symbol` for a
751
+ * callsite in `file`, or `undefined` when no base is proven (the
752
+ * callsite then behaves exactly as without this feature — never a new
753
+ * blocker). Precedence, most specific wins:
754
+ *
755
+ * 1. The symbol's module-scope creation IN the callsite file.
756
+ * 2. The creation in the relative-import module that provides the
757
+ * binding (the existing bounded import machinery, nothing looser).
758
+ * 3. A UNIQUE declaration of the symbol across the scanned product
759
+ * set (the configured-symbol channel already treats the name as
760
+ * global, and real clients are singletons) — alias-imported
761
+ * creations resolve here. Every same-named creation found must be
762
+ * a modeled `axios.create` and all proven bases must agree;
763
+ * disagreement, a second distinct base, or any unprovable
764
+ * same-named creation vetoes the join (fail closed). The search
765
+ * never leaves the top-level clientScanRoots (when present) so an
766
+ * out-of-scope e2e mock instance cannot poison product calls, and
767
+ * it never applies to the bare `axios` global, whose base is
768
+ * axiomatically absent unless shadowed in the callsite file itself.
769
+ *
770
+ * An unprovable base at tiers 1–2 is authoritative for that binding —
771
+ * no fallback to the name search.
772
+ */
773
+ instanceBaseURL(symbol, file) {
774
+ const memoKey = `${file}::${symbol}`;
775
+ if (this.baseMemo.has(memoKey))
776
+ return this.baseMemo.get(memoKey);
777
+ let result;
778
+ const own = this.creationBase(symbol, file, new Set([memoKey]));
779
+ if (own.state === 'absent') {
780
+ result = symbol === 'axios' ? undefined : this.uniqueDeclaredBase(symbol);
781
+ }
782
+ else {
783
+ result = own.state === 'proven' ? own.base : undefined;
784
+ }
785
+ this.baseMemo.set(memoKey, result);
786
+ return result;
787
+ }
788
+ /**
789
+ * Resolves the symbol's creation in `file` (local constant first, then
790
+ * a relative-imported binding) and extracts its base. Absent means no
791
+ * modeled creation was found for this binding.
792
+ */
793
+ creationBase(symbol, file, seen) {
794
+ const local = this.modelOf(file)?.constants.get(symbol);
795
+ if (local !== undefined) {
796
+ return this.creationInitializerBase(local, file, seen);
797
+ }
798
+ const imported = this.importedFrom(file, symbol);
799
+ if (imported !== null) {
800
+ const initializer = this.modelOf(imported)?.constants.get(symbol);
801
+ if (initializer !== undefined) {
802
+ return this.creationInitializerBase(initializer, imported, seen);
803
+ }
804
+ }
805
+ return ABSENT_PROOF;
806
+ }
807
+ creationInitializerBase(initializer, file, seen) {
808
+ if (!isAxiosCreateCall(initializer))
809
+ return UNPROVEN_PROOF;
810
+ return this.axiosCreateBase(initializer.arguments[0], file, seen);
811
+ }
812
+ /**
813
+ * The unique same-named creation across the scanned product set, with
814
+ * the agreement rules from {@link instanceBaseURL} tier 3. Files are
815
+ * visited in sorted order; the verdict is a pure function of the set.
816
+ */
817
+ uniqueDeclaredBase(symbol) {
818
+ let unique;
819
+ for (const candidateFile of [...this.scannedFiles.keys()].sort()) {
820
+ if (!fileInClientScanRoots(this.config, candidateFile))
821
+ continue;
822
+ const initializer = this.modelOf(candidateFile)?.constants.get(symbol);
823
+ if (initializer === undefined)
824
+ continue;
825
+ const proof = this.creationInitializerBase(initializer, candidateFile, new Set([`${candidateFile}::${symbol}`]));
826
+ if (proof.state !== 'proven')
827
+ return undefined;
828
+ if (unique === undefined)
829
+ unique = proof.base;
830
+ else if (unique !== proof.base)
831
+ return undefined;
832
+ }
833
+ return unique;
834
+ }
835
+ /** Extracts the baseURL proof from one `axios.create(...)` argument. */
836
+ axiosCreateBase(argument, file, seen) {
837
+ if (argument === undefined)
838
+ return PROVEN_EMPTY_PROOF;
839
+ return this.baseFromConfigExpression(argument, file, seen);
840
+ }
841
+ baseFromConfigExpression(node, file, seen) {
842
+ if (ts.isObjectLiteralExpression(node))
843
+ return this.baseFromConfigObject(node, file, seen);
844
+ if (ts.isIdentifier(node))
845
+ return this.baseFromConstantObject(node.text, file, seen);
846
+ return UNPROVEN_PROOF;
847
+ }
848
+ /**
849
+ * Resolves a config CONSTANT (`axios.create(config)`) through the
850
+ * same local/relative-import machinery as the value table, cycle-
851
+ * guarded via `seen`.
852
+ */
853
+ baseFromConstantObject(name, file, seen) {
854
+ const local = this.resolveConstantInitializer(name, file, seen);
855
+ if (local !== undefined) {
856
+ const [initializer, originFile] = local;
857
+ const key = `${originFile}::${name}`;
858
+ seen.add(key);
859
+ const result = this.baseFromConfigExpression(initializer, originFile, seen);
860
+ seen.delete(key);
861
+ return result;
862
+ }
863
+ return UNPROVEN_PROOF;
864
+ }
865
+ /**
866
+ * One config object literal, honoring JavaScript property semantics:
867
+ * source order, last writer wins — a direct `baseURL` property and a
868
+ * spread of a proven constant config participate equally, so
869
+ * `{...a, baseURL: '/x'}` proves '/x' and `{baseURL: '/x', ...a}`
870
+ * proves only what `a` proves.
871
+ */
872
+ baseFromConfigObject(object, file, seen) {
873
+ let result = PROVEN_EMPTY_PROOF; // no baseURL member: proven baseless
874
+ for (const property of object.properties) {
875
+ if (ts.isPropertyAssignment(property) || ts.isShorthandPropertyAssignment(property)) {
876
+ if (!ts.isIdentifier(property.name) || property.name.text !== 'baseURL')
877
+ continue;
878
+ const initializer = ts.isPropertyAssignment(property) ? property.initializer : property.name;
879
+ const resolved = this.evaluate(initializer, file);
880
+ result = resolved.kind === 'literal' ? { state: 'proven', base: resolved.text } : UNPROVEN_PROOF;
881
+ }
882
+ else if (ts.isSpreadAssignment(property)) {
883
+ const spread = property.expression;
884
+ result = ts.isIdentifier(spread)
885
+ ? this.baseFromConstantObject(spread.text, file, seen)
886
+ : UNPROVEN_PROOF;
887
+ }
888
+ }
889
+ return result;
890
+ }
891
+ /** Local constant initializer, else the relative-imported one. */
892
+ resolveConstantInitializer(name, file, seen) {
893
+ const local = this.modelOf(file)?.constants.get(name);
894
+ if (local !== undefined && !seen.has(`${file}::${name}`))
895
+ return [local, file];
896
+ const imported = this.importedFrom(file, name);
897
+ if (imported !== null && !seen.has(`${imported}::${name}`)) {
898
+ const initializer = this.modelOf(imported)?.constants.get(name);
899
+ if (initializer !== undefined)
900
+ return [initializer, imported];
901
+ }
902
+ return undefined;
903
+ }
904
+ /** `import { NAME } from './m'` — resolves NAME's declaring scanned file. */
905
+ importedFrom(file, name) {
906
+ const model = this.modelOf(file);
907
+ if (model === undefined)
908
+ return null;
909
+ let found = null;
910
+ const visit = (node) => {
911
+ if (found !== null)
912
+ return;
913
+ if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) {
914
+ const specifier = node.moduleSpecifier.text;
915
+ if (specifier.startsWith('./') || specifier.startsWith('../')) {
916
+ const resolvedFile = this.resolveSpecifier(file, specifier);
917
+ if (resolvedFile !== null) {
918
+ const clause = node.importClause;
919
+ if (clause?.namedBindings !== undefined && ts.isNamedImports(clause.namedBindings)) {
920
+ for (const element of clause.namedBindings.elements) {
921
+ const imported = element.propertyName?.text ?? element.name.text;
922
+ if (imported === name) {
923
+ found = resolvedFile;
924
+ return;
925
+ }
926
+ }
927
+ }
928
+ }
929
+ }
930
+ }
931
+ ts.forEachChild(node, visit);
932
+ };
933
+ visit(model.source);
934
+ return found;
935
+ }
936
+ evaluateImported(targetFile, name) {
937
+ const model = this.modelOf(targetFile);
938
+ const initializer = model?.constants.get(name);
939
+ if (initializer === undefined)
940
+ return UNRESOLVED_VALUE;
941
+ return this.evaluate(initializer, targetFile);
942
+ }
943
+ resolveSpecifier(fromFile, specifier) {
944
+ const base = posix.dirname(fromFile.split('\\').join('/'));
945
+ const joined = posix.normalize(posix.join(base, specifier));
946
+ const candidates = [
947
+ joined,
948
+ `${joined}.ts`,
949
+ `${joined}.tsx`,
950
+ `${joined}.js`,
951
+ `${joined}.jsx`,
952
+ `${joined}.mjs`,
953
+ `${joined}.cjs`,
954
+ `${joined}/index.ts`,
955
+ `${joined}/index.tsx`,
956
+ `${joined}/index.js`,
957
+ ];
958
+ for (const candidate of candidates) {
959
+ if (this.scannedFiles.has(candidate))
960
+ return candidate;
961
+ }
962
+ return null;
963
+ }
964
+ }
965
+ /**
966
+ * Validates the include/exclude scoping fields of one object-form
967
+ * entry. New surface, so strict: a present-but-not-array include or
968
+ * exclude is malformed and throws (fail closed — silently ignoring a
969
+ * scoping directive would scan MORE than the configuration asked for,
970
+ * resurfacing the harness false positives scoping exists to remove).
971
+ */
972
+ function parseEntryScoping(record, kind, path) {
973
+ const scoping = {};
974
+ for (const key of ['include', 'exclude']) {
975
+ const value = record[key];
976
+ if (value === undefined)
977
+ continue;
978
+ if (!Array.isArray(value)) {
979
+ throw new Error(`invalid http client config: ${kind} '${String(record['name'])}' ${key} must be an array of globs at ${path}`);
980
+ }
981
+ scoping[key] = value.map((glob) => String(glob));
982
+ }
983
+ return scoping;
984
+ }
985
+ /**
986
+ * Reads a client-scan config document. Returns the default config when
987
+ * the file is absent; malformed documents throw (fail closed — the CLI
988
+ * surfaces the error instead of scanning with partial trust).
989
+ *
990
+ * Accepted shapes (phase 3 scan-scoping): `clientSymbols` entries are a
991
+ * plain string (back-compat, unscoped) or `{ name, include?, exclude? }`;
992
+ * `wrapperFunctions` / `urlBuilders` entries carry the same optional
993
+ * include/exclude next to their existing `method` / `base` fields; the
994
+ * top-level `clientScanRoots` / `serverScanRoots` arrays scope where
995
+ * client-call and server-route scanning apply at all. Consistent with
996
+ * the pre-existing parser posture: unknown keys are ignored, non-array
997
+ * known keys are ignored, but malformed ENTRY values throw (the wrapper
998
+ * verb check predates this; the new scoping fields throw on wrong
999
+ * shapes and missing names because silently dropping a scope widens the
1000
+ * scan instead of narrowing it).
1001
+ */
1002
+ export function readClientScanConfigOrNull(path) {
1003
+ if (path === null)
1004
+ return DEFAULT_CLIENT_SCAN_CONFIG;
1005
+ let text;
1006
+ try {
1007
+ text = readFileSync(path, 'utf8');
1008
+ }
1009
+ catch {
1010
+ return DEFAULT_CLIENT_SCAN_CONFIG; // absence is normal; malformed is not (below)
1011
+ }
1012
+ // YAML is intentionally not a dependency here: the document is JSON or
1013
+ // JSON-with-comments parsed by the CLI layer. This module accepts only
1014
+ // plain JSON objects.
1015
+ const parsed = JSON.parse(text);
1016
+ if (parsed === null || typeof parsed !== 'object') {
1017
+ throw new Error(`invalid http client config: expected an object at ${path}`);
1018
+ }
1019
+ const document = parsed;
1020
+ const config = {};
1021
+ if (Array.isArray(document['clientScanRoots'])) {
1022
+ config.clientScanRoots = document['clientScanRoots'].map((root) => String(root));
1023
+ }
1024
+ if (Array.isArray(document['serverScanRoots'])) {
1025
+ config.serverScanRoots = document['serverScanRoots'].map((root) => String(root));
1026
+ }
1027
+ if (Array.isArray(document['clientSymbols'])) {
1028
+ config.clientSymbols = document['clientSymbols'].map((entry) => {
1029
+ // Plain string: the original shape, unscoped — byte-identical.
1030
+ if (typeof entry === 'string')
1031
+ return entry;
1032
+ if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) {
1033
+ throw new Error(`invalid http client config: clientSymbols entries must be a name or a {name, include?, exclude?} object at ${path}`);
1034
+ }
1035
+ const record = entry;
1036
+ if (record['name'] === undefined) {
1037
+ throw new Error(`invalid http client config: clientSymbols object entries must carry a name at ${path}`);
1038
+ }
1039
+ return { name: String(record['name']), ...parseEntryScoping(record, 'clientSymbols', path) };
1040
+ });
1041
+ }
1042
+ if (Array.isArray(document['wrapperFunctions'])) {
1043
+ config.wrapperFunctions = document['wrapperFunctions'].map((entry) => {
1044
+ const record = entry;
1045
+ const method = normalizeHttpMethodValue(String(record['method'] ?? 'GET'));
1046
+ if (method === null) {
1047
+ throw new Error(`invalid http client config: wrapper method must be a concrete verb at ${path}`);
1048
+ }
1049
+ return { name: String(record['name']), method, ...parseEntryScoping(record, 'wrapperFunctions', path) };
1050
+ });
1051
+ }
1052
+ if (Array.isArray(document['urlBuilders'])) {
1053
+ config.urlBuilders = document['urlBuilders'].map((entry) => {
1054
+ if (typeof entry === 'string')
1055
+ return { name: entry };
1056
+ const record = entry;
1057
+ return {
1058
+ name: String(record['name']),
1059
+ base: record['base'] === undefined ? undefined : String(record['base']),
1060
+ ...parseEntryScoping(record, 'urlBuilders', path),
1061
+ };
1062
+ });
1063
+ }
1064
+ if (Array.isArray(document['sameOriginHosts'])) {
1065
+ config.sameOriginHosts = document['sameOriginHosts'].map((host) => String(host));
1066
+ }
1067
+ return config;
1068
+ }
1069
+ //# sourceMappingURL=client-calls.js.map