@cmflow/atlas 3.4.0-alpha.1

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,4514 @@
1
+ #!/usr/bin/env node
2
+ import { Command, InvalidArgumentError, Option } from "commander";
3
+ import path from "node:path";
4
+ import { pathToFileURL } from "node:url";
5
+ import { cancel, intro, log, note, outro, progress, spinner } from "@clack/prompts";
6
+ import fs from "node:fs/promises";
7
+ import { createDirectus, createItem, deleteItem, readItems, rest, staticToken, updateItem } from "@directus/sdk";
8
+ import { AsyncLocalStorage } from "node:async_hooks";
9
+ import { globby } from "globby";
10
+ import { Node, Project, SyntaxKind } from "ts-morph";
11
+ import { minimatch } from "minimatch";
12
+ import fs$1 from "node:fs";
13
+ import { createHash } from "node:crypto";
14
+ import { parse, stringify } from "yaml";
15
+ import { Worker } from "node:worker_threads";
16
+ import { NoObjectGeneratedError, Output, generateText } from "ai";
17
+ import { z } from "zod";
18
+ import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
19
+ //#region src/utils/config.ts
20
+ let _config;
21
+ function setUserConfig(config) {
22
+ _config = config;
23
+ }
24
+ function getUserConfig() {
25
+ if (!_config) throw new Error("Atlas config not loaded. Run Atlas from a directory containing atlas.config.ts or pass --config.");
26
+ return _config;
27
+ }
28
+ async function loadAtlasConfig(configPath) {
29
+ const filePath = path.resolve(configPath);
30
+ try {
31
+ let mod;
32
+ if (filePath.endsWith(".ts")) {
33
+ const { tsImport } = await import("tsx/esm/api");
34
+ mod = await tsImport(filePath, import.meta.url);
35
+ } else mod = await import(pathToFileURL(filePath).href);
36
+ const config = mod.default ?? mod;
37
+ const repoRoot = config.repoRoot || process.cwd();
38
+ return {
39
+ ...config,
40
+ repoRoot,
41
+ cwd: repoRoot
42
+ };
43
+ } catch (error) {
44
+ throw new Error(`Unable to load Atlas configuration at ${filePath}: ${error instanceof Error ? error.message : String(error)}`);
45
+ }
46
+ }
47
+ //#endregion
48
+ //#region src/services/taskProgressService.ts
49
+ function formatDuration(durationMs) {
50
+ if (durationMs < 1e3) return `${durationMs}ms`;
51
+ return `${(durationMs / 1e3).toFixed(1)}s`;
52
+ }
53
+ var TaskProgressService = class {
54
+ #storage = new AsyncLocalStorage();
55
+ attach(sink, task) {
56
+ return this.#storage.run(sink, task);
57
+ }
58
+ log(message) {
59
+ this.#storage.getStore()?.log(message);
60
+ }
61
+ report(message) {
62
+ const sink = this.#storage.getStore();
63
+ (sink?.report || sink?.log)?.(message);
64
+ }
65
+ createStepProgress(classify = (message) => ({
66
+ id: message,
67
+ title: message
68
+ })) {
69
+ const progress = spinner();
70
+ let active;
71
+ const finishActive = (label) => {
72
+ if (!active) return;
73
+ const duration = formatDuration(Date.now() - active.startedAt);
74
+ progress.stop(`${label || active.completedTitle || `${active.title} completed`} (${duration})`);
75
+ active = void 0;
76
+ };
77
+ const start = (step) => {
78
+ if (active?.id === step.id) {
79
+ progress.message(step.detail ? `${step.title}: ${step.detail}` : step.title);
80
+ return;
81
+ }
82
+ finishActive();
83
+ active = {
84
+ id: step.id,
85
+ title: step.title,
86
+ completedTitle: step.completedTitle,
87
+ startedAt: Date.now()
88
+ };
89
+ progress.start(step.detail ? `${step.title}: ${step.detail}` : step.title);
90
+ };
91
+ const execute = (task) => this.attach({
92
+ log: (message) => progress.message(active ? `${active.title}: ${message}` : message),
93
+ report: (message) => start(classify(message))
94
+ }, task);
95
+ return {
96
+ report(message) {
97
+ start(classify(message));
98
+ },
99
+ start,
100
+ execute,
101
+ run: async (step, task) => {
102
+ start(step);
103
+ await new Promise((resolve) => setImmediate(resolve));
104
+ const result = await execute(task);
105
+ finishActive();
106
+ return result;
107
+ },
108
+ finish(label) {
109
+ finishActive(label);
110
+ },
111
+ fail(label) {
112
+ if (!active) return;
113
+ const duration = formatDuration(Date.now() - active.startedAt);
114
+ progress.stop(`${label} (${duration})`);
115
+ active = void 0;
116
+ }
117
+ };
118
+ }
119
+ };
120
+ const taskProgressService = new TaskProgressService();
121
+ //#endregion
122
+ //#region src/utils/catalogueStats.ts
123
+ function calculateNeedsReviewPercentage(inputProperties, outputProperties, needsReview) {
124
+ const properties = inputProperties + outputProperties;
125
+ return properties ? Number((needsReview / properties * 100).toFixed(2)) : 0;
126
+ }
127
+ //#endregion
128
+ //#region src/services/analysisFileService.ts
129
+ function shouldKeepAnalysisFile(filePath) {
130
+ const normalizedProjectPath = filePath.replaceAll(path.sep, "/").replace(/^.*?(app\/)/, "app/");
131
+ return !getUserConfig().analysis.excluded.some((pattern) => minimatch(normalizedProjectPath, pattern, { dot: true }));
132
+ }
133
+ function filterAnalysisFiles(filePaths) {
134
+ return filePaths.filter(shouldKeepAnalysisFile);
135
+ }
136
+ //#endregion
137
+ //#region src/services/useCaseAnalysisService.ts
138
+ function normalizeCodeText(value) {
139
+ return value.replace(/\s+/g, " ").replace(/;$/, "").trim();
140
+ }
141
+ function describeRuleEffect(node) {
142
+ if (Node.isBlock(node)) return node.getStatements().map(describeRuleEffect).filter(Boolean).join("; ");
143
+ if (Node.isReturnStatement(node)) return node.getExpression() ? `return ${normalizeCodeText(node.getExpression().getText())}` : "return";
144
+ if (Node.isThrowStatement(node)) return `throw ${normalizeCodeText(node.getExpression().getText())}`;
145
+ if (Node.isExpressionStatement(node)) {
146
+ const expression = node.getExpression();
147
+ if (Node.isCallExpression(expression)) {
148
+ const callee = expression.getExpression();
149
+ if (Node.isPropertyAccessExpression(callee) && ["push", "unshift"].includes(callee.getName())) return `add ${expression.getArguments().map((argument) => normalizeCodeText(argument.getText())).join(", ")} to ${normalizeCodeText(callee.getExpression().getText())}`;
150
+ }
151
+ return normalizeCodeText(expression.getText());
152
+ }
153
+ return normalizeCodeText(node.getText());
154
+ }
155
+ function extractUseCaseRules(sourceFile) {
156
+ return sourceFile.getDescendantsOfKind(SyntaxKind.IfStatement).map((statement) => ({
157
+ condition: normalizeCodeText(statement.getExpression().getText()),
158
+ effect: describeRuleEffect(statement.getThenStatement()),
159
+ elseEffect: statement.getElseStatement() ? describeRuleEffect(statement.getElseStatement()) : void 0,
160
+ sourceFile: sourceFile.getFilePath()
161
+ }));
162
+ }
163
+ function getAggregationTarget(call) {
164
+ let current = call;
165
+ while (current) {
166
+ const parent = current.getParent();
167
+ if (!parent) return void 0;
168
+ if (Node.isVariableDeclaration(parent)) return normalizeCodeText(parent.getName());
169
+ if (Node.isReturnStatement(parent)) return "return";
170
+ if (Node.isStatement(parent)) return void 0;
171
+ current = parent;
172
+ }
173
+ }
174
+ function getAggregationCondition(call) {
175
+ const callback = call.getArguments().find((argument) => Node.isArrowFunction(argument) || Node.isFunctionExpression(argument));
176
+ if (!callback || !Node.isArrowFunction(callback) && !Node.isFunctionExpression(callback)) return void 0;
177
+ const body = callback.getBody();
178
+ if (!Node.isBlock(body)) return normalizeCodeText(body.getText());
179
+ const condition = body.getDescendantsOfKind(SyntaxKind.IfStatement)[0]?.getExpression();
180
+ return condition ? normalizeCodeText(condition.getText()) : void 0;
181
+ }
182
+ function getAggregationType(operation, call, condition) {
183
+ if (operation === "reduce") return call.getDescendantsOfKind(SyntaxKind.CallExpression).some((nestedCall) => {
184
+ const expression = nestedCall.getExpression();
185
+ return Node.isPropertyAccessExpression(expression) && ["push", "unshift"].includes(expression.getName());
186
+ }) ? "filter" : "reduction";
187
+ if (operation === "find") return condition && /={2,3}/.test(condition) ? "join" : "lookup";
188
+ if (operation === "some") return "membership";
189
+ return operation === "map" ? "mapping" : "filter";
190
+ }
191
+ function extractUseCaseAggregations(sourceFile) {
192
+ const supportedOperations = /* @__PURE__ */ new Set([
193
+ "filter",
194
+ "find",
195
+ "map",
196
+ "reduce",
197
+ "some"
198
+ ]);
199
+ const candidates = [];
200
+ for (const call of sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression)) {
201
+ const expression = call.getExpression();
202
+ if (!Node.isPropertyAccessExpression(expression)) continue;
203
+ const operation = expression.getName();
204
+ if (!supportedOperations.has(operation)) continue;
205
+ const callback = call.getArguments().find((argument) => Node.isArrowFunction(argument) || Node.isFunctionExpression(argument));
206
+ const accumulator = operation === "reduce" && callback && (Node.isArrowFunction(callback) || Node.isFunctionExpression(callback)) ? callback.getParameters()[0]?.getName() : void 0;
207
+ const condition = getAggregationCondition(call);
208
+ const targetCollection = accumulator || getAggregationTarget(call);
209
+ candidates.push({
210
+ type: getAggregationType(operation, call, condition),
211
+ operation,
212
+ sourceCollection: normalizeCodeText(expression.getExpression().getText()),
213
+ targetCollection,
214
+ condition,
215
+ description: [
216
+ `${operation} ${normalizeCodeText(expression.getExpression().getText())}`,
217
+ targetCollection ? `into ${targetCollection}` : void 0,
218
+ condition ? `when ${condition}` : void 0
219
+ ].filter(Boolean).join(" "),
220
+ sourceFile: sourceFile.getFilePath()
221
+ });
222
+ }
223
+ const map = /* @__PURE__ */ new Map();
224
+ for (const candidate of candidates) map.set([
225
+ candidate.type,
226
+ candidate.operation,
227
+ candidate.sourceCollection,
228
+ candidate.targetCollection || "",
229
+ candidate.condition || "",
230
+ candidate.sourceFile
231
+ ].join(":"), candidate);
232
+ return [...map.values()];
233
+ }
234
+ //#endregion
235
+ //#region src/services/analysisCacheService.ts
236
+ /**
237
+ * Memoizes expensive ts-morph descendant queries for the lifetime of a source
238
+ * file. Weak keys let TypeScript release parsed files after an analysis run.
239
+ */
240
+ var AnalysisCacheService = class {
241
+ constructor() {
242
+ this.callExpressionsByFile = /* @__PURE__ */ new WeakMap();
243
+ }
244
+ getCallExpressions(sourceFile) {
245
+ const cached = this.callExpressionsByFile.get(sourceFile);
246
+ if (cached) return cached;
247
+ const calls = sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression);
248
+ this.callExpressionsByFile.set(sourceFile, calls);
249
+ return calls;
250
+ }
251
+ };
252
+ const analysisCacheService = new AnalysisCacheService();
253
+ //#endregion
254
+ //#region src/services/openapi/downloadService.ts
255
+ async function downloadOpenApiDocument(url, timeoutMs = getUserConfig().openapiTimeoutMs) {
256
+ const response = await fetch(url, {
257
+ headers: { accept: "application/json" },
258
+ signal: AbortSignal.timeout(timeoutMs)
259
+ });
260
+ if (!response.ok) throw new Error(`Unable to fetch OpenAPI document from ${url}: ${response.status} ${response.statusText}`);
261
+ const reader = response.body?.getReader();
262
+ if (!reader) return response.json();
263
+ const decoder = new TextDecoder();
264
+ let content = "";
265
+ while (true) {
266
+ const { done, value } = await reader.read();
267
+ if (done) break;
268
+ content += decoder.decode(value, { stream: true });
269
+ taskProgressService.log(`Downloading (${(content.length / 1048576).toFixed(1)} MB)`);
270
+ }
271
+ taskProgressService.log("Parsing document");
272
+ return JSON.parse(content + decoder.decode());
273
+ }
274
+ async function loadOpenApiDocument(url, timeoutMs) {
275
+ try {
276
+ return await downloadOpenApiDocument(url, timeoutMs);
277
+ } catch (error) {
278
+ if (error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError")) throw new Error(`OpenAPI download timed out after ${timeoutMs}ms: ${url}`);
279
+ throw new Error(`Unable to fetch OpenAPI document from ${url}: ${error instanceof Error ? error.message : String(error)}`);
280
+ }
281
+ }
282
+ //#endregion
283
+ //#region src/services/openapi/schemaService.ts
284
+ function resolveOpenApiReference(value, swagger) {
285
+ if (!value || typeof value !== "object" || typeof value.$ref !== "string" || !value.$ref.startsWith("#/")) return value;
286
+ let current = swagger;
287
+ for (const segment of value.$ref.replace("#/", "").split("/").map((part) => part.replace(/~1/g, "/").replace(/~0/g, "~"))) {
288
+ current = current?.[segment];
289
+ if (current === void 0) return value;
290
+ }
291
+ return current;
292
+ }
293
+ //#endregion
294
+ //#region src/services/openapi/propertyExtractionService.ts
295
+ function isSuccessStatusCode(statusCode) {
296
+ return /^\d+$/.test(statusCode) && Number(statusCode) >= 200 && Number(statusCode) <= 299;
297
+ }
298
+ function selectPreferredSchema(content, swagger) {
299
+ if (!content || typeof content !== "object") return;
300
+ if (content["application/json"]?.schema) return resolveOpenApiReference(content["application/json"].schema, swagger);
301
+ const firstSchema = Object.values(content).find((entry) => entry?.schema);
302
+ return firstSchema ? resolveOpenApiReference(firstSchema.schema, swagger) : void 0;
303
+ }
304
+ function extractLeafProperties(schema, swagger, currentPath = "") {
305
+ const resolvedSchema = resolveOpenApiReference(schema, swagger);
306
+ if (!resolvedSchema || typeof resolvedSchema !== "object") return [];
307
+ if (Array.isArray(resolvedSchema.allOf)) return resolvedSchema.allOf.flatMap((item) => extractLeafProperties(item, swagger, currentPath));
308
+ if (resolvedSchema.type === "array" || resolvedSchema.items) {
309
+ const arrayPath = currentPath ? `${currentPath}[]` : "[]";
310
+ return extractLeafProperties(resolvedSchema.items, swagger, arrayPath);
311
+ }
312
+ const properties = resolvedSchema.properties || {};
313
+ if (!Object.keys(properties).length) return currentPath ? [{
314
+ path: currentPath,
315
+ description: resolvedSchema.description,
316
+ deprecated: resolvedSchema.deprecated
317
+ }] : [];
318
+ return Object.entries(properties).flatMap(([propertyName, propertySchema]) => {
319
+ return extractLeafProperties(propertySchema, swagger, currentPath ? `${currentPath}.${propertyName}` : propertyName);
320
+ });
321
+ }
322
+ function mapInputType(inType) {
323
+ if (inType === "query") return "QUERY";
324
+ if (inType === "path") return "PATH";
325
+ if (inType === "header") return "HEADER";
326
+ return null;
327
+ }
328
+ function extractOpenApiInputProperties(operation, swagger) {
329
+ const map = /* @__PURE__ */ new Map();
330
+ const parameters = Array.isArray(operation.parameters) ? operation.parameters : [];
331
+ for (const rawParameter of parameters) {
332
+ const parameter = resolveOpenApiReference(rawParameter, swagger);
333
+ const inputType = mapInputType(parameter?.in);
334
+ if (!parameter || !inputType || !parameter.name) continue;
335
+ const properties = extractLeafProperties(parameter.schema, swagger, parameter.name);
336
+ const resolvedProperties = properties.length ? properties : [{
337
+ path: parameter.name,
338
+ description: parameter.description,
339
+ deprecated: parameter.deprecated
340
+ }];
341
+ for (const property of resolvedProperties) map.set(`${inputType}:${property.path}`, {
342
+ path: property.path,
343
+ description: property.description || parameter.description,
344
+ deprecated: property.deprecated ?? parameter.deprecated ?? false,
345
+ type: inputType
346
+ });
347
+ }
348
+ const requestBody = resolveOpenApiReference(operation.requestBody, swagger);
349
+ if (requestBody?.content) {
350
+ const schema = selectPreferredSchema(requestBody.content, swagger);
351
+ for (const property of extractLeafProperties(schema, swagger)) map.set(`BODY:${property.path}`, {
352
+ path: property.path,
353
+ description: property.description || requestBody.description,
354
+ deprecated: property.deprecated ?? false,
355
+ type: "BODY"
356
+ });
357
+ }
358
+ return [...map.values()];
359
+ }
360
+ function extractOpenApiOutputProperties(operation, swagger) {
361
+ const map = /* @__PURE__ */ new Map();
362
+ const responses = operation?.responses || {};
363
+ for (const [statusCode, rawResponse] of Object.entries(responses)) {
364
+ if (!isSuccessStatusCode(statusCode)) continue;
365
+ const response = resolveOpenApiReference(rawResponse, swagger);
366
+ const schema = selectPreferredSchema(response?.content, swagger);
367
+ for (const property of extractLeafProperties(schema, swagger)) map.set(property.path, {
368
+ path: property.path,
369
+ description: property.description || response?.description,
370
+ deprecated: property.deprecated ?? false,
371
+ type: "RESPONSE_BODY"
372
+ });
373
+ }
374
+ return [...map.values()];
375
+ }
376
+ //#endregion
377
+ //#region src/services/backendTypeService.ts
378
+ let knownBackendTypesCache = null;
379
+ function isKnownBackendType(value) {
380
+ const config = getUserConfig();
381
+ const filePath = path.resolve(config.repoRoot, config.backendTypesFile);
382
+ if (!knownBackendTypesCache || knownBackendTypesCache.filePath !== filePath) {
383
+ const fileContent = fs$1.readFileSync(filePath, "utf8");
384
+ knownBackendTypesCache = {
385
+ filePath,
386
+ values: new Set([...fileContent.matchAll(/([A-Z0-9_]+)\s*=\s*"([A-Z0-9_]+)"/g)].map(([, key, backend]) => key === backend ? backend : null).filter((backend) => Boolean(backend)))
387
+ };
388
+ }
389
+ return knownBackendTypesCache.values.has(value);
390
+ }
391
+ //#endregion
392
+ //#region src/services/backendSourceService.ts
393
+ function inferBackendNameFromFile(sourceFile) {
394
+ const declaredType = sourceFile.getFullText().match(/BackendTypes\.([A-Z0-9_]+)/)?.[1];
395
+ if (declaredType) return isKnownBackendType(declaredType) ? declaredType : null;
396
+ const parts = sourceFile.getFilePath().split(path.sep);
397
+ const backIndex = parts.lastIndexOf("back");
398
+ const infrastructureIndex = parts.lastIndexOf("_infra");
399
+ const candidate = backIndex >= 0 ? parts[backIndex + 1] : infrastructureIndex >= 0 ? parts[infrastructureIndex + 2] : void 0;
400
+ return candidate && isKnownBackendType(candidate.toUpperCase()) ? candidate.toUpperCase() : null;
401
+ }
402
+ //#endregion
403
+ //#region src/services/backendRouteExtractionService.ts
404
+ function looksLikeBackendRoute(value) {
405
+ return value.startsWith("/") || /^https?:\/\//.test(value) || /^graphql$/i.test(value);
406
+ }
407
+ function extractBackendRouteCandidates(params) {
408
+ const methods = /* @__PURE__ */ new Set([
409
+ "get",
410
+ "post",
411
+ "put",
412
+ "patch",
413
+ "delete",
414
+ "head",
415
+ "query"
416
+ ]);
417
+ return analysisCacheService.getCallExpressions(params.sourceFile).flatMap((call) => {
418
+ if (params.reachableFunctionNames && !params.reachableFunctionNames.has(params.enclosingCallableName(call))) return [];
419
+ const expression = call.getExpression();
420
+ if (!Node.isPropertyAccessExpression(expression) || !methods.has(expression.getName())) return [];
421
+ const firstArgument = call.getArguments()[0];
422
+ if (!firstArgument || !Node.isStringLiteral(firstArgument) && !Node.isNoSubstitutionTemplateLiteral(firstArgument)) return [];
423
+ const route = firstArgument.getLiteralValue();
424
+ if (!looksLikeBackendRoute(route)) return [];
425
+ return [{
426
+ backend: params.backendName,
427
+ method: expression.getName() === "query" ? "POST" : expression.getName().toUpperCase(),
428
+ route,
429
+ sourceFile: params.sourceFile.getFilePath()
430
+ }];
431
+ });
432
+ }
433
+ //#endregion
434
+ //#region src/utils/routeKey.ts
435
+ function stableKey$2(...parts) {
436
+ return createHash("sha1").update(parts.join("::")).digest("hex");
437
+ }
438
+ function buildRouteKey(method, routePath) {
439
+ return stableKey$2(method, routePath);
440
+ }
441
+ //#endregion
442
+ //#region src/utils/pathNormalization.ts
443
+ function normalizePathForMatch(value) {
444
+ return value.replace(/\[\]/g, "").replace(/\{[^}]+\}/g, "").replace(/[^a-z0-9]/gi, "").toLowerCase();
445
+ }
446
+ function lastPathSegment(value) {
447
+ return value.split(/[.[\]]+/).filter(Boolean).at(-1)?.toLowerCase() || value.toLowerCase();
448
+ }
449
+ function normalizeDescription(description) {
450
+ return description?.trim() || "";
451
+ }
452
+ //#endregion
453
+ //#region src/utils/resolveAliasPath.ts
454
+ function resolveAliasPath(moduleSpecifier, aliases, rootPath) {
455
+ for (const [alias, target] of Object.entries(aliases)) {
456
+ if (moduleSpecifier !== alias && !moduleSpecifier.startsWith(`${alias}/`)) continue;
457
+ const modulePath = moduleSpecifier.slice(alias.length).replace(/^\//, "");
458
+ return path.join(rootPath, target, modulePath);
459
+ }
460
+ return null;
461
+ }
462
+ //#endregion
463
+ //#region src/utils/tryResolveWithExtensions.ts
464
+ function tryResolveWithExtensions(basePath) {
465
+ const ext = path.extname(basePath);
466
+ const withoutExt = ext ? basePath.slice(0, -ext.length) : basePath;
467
+ const candidates = [
468
+ basePath,
469
+ ext === ".js" ? `${withoutExt}.ts` : null,
470
+ ext === ".ts" ? `${withoutExt}.js` : null,
471
+ ext === ".mjs" ? `${withoutExt}.mts` : null,
472
+ ext === ".mts" ? `${withoutExt}.mjs` : null,
473
+ `${basePath}.ts`,
474
+ `${basePath}.js`,
475
+ `${basePath}.mts`,
476
+ `${basePath}.mjs`,
477
+ path.join(withoutExt, "index.ts"),
478
+ path.join(withoutExt, "index.js"),
479
+ path.join(basePath, "index.ts"),
480
+ path.join(basePath, "index.js")
481
+ ].filter((candidate) => Boolean(candidate));
482
+ for (const candidate of candidates) try {
483
+ const normalized = path.normalize(candidate);
484
+ if (fs$1.existsSync(normalized)) return normalized;
485
+ } catch {
486
+ continue;
487
+ }
488
+ return null;
489
+ }
490
+ //#endregion
491
+ //#region src/utils/resolveModulePath.ts
492
+ function resolveModulePath(sourceFilePath, moduleSpecifier, aliases, rootPath) {
493
+ if (moduleSpecifier.startsWith(".")) return tryResolveWithExtensions(path.resolve(path.dirname(sourceFilePath), moduleSpecifier));
494
+ const aliased = resolveAliasPath(moduleSpecifier, aliases, rootPath);
495
+ return aliased ? tryResolveWithExtensions(aliased) : null;
496
+ }
497
+ //#endregion
498
+ //#region src/utils/dedupeByKey.ts
499
+ function dedupeByKey(items, keyFn) {
500
+ const map = /* @__PURE__ */ new Map();
501
+ for (const item of items) map.set(keyFn(item), item);
502
+ return [...map.values()];
503
+ }
504
+ //#endregion
505
+ //#region src/services/codeAnalysisService.ts
506
+ const userConfig = new Proxy({}, { get: (_target, property) => Reflect.get(getUserConfig(), property) });
507
+ function getStringLiteralValue(objectLiteral, propertyName) {
508
+ const property = objectLiteral.getProperty(propertyName);
509
+ if (!property || !Node.isPropertyAssignment(property)) return;
510
+ const initializer = property.getInitializer();
511
+ if (!initializer) return;
512
+ if (Node.isStringLiteral(initializer) || Node.isNoSubstitutionTemplateLiteral(initializer)) return initializer.getLiteralValue();
513
+ }
514
+ function expressionToText(expression) {
515
+ if (!expression) return;
516
+ if (Node.isIdentifier(expression)) return expression.getText();
517
+ if (Node.isPropertyAccessExpression(expression)) return expression.getText();
518
+ }
519
+ function extractRoutesFromArray(sourceFilePath, routesArray) {
520
+ const routes = [];
521
+ for (const element of routesArray.getElements()) {
522
+ if (!Node.isObjectLiteralExpression(element)) continue;
523
+ const method = getStringLiteralValue(element, "method");
524
+ const routePath = getStringLiteralValue(element, "path");
525
+ const handlerRef = expressionToText(Node.isPropertyAssignment(element.getProperty("handler")) ? element.getProperty("handler").getInitializer() : void 0);
526
+ if (!method || !routePath) continue;
527
+ routes.push({
528
+ method: method.toUpperCase(),
529
+ path: routePath,
530
+ file: sourceFilePath,
531
+ handlerRef
532
+ });
533
+ }
534
+ return routes;
535
+ }
536
+ function isHttpClientInfrastructure(sourceFile) {
537
+ return sourceFile.getClasses().some((declaration) => declaration.getExtends()?.getText() === "HttpClient");
538
+ }
539
+ function extractMapperType(expression) {
540
+ if (Node.isNonNullExpression(expression) || Node.isAsExpression(expression) || Node.isTypeAssertion(expression)) return extractMapperType(expression.getExpression());
541
+ if (!Node.isCallExpression(expression)) return;
542
+ return userConfig.analysis.rules.find((rule) => rule.match(expression, userConfig))?.parse(expression, userConfig)?.mapperType;
543
+ }
544
+ function unwrapMappingUtilityExpression(expression) {
545
+ if (Node.isNonNullExpression(expression) || Node.isAsExpression(expression) || Node.isTypeAssertion(expression)) return unwrapMappingUtilityExpression(expression.getExpression());
546
+ if (!Node.isCallExpression(expression)) return expression;
547
+ if (!(userConfig.analysis.rules.find((rule) => rule.match(expression, userConfig))?.parse(expression, userConfig))?.transparent) return expression;
548
+ const firstArg = expression.getArguments()[0];
549
+ return firstArg && Node.isExpression(firstArg) ? unwrapMappingUtilityExpression(firstArg) : expression;
550
+ }
551
+ function looksLikeBackendFieldAccess(value) {
552
+ return value.includes(".") && !userConfig.analysis.backendFieldAccess.excludedPrefixes.some((prefix) => value.startsWith(prefix));
553
+ }
554
+ function firstBackendPropertyAccess(expression) {
555
+ const unwrappedExpression = unwrapMappingUtilityExpression(expression);
556
+ for (const rule of userConfig.analysis.rules) {
557
+ if (!rule.match(unwrappedExpression, userConfig)) continue;
558
+ const result = rule.parse(unwrappedExpression, userConfig);
559
+ if (result?.backendField) return result.backendField;
560
+ }
561
+ if (Node.isIdentifier(unwrappedExpression)) {
562
+ const initializer = (unwrappedExpression.getSymbol()?.getDeclarations().find(Node.isVariableDeclaration))?.getInitializer();
563
+ const initializerCallee = Node.isCallExpression(initializer) ? initializer.getExpression().getText() : "";
564
+ if (initializer && Node.isExpression(initializer) && /^back(?:\.serialize)?\./.test(initializerCallee)) {
565
+ const resolvedField = firstBackendPropertyAccess(initializer);
566
+ if (resolvedField && resolvedField !== unwrappedExpression.getText()) return resolvedField;
567
+ }
568
+ return unwrappedExpression.getText();
569
+ }
570
+ if (Node.isPropertyAccessExpression(unwrappedExpression)) {
571
+ const value = unwrappedExpression.getText();
572
+ return looksLikeBackendFieldAccess(value) ? value : void 0;
573
+ }
574
+ if (Node.isElementAccessExpression(unwrappedExpression)) {
575
+ const argument = unwrappedExpression.getArgumentExpression();
576
+ if (argument && Node.isExpression(argument)) {
577
+ const argumentProperty = firstBackendPropertyAccess(argument);
578
+ if (argumentProperty) return argumentProperty;
579
+ }
580
+ return unwrappedExpression.getText();
581
+ }
582
+ if (Node.isCallExpression(unwrappedExpression)) {
583
+ const callTarget = unwrappedExpression.getExpression();
584
+ if (!unwrappedExpression.getArguments().length && Node.isPropertyAccessExpression(callTarget)) {
585
+ const receiver = callTarget.getExpression();
586
+ if (Node.isExpression(receiver)) {
587
+ const receiverProperty = firstBackendPropertyAccess(receiver);
588
+ if (receiverProperty) return receiverProperty;
589
+ }
590
+ }
591
+ const firstArg = unwrappedExpression.getArguments()[0];
592
+ if (firstArg && Node.isExpression(firstArg)) return firstBackendPropertyAccess(firstArg);
593
+ }
594
+ if (Node.isBinaryExpression(unwrappedExpression)) {
595
+ const left = unwrappedExpression.getLeft();
596
+ const right = unwrappedExpression.getRight();
597
+ if (unwrappedExpression.getOperatorToken().getKind() === SyntaxKind.QuestionQuestionToken) return firstBackendPropertyAccess(left) || firstBackendPropertyAccess(right);
598
+ const isTransparentWrapper = Node.isCallExpression(left) && userConfig.analysis.rules.some((rule) => rule.match(left, userConfig) && rule.parse(left, userConfig)?.transparent);
599
+ if (Node.isNullLiteral(right) && isTransparentWrapper) return firstBackendPropertyAccess(left);
600
+ }
601
+ const propertyAccess = unwrappedExpression.getDescendantsOfKind(SyntaxKind.PropertyAccessExpression).map((descendant) => descendant.getText()).find(looksLikeBackendFieldAccess);
602
+ if (propertyAccess) return propertyAccess;
603
+ return unwrappedExpression.getDescendantsOfKind(SyntaxKind.ElementAccessExpression)[0]?.getText();
604
+ }
605
+ function propertyAccessMatchingField(expression, field) {
606
+ return [...Node.isPropertyAccessExpression(expression) ? [expression] : [], ...expression.getDescendantsOfKind(SyntaxKind.PropertyAccessExpression)].map((access) => access.getText()).find((value) => looksLikeBackendFieldAccess(value) && lastPathSegment(value) === lastPathSegment(field));
607
+ }
608
+ function collectObjectLiteralMappings(objectLiteral, currentPath = "") {
609
+ const mappings = [];
610
+ for (const property of objectLiteral.getProperties()) {
611
+ if (Node.isShorthandPropertyAssignment(property)) {
612
+ const name = property.getName();
613
+ mappings.push({
614
+ apiPath: currentPath ? `${currentPath}.${name}` : name,
615
+ backendField: name,
616
+ line: property.getStartLineNumber()
617
+ });
618
+ continue;
619
+ }
620
+ if (!Node.isPropertyAssignment(property)) continue;
621
+ const name = property.getName().replace(/^["']|["']$/g, "");
622
+ const nextPath = currentPath ? `${currentPath}.${name}` : name;
623
+ const initializer = property.getInitializer();
624
+ if (!initializer) continue;
625
+ const unwrappedInitializer = Node.isAsExpression(initializer) || Node.isTypeAssertion(initializer) || Node.isSatisfiesExpression(initializer) ? initializer.getExpression() : initializer;
626
+ if (Node.isObjectLiteralExpression(unwrappedInitializer)) {
627
+ mappings.push(...collectObjectLiteralMappings(unwrappedInitializer, nextPath));
628
+ continue;
629
+ }
630
+ if (Node.isArrayLiteralExpression(unwrappedInitializer)) {
631
+ const objectChild = unwrappedInitializer.getElements().find(Node.isObjectLiteralExpression);
632
+ if (objectChild) {
633
+ mappings.push(...collectObjectLiteralMappings(objectChild, `${nextPath}[]`));
634
+ continue;
635
+ }
636
+ }
637
+ mappings.push({
638
+ apiPath: nextPath,
639
+ backendField: firstBackendPropertyAccess(unwrappedInitializer),
640
+ mapperType: extractMapperType(unwrappedInitializer),
641
+ line: property.getStartLineNumber()
642
+ });
643
+ }
644
+ return mappings;
645
+ }
646
+ function propertyAssignmentPath(property) {
647
+ const segments = [...property.getAncestors().filter(Node.isPropertyAssignment).map((ancestor) => ancestor.getName().replace(/^['"]|['"]$/g, "")).reverse(), property.getName().replace(/^['"]|['"]$/g, "")];
648
+ const mapDepth = property.getAncestors().filter((ancestor) => {
649
+ if (!Node.isCallExpression(ancestor)) return false;
650
+ const expression = ancestor.getExpression();
651
+ return Node.isPropertyAccessExpression(expression) && expression.getName() === "map";
652
+ }).length;
653
+ const arrayPrefix = "[]".repeat(mapDepth);
654
+ return arrayPrefix ? `${arrayPrefix}.${segments.join(".")}` : segments.join(".");
655
+ }
656
+ function enclosingCallableName(node) {
657
+ const callables = node.getAncestors().filter((ancestor) => Node.isFunctionDeclaration(ancestor) || Node.isMethodDeclaration(ancestor) || Node.isArrowFunction(ancestor) || Node.isFunctionExpression(ancestor));
658
+ for (const callable of callables) {
659
+ if (Node.isFunctionDeclaration(callable) || Node.isMethodDeclaration(callable)) {
660
+ const name = callable.getName();
661
+ if (name) return name;
662
+ continue;
663
+ }
664
+ const parent = callable.getParent();
665
+ if (parent && Node.isVariableDeclaration(parent)) return parent.getName();
666
+ }
667
+ return "";
668
+ }
669
+ function mapperDirectionFromName(name) {
670
+ if (userConfig.analysis.mapperNaming.inputPatterns.some((pattern) => pattern.test(name))) return "input";
671
+ if (userConfig.analysis.mapperNaming.outputPatterns.some((pattern) => pattern.test(name))) return "output";
672
+ }
673
+ function collectApiMapperDirections(sourceFile) {
674
+ const directions = /* @__PURE__ */ new Map();
675
+ const isApiMapperFile = sourceFile.getFilePath().includes(`${path.sep}mappers${path.sep}`);
676
+ for (const declaration of sourceFile.getFunctions()) {
677
+ const name = declaration.getName();
678
+ const direction = name ? mapperDirectionFromName(name) || (isApiMapperFile && /^map[A-Z]/.test(name) ? "output" : void 0) : void 0;
679
+ if (name && direction) directions.set(name, direction);
680
+ }
681
+ for (const declaration of sourceFile.getVariableDeclarations()) {
682
+ const initializer = declaration.getInitializer();
683
+ if (!initializer || !Node.isArrowFunction(initializer) && !Node.isFunctionExpression(initializer)) continue;
684
+ const name = declaration.getName();
685
+ const direction = mapperDirectionFromName(name) || (isApiMapperFile && /^map[A-Z]/.test(name) ? "output" : void 0);
686
+ if (direction) directions.set(name, direction);
687
+ }
688
+ let changed = true;
689
+ while (changed) {
690
+ changed = false;
691
+ for (const call of sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression)) {
692
+ const direction = directions.get(enclosingCallableName(call));
693
+ const calledName = calledFunctionName(call);
694
+ if (direction && calledName && !directions.has(calledName)) {
695
+ directions.set(calledName, direction);
696
+ changed = true;
697
+ }
698
+ }
699
+ }
700
+ return directions;
701
+ }
702
+ function joinApiFieldPath(prefix, field) {
703
+ if (!prefix) return field;
704
+ return `${prefix}.${field.replace(/^\[\]\.?/, "")}`;
705
+ }
706
+ function localCallableReturnsArray(sourceFile, callableName) {
707
+ const declaration = sourceFile.getFunctions().find((item) => item.getName() === callableName);
708
+ if (!declaration) return false;
709
+ return declaration.getDescendantsOfKind(SyntaxKind.ReturnStatement).some((statement) => {
710
+ const expression = statement.getExpression();
711
+ if (Node.isArrayLiteralExpression(expression)) return true;
712
+ if (!Node.isCallExpression(expression)) return false;
713
+ const callee = expression.getExpression();
714
+ return Node.isPropertyAccessExpression(callee) && callee.getName() === "map";
715
+ });
716
+ }
717
+ function extractApiFieldSourceCandidates(sourceFile) {
718
+ const mapperDirections = collectApiMapperDirections(sourceFile);
719
+ const candidates = [...sourceFile.getDescendantsOfKind(SyntaxKind.PropertyAssignment), ...sourceFile.getDescendantsOfKind(SyntaxKind.ShorthandPropertyAssignment)].sort((left, right) => left.getStart() - right.getStart()).flatMap((property) => {
720
+ if (Node.isShorthandPropertyAssignment(property)) {
721
+ const functionName = enclosingCallableName(property);
722
+ const direction = mapperDirections.get(functionName);
723
+ if (!direction) return [];
724
+ return [{
725
+ direction,
726
+ field: propertyAssignmentPath(property),
727
+ domainField: property.getName(),
728
+ sourceFile: sourceFile.getFilePath(),
729
+ line: property.getStartLineNumber(),
730
+ callableName: functionName
731
+ }];
732
+ }
733
+ const initializer = property.getInitializer();
734
+ if (!initializer || Node.isObjectLiteralExpression(initializer)) return [];
735
+ const usesApiMapper = (Node.isCallExpression(initializer) ? [initializer, ...initializer.getDescendantsOfKind(SyntaxKind.CallExpression)] : initializer.getDescendantsOfKind(SyntaxKind.CallExpression)).some((call) => userConfig.analysis.rules.find((rule) => rule.match(call, userConfig))?.parse(call, userConfig)?.apiMapping);
736
+ const functionName = enclosingCallableName(property);
737
+ const direction = mapperDirections.get(functionName) || (usesApiMapper ? "output" : void 0);
738
+ if (!direction) return [];
739
+ const objectField = propertyAssignmentPath(property);
740
+ const mappedValue = direction === "input" ? propertyAccessMatchingField(initializer, objectField) || firstBackendPropertyAccess(initializer) : firstBackendPropertyAccess(initializer);
741
+ const inputField = mappedValue?.replace(/^[^.]+\./, "");
742
+ return [{
743
+ direction,
744
+ field: direction === "input" && inputField ? inputField : objectField,
745
+ domainField: direction === "input" ? objectField : mappedValue,
746
+ sourceFile: sourceFile.getFilePath(),
747
+ line: property.getStartLineNumber(),
748
+ callableName: functionName
749
+ }];
750
+ });
751
+ const candidatesByCallable = /* @__PURE__ */ new Map();
752
+ for (const candidate of candidates) {
753
+ const values = candidatesByCallable.get(candidate.callableName) || [];
754
+ values.push(candidate);
755
+ candidatesByCallable.set(candidate.callableName, values);
756
+ }
757
+ const calledLocalFunctions = /* @__PURE__ */ new Set();
758
+ const qualifiedCandidates = [];
759
+ for (const call of sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression)) {
760
+ const calledName = calledFunctionName(call);
761
+ if (!calledName || !candidatesByCallable.has(calledName)) continue;
762
+ calledLocalFunctions.add(calledName);
763
+ const property = call.getFirstAncestorByKind(SyntaxKind.PropertyAssignment);
764
+ const spread = call.getFirstAncestorByKind(SyntaxKind.SpreadAssignment);
765
+ const prefix = property ? propertyAssignmentPath(property) : spread ? "" : void 0;
766
+ if (prefix === void 0) {
767
+ const mapCall = call.getAncestors().find((ancestor) => {
768
+ if (!Node.isCallExpression(ancestor)) return false;
769
+ const expression = ancestor.getExpression();
770
+ return Node.isPropertyAccessExpression(expression) && expression.getName() === "map";
771
+ });
772
+ const callback = mapCall?.getArguments()[0];
773
+ const callbackParameter = callback && (Node.isArrowFunction(callback) || Node.isFunctionExpression(callback)) ? callback.getParameters()[0]?.getName() : void 0;
774
+ const mapExpression = mapCall?.getExpression();
775
+ const collection = mapExpression && Node.isPropertyAccessExpression(mapExpression) ? mapExpression.getExpression().getText() : void 0;
776
+ const variable = call.getFirstAncestorByKind(SyntaxKind.VariableDeclaration)?.getName();
777
+ if (!variable || !collection || !callbackParameter) continue;
778
+ for (const candidate of candidatesByCallable.get(calledName) || []) qualifiedCandidates.push({
779
+ ...candidate,
780
+ field: `${variable}[].${candidate.field}`,
781
+ domainField: candidate.domainField?.startsWith(`${callbackParameter}.`) ? `${collection}[].${candidate.domainField.slice(callbackParameter.length + 1)}` : candidate.domainField
782
+ });
783
+ continue;
784
+ }
785
+ const qualifiedPrefix = localCallableReturnsArray(sourceFile, calledName) ? `${prefix}[]` : prefix;
786
+ for (const candidate of candidatesByCallable.get(calledName) || []) qualifiedCandidates.push({
787
+ ...candidate,
788
+ field: joinApiFieldPath(qualifiedPrefix, candidate.field)
789
+ });
790
+ }
791
+ return dedupeByKey([...candidates.filter((candidate) => !calledLocalFunctions.has(candidate.callableName)), ...qualifiedCandidates], (candidate) => `${candidate.direction}:${candidate.field}:${candidate.domainField || ""}:${candidate.sourceFile}:${candidate.line}`).map(({ callableName: _callableName, ...candidate }) => candidate);
792
+ }
793
+ /**
794
+ * Resolves the value passed to a local output mapper back to its parameter.
795
+ * This preserves the domain root across a handler-to-mapper boundary without
796
+ * weakening matching to a field-name-only comparison.
797
+ */
798
+ function extractRouteApiFieldSourceCandidates(sourceFiles) {
799
+ const candidates = sourceFiles.flatMap(extractApiFieldSourceCandidates);
800
+ const collectionOutputByVariable = /* @__PURE__ */ new Map();
801
+ for (const sourceFile of sourceFiles) for (const call of sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression)) {
802
+ const mapperName = calledFunctionName(call);
803
+ if (!mapperName) continue;
804
+ for (const mapperFile of sourceFiles) {
805
+ const mapper = mapperFile.getFunctions().find((declaration) => declaration.getName() === mapperName);
806
+ const returned = mapper?.getDescendantsOfKind(SyntaxKind.ReturnStatement).map(resolveReturnedObjectLiteral).find((value) => Boolean(value));
807
+ if (!mapper || !returned) continue;
808
+ for (const [index, parameter] of mapper.getParameters().entries()) {
809
+ const argument = call.getArguments()[index];
810
+ if (!argument || !Node.isIdentifier(argument)) continue;
811
+ const output = returned.getProperties().find((property) => Node.isShorthandPropertyAssignment(property) && property.getName() === parameter.getName() || Node.isPropertyAssignment(property) && property.getInitializer()?.getText() === parameter.getName());
812
+ if (output && (Node.isShorthandPropertyAssignment(output) || Node.isPropertyAssignment(output))) collectionOutputByVariable.set(`${sourceFile.getFilePath()}:${argument.getText()}`, output.getName());
813
+ }
814
+ }
815
+ }
816
+ const qualifiedCollectionCandidates = candidates.map((candidate) => {
817
+ const [variable, ...suffix] = candidate.field.split("[].");
818
+ const output = collectionOutputByVariable.get(`${candidate.sourceFile}:${variable}`);
819
+ return output && suffix.length ? {
820
+ ...candidate,
821
+ field: `${output}[].${suffix.join("[].")}`
822
+ } : candidate;
823
+ });
824
+ const aliasesByMapper = /* @__PURE__ */ new Map();
825
+ for (const sourceFile of sourceFiles) for (const call of sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression)) {
826
+ const mapperName = calledFunctionName(call);
827
+ const argument = call.getArguments()[0];
828
+ if (!mapperName || !argument || !Node.isExpression(argument) || !(Node.isIdentifier(argument) || Node.isPropertyAccessExpression(argument) || Node.isElementAccessExpression(argument))) continue;
829
+ for (const mapperFile of sourceFiles) {
830
+ const mapper = mapperFile.getFunctions().find((declaration) => declaration.getName() === mapperName);
831
+ const parameter = mapper?.getParameters()[0]?.getName();
832
+ if (!mapper || !parameter || mapperDirectionFromName(mapperName) !== "output") continue;
833
+ const key = `${mapperFile.getFilePath()}:${mapperName}`;
834
+ const aliases = aliasesByMapper.get(key) || [];
835
+ const ownerName = sourceFile.getFunctions().find((declaration) => declaration.getStartLineNumber() <= call.getStartLineNumber() && call.getStartLineNumber() <= declaration.getEndLineNumber())?.getName();
836
+ aliases.push({
837
+ parameter,
838
+ argument: argument.getText(),
839
+ ownerMapperKey: ownerName && mapperDirectionFromName(ownerName) === "output" ? `${sourceFile.getFilePath()}:${ownerName}` : void 0
840
+ });
841
+ aliasesByMapper.set(key, aliases);
842
+ }
843
+ }
844
+ const resolveArgument = (argument, ownerMapperKey, seen = /* @__PURE__ */ new Set()) => {
845
+ if (!ownerMapperKey) return argument;
846
+ const [root, ...suffix] = argument.split(".");
847
+ const alias = (aliasesByMapper.get(ownerMapperKey) || []).find((item) => item.parameter === root);
848
+ if (!alias || seen.has(`${ownerMapperKey}:${root}`)) return argument;
849
+ seen.add(`${ownerMapperKey}:${root}`);
850
+ return resolveArgument(`${alias.argument}${suffix.length ? `.${suffix.join(".")}` : ""}`, alias.ownerMapperKey, seen);
851
+ };
852
+ return dedupeByKey(qualifiedCollectionCandidates.flatMap((candidate) => {
853
+ if (candidate.direction !== "output" || !candidate.domainField) return [candidate];
854
+ const mapperName = (sourceFiles.find((file) => file.getFilePath() === candidate.sourceFile)?.getFunctions().find((declaration) => declaration.getStartLineNumber() <= candidate.line && candidate.line <= declaration.getEndLineNumber()))?.getName();
855
+ if (!mapperName) return [candidate];
856
+ const aliases = aliasesByMapper.get(`${candidate.sourceFile}:${mapperName}`) || [];
857
+ if (!aliases.length) return [candidate];
858
+ return aliases.map(({ parameter, argument, ownerMapperKey }) => ({
859
+ ...candidate,
860
+ domainField: candidate.domainField === parameter ? resolveArgument(argument, ownerMapperKey) : candidate.domainField?.startsWith(`${parameter}.`) ? resolveArgument(`${argument}${candidate.domainField.slice(parameter.length)}`, ownerMapperKey) : candidate.domainField
861
+ }));
862
+ }), (candidate) => `${candidate.direction}:${candidate.field}:${candidate.domainField || ""}:${candidate.sourceFile}:${candidate.line}`);
863
+ }
864
+ function enclosingFunctionName(node) {
865
+ return node.getFirstAncestorByKind(SyntaxKind.FunctionDeclaration)?.getName();
866
+ }
867
+ function isDomainToBackendMapper(node) {
868
+ return userConfig.analysis.mapperNaming.domainToBackendPattern.test(enclosingFunctionName(node) || "");
869
+ }
870
+ function resolveReturnedObjectLiteral(returnStatement) {
871
+ const expression = returnStatement.getExpression();
872
+ if (!expression) return;
873
+ const unwrappedExpression = Node.isAsExpression(expression) || Node.isTypeAssertion(expression) || Node.isSatisfiesExpression(expression) ? expression.getExpression() : expression;
874
+ if (Node.isObjectLiteralExpression(unwrappedExpression)) return unwrappedExpression;
875
+ if (Node.isCallExpression(unwrappedExpression)) {
876
+ const firstArgument = unwrappedExpression.getArguments()[0];
877
+ if (userConfig.analysis.rules.some((rule) => rule.match(unwrappedExpression, userConfig) && rule.parse(unwrappedExpression, userConfig)?.transparent) && firstArgument && Node.isObjectLiteralExpression(firstArgument)) return firstArgument;
878
+ }
879
+ if (!Node.isIdentifier(unwrappedExpression)) return;
880
+ const initializer = (unwrappedExpression.getSymbol()?.getDeclarations().find(Node.isVariableDeclaration))?.getInitializer();
881
+ return initializer && Node.isObjectLiteralExpression(initializer) ? initializer : void 0;
882
+ }
883
+ function resolveObjectLiteralExpression(expression) {
884
+ if (Node.isObjectLiteralExpression(expression)) return expression;
885
+ if (!Node.isIdentifier(expression)) return;
886
+ const initializer = (expression.getSymbol()?.getDeclarations().find(Node.isVariableDeclaration))?.getInitializer();
887
+ return initializer && Node.isExpression(initializer) ? resolveObjectLiteralExpression(initializer) : void 0;
888
+ }
889
+ function resolveRequestPayloadObject(call) {
890
+ const optionsArgument = call.getArguments()[1];
891
+ if (!optionsArgument || !Node.isExpression(optionsArgument)) return;
892
+ const optionsObject = resolveObjectLiteralExpression(optionsArgument);
893
+ if (!optionsObject) return;
894
+ for (const propertyName of [
895
+ "body",
896
+ "json",
897
+ "data",
898
+ "payload",
899
+ "qs",
900
+ "query",
901
+ "searchParams"
902
+ ]) {
903
+ const property = optionsObject.getProperty(propertyName);
904
+ if (property && Node.isPropertyAssignment(property)) {
905
+ const initializer = property.getInitializer();
906
+ if (initializer && Node.isExpression(initializer)) {
907
+ const payloadObject = resolveObjectLiteralExpression(initializer);
908
+ if (payloadObject) return payloadObject;
909
+ }
910
+ }
911
+ if (property && Node.isShorthandPropertyAssignment(property)) {
912
+ const initializer = (property.getValueSymbol()?.getDeclarations().find(Node.isVariableDeclaration))?.getInitializer();
913
+ if (initializer && Node.isExpression(initializer)) {
914
+ const payloadObject = resolveObjectLiteralExpression(initializer);
915
+ if (payloadObject) return payloadObject;
916
+ }
917
+ }
918
+ }
919
+ return optionsObject;
920
+ }
921
+ function isBackendRequestCall(call) {
922
+ const expression = call.getExpression();
923
+ if (!Node.isPropertyAccessExpression(expression) || ![
924
+ "get",
925
+ "post",
926
+ "put",
927
+ "patch",
928
+ "delete",
929
+ "head",
930
+ "query"
931
+ ].includes(expression.getName())) return false;
932
+ const routeArgument = call.getArguments()[0];
933
+ return Boolean(routeArgument && (Node.isStringLiteral(routeArgument) || Node.isNoSubstitutionTemplateLiteral(routeArgument)) && looksLikeBackendRoute(routeArgument.getLiteralValue()));
934
+ }
935
+ function isTechnicalRequestValue(value) {
936
+ const root = value.split(/[.[\]]/).find(Boolean);
937
+ return root === "context" || root === "ctx";
938
+ }
939
+ function normalizeBackendFieldForBackend(backendName, field) {
940
+ return backendName === "QUABLE_REST" && field.startsWith("i18n.") ? `document.attributes.${field.slice(5)}` : field;
941
+ }
942
+ function extractBackendFieldCandidates(sourceFile, backendName, reachableFunctionNames) {
943
+ const candidates = [];
944
+ const localFunctionsByName = new Map(sourceFile.getFunctions().filter((declaration) => Boolean(declaration.getName())).map((declaration) => [declaration.getName(), declaration]));
945
+ const shorthandPropertiesByName = /* @__PURE__ */ new Map();
946
+ for (const property of sourceFile.getDescendantsOfKind(SyntaxKind.ShorthandPropertyAssignment)) {
947
+ const properties = shorthandPropertiesByName.get(property.getName()) || [];
948
+ properties.push(property);
949
+ shorthandPropertiesByName.set(property.getName(), properties);
950
+ }
951
+ const addBackendToDomainMappings = (objectLiteral, constructorDomainRoot) => {
952
+ for (const mapping of collectObjectLiteralMappings(objectLiteral)) {
953
+ if (!mapping.backendField) continue;
954
+ const sourceRoot = mapping.backendField.split(/[.[\]]/).find(Boolean);
955
+ const mappedDomainName = enclosingCallableName(objectLiteral).match(userConfig.analysis.mapperNaming.domainNameFromToDomainPattern)?.[1];
956
+ const domainRoot = constructorDomainRoot || (sourceRoot === "response" && mappedDomainName ? `${mappedDomainName.charAt(0).toLowerCase()}${mappedDomainName.slice(1)}` : sourceRoot);
957
+ const domainPath = domainRoot && normalizePathForMatch(domainRoot) !== normalizePathForMatch(mapping.apiPath) ? `${domainRoot}.${mapping.apiPath}` : mapping.apiPath;
958
+ candidates.push({
959
+ backend: backendName,
960
+ direction: "output",
961
+ apiPathCandidate: domainPath,
962
+ backendField: mapping.backendField,
963
+ sourceFile: sourceFile.getFilePath(),
964
+ sourceLine: mapping.line,
965
+ confidence: normalizePathForMatch(mapping.apiPath) === normalizePathForMatch(mapping.backendField) ? 95 : 65,
966
+ mapperType: mapping.mapperType
967
+ });
968
+ }
969
+ };
970
+ for (const constructorCall of sourceFile.getDescendantsOfKind(SyntaxKind.NewExpression)) {
971
+ if (reachableFunctionNames && !reachableFunctionNames.has(enclosingCallableName(constructorCall))) continue;
972
+ const objectArgument = constructorCall.getArguments().find(Node.isObjectLiteralExpression);
973
+ if (objectArgument) {
974
+ const constructorName = constructorCall.getExpression().getText().split(".").at(-1);
975
+ const mapperName = enclosingCallableName(constructorCall);
976
+ const mapsConstructedType = constructorName && userConfig.analysis.mapperNaming.constructedTypeToDomainPattern(constructorName).test(mapperName);
977
+ const constructorDomainRoot = constructorName && mapsConstructedType ? `${constructorName.charAt(0).toLowerCase()}${constructorName.slice(1)}` : void 0;
978
+ addBackendToDomainMappings(objectArgument, constructorDomainRoot);
979
+ const parentProperty = constructorCall.getFirstAncestorByKind(SyntaxKind.PropertyAssignment);
980
+ const mappedDomainName = mapperName.match(userConfig.analysis.mapperNaming.domainNameFromToDomainPattern)?.[1];
981
+ if (!constructorDomainRoot && parentProperty && mappedDomainName) {
982
+ const domainRoot = `${mappedDomainName.charAt(0).toLowerCase()}${mappedDomainName.slice(1)}`;
983
+ const propertyName = parentProperty.getName().replace(/^['"]|['"]$/g, "");
984
+ for (const mapping of collectObjectLiteralMappings(objectArgument)) {
985
+ if (!mapping.backendField) continue;
986
+ candidates.push({
987
+ backend: backendName,
988
+ direction: "output",
989
+ apiPathCandidate: `${domainRoot}.${propertyName}.${mapping.apiPath}`,
990
+ backendField: mapping.backendField,
991
+ sourceFile: sourceFile.getFilePath(),
992
+ sourceLine: mapping.line,
993
+ confidence: 65,
994
+ mapperType: mapping.mapperType
995
+ });
996
+ }
997
+ }
998
+ }
999
+ }
1000
+ for (const objectLiteral of sourceFile.getDescendantsOfKind(SyntaxKind.ObjectLiteralExpression)) {
1001
+ if (!objectLiteral.getProperties().some(Node.isShorthandPropertyAssignment)) continue;
1002
+ if (!objectLiteral.getFirstAncestorByKind(SyntaxKind.ArrowFunction)) continue;
1003
+ if (reachableFunctionNames && !reachableFunctionNames.has(enclosingCallableName(objectLiteral))) continue;
1004
+ addBackendToDomainMappings(objectLiteral);
1005
+ }
1006
+ for (const assignment of sourceFile.getDescendantsOfKind(SyntaxKind.BinaryExpression)) {
1007
+ if (assignment.getOperatorToken().getKind() !== SyntaxKind.EqualsToken) continue;
1008
+ if (reachableFunctionNames && !reachableFunctionNames.has(enclosingCallableName(assignment))) continue;
1009
+ const left = assignment.getLeft();
1010
+ const right = assignment.getRight();
1011
+ if (Node.isPropertyAccessExpression(left) && Node.isExpression(right)) {
1012
+ const backendField = firstBackendPropertyAccess(right);
1013
+ if (backendField) candidates.push({
1014
+ backend: backendName,
1015
+ direction: "output",
1016
+ apiPathCandidate: left.getText(),
1017
+ backendField,
1018
+ sourceFile: sourceFile.getFilePath(),
1019
+ sourceLine: assignment.getStartLineNumber(),
1020
+ confidence: 65
1021
+ });
1022
+ continue;
1023
+ }
1024
+ if (!Node.isElementAccessExpression(left) || !Node.isElementAccessExpression(right)) continue;
1025
+ const key = left.getArgumentExpression();
1026
+ const backendKey = right.getArgumentExpression();
1027
+ if (!key || !backendKey || key.getText() !== backendKey.getText()) continue;
1028
+ candidates.push({
1029
+ backend: backendName,
1030
+ apiPathCandidate: key.getText(),
1031
+ backendField: right.getText(),
1032
+ sourceFile: sourceFile.getFilePath(),
1033
+ sourceLine: assignment.getStartLineNumber(),
1034
+ confidence: 0,
1035
+ requiresReview: true,
1036
+ reviewReason: `Dynamic key mapping: ${assignment.getText()}`
1037
+ });
1038
+ }
1039
+ for (const returnStatement of sourceFile.getDescendantsOfKind(SyntaxKind.ReturnStatement)) {
1040
+ if (reachableFunctionNames && !reachableFunctionNames.has(enclosingCallableName(returnStatement))) continue;
1041
+ const objectLiteral = resolveReturnedObjectLiteral(returnStatement);
1042
+ if (!objectLiteral) continue;
1043
+ for (const mapping of collectObjectLiteralMappings(objectLiteral)) {
1044
+ if (!mapping.backendField) continue;
1045
+ const domainToBackend = isDomainToBackendMapper(returnStatement);
1046
+ const apiPathCandidate = domainToBackend ? mapping.backendField : mapping.apiPath;
1047
+ const backendField = domainToBackend ? mapping.apiPath : mapping.backendField;
1048
+ candidates.push({
1049
+ backend: backendName,
1050
+ direction: domainToBackend ? "input" : "output",
1051
+ apiPathCandidate,
1052
+ backendField,
1053
+ sourceFile: sourceFile.getFilePath(),
1054
+ sourceLine: mapping.line,
1055
+ confidence: normalizePathForMatch(apiPathCandidate) === normalizePathForMatch(backendField) ? 95 : 65,
1056
+ mapperType: mapping.mapperType
1057
+ });
1058
+ }
1059
+ }
1060
+ for (const call of analysisCacheService.getCallExpressions(sourceFile).filter(isBackendRequestCall)) {
1061
+ if (reachableFunctionNames && !reachableFunctionNames.has(enclosingCallableName(call))) continue;
1062
+ const payloadObject = resolveRequestPayloadObject(call);
1063
+ if (!payloadObject) continue;
1064
+ for (const mapping of collectObjectLiteralMappings(payloadObject)) {
1065
+ if (!mapping.backendField || isTechnicalRequestValue(mapping.backendField)) continue;
1066
+ candidates.push({
1067
+ backend: backendName,
1068
+ direction: "input",
1069
+ apiPathCandidate: mapping.backendField,
1070
+ backendField: mapping.apiPath,
1071
+ sourceFile: sourceFile.getFilePath(),
1072
+ sourceLine: mapping.line,
1073
+ confidence: normalizePathForMatch(mapping.backendField) === normalizePathForMatch(mapping.apiPath) ? 95 : 65,
1074
+ mapperType: mapping.mapperType
1075
+ });
1076
+ }
1077
+ }
1078
+ for (const call of analysisCacheService.getCallExpressions(sourceFile)) {
1079
+ if (reachableFunctionNames && !reachableFunctionNames.has(enclosingCallableName(call))) continue;
1080
+ if (isDomainToBackendMapper(call)) continue;
1081
+ const expression = call.getExpression();
1082
+ if (!Node.isPropertyAccessExpression(expression) || !userConfig.analysis.neutralExpressionMatchers.some((matcher) => expression.getExpression().getText().startsWith(matcher.prefix.replace(/\.$/, "")))) continue;
1083
+ const firstArg = call.getArguments()[0];
1084
+ if (!firstArg || !Node.isExpression(firstArg)) continue;
1085
+ const backendField = firstBackendPropertyAccess(firstArg);
1086
+ if (!backendField) continue;
1087
+ candidates.push({
1088
+ backend: backendName,
1089
+ backendField,
1090
+ sourceFile: sourceFile.getFilePath(),
1091
+ sourceLine: call.getStartLineNumber(),
1092
+ confidence: 55,
1093
+ mapperType: extractMapperType(call)
1094
+ });
1095
+ }
1096
+ for (const call of analysisCacheService.getCallExpressions(sourceFile)) {
1097
+ const calledName = calledFunctionName(call);
1098
+ const declaration = calledName ? localFunctionsByName.get(calledName) : void 0;
1099
+ const firstParameter = declaration?.getParameters()[0]?.getName();
1100
+ const firstArgument = call.getArguments()[0];
1101
+ if (!declaration || !firstParameter || !firstArgument || !Node.isExpression(firstArgument)) continue;
1102
+ const argumentPath = firstBackendPropertyAccess(firstArgument);
1103
+ const startLine = declaration.getStartLineNumber();
1104
+ const endLine = declaration.getEndLineNumber();
1105
+ const helperCandidates = candidates.filter((candidate) => candidate.sourceLine !== void 0 && candidate.sourceLine >= startLine && candidate.sourceLine <= endLine);
1106
+ for (const candidate of helperCandidates) {
1107
+ if (argumentPath?.includes(".") && candidate.apiPathCandidate?.startsWith(`${firstParameter}.`)) candidates.push({
1108
+ ...candidate,
1109
+ apiPathCandidate: `${argumentPath}${candidate.apiPathCandidate.slice(firstParameter.length)}`
1110
+ });
1111
+ const collectionExpression = call.getAncestors().find((ancestor) => {
1112
+ if (!Node.isCallExpression(ancestor)) return false;
1113
+ const expression = ancestor.getExpression();
1114
+ return Node.isPropertyAccessExpression(expression) && expression.getName() === "map";
1115
+ })?.getExpression();
1116
+ if (collectionExpression && Node.isPropertyAccessExpression(collectionExpression) && candidate.apiPathCandidate?.startsWith(`${firstParameter}.`)) candidates.push({
1117
+ ...candidate,
1118
+ apiPathCandidate: `${collectionExpression.getExpression().getText()}[].${candidate.apiPathCandidate.slice(firstParameter.length + 1)}`
1119
+ });
1120
+ const property = call.getFirstAncestorByKind(SyntaxKind.PropertyAssignment);
1121
+ const constructorName = call.getFirstAncestorByKind(SyntaxKind.NewExpression)?.getExpression().getText().split(".").at(-1);
1122
+ if (!property || !constructorName || !candidate.apiPathCandidate || candidate.apiPathCandidate.startsWith(`${firstParameter}.`)) continue;
1123
+ candidates.push({
1124
+ ...candidate,
1125
+ apiPathCandidate: `${constructorName.charAt(0).toLowerCase()}${constructorName.slice(1)}.${property.getName()}.${candidate.apiPathCandidate}`
1126
+ });
1127
+ }
1128
+ const localVariableName = call.getFirstAncestorByKind(SyntaxKind.VariableDeclaration)?.getName();
1129
+ if (!localVariableName) continue;
1130
+ const shorthandProperties = shorthandPropertiesByName.get(localVariableName) || [];
1131
+ for (const shorthandProperty of shorthandProperties) {
1132
+ const constructorName = shorthandProperty.getFirstAncestorByKind(SyntaxKind.NewExpression)?.getExpression().getText().split(".").at(-1);
1133
+ if (!constructorName) continue;
1134
+ for (const candidate of helperCandidates) {
1135
+ if (!candidate.apiPathCandidate) continue;
1136
+ candidates.push({
1137
+ ...candidate,
1138
+ apiPathCandidate: `${constructorName.charAt(0).toLowerCase()}${constructorName.slice(1)}.${localVariableName}.${candidate.apiPathCandidate}`
1139
+ });
1140
+ }
1141
+ }
1142
+ }
1143
+ return candidates.map((candidate) => ({
1144
+ ...candidate,
1145
+ backendField: normalizeBackendFieldForBackend(backendName, candidate.backendField)
1146
+ }));
1147
+ }
1148
+ /**
1149
+ * Carries a backend response mapper's Domain root through a client return and
1150
+ * the variable receiving that client call in the route handler.
1151
+ */
1152
+ function extractRouteBackendFieldCandidates(backendSourceFiles, reachableFunctionNames, callingSourceFiles) {
1153
+ const candidates = backendSourceFiles.flatMap((sourceFile) => {
1154
+ const backendName = inferBackendNameFromFile(sourceFile);
1155
+ return backendName && !isHttpClientInfrastructure(sourceFile) ? extractBackendFieldCandidates(sourceFile, backendName, reachableFunctionNames) : [];
1156
+ });
1157
+ const allSourceFiles = dedupeByKey([...backendSourceFiles, ...callingSourceFiles], (file) => file.getFilePath());
1158
+ const outputCandidatesByApiPath = /* @__PURE__ */ new Map();
1159
+ for (const candidate of candidates) {
1160
+ if (candidate.direction !== "output" || !candidate.apiPathCandidate) continue;
1161
+ const key = normalizePathForMatch(candidate.apiPathCandidate);
1162
+ const values = outputCandidatesByApiPath.get(key) || [];
1163
+ values.push(candidate);
1164
+ outputCandidatesByApiPath.set(key, values);
1165
+ }
1166
+ const callsByName = /* @__PURE__ */ new Map();
1167
+ for (const sourceFile of allSourceFiles) for (const call of analysisCacheService.getCallExpressions(sourceFile)) {
1168
+ const name = calledFunctionName(call);
1169
+ if (!name) continue;
1170
+ const values = callsByName.get(name) || [];
1171
+ values.push(call);
1172
+ callsByName.set(name, values);
1173
+ }
1174
+ const receiverNamesByMapper = /* @__PURE__ */ new Map();
1175
+ const receiverNamesForMapper = (mapperName) => {
1176
+ const cached = receiverNamesByMapper.get(mapperName);
1177
+ if (cached) return cached;
1178
+ const receiverNames = [...new Set((callsByName.get(mapperName) || []).flatMap((call) => {
1179
+ const returnExpression = call.getFirstAncestorByKind(SyntaxKind.ReturnStatement)?.getExpression();
1180
+ const clientName = returnExpression === call || returnExpression?.getDescendants().includes(call) ? enclosingCallableName(call) : void 0;
1181
+ return clientName ? [clientName] : [];
1182
+ }))].flatMap((clientName) => (callsByName.get(clientName) || []).flatMap((call) => {
1183
+ const receiver = call.getFirstAncestorByKind(SyntaxKind.VariableDeclaration);
1184
+ return receiver ? [receiver.getName()] : [];
1185
+ }));
1186
+ receiverNamesByMapper.set(mapperName, receiverNames);
1187
+ return receiverNames;
1188
+ };
1189
+ const linkedCandidates = candidates.flatMap((candidate) => {
1190
+ if (candidate.direction !== "output" || !candidate.apiPathCandidate) return [candidate];
1191
+ return [candidate, ...(outputCandidatesByApiPath.get(normalizePathForMatch(candidate.backendField)) || []).filter((downstreamCandidate) => downstreamCandidate !== candidate && downstreamCandidate.backend === candidate.backend).map((downstreamCandidate) => ({
1192
+ ...candidate,
1193
+ backendField: downstreamCandidate.backendField,
1194
+ sourceFile: downstreamCandidate.sourceFile,
1195
+ sourceLine: downstreamCandidate.sourceLine,
1196
+ mapperType: downstreamCandidate.mapperType
1197
+ }))];
1198
+ });
1199
+ const bindingNamesForCall = (call) => {
1200
+ const declaration = call.getFirstAncestorByKind(SyntaxKind.VariableDeclaration);
1201
+ if (!declaration) return [];
1202
+ const name = declaration.getNameNode();
1203
+ if (Node.isIdentifier(name)) return [name.getText()];
1204
+ const values = call.getFirstAncestorByKind(SyntaxKind.ArrayLiteralExpression);
1205
+ if (!values || !Node.isArrayBindingPattern(name)) return [];
1206
+ const index = values.getElements().findIndex((value) => value === call || value.getDescendants().includes(call));
1207
+ const binding = name.getElements()[index];
1208
+ return binding && Node.isBindingElement(binding) ? [binding.getNameNode().getText()] : [];
1209
+ };
1210
+ const sourceFilesByPath = new Map(backendSourceFiles.map((file) => [file.getFilePath(), file]));
1211
+ const collectionOwnersByMapper = /* @__PURE__ */ new Map();
1212
+ const findAliasesByCollection = /* @__PURE__ */ new Map();
1213
+ const spreadOwnersByLocal = /* @__PURE__ */ new Map();
1214
+ for (const source of allSourceFiles) {
1215
+ for (const declaration of source.getDescendantsOfKind(SyntaxKind.VariableDeclaration)) {
1216
+ const initializer = declaration.getInitializer();
1217
+ if (!initializer || !Node.isCallExpression(initializer) || !Node.isPropertyAccessExpression(initializer.getExpression())) continue;
1218
+ const find = initializer.getExpression().asKindOrThrow(SyntaxKind.PropertyAccessExpression);
1219
+ if (find.getName() !== "find") continue;
1220
+ const collection = find.getExpression().getText();
1221
+ findAliasesByCollection.set(collection, [...findAliasesByCollection.get(collection) || [], declaration.getName()]);
1222
+ }
1223
+ for (const spread of source.getDescendantsOfKind(SyntaxKind.SpreadAssignment)) {
1224
+ const owner = enclosingCallableName(spread);
1225
+ if (!owner || !spread.getFirstAncestorByKind(SyntaxKind.ReturnStatement)) continue;
1226
+ const local = spread.getExpression().getText();
1227
+ spreadOwnersByLocal.set(local, [...spreadOwnersByLocal.get(local) || [], owner]);
1228
+ }
1229
+ for (const mapCall of analysisCacheService.getCallExpressions(source)) {
1230
+ const expression = mapCall.getExpression();
1231
+ if (!Node.isPropertyAccessExpression(expression) || expression.getName() !== "map") continue;
1232
+ const owner = enclosingCallableName(mapCall);
1233
+ if (!owner) continue;
1234
+ for (const argument of mapCall.getArguments().filter(Node.isIdentifier)) {
1235
+ const key = `${source.getFilePath()}:${argument.getText()}`;
1236
+ collectionOwnersByMapper.set(key, [...collectionOwnersByMapper.get(key) || [], owner]);
1237
+ }
1238
+ }
1239
+ }
1240
+ const propagatedCandidates = [...linkedCandidates];
1241
+ const queuedCandidates = [...linkedCandidates];
1242
+ const seenCandidates = new Set(linkedCandidates.map((candidate) => `${candidate.backend}:${candidate.backendField}:${candidate.apiPathCandidate || ""}:${candidate.sourceFile}:${candidate.sourceLine || ""}`));
1243
+ while (queuedCandidates.length) {
1244
+ const candidate = queuedCandidates.shift();
1245
+ if (candidate.direction !== "output" || !candidate.apiPathCandidate) continue;
1246
+ const add = (pathCandidate) => {
1247
+ const key = `${candidate.backend}:${candidate.backendField}:${pathCandidate}:${candidate.sourceFile}:${candidate.sourceLine || ""}`;
1248
+ if (seenCandidates.has(key)) return;
1249
+ seenCandidates.add(key);
1250
+ const propagated = {
1251
+ ...candidate,
1252
+ apiPathCandidate: pathCandidate
1253
+ };
1254
+ propagatedCandidates.push(propagated);
1255
+ queuedCandidates.push(propagated);
1256
+ };
1257
+ const mapper = sourceFilesByPath.get(candidate.sourceFile)?.getFunctions().find((declaration) => candidate.sourceLine && declaration.getStartLineNumber() <= candidate.sourceLine && candidate.sourceLine <= declaration.getEndLineNumber());
1258
+ for (const owner of collectionOwnersByMapper.get(`${candidate.sourceFile}:${mapper?.getName() || ""}`) || []) add(`${owner}[].${lastPathSegment(candidate.apiPathCandidate)}`);
1259
+ const [root, ...segments] = candidate.apiPathCandidate.split(".");
1260
+ const functionName = root.replace(/\[\]$/, "");
1261
+ const collection = root.endsWith("[]");
1262
+ const suffix = segments.join(".");
1263
+ for (const call of callsByName.get(functionName) || []) for (const binding of bindingNamesForCall(call)) add(`${binding}${collection ? "[]" : ""}.${suffix}`);
1264
+ if (collection) for (const alias of findAliasesByCollection.get(functionName) || []) add(`${alias}.${suffix}`);
1265
+ if (!collection) for (const owner of spreadOwnersByLocal.get(functionName) || []) add(`${owner}.${suffix}`);
1266
+ }
1267
+ return dedupeByKey(propagatedCandidates.flatMap((candidate) => {
1268
+ if (candidate.direction !== "output" || !candidate.apiPathCandidate || !candidate.sourceLine) return [candidate];
1269
+ const mapper = backendSourceFiles.find((file) => file.getFilePath() === candidate.sourceFile)?.getFunctions().find((declaration) => declaration.getStartLineNumber() <= candidate.sourceLine && candidate.sourceLine <= declaration.getEndLineNumber());
1270
+ const mapperName = mapper?.getName();
1271
+ const mapperParameter = mapper?.getParameters()[0]?.getName();
1272
+ if (!mapperName || !mapperParameter || !candidate.apiPathCandidate.startsWith(`${mapperParameter}.`)) return [candidate];
1273
+ const receiverNames = receiverNamesForMapper(mapperName);
1274
+ if (!receiverNames.length) return [candidate];
1275
+ return [candidate, ...receiverNames.map((receiverName) => ({
1276
+ ...candidate,
1277
+ apiPathCandidate: `${receiverName}${candidate.apiPathCandidate.slice(mapperParameter.length)}`
1278
+ }))];
1279
+ }), (candidate) => `${candidate.backend}:${candidate.direction || ""}:${candidate.apiPathCandidate || ""}:${candidate.backendField}:${candidate.sourceFile}:${candidate.sourceLine || ""}`);
1280
+ }
1281
+ function calledFunctionName(call) {
1282
+ const expression = call.getExpression();
1283
+ if (Node.isIdentifier(expression)) return expression.getText();
1284
+ if (Node.isPropertyAccessExpression(expression)) return expression.getName();
1285
+ }
1286
+ function referencedFunctionNames(call) {
1287
+ return call.getArguments().flatMap((argument) => {
1288
+ if (Node.isIdentifier(argument)) return [argument.getText()];
1289
+ if (Node.isPropertyAccessExpression(argument)) return [argument.getName()];
1290
+ return [];
1291
+ });
1292
+ }
1293
+ function collectReachableBackendFunctionNames(entrySourceFiles, backendSourceFiles) {
1294
+ const reachable = new Set(entrySourceFiles.flatMap((sourceFile) => analysisCacheService.getCallExpressions(sourceFile).map(calledFunctionName).filter((name) => Boolean(name))));
1295
+ for (const sourceFile of backendSourceFiles) for (const property of sourceFile.getDescendantsOfKind(SyntaxKind.PropertyAssignment)) {
1296
+ if (property.getName() !== "mapper") continue;
1297
+ const mapperName = expressionToText(property.getInitializer());
1298
+ if (mapperName) reachable.add(mapperName.split(".").at(-1) || mapperName);
1299
+ const mapperImport = property.getInitializer()?.getSymbol()?.getDeclarations().find(Node.isImportSpecifier);
1300
+ if (mapperImport) reachable.add(mapperImport.getName());
1301
+ }
1302
+ let changed = true;
1303
+ while (changed) {
1304
+ changed = false;
1305
+ for (const sourceFile of backendSourceFiles) for (const call of analysisCacheService.getCallExpressions(sourceFile)) {
1306
+ if (!reachable.has(enclosingCallableName(call))) continue;
1307
+ const calledName = calledFunctionName(call);
1308
+ if (calledName && !reachable.has(calledName)) {
1309
+ reachable.add(calledName);
1310
+ changed = true;
1311
+ }
1312
+ for (const referencedName of referencedFunctionNames(call)) if (!reachable.has(referencedName)) {
1313
+ reachable.add(referencedName);
1314
+ changed = true;
1315
+ }
1316
+ }
1317
+ }
1318
+ return reachable;
1319
+ }
1320
+ function resolveHandlerFile(routeSourceFile, handlerRef, resolvePath = (sourceFilePath, moduleSpecifier) => resolveModulePath(sourceFilePath, moduleSpecifier, userConfig.resolver.alias, userConfig.repoRoot)) {
1321
+ if (!handlerRef) return {};
1322
+ if (!handlerRef.includes(".")) {
1323
+ if (routeSourceFile.getFunction(handlerRef) || routeSourceFile.getVariableDeclaration(handlerRef)) return {
1324
+ handlerName: handlerRef,
1325
+ handlerFile: routeSourceFile.getFilePath()
1326
+ };
1327
+ for (const importDecl of routeSourceFile.getImportDeclarations()) {
1328
+ if (!importDecl.getNamedImports().find((entry) => entry.getName() === handlerRef)) continue;
1329
+ const resolved = resolvePath(routeSourceFile.getFilePath(), importDecl.getModuleSpecifierValue());
1330
+ if (resolved) return {
1331
+ handlerName: handlerRef,
1332
+ handlerFile: resolved
1333
+ };
1334
+ }
1335
+ return { handlerName: handlerRef };
1336
+ }
1337
+ const [namespaceAlias, memberName] = handlerRef.split(".");
1338
+ for (const importDecl of routeSourceFile.getImportDeclarations()) if (importDecl.getNamespaceImport()?.getText() === namespaceAlias) {
1339
+ const resolved = resolvePath(routeSourceFile.getFilePath(), importDecl.getModuleSpecifierValue());
1340
+ if (resolved) return {
1341
+ handlerName: memberName,
1342
+ handlerFile: resolved
1343
+ };
1344
+ }
1345
+ return { handlerName: memberName };
1346
+ }
1347
+ async function yieldToEventLoop() {
1348
+ await new Promise((resolve) => setImmediate(resolve));
1349
+ }
1350
+ async function collectDependencyFiles(project, entryFilePath, routeLabel, resolvePath = (sourceFilePath, moduleSpecifier) => resolveModulePath(sourceFilePath, moduleSpecifier, userConfig.resolver.alias, userConfig.repoRoot)) {
1351
+ const queue = [entryFilePath];
1352
+ const visited = /* @__PURE__ */ new Set();
1353
+ while (queue.length) {
1354
+ const current = queue.shift();
1355
+ if (visited.has(current)) continue;
1356
+ visited.add(current);
1357
+ if (visited.size % 20 === 0) {
1358
+ taskProgressService.log(`${routeLabel} ... Dependency graph: ${visited.size} files scanned, ${queue.length} queued`);
1359
+ await yieldToEventLoop();
1360
+ }
1361
+ let sourceFile = project.getSourceFile(current);
1362
+ if (!sourceFile) try {
1363
+ sourceFile = project.addSourceFileAtPath(current);
1364
+ } catch {
1365
+ continue;
1366
+ }
1367
+ const moduleSpecifiers = [...sourceFile.getImportDeclarations().map((declaration) => declaration.getModuleSpecifierValue()), ...sourceFile.getExportDeclarations().map((declaration) => declaration.getModuleSpecifierValue()).filter((value) => Boolean(value))];
1368
+ for (const moduleSpecifier of moduleSpecifiers) {
1369
+ const resolved = resolvePath(sourceFile.getFilePath(), moduleSpecifier);
1370
+ if (!resolved || visited.has(resolved) || !shouldKeepAnalysisFile(resolved)) continue;
1371
+ queue.push(resolved);
1372
+ }
1373
+ }
1374
+ return [...visited];
1375
+ }
1376
+ async function analyzeCodebaseRouteContracts(params) {
1377
+ const { cwd, openApiDocument: swagger, selectedRouteKeys, routeContracts, onDocument, routeAnalysisScope } = params;
1378
+ taskProgressService.log("Discovering route files");
1379
+ const routeFiles = await globby(["app/_api/**/routes.@(js|ts)", "app/legacy/**/routes.@(js|ts)"], {
1380
+ cwd,
1381
+ absolute: true
1382
+ });
1383
+ taskProgressService.log(`${routeFiles.length} route files discovered`);
1384
+ const project = new Project({
1385
+ skipAddingFilesFromTsConfig: true,
1386
+ compilerOptions: {
1387
+ allowJs: true,
1388
+ checkJs: false,
1389
+ target: 99,
1390
+ module: 99
1391
+ }
1392
+ });
1393
+ const routeDeclarations = [];
1394
+ for (const routeFile of routeFiles) {
1395
+ const routesInitializer = project.addSourceFileAtPath(routeFile).getVariableDeclaration("routes")?.getInitializer();
1396
+ if (!routesInitializer) continue;
1397
+ if (Node.isArrayLiteralExpression(routesInitializer)) {
1398
+ routeDeclarations.push(...extractRoutesFromArray(routeFile, routesInitializer));
1399
+ continue;
1400
+ }
1401
+ if (Node.isAsExpression(routesInitializer) || Node.isSatisfiesExpression(routesInitializer)) {
1402
+ const expression = routesInitializer.getExpression();
1403
+ if (Node.isArrayLiteralExpression(expression)) routeDeclarations.push(...extractRoutesFromArray(routeFile, expression));
1404
+ }
1405
+ }
1406
+ const routeDeclarationsByKey = dedupeByKey(routeDeclarations, (item) => stableKey$2(item.method, item.path, item.file, item.handlerRef || ""));
1407
+ const selectedRouteDeclarations = selectedRouteKeys ? routeDeclarationsByKey.filter((route) => selectedRouteKeys.has(stableKey$2(route.method, route.path))) : routeDeclarationsByKey;
1408
+ taskProgressService.log(`${selectedRouteDeclarations.length} route definitions selected from ${routeDeclarationsByKey.length} discovered`);
1409
+ const documents = [];
1410
+ const dependencyFilesByHandler = /* @__PURE__ */ new Map();
1411
+ const resolvedModulePaths = /* @__PURE__ */ new Map();
1412
+ const resolveCachedModulePath = (sourceFilePath, moduleSpecifier) => {
1413
+ const key = `${sourceFilePath}${moduleSpecifier}`;
1414
+ if (resolvedModulePaths.has(key)) return resolvedModulePaths.get(key);
1415
+ const resolved = resolveModulePath(sourceFilePath, moduleSpecifier, userConfig.resolver.alias, userConfig.repoRoot);
1416
+ resolvedModulePaths.set(key, resolved);
1417
+ return resolved;
1418
+ };
1419
+ const rulesByFile = /* @__PURE__ */ new Map();
1420
+ const aggregationsByFile = /* @__PURE__ */ new Map();
1421
+ const backendFieldsByGraph = /* @__PURE__ */ new Map();
1422
+ for (const [index, routeDeclaration] of selectedRouteDeclarations.entries()) {
1423
+ const routeLabel = `[${index + 1}/${selectedRouteDeclarations.length}] - ${routeDeclaration.method} ${routeDeclaration.path}`;
1424
+ taskProgressService.log(`${routeLabel} ...`);
1425
+ await yieldToEventLoop();
1426
+ const routeKey = stableKey$2(routeDeclaration.method, routeDeclaration.path);
1427
+ const routeContract = routeContracts?.get(routeKey);
1428
+ const operation = swagger?.paths?.[routeDeclaration.path]?.[routeDeclaration.method.toLowerCase()];
1429
+ if (!routeContract && (!operation || !swagger)) continue;
1430
+ const routeSourceFile = project.getSourceFile(routeDeclaration.file);
1431
+ taskProgressService.log(`${routeLabel} ... Resolving handler`);
1432
+ const handler = resolveHandlerFile(routeSourceFile, routeDeclaration.handlerRef, resolveCachedModulePath);
1433
+ const topologyScope = routeAnalysisScope ? await routeAnalysisScope({
1434
+ method: routeDeclaration.method,
1435
+ path: routeDeclaration.path
1436
+ }) : void 0;
1437
+ if (routeAnalysisScope && !topologyScope) continue;
1438
+ taskProgressService.log(topologyScope ? `${routeLabel} ... Loading dependencies from backend graph` : `${routeLabel} ... Collecting dependencies`);
1439
+ const analysisFiles = filterAnalysisFiles(dedupeByKey(await (topologyScope ? topologyScope.analysisFiles : handler.handlerFile ? dependencyFilesByHandler.get(handler.handlerFile) || (() => {
1440
+ const files = collectDependencyFiles(project, handler.handlerFile, routeLabel, resolveCachedModulePath);
1441
+ dependencyFilesByHandler.set(handler.handlerFile, files);
1442
+ return files;
1443
+ })() : [routeDeclaration.file]), (value) => value));
1444
+ taskProgressService.log(`${routeLabel} ... Extracting mappings from ${analysisFiles.length} relevant files`);
1445
+ await yieldToEventLoop();
1446
+ const backendSourceFiles = analysisFiles.filter((file) => file.includes(`${path.sep}app${path.sep}_infra${path.sep}back${path.sep}`)).map((file) => project.getSourceFile(file) || project.addSourceFileAtPath(file)).filter((sourceFile) => {
1447
+ if (!topologyScope) return true;
1448
+ const backendName = inferBackendNameFromFile(sourceFile);
1449
+ return Boolean(backendName && topologyScope.backendTypes.has(backendName));
1450
+ });
1451
+ const apiSourceFiles = analysisFiles.filter((file) => file.includes(`${path.sep}app${path.sep}_api${path.sep}`)).map((file) => project.getSourceFile(file) || project.addSourceFileAtPath(file));
1452
+ const useCaseSourceFiles = analysisFiles.filter((file) => file.includes(`${path.sep}app${path.sep}_usecases${path.sep}`)).map((file) => project.getSourceFile(file) || project.addSourceFileAtPath(file));
1453
+ const reachableBackendFunctionNames = collectReachableBackendFunctionNames([...apiSourceFiles, ...useCaseSourceFiles], backendSourceFiles);
1454
+ const backendNames = dedupeByKey([...topologyScope?.backendTypes || [], ...backendSourceFiles.map((sourceFile) => inferBackendNameFromFile(sourceFile)).filter((value) => Boolean(value))], (value) => value);
1455
+ const backendRouteCandidates = dedupeByKey(backendSourceFiles.flatMap((sourceFile) => {
1456
+ const backendName = inferBackendNameFromFile(sourceFile);
1457
+ return backendName ? extractBackendRouteCandidates({
1458
+ sourceFile,
1459
+ backendName,
1460
+ reachableFunctionNames: reachableBackendFunctionNames,
1461
+ enclosingCallableName
1462
+ }) : [];
1463
+ }), (item) => `${item.backend}:${item.method}:${item.route}:${item.sourceFile}`);
1464
+ const backendGraphKey = `${backendSourceFiles.map((file) => file.getFilePath()).sort().join("|")}::${[...reachableBackendFunctionNames].sort().join("|")}`;
1465
+ const backendFieldCandidates = backendFieldsByGraph.get(backendGraphKey) || (() => {
1466
+ const candidates = extractRouteBackendFieldCandidates(backendSourceFiles, reachableBackendFunctionNames, [...apiSourceFiles, ...useCaseSourceFiles]);
1467
+ backendFieldsByGraph.set(backendGraphKey, candidates);
1468
+ return candidates;
1469
+ })();
1470
+ const apiFieldSourceCandidates = extractRouteApiFieldSourceCandidates(apiSourceFiles);
1471
+ const rules = dedupeByKey(useCaseSourceFiles.flatMap((sourceFile) => {
1472
+ const filePath = sourceFile.getFilePath();
1473
+ const cached = rulesByFile.get(filePath) || extractUseCaseRules(sourceFile);
1474
+ rulesByFile.set(filePath, cached);
1475
+ return cached;
1476
+ }), (item) => `${item.condition}:${item.effect}:${item.elseEffect || ""}:${item.sourceFile}`);
1477
+ const aggregations = dedupeByKey(useCaseSourceFiles.flatMap((sourceFile) => {
1478
+ const filePath = sourceFile.getFilePath();
1479
+ const cached = aggregationsByFile.get(filePath) || extractUseCaseAggregations(sourceFile);
1480
+ aggregationsByFile.set(filePath, cached);
1481
+ return cached;
1482
+ }), (item) => `${item.type}:${item.operation}:${item.sourceCollection}:${item.targetCollection || ""}:${item.condition || ""}:${item.sourceFile}`);
1483
+ const version = routeContract?.version || swagger?.info?.version || "unknown";
1484
+ const document = {
1485
+ key: routeKey,
1486
+ method: routeDeclaration.method,
1487
+ path: routeDeclaration.path,
1488
+ version,
1489
+ routeFile: routeDeclaration.file,
1490
+ handlerName: handler.handlerName,
1491
+ handlerFile: handler.handlerFile,
1492
+ analysisFiles,
1493
+ input: routeContract?.input || extractOpenApiInputProperties(operation, swagger),
1494
+ output: routeContract?.output || extractOpenApiOutputProperties(operation, swagger),
1495
+ backendNames,
1496
+ backendRouteCandidates,
1497
+ backendFieldCandidates,
1498
+ apiFieldSourceCandidates,
1499
+ mappingContext: topologyScope?.mappingContext,
1500
+ backendPathSources: topologyScope?.backendPathSources,
1501
+ rules,
1502
+ aggregations
1503
+ };
1504
+ documents.push(document);
1505
+ await onDocument?.(document);
1506
+ }
1507
+ taskProgressService.log(`Static analysis completed: ${documents.length} exposed routes`);
1508
+ return documents.sort((a, b) => a.path.localeCompare(b.path) || a.method.localeCompare(b.method));
1509
+ }
1510
+ function matchesNormalizedPathSuffix(left, right) {
1511
+ const leftKey = normalizePathForMatch(left);
1512
+ const rightKey = normalizePathForMatch(right);
1513
+ return leftKey.length >= 4 && rightKey.length >= 4 && (leftKey.endsWith(rightKey) || rightKey.endsWith(leftKey));
1514
+ }
1515
+ function candidateDomainPaths(candidate, direction) {
1516
+ if (!candidate.apiPathCandidate) return [];
1517
+ if (direction === "input") return [candidate.apiPathCandidate.replace(/^[^.]+\./, "")];
1518
+ const paths = [candidate.apiPathCandidate];
1519
+ if (!candidate.apiPathCandidate.includes(".")) {
1520
+ const backendRoot = candidate.backendField.split(/[.[\]]/).find(Boolean);
1521
+ if (backendRoot) paths.push(`${backendRoot}.${candidate.apiPathCandidate}`);
1522
+ }
1523
+ return paths;
1524
+ }
1525
+ function matchesDomainPath(domainPath, candidate, direction) {
1526
+ if (!domainPath) return false;
1527
+ const domainKey = normalizePathForMatch(domainPath);
1528
+ return candidateDomainPaths(candidate, direction).some((candidatePath) => normalizePathForMatch(candidatePath) === domainKey);
1529
+ }
1530
+ function matchCandidates(property, document, direction) {
1531
+ const propertyKey = normalizePathForMatch(property.path);
1532
+ const domainPath = resolveApiFieldSourceCandidate(document, property, direction)?.domainField;
1533
+ const domainKey = domainPath ? normalizePathForMatch(domainPath) : void 0;
1534
+ const mapperBackendSources = (document.mappingContext || []).filter((context) => context.layer === "backend" && context.direction === direction).map((context) => `${context.backend_type}:${context.source}`);
1535
+ const scopedBackendSources = new Set(mapperBackendSources.length ? [...mapperBackendSources, ...(document.backendPathSources || []).map((context) => `${context.backend}:${context.sourceFile}`)] : []);
1536
+ const matchingCandidates = document.backendFieldCandidates.filter((candidate) => candidate.backend && candidate.backendField).filter((candidate) => !candidate.direction || candidate.direction === direction).filter((candidate) => {
1537
+ if (direction === "output" && domainKey) return Boolean(candidate.apiPathCandidate && matchesDomainPath(domainPath, candidate, direction));
1538
+ if (domainKey && candidate.apiPathCandidate && matchesDomainPath(domainPath, candidate, direction)) return true;
1539
+ if (candidate.apiPathCandidate && normalizePathForMatch(candidate.apiPathCandidate) === propertyKey) return true;
1540
+ if (candidate.requiresReview && candidate.apiPathCandidate && lastPathSegment(property.path) === candidate.apiPathCandidate) return true;
1541
+ if (matchesNormalizedPathSuffix(property.path, candidate.backendField)) return true;
1542
+ return false;
1543
+ }).map((candidate) => ({
1544
+ ...candidate,
1545
+ confidence: candidate.apiPathCandidate && (normalizePathForMatch(candidate.apiPathCandidate) === propertyKey || matchesNormalizedPathSuffix(property.path, candidate.backendField) || matchesDomainPath(domainPath, candidate, direction)) ? candidate.requiresReview ? candidate.confidence : Math.max(candidate.confidence, 90) : candidate.confidence
1546
+ }));
1547
+ if (!scopedBackendSources.size) return matchingCandidates;
1548
+ return [...new Set(matchingCandidates.map((candidate) => candidate.backend))].flatMap((backend) => {
1549
+ const backendMatches = matchingCandidates.filter((candidate) => candidate.backend === backend);
1550
+ const scopedMatches = backendMatches.filter((candidate) => scopedBackendSources.has(`${candidate.backend}:${candidate.sourceFile}`));
1551
+ return scopedMatches.length ? scopedMatches : backendMatches;
1552
+ });
1553
+ }
1554
+ function evidenceStatusFromCandidates(candidates) {
1555
+ if (!candidates.length) return "needs_review";
1556
+ if (candidates.some((candidate) => candidate.requiresReview)) return "needs_review";
1557
+ return candidates.some((candidate) => candidate.confidence >= 90) ? "confirmed" : "inferred";
1558
+ }
1559
+ function backendFieldSource(candidate) {
1560
+ return candidate.sourceLine ? `${candidate.sourceFile}:${candidate.sourceLine}` : candidate.sourceFile;
1561
+ }
1562
+ function isTransversalInput(property) {
1563
+ const rootField = property.path.split(/[.[\]]/).find(Boolean)?.toLowerCase();
1564
+ const leafField = lastPathSegment(property.path).toLowerCase();
1565
+ return userConfig.analysis.transversalInputs.includes(property.path) || userConfig.analysis.transversalInputs.includes(leafField) || Boolean(rootField && userConfig.analysis.transversalInputs.includes(rootField));
1566
+ }
1567
+ function isIgnoredOutput(property) {
1568
+ return userConfig.analysis.ignoredOutputs.some((ignoredPath) => property.path === ignoredPath || property.path.startsWith(`${ignoredPath}.`) || property.path.includes(`.${ignoredPath}.`) || property.path.endsWith(`.${ignoredPath}`));
1569
+ }
1570
+ function unresolvedBackendCandidates(property, domainField, document, direction) {
1571
+ const expectedPath = domainField || property.path;
1572
+ const expectedField = lastPathSegment(expectedPath);
1573
+ const candidates = document.backendFieldCandidates.filter((candidate) => candidate.direction !== (direction === "output" ? "input" : "output")).filter((candidate) => candidate.apiPathCandidate && lastPathSegment(candidate.apiPathCandidate) === expectedField).slice(0, 3);
1574
+ return candidates.length ? candidates.map((candidate) => ({
1575
+ backend: candidate.backend,
1576
+ field: candidate.backendField,
1577
+ domain_field: candidate.apiPathCandidate,
1578
+ source_file: backendFieldSource(candidate),
1579
+ reason: `Candidate domain path "${candidate.apiPathCandidate}" does not deterministically reach "${expectedPath}"; an intermediate helper result or local alias was not resolved.`
1580
+ })) : void 0;
1581
+ }
1582
+ function resolveApiFieldSourceCandidate(document, property, direction) {
1583
+ const exactMatches = (document.apiFieldSourceCandidates || []).filter((candidate) => candidate.direction === direction).filter((candidate) => normalizePathForMatch(candidate.field) === normalizePathForMatch(property.path));
1584
+ const scopedApiSources = (document.mappingContext || []).filter((context) => context.layer === "api" && context.direction === direction).map((context) => context.source);
1585
+ const sourceRank = new Map(scopedApiSources.map((source, index) => [source, index]));
1586
+ return exactMatches.map((candidate, index) => ({
1587
+ candidate,
1588
+ index,
1589
+ rank: sourceRank.get(candidate.sourceFile) ?? Number.MAX_SAFE_INTEGER
1590
+ })).sort((left, right) => left.rank - right.rank || left.index - right.index)[0]?.candidate;
1591
+ }
1592
+ function resolveApiPropertySource(document, property, direction, matches = []) {
1593
+ const match = resolveApiFieldSourceCandidate(document, property, direction);
1594
+ return match ? `${match.sourceFile}:${match.line}` : matches[0] ? backendFieldSource(matches[0]) : document.routeFile || null;
1595
+ }
1596
+ function ensureBackendRouteRecord(backendRoutesByKey, document, candidate) {
1597
+ const key = stableKey$2(document.key, candidate.backend, candidate.route);
1598
+ const existing = backendRoutesByKey.get(key);
1599
+ if (existing) return existing;
1600
+ const created = {
1601
+ key,
1602
+ backend: candidate.backend,
1603
+ route_key: document.key,
1604
+ route: `${candidate.method || "CALL"} ${candidate.route}`,
1605
+ source_file: candidate.sourceFile,
1606
+ provenance: "code_analysis"
1607
+ };
1608
+ backendRoutesByKey.set(key, created);
1609
+ return created;
1610
+ }
1611
+ function resolveBackendRouteCandidate(document, match) {
1612
+ const sameBackendCandidates = document.backendRouteCandidates.filter((candidate) => candidate.backend === match.backend);
1613
+ return sameBackendCandidates.find((candidate) => candidate.sourceFile === match.sourceFile) || sameBackendCandidates[0];
1614
+ }
1615
+ async function buildCatalogue(documents) {
1616
+ const routes = documents.map((document) => ({
1617
+ key: document.key,
1618
+ method: document.method,
1619
+ path: document.path,
1620
+ version: document.version,
1621
+ source_file: document.routeFile || null,
1622
+ analysis_files: document.analysisFiles,
1623
+ backends: document.backendNames
1624
+ }));
1625
+ const routeInputProperties = [];
1626
+ const routeOutputProperties = [];
1627
+ const backendRoutesByKey = /* @__PURE__ */ new Map();
1628
+ const backendPropertiesByKey = /* @__PURE__ */ new Map();
1629
+ const mappingEvidence = [];
1630
+ const rules = documents.flatMap((document) => document.rules.map((rule) => ({
1631
+ route_key: document.key,
1632
+ condition: rule.condition,
1633
+ effect: rule.effect,
1634
+ else_effect: rule.elseEffect,
1635
+ source_file: rule.sourceFile
1636
+ })));
1637
+ const aggregations = documents.flatMap((document) => document.aggregations.map((aggregation) => ({
1638
+ route_key: document.key,
1639
+ type: aggregation.type,
1640
+ operation: aggregation.operation,
1641
+ source_collection: aggregation.sourceCollection,
1642
+ target_collection: aggregation.targetCollection,
1643
+ condition: aggregation.condition,
1644
+ description: aggregation.description,
1645
+ source_file: aggregation.sourceFile
1646
+ })));
1647
+ for (const document of documents) {
1648
+ for (const backendRoute of document.backendRouteCandidates) ensureBackendRouteRecord(backendRoutesByKey, document, backendRoute);
1649
+ for (const property of document.input) {
1650
+ const matches = matchCandidates(property, document, "input");
1651
+ const apiSource = resolveApiFieldSourceCandidate(document, property, "input");
1652
+ const apiPropertyKey = stableKey$2(document.key, "input", property.path);
1653
+ const backendNames = dedupeByKey(matches.map((candidate) => candidate.backend), (value) => value);
1654
+ const isTransverseWithoutMapping = isTransversalInput(property) && !matches.length;
1655
+ const evidenceStatus = isTransverseWithoutMapping ? "confirmed" : evidenceStatusFromCandidates(matches);
1656
+ const backendMappings = dedupeByKey(matches.map((match) => {
1657
+ const backendRouteCandidate = resolveBackendRouteCandidate(document, match);
1658
+ return {
1659
+ backend: match.backend,
1660
+ method: backendRouteCandidate?.method || null,
1661
+ route: backendRouteCandidate?.route || "unknown",
1662
+ field: match.backendField,
1663
+ source_file: backendFieldSource(match),
1664
+ confidence: match.confidence,
1665
+ mapper_type: match.mapperType,
1666
+ reason: match.reviewReason
1667
+ };
1668
+ }), (mapping) => `${mapping.backend}:${mapping.method || "CALL"}:${mapping.route}:${mapping.field}:${mapping.source_file}`);
1669
+ routeInputProperties.push({
1670
+ key: apiPropertyKey,
1671
+ route_key: document.key,
1672
+ direction: "input",
1673
+ field: property.path,
1674
+ domain_field: apiSource?.domainField,
1675
+ transversal: isTransversalInput(property) || void 0,
1676
+ backend_names: backendNames,
1677
+ backend_mappings: backendMappings,
1678
+ description: normalizeDescription(property.description),
1679
+ source_file: resolveApiPropertySource(document, property, "input", matches),
1680
+ analysis_files: document.analysisFiles,
1681
+ evidence_status: evidenceStatus
1682
+ });
1683
+ if (!matches.length && !isTransverseWithoutMapping) mappingEvidence.push({
1684
+ key: stableKey$2(apiPropertyKey, "needs_review"),
1685
+ evidence_type: "static_analysis",
1686
+ status: "needs_review",
1687
+ confidence_score: 0,
1688
+ comment: `No backend property candidate matched ${property.path}`,
1689
+ input_property_key: apiPropertyKey,
1690
+ backend_property_key: stableKey$2(document.key, "unmatched", "input", property.path),
1691
+ source_file: document.routeFile || null
1692
+ });
1693
+ for (const match of matches) {
1694
+ const backendRouteKey = ensureBackendRouteRecord(backendRoutesByKey, document, resolveBackendRouteCandidate(document, match) || {
1695
+ backend: match.backend,
1696
+ route: "unknown",
1697
+ sourceFile: match.sourceFile
1698
+ }).key;
1699
+ const backendPropertyKey = stableKey$2(backendRouteKey, "input", match.backendField);
1700
+ backendPropertiesByKey.set(backendPropertyKey, {
1701
+ key: backendPropertyKey,
1702
+ backend_route_key: backendRouteKey,
1703
+ backend: match.backend,
1704
+ field: match.backendField,
1705
+ direction: "input",
1706
+ source_file: backendFieldSource(match),
1707
+ provenance: "code_analysis"
1708
+ });
1709
+ mappingEvidence.push({
1710
+ key: stableKey$2(apiPropertyKey, backendPropertyKey),
1711
+ evidence_type: "static_analysis",
1712
+ status: match.requiresReview ? "needs_review" : match.confidence >= 90 ? "confirmed" : "inferred",
1713
+ confidence_score: match.confidence,
1714
+ comment: match.reviewReason || `Code analysis candidate from ${path.basename(match.sourceFile)}`,
1715
+ input_property_key: apiPropertyKey,
1716
+ backend_property_key: backendPropertyKey,
1717
+ source_file: backendFieldSource(match)
1718
+ });
1719
+ }
1720
+ }
1721
+ for (const property of document.output) {
1722
+ if (isIgnoredOutput(property)) continue;
1723
+ const matches = matchCandidates(property, document, "output");
1724
+ const apiSource = resolveApiFieldSourceCandidate(document, property, "output");
1725
+ const apiPropertyKey = stableKey$2(document.key, "output", property.path);
1726
+ const backendNames = dedupeByKey(matches.map((candidate) => candidate.backend), (value) => value);
1727
+ const evidenceStatus = evidenceStatusFromCandidates(matches);
1728
+ const backendMappings = dedupeByKey(matches.map((match) => {
1729
+ const backendRouteCandidate = resolveBackendRouteCandidate(document, match);
1730
+ return {
1731
+ backend: match.backend,
1732
+ method: backendRouteCandidate?.method || null,
1733
+ route: backendRouteCandidate?.route || "unknown",
1734
+ field: match.backendField,
1735
+ source_file: backendFieldSource(match),
1736
+ confidence: match.confidence,
1737
+ mapper_type: match.mapperType,
1738
+ reason: match.reviewReason
1739
+ };
1740
+ }), (mapping) => `${mapping.backend}:${mapping.method || "CALL"}:${mapping.route}:${mapping.field}:${mapping.source_file}`);
1741
+ routeOutputProperties.push({
1742
+ key: apiPropertyKey,
1743
+ route_key: document.key,
1744
+ direction: "output",
1745
+ field: property.path,
1746
+ domain_field: apiSource?.domainField,
1747
+ backend_names: backendNames,
1748
+ backend_mappings: backendMappings,
1749
+ description: normalizeDescription(property.description),
1750
+ source_file: resolveApiPropertySource(document, property, "output", matches),
1751
+ analysis_files: document.analysisFiles,
1752
+ evidence_status: evidenceStatus,
1753
+ ...matches.length ? {} : { unresolved_backend_candidates: unresolvedBackendCandidates(property, apiSource?.domainField, document, "output") }
1754
+ });
1755
+ if (!matches.length) mappingEvidence.push({
1756
+ key: stableKey$2(apiPropertyKey, "needs_review"),
1757
+ evidence_type: "static_analysis",
1758
+ status: "needs_review",
1759
+ confidence_score: 0,
1760
+ comment: `No backend property candidate matched ${property.path}`,
1761
+ output_property_key: apiPropertyKey,
1762
+ backend_property_key: stableKey$2(document.key, "unmatched", "output", property.path),
1763
+ source_file: document.routeFile || null
1764
+ });
1765
+ for (const match of matches) {
1766
+ const backendRouteKey = ensureBackendRouteRecord(backendRoutesByKey, document, resolveBackendRouteCandidate(document, match) || {
1767
+ backend: match.backend,
1768
+ route: "unknown",
1769
+ sourceFile: match.sourceFile
1770
+ }).key;
1771
+ const backendPropertyKey = stableKey$2(backendRouteKey, "output", match.backendField);
1772
+ backendPropertiesByKey.set(backendPropertyKey, {
1773
+ key: backendPropertyKey,
1774
+ backend_route_key: backendRouteKey,
1775
+ backend: match.backend,
1776
+ field: match.backendField,
1777
+ direction: "output",
1778
+ source_file: backendFieldSource(match),
1779
+ provenance: "code_analysis"
1780
+ });
1781
+ mappingEvidence.push({
1782
+ key: stableKey$2(apiPropertyKey, backendPropertyKey),
1783
+ evidence_type: "static_analysis",
1784
+ status: match.requiresReview ? "needs_review" : match.confidence >= 90 ? "confirmed" : "inferred",
1785
+ confidence_score: match.confidence,
1786
+ comment: match.reviewReason || `Code analysis candidate from ${path.basename(match.sourceFile)}`,
1787
+ output_property_key: apiPropertyKey,
1788
+ backend_property_key: backendPropertyKey,
1789
+ source_file: backendFieldSource(match)
1790
+ });
1791
+ }
1792
+ }
1793
+ }
1794
+ const needsReviewCount = routeInputProperties.filter((item) => item.evidence_status === "needs_review").length + routeOutputProperties.filter((item) => item.evidence_status === "needs_review").length;
1795
+ return {
1796
+ generated_at: (/* @__PURE__ */ new Date()).toISOString(),
1797
+ routes,
1798
+ route_input_properties: routeInputProperties,
1799
+ route_output_properties: routeOutputProperties,
1800
+ backend_routes: [...backendRoutesByKey.values()],
1801
+ backend_properties: [...backendPropertiesByKey.values()],
1802
+ mapping_evidence: mappingEvidence,
1803
+ rules,
1804
+ aggregations,
1805
+ stats: {
1806
+ routes: routes.length,
1807
+ input_properties: routeInputProperties.length,
1808
+ output_properties: routeOutputProperties.length,
1809
+ backend_routes: backendRoutesByKey.size,
1810
+ backend_properties: backendPropertiesByKey.size,
1811
+ mapping_evidence: mappingEvidence.length,
1812
+ rules: rules.length,
1813
+ aggregations: aggregations.length,
1814
+ needs_review: needsReviewCount,
1815
+ needs_review_percentage: calculateNeedsReviewPercentage(routeInputProperties.length, routeOutputProperties.length, needsReviewCount)
1816
+ }
1817
+ };
1818
+ }
1819
+ //#endregion
1820
+ //#region src/services/directusSyncService.ts
1821
+ const ROUTE_STATUSES = { published: "published" };
1822
+ const LOCAL_SOURCE_FILE = "digital-api:tools/datasource-catalogue";
1823
+ const DIRECTUS_RETRY_MAX_ATTEMPTS = 6;
1824
+ const DIRECTUS_RETRY_INITIAL_DELAY_MS = 1e3;
1825
+ const DIRECTUS_RETRY_MAX_DELAY_MS = 8e3;
1826
+ const DIRECTUS_DELETE_WORKERS = 2;
1827
+ function isRecord$2(value) {
1828
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1829
+ }
1830
+ function isServiceUnavailableError(error) {
1831
+ if (!isRecord$2(error)) return false;
1832
+ if ((isRecord$2(error.response) ? error.response.status : void 0) === 503) return true;
1833
+ return Array.isArray(error.errors) && error.errors.some((item) => isRecord$2(item) && isRecord$2(item.extensions) && item.extensions.code === "SERVICE_UNAVAILABLE");
1834
+ }
1835
+ function isTransientNetworkError(error) {
1836
+ if (error instanceof TypeError && error.message === "fetch failed") return true;
1837
+ if (!isRecord$2(error)) return false;
1838
+ if (error.message === "fetch failed") return true;
1839
+ const cause = isRecord$2(error.cause) ? error.cause : void 0;
1840
+ return cause?.code === "ECONNRESET" || cause?.code === "ECONNREFUSED" || cause?.code === "ETIMEDOUT" || cause?.code === "UND_ERR_CONNECT_TIMEOUT";
1841
+ }
1842
+ async function retryDirectusRequest(request, options = {}) {
1843
+ const maxAttempts = options.maxAttempts ?? DIRECTUS_RETRY_MAX_ATTEMPTS;
1844
+ const initialDelayMs = options.initialDelayMs ?? DIRECTUS_RETRY_INITIAL_DELAY_MS;
1845
+ const maxDelayMs = options.maxDelayMs ?? DIRECTUS_RETRY_MAX_DELAY_MS;
1846
+ const sleep = options.sleep || ((delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs)));
1847
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) try {
1848
+ return await request();
1849
+ } catch (error) {
1850
+ if (!isServiceUnavailableError(error) && !isTransientNetworkError(error) || attempt === maxAttempts) throw error;
1851
+ const delayMs = Math.min(initialDelayMs * 2 ** (attempt - 1), maxDelayMs);
1852
+ taskProgressService.report(`Directus request temporarily unavailable; retrying ${attempt + 1}/${maxAttempts} in ${(delayMs / 1e3).toFixed(1)}s`);
1853
+ await sleep(delayMs);
1854
+ }
1855
+ throw new Error("Directus retry attempts exhausted");
1856
+ }
1857
+ function toRouteIdentifier(route) {
1858
+ return `${route.method} ${route.path}`;
1859
+ }
1860
+ function toBackendRouteIdentifier(route) {
1861
+ return toRouteIdentifier(route);
1862
+ }
1863
+ function groupBackendRoutesForDirectusSync(backendRoutes, apiRoutesByKey) {
1864
+ const groups = /* @__PURE__ */ new Map();
1865
+ for (const backendRoute of backendRoutes) {
1866
+ const apiRoute = apiRoutesByKey.get(backendRoute.route_key);
1867
+ const key = apiRoute ? `${backendRoute.backend}${apiRoute.method}${apiRoute.path}` : `${backendRoute.backend}missing-api-route${backendRoute.key}`;
1868
+ const group = groups.get(key);
1869
+ if (group) group.keys.push(backendRoute.key);
1870
+ else groups.set(key, {
1871
+ route: backendRoute,
1872
+ keys: [backendRoute.key]
1873
+ });
1874
+ }
1875
+ return [...groups.values()];
1876
+ }
1877
+ function selectCatalogueForDirectusPush(catalogue, routeSelector) {
1878
+ if (!routeSelector) return catalogue;
1879
+ const routes = catalogue.routes.filter((route) => route.method === routeSelector.method && route.path === routeSelector.path);
1880
+ if (!routes.length) throw new Error(`Route not found in catalogue: ${routeSelector.method} ${routeSelector.path}`);
1881
+ const routeKeys = new Set(routes.map((route) => route.key));
1882
+ const routeInputProperties = catalogue.route_input_properties.filter((property) => routeKeys.has(property.route_key));
1883
+ const routeOutputProperties = catalogue.route_output_properties.filter((property) => routeKeys.has(property.route_key));
1884
+ const backendRoutes = catalogue.backend_routes.filter((route) => routeKeys.has(route.route_key));
1885
+ const backendRouteKeys = new Set(backendRoutes.map((route) => route.key));
1886
+ const backendProperties = catalogue.backend_properties.filter((property) => backendRouteKeys.has(property.backend_route_key));
1887
+ const apiPropertyKeys = new Set([...routeInputProperties, ...routeOutputProperties].map((property) => property.key));
1888
+ const backendPropertyKeys = new Set(backendProperties.map((property) => property.key));
1889
+ return {
1890
+ ...catalogue,
1891
+ routes,
1892
+ route_input_properties: routeInputProperties,
1893
+ route_output_properties: routeOutputProperties,
1894
+ backend_routes: backendRoutes,
1895
+ backend_properties: backendProperties,
1896
+ mapping_evidence: catalogue.mapping_evidence.filter((evidence) => backendPropertyKeys.has(evidence.backend_property_key) && (apiPropertyKeys.has(evidence.input_property_key || "") || apiPropertyKeys.has(evidence.output_property_key || ""))),
1897
+ rules: catalogue.rules.filter((rule) => routeKeys.has(rule.route_key)),
1898
+ aggregations: catalogue.aggregations.filter((aggregation) => routeKeys.has(aggregation.route_key))
1899
+ };
1900
+ }
1901
+ function inferInputType(field) {
1902
+ if (field.startsWith("params.")) return "PATH";
1903
+ if (field.startsWith("query.")) return "QUERY";
1904
+ if (field.startsWith("headers.")) return "HEADER";
1905
+ return "BODY";
1906
+ }
1907
+ function normalizeLookupKey(value) {
1908
+ return value.toUpperCase().replace(/[^A-Z0-9]/g, "");
1909
+ }
1910
+ function normalizeDirectusPath(value) {
1911
+ return value.replace(/\?\.\[/g, "[").replace(/\?\./g, ".");
1912
+ }
1913
+ function toPascalCase(value) {
1914
+ return value.toLowerCase().replace(/(^|[^a-z0-9]+)([a-z0-9])/g, (_match, _separator, character) => character.toUpperCase());
1915
+ }
1916
+ function buildTranslationSummary(route) {
1917
+ const routeIdentifier = toRouteIdentifier(route);
1918
+ const primarySource = route.analysis_files[0] || route.source_file || LOCAL_SOURCE_FILE;
1919
+ return [{
1920
+ languages_code: "en-US",
1921
+ summary: routeIdentifier,
1922
+ description: `Generated from ${path.basename(primarySource)} on 2026-07-16`
1923
+ }];
1924
+ }
1925
+ async function createClient(baseUrl, token) {
1926
+ const client = createDirectus(baseUrl).with(rest()).with(staticToken(token));
1927
+ return { request: (request) => retryDirectusRequest(() => client.request(request)) };
1928
+ }
1929
+ function directusReadItems(collection, query) {
1930
+ return readItems(collection, query);
1931
+ }
1932
+ function directusCreateItem(collection, payload) {
1933
+ return createItem(collection, payload);
1934
+ }
1935
+ function directusUpdateItem(collection, id, payload) {
1936
+ return updateItem(collection, id, payload);
1937
+ }
1938
+ function directusDeleteItem(collection, id) {
1939
+ return deleteItem(collection, id);
1940
+ }
1941
+ async function findOrphanedPropertyLinks(client) {
1942
+ const [inputProperties, outputProperties, backendProperties, inputLinks, outputLinks] = await Promise.all([
1943
+ client.request(directusReadItems("route_input_properties", {
1944
+ fields: ["id"],
1945
+ limit: -1
1946
+ })),
1947
+ client.request(directusReadItems("route_output_properties", {
1948
+ fields: ["id"],
1949
+ limit: -1
1950
+ })),
1951
+ client.request(directusReadItems("backend_properties", {
1952
+ fields: ["id"],
1953
+ limit: -1
1954
+ })),
1955
+ client.request(directusReadItems("route_input_properties_backend_properties", {
1956
+ fields: [
1957
+ "id",
1958
+ "route_input_properties_id",
1959
+ "backend_properties_id"
1960
+ ],
1961
+ limit: -1
1962
+ })),
1963
+ client.request(directusReadItems("route_output_properties_backend_properties", {
1964
+ fields: [
1965
+ "id",
1966
+ "route_output_properties_id",
1967
+ "backend_properties_id"
1968
+ ],
1969
+ limit: -1
1970
+ }))
1971
+ ]);
1972
+ const ids = (items) => new Set(items.map((item) => String(item.id)));
1973
+ const inputIds = ids(inputProperties);
1974
+ const outputIds = ids(outputProperties);
1975
+ const backendIds = ids(backendProperties);
1976
+ return {
1977
+ input: inputLinks.filter((link) => !inputIds.has(String(link.route_input_properties_id)) || !backendIds.has(String(link.backend_properties_id))),
1978
+ output: outputLinks.filter((link) => !outputIds.has(String(link.route_output_properties_id)) || !backendIds.has(String(link.backend_properties_id)))
1979
+ };
1980
+ }
1981
+ async function deleteOrphanedPropertyLinks(client, orphanedLinks) {
1982
+ const links = [...orphanedLinks.input.map((link) => ({
1983
+ collection: "route_input_properties_backend_properties",
1984
+ id: link.id
1985
+ })), ...orphanedLinks.output.map((link) => ({
1986
+ collection: "route_output_properties_backend_properties",
1987
+ id: link.id
1988
+ }))];
1989
+ let nextIndex = 0;
1990
+ await Promise.all(Array.from({ length: Math.min(DIRECTUS_DELETE_WORKERS, links.length) }, async () => {
1991
+ while (nextIndex < links.length) {
1992
+ const link = links[nextIndex++];
1993
+ await client.request(directusDeleteItem(link.collection, link.id));
1994
+ }
1995
+ }));
1996
+ return links.length;
1997
+ }
1998
+ async function cleanupOrphanedPropertyLinks(client) {
1999
+ taskProgressService.report("Cleaning orphaned property links");
2000
+ const removed = await deleteOrphanedPropertyLinks(client, await findOrphanedPropertyLinks(client));
2001
+ taskProgressService.report(`Removed ${removed} orphaned property links`);
2002
+ return removed;
2003
+ }
2004
+ async function cleanDirectusOrphanedPropertyLinks(options) {
2005
+ taskProgressService.report("Connecting to Directus");
2006
+ const client = await createClient(options.baseUrl, options.token);
2007
+ taskProgressService.report("Finding orphaned property links");
2008
+ const orphanedLinks = await findOrphanedPropertyLinks(client);
2009
+ const orphanedCount = orphanedLinks.input.length + orphanedLinks.output.length;
2010
+ if (options.dryRun) {
2011
+ taskProgressService.report(`${orphanedCount} orphaned property links found (dry-run)`);
2012
+ return {
2013
+ orphanedLinks: orphanedCount,
2014
+ removedOrphanedLinks: 0
2015
+ };
2016
+ }
2017
+ taskProgressService.report("Removing orphaned property links");
2018
+ const removedOrphanedLinks = await deleteOrphanedPropertyLinks(client, orphanedLinks);
2019
+ taskProgressService.report(`Removed ${removedOrphanedLinks} orphaned property links`);
2020
+ return {
2021
+ orphanedLinks: orphanedCount,
2022
+ removedOrphanedLinks
2023
+ };
2024
+ }
2025
+ async function loadDirectusReferenceData(client) {
2026
+ const values = await client.request(directusReadItems("predefined_values", {
2027
+ filter: { type: { _in: [
2028
+ "backend",
2029
+ "in_type",
2030
+ "out_type"
2031
+ ] } },
2032
+ fields: [
2033
+ "id",
2034
+ "label",
2035
+ "type"
2036
+ ],
2037
+ limit: -1
2038
+ }));
2039
+ const backendIds = /* @__PURE__ */ new Map();
2040
+ const inTypeIds = /* @__PURE__ */ new Map();
2041
+ const outTypeIds = /* @__PURE__ */ new Map();
2042
+ for (const value of values) {
2043
+ const normalizedKeys = [normalizeLookupKey(value.id), normalizeLookupKey(value.label)];
2044
+ const target = value.type === "backend" ? backendIds : value.type === "in_type" ? inTypeIds : outTypeIds;
2045
+ for (const key of normalizedKeys) target.set(key, value.id);
2046
+ }
2047
+ return {
2048
+ backendIds,
2049
+ inTypeIds,
2050
+ outTypeIds
2051
+ };
2052
+ }
2053
+ async function resolveBackendReference(client, references, backend) {
2054
+ const lookupKey = normalizeLookupKey(backend);
2055
+ const existingId = references.backendIds.get(lookupKey);
2056
+ if (existingId) return {
2057
+ id: existingId,
2058
+ created: false
2059
+ };
2060
+ if (!isKnownBackendType(backend)) return { created: false };
2061
+ const created = await client.request(directusCreateItem("predefined_values", {
2062
+ id: backend,
2063
+ label: toPascalCase(backend),
2064
+ type: "backend"
2065
+ }));
2066
+ const id = typeof created === "string" ? created : created.id;
2067
+ references.backendIds.set(lookupKey, id);
2068
+ return {
2069
+ id,
2070
+ created: true
2071
+ };
2072
+ }
2073
+ async function upsertRoute(client, route) {
2074
+ const routeIdentifier = toRouteIdentifier(route);
2075
+ const [existing] = await client.request(directusReadItems("routes", {
2076
+ filter: { route: { _eq: routeIdentifier } },
2077
+ fields: ["id", "route"],
2078
+ limit: 1
2079
+ }));
2080
+ const payload = {
2081
+ route: routeIdentifier,
2082
+ status: ROUTE_STATUSES.published,
2083
+ deprecated: false,
2084
+ first_published_version: route.version,
2085
+ last_published_version: route.version,
2086
+ translations: buildTranslationSummary(route)
2087
+ };
2088
+ if (!existing) {
2089
+ const created = await client.request(directusCreateItem("routes", payload));
2090
+ return typeof created === "string" ? created : created.id;
2091
+ }
2092
+ await client.request(directusUpdateItem("routes", existing.id, payload));
2093
+ return existing.id;
2094
+ }
2095
+ async function upsertInputProperty(client, property, routeId, inType) {
2096
+ const field = normalizeDirectusPath(property.field);
2097
+ const [existing] = await client.request(directusReadItems("route_input_properties", {
2098
+ filter: {
2099
+ route_id: { _eq: routeId },
2100
+ path: { _eq: field },
2101
+ in_type: { _eq: inType }
2102
+ },
2103
+ fields: [
2104
+ "id",
2105
+ "route_id",
2106
+ "path",
2107
+ "in_type"
2108
+ ],
2109
+ limit: 1
2110
+ }));
2111
+ const payload = {
2112
+ route_id: routeId,
2113
+ path: field,
2114
+ in_type: inType,
2115
+ description: property.description || null,
2116
+ source_file: property.source_file || LOCAL_SOURCE_FILE,
2117
+ deprecated: false
2118
+ };
2119
+ if (!existing) {
2120
+ const created = await client.request(directusCreateItem("route_input_properties", payload));
2121
+ return typeof created === "string" ? created : created.id;
2122
+ }
2123
+ await client.request(directusUpdateItem("route_input_properties", existing.id, payload));
2124
+ return existing.id;
2125
+ }
2126
+ async function upsertOutputProperty(client, property, routeId, outType) {
2127
+ const field = normalizeDirectusPath(property.field);
2128
+ const [existing] = await client.request(directusReadItems("route_output_properties", {
2129
+ filter: {
2130
+ route_id: { _eq: routeId },
2131
+ path: { _eq: field },
2132
+ out_type: { _eq: outType }
2133
+ },
2134
+ fields: [
2135
+ "id",
2136
+ "route_id",
2137
+ "path",
2138
+ "out_type"
2139
+ ],
2140
+ limit: 1
2141
+ }));
2142
+ const payload = {
2143
+ route_id: routeId,
2144
+ path: field,
2145
+ out_type: outType,
2146
+ description: property.description || null,
2147
+ source_file: property.source_file || LOCAL_SOURCE_FILE,
2148
+ deprecated: false,
2149
+ is_dynamic: field.includes("[]") || field.includes("{")
2150
+ };
2151
+ if (!existing) {
2152
+ const created = await client.request(directusCreateItem("route_output_properties", payload));
2153
+ return typeof created === "string" ? created : created.id;
2154
+ }
2155
+ await client.request(directusUpdateItem("route_output_properties", existing.id, payload));
2156
+ return existing.id;
2157
+ }
2158
+ async function upsertBackendRoute(client, apiRoute, backendRoute, backendValueId) {
2159
+ const routeValue = toBackendRouteIdentifier(apiRoute);
2160
+ const [existing] = await client.request(directusReadItems("backend_routes", {
2161
+ filter: { route: { _eq: routeValue } },
2162
+ fields: [
2163
+ "id",
2164
+ "route",
2165
+ "backend"
2166
+ ],
2167
+ limit: 1
2168
+ }));
2169
+ const payload = {
2170
+ route: routeValue,
2171
+ backend: backendValueId
2172
+ };
2173
+ if (!existing) {
2174
+ const created = await client.request(directusCreateItem("backend_routes", payload));
2175
+ return typeof created === "string" ? created : created.id;
2176
+ }
2177
+ return existing.id;
2178
+ }
2179
+ async function upsertBackendProperty(client, property, backendRouteId, typeId) {
2180
+ const field = normalizeDirectusPath(property.field);
2181
+ const [existing] = await client.request(directusReadItems("backend_properties", {
2182
+ filter: {
2183
+ route: { _eq: backendRouteId },
2184
+ path: { _eq: field },
2185
+ type: { _eq: typeId }
2186
+ },
2187
+ fields: [
2188
+ "id",
2189
+ "route",
2190
+ "path",
2191
+ "type",
2192
+ "source_file"
2193
+ ],
2194
+ limit: 1
2195
+ }));
2196
+ const payload = {
2197
+ route: backendRouteId,
2198
+ path: field || null,
2199
+ type: typeId,
2200
+ source_file: property.source_file || LOCAL_SOURCE_FILE,
2201
+ comments: property.provenance === "ai" ? "Inferred with AI" : null,
2202
+ content_name: null
2203
+ };
2204
+ if (!existing) {
2205
+ const created = await client.request(directusCreateItem("backend_properties", payload));
2206
+ return typeof created === "string" ? created : created.id;
2207
+ }
2208
+ return existing.id;
2209
+ }
2210
+ async function ensureInputLink(client, inputPropertyId, backendPropertyId) {
2211
+ const [existing] = await client.request(directusReadItems("route_input_properties_backend_properties", {
2212
+ filter: {
2213
+ route_input_properties_id: { _eq: inputPropertyId },
2214
+ backend_properties_id: { _eq: backendPropertyId }
2215
+ },
2216
+ fields: ["id"],
2217
+ limit: 1
2218
+ }));
2219
+ if (existing) return false;
2220
+ await client.request(directusCreateItem("route_input_properties_backend_properties", {
2221
+ route_input_properties_id: inputPropertyId,
2222
+ backend_properties_id: backendPropertyId
2223
+ }));
2224
+ return true;
2225
+ }
2226
+ async function ensureOutputLink(client, outputPropertyId, backendPropertyId) {
2227
+ const [existing] = await client.request(directusReadItems("route_output_properties_backend_properties", {
2228
+ filter: {
2229
+ route_output_properties_id: { _eq: outputPropertyId },
2230
+ backend_properties_id: { _eq: backendPropertyId }
2231
+ },
2232
+ fields: ["id"],
2233
+ limit: 1
2234
+ }));
2235
+ if (existing) return false;
2236
+ await client.request(directusCreateItem("route_output_properties_backend_properties", {
2237
+ route_output_properties_id: outputPropertyId,
2238
+ backend_properties_id: backendPropertyId
2239
+ }));
2240
+ return true;
2241
+ }
2242
+ async function pushCatalogueToDirectus(catalogue, options) {
2243
+ const pushedCollections = [
2244
+ "predefined_values",
2245
+ "routes",
2246
+ "route_input_properties",
2247
+ "route_output_properties",
2248
+ "backend_routes",
2249
+ "backend_properties",
2250
+ "route_input_properties_backend_properties",
2251
+ "route_output_properties_backend_properties"
2252
+ ];
2253
+ const skippedCollections = ["mapping_evidence"];
2254
+ const catalogueToPush = selectCatalogueForDirectusPush(catalogue, options.routeSelector);
2255
+ if (options.dryRun) {
2256
+ taskProgressService.report("Validating reconstructed catalogue (dry-run)");
2257
+ return {
2258
+ pushedCollections,
2259
+ pushedItems: catalogueToPush.routes.length + catalogueToPush.route_input_properties.length + catalogueToPush.route_output_properties.length + catalogueToPush.backend_routes.length + catalogueToPush.backend_properties.length + catalogueToPush.mapping_evidence.length,
2260
+ skippedCollections,
2261
+ warnings: [],
2262
+ removedOrphanedLinks: 0
2263
+ };
2264
+ }
2265
+ taskProgressService.report("Connecting to Directus");
2266
+ const client = await createClient(options.baseUrl, options.token);
2267
+ taskProgressService.report("Loading predefined values from Directus");
2268
+ const refs = await loadDirectusReferenceData(client);
2269
+ const warnings = /* @__PURE__ */ new Set();
2270
+ let pushedItems = 0;
2271
+ const apiRoutesByKey = new Map(catalogueToPush.routes.map((route) => [route.key, route]));
2272
+ const backendRouteGroups = groupBackendRoutesForDirectusSync(catalogueToPush.backend_routes, apiRoutesByKey);
2273
+ const backendRouteGroupsByApiRouteKey = /* @__PURE__ */ new Map();
2274
+ const backendRouteApiRouteKeys = /* @__PURE__ */ new Map();
2275
+ for (const group of backendRouteGroups) {
2276
+ const routeKey = group.route.route_key;
2277
+ if (!apiRoutesByKey.has(routeKey)) {
2278
+ warnings.add(`MISSING_API_ROUTE: backend route ${group.route.key} could not be linked to an API route`);
2279
+ continue;
2280
+ }
2281
+ backendRouteGroupsByApiRouteKey.set(routeKey, [...backendRouteGroupsByApiRouteKey.get(routeKey) || [], group]);
2282
+ for (const key of group.keys) backendRouteApiRouteKeys.set(key, routeKey);
2283
+ }
2284
+ for (const [routeIndex, route] of catalogueToPush.routes.entries()) {
2285
+ taskProgressService.report(`Synchronizing routes ${routeIndex + 1}/${catalogueToPush.routes.length}: ${route.method} ${route.path}`);
2286
+ const routeId = await upsertRoute(client, route);
2287
+ pushedItems += 1;
2288
+ const backendRouteIds = /* @__PURE__ */ new Map();
2289
+ const backendPropertyIds = /* @__PURE__ */ new Map();
2290
+ const routeBackendGroups = backendRouteGroupsByApiRouteKey.get(route.key) || [];
2291
+ for (const [index, backendRouteGroup] of routeBackendGroups.entries()) {
2292
+ const { route: backendRoute, keys } = backendRouteGroup;
2293
+ taskProgressService.report(`Synchronizing backend routes ${index + 1}/${routeBackendGroups.length}: ${backendRoute.backend}`);
2294
+ const backendReference = await resolveBackendReference(client, refs, backendRoute.backend);
2295
+ if (!backendReference.id) {
2296
+ warnings.add(`UNKNOWN_BACKEND_TYPE: ${backendRoute.backend} is not declared in BackendTypes.ts for route ${route.method} ${route.path}`);
2297
+ continue;
2298
+ }
2299
+ if (backendReference.created) pushedItems += 1;
2300
+ const backendRouteId = await upsertBackendRoute(client, route, backendRoute, backendReference.id);
2301
+ for (const key of keys) backendRouteIds.set(key, backendRouteId);
2302
+ pushedItems += 1;
2303
+ }
2304
+ const routeBackendProperties = catalogueToPush.backend_properties.filter((property) => backendRouteApiRouteKeys.get(property.backend_route_key) === route.key);
2305
+ for (const [index, backendProperty] of routeBackendProperties.entries()) {
2306
+ taskProgressService.report(`Synchronizing backend properties ${index + 1}/${routeBackendProperties.length}: ${backendProperty.field}`);
2307
+ const backendRouteId = backendRouteIds.get(backendProperty.backend_route_key);
2308
+ if (!backendRouteId) {
2309
+ warnings.add(`MISSING_BACKEND_ROUTE_ID: backend property ${backendProperty.key} could not be linked to a backend route`);
2310
+ continue;
2311
+ }
2312
+ const typeId = backendProperty.direction === "output" ? refs.outTypeIds.get("RESPONSEBODY") : refs.inTypeIds.get(normalizeLookupKey(inferInputType(backendProperty.field || "")));
2313
+ if (!typeId) {
2314
+ warnings.add(`UNKNOWN_BACKEND_PROPERTY_TYPE: ${backendProperty.direction} ${backendProperty.field}`);
2315
+ continue;
2316
+ }
2317
+ const backendPropertyId = await upsertBackendProperty(client, backendProperty, backendRouteId, typeId);
2318
+ backendPropertyIds.set(backendProperty.key, backendPropertyId);
2319
+ pushedItems += 1;
2320
+ }
2321
+ const routeInputProperties = catalogueToPush.route_input_properties.filter((property) => property.route_key === route.key);
2322
+ for (const [index, property] of routeInputProperties.entries()) {
2323
+ taskProgressService.report(`Synchronizing input properties ${index + 1}/${routeInputProperties.length}: ${property.field}`);
2324
+ const inType = refs.inTypeIds.get(normalizeLookupKey(inferInputType(property.field)));
2325
+ if (!inType) {
2326
+ warnings.add(`UNKNOWN_IN_TYPE: ${property.field}`);
2327
+ continue;
2328
+ }
2329
+ const inputPropertyId = await upsertInputProperty(client, property, routeId, inType);
2330
+ pushedItems += 1;
2331
+ for (const evidence of catalogueToPush.mapping_evidence.filter((item) => item.input_property_key === property.key)) {
2332
+ const backendPropertyId = backendPropertyIds.get(evidence.backend_property_key);
2333
+ if (backendPropertyId && await ensureInputLink(client, inputPropertyId, backendPropertyId)) pushedItems += 1;
2334
+ }
2335
+ }
2336
+ const routeOutputProperties = catalogueToPush.route_output_properties.filter((property) => property.route_key === route.key);
2337
+ for (const [index, property] of routeOutputProperties.entries()) {
2338
+ taskProgressService.report(`Synchronizing output properties ${index + 1}/${routeOutputProperties.length}: ${property.field}`);
2339
+ const outType = refs.outTypeIds.get("RESPONSEBODY");
2340
+ if (!outType) {
2341
+ warnings.add("UNKNOWN_OUT_TYPE: RESPONSE_BODY");
2342
+ continue;
2343
+ }
2344
+ const outputPropertyId = await upsertOutputProperty(client, property, routeId, outType);
2345
+ pushedItems += 1;
2346
+ for (const evidence of catalogueToPush.mapping_evidence.filter((item) => item.output_property_key === property.key)) {
2347
+ const backendPropertyId = backendPropertyIds.get(evidence.backend_property_key);
2348
+ if (backendPropertyId && await ensureOutputLink(client, outputPropertyId, backendPropertyId)) pushedItems += 1;
2349
+ }
2350
+ }
2351
+ }
2352
+ if (options.outputPath) await fs.appendFile(options.outputPath, "", "utf8");
2353
+ const removedOrphanedLinks = await cleanupOrphanedPropertyLinks(client);
2354
+ taskProgressService.report("Finalizing Directus synchronization");
2355
+ if (warnings.size) {
2356
+ console.warn("========================================");
2357
+ console.warn("BIG WARNING: missing predefined values or unresolved Directus links");
2358
+ for (const warning of warnings) console.warn(`- ${warning}`);
2359
+ console.warn("========================================");
2360
+ }
2361
+ return {
2362
+ pushedCollections,
2363
+ pushedItems,
2364
+ skippedCollections,
2365
+ warnings: Array.from(warnings),
2366
+ removedOrphanedLinks
2367
+ };
2368
+ }
2369
+ //#endregion
2370
+ //#region src/utils/routeReview.ts
2371
+ function sanitizePathSegment(value) {
2372
+ return value.replace(/[{}]/g, "").replace(/[^a-z0-9._-]+/gi, "_").replace(/_+/g, "_").replace(/^_+|_+$/g, "") || "root";
2373
+ }
2374
+ function buildRouteReviewRelativePath(method, routePath) {
2375
+ const rawSegments = routePath.split("/").filter(Boolean);
2376
+ const versionSegment = rawSegments.find((segment) => /^v\d+$/i.test(segment));
2377
+ const sanitizedResourceSegments = rawSegments.filter((segment) => segment !== versionSegment).map(sanitizePathSegment);
2378
+ const resourceDir = sanitizedResourceSegments.length ? path.join(...sanitizedResourceSegments) : "root";
2379
+ const versionDir = sanitizePathSegment(versionSegment || "unversioned");
2380
+ const fileNameParts = [
2381
+ method.toLowerCase(),
2382
+ versionDir,
2383
+ ...sanitizedResourceSegments
2384
+ ];
2385
+ return path.join(resourceDir, versionDir, `${fileNameParts.join("_")}.yaml`);
2386
+ }
2387
+ function parseBackendRoute(route) {
2388
+ const match = route.match(/^(GET|POST|PUT|PATCH|DELETE|HEAD|CALL)\s+(.+)$/);
2389
+ if (!match || match[1] === "CALL" && match[2] === "unknown") return;
2390
+ return {
2391
+ method: match[1] === "CALL" ? null : match[1],
2392
+ route: match[2]
2393
+ };
2394
+ }
2395
+ function buildReviewProperty(property) {
2396
+ const backends = property.backend_mappings.map((mapping) => ({
2397
+ type: mapping.backend,
2398
+ field: mapping.field,
2399
+ source: mapping.source_file,
2400
+ ...mapping.route !== "unknown" || mapping.method ? { operation: {
2401
+ method: mapping.method,
2402
+ route: mapping.route
2403
+ } } : {},
2404
+ confidence: mapping.confidence,
2405
+ mapper_type: mapping.mapper_type,
2406
+ provenance: mapping.provenance,
2407
+ reason: mapping.reason
2408
+ }));
2409
+ const suggestions = property.inference_suggestions?.map((suggestion) => ({
2410
+ type: suggestion.backend,
2411
+ field: suggestion.field,
2412
+ source: suggestion.source_file,
2413
+ ...suggestion.route !== "unknown" || suggestion.method ? { operation: {
2414
+ method: suggestion.method,
2415
+ route: suggestion.route
2416
+ } } : {},
2417
+ confidence: suggestion.confidence,
2418
+ reason: suggestion.reason,
2419
+ status: suggestion.status,
2420
+ rejection_reason: suggestion.rejection_reason
2421
+ }));
2422
+ const unresolvedBackendCandidates = property.unresolved_backend_candidates?.map((candidate) => ({
2423
+ type: candidate.backend,
2424
+ field: candidate.field,
2425
+ domain_field: candidate.domain_field,
2426
+ source: candidate.source_file,
2427
+ reason: candidate.reason
2428
+ }));
2429
+ const mapping = {};
2430
+ if (property.domain_field) mapping.domain = { field: property.domain_field };
2431
+ if (backends.length) mapping.backends = backends;
2432
+ const hasMapping = Boolean(mapping.domain || mapping.backends?.length);
2433
+ return {
2434
+ field: property.field,
2435
+ description: property.description,
2436
+ source: property.source_file,
2437
+ evidence_status: property.evidence_status,
2438
+ transversal: property.transversal,
2439
+ ...hasMapping ? { mapping } : {},
2440
+ ...suggestions?.length || unresolvedBackendCandidates?.length ? { review: {
2441
+ ...suggestions?.length ? { suggestions } : {},
2442
+ ...unresolvedBackendCandidates?.length ? { unresolved_backend_candidates: unresolvedBackendCandidates } : {}
2443
+ } } : {}
2444
+ };
2445
+ }
2446
+ function buildRouteReviewDocument(catalogue, routeKey) {
2447
+ const route = catalogue.routes.find((item) => item.key === routeKey);
2448
+ if (!route) throw new Error(`Unable to build review document for unknown route key ${routeKey}`);
2449
+ const backendRoutes = catalogue.backend_routes.filter((item) => item.route_key === routeKey);
2450
+ const routeProperties = [...catalogue.route_input_properties, ...catalogue.route_output_properties].filter((property) => property.route_key === routeKey);
2451
+ const mappedBackendTypes = new Set(routeProperties.flatMap((property) => property.backend_names));
2452
+ const datasources = backendRoutes.map((backendRoute) => ({
2453
+ type: backendRoute.backend,
2454
+ source: backendRoute.source_file,
2455
+ ...parseBackendRoute(backendRoute.route) ? { operation: parseBackendRoute(backendRoute.route) } : {},
2456
+ ...backendRoute.provenance === "ai" ? { provenance: backendRoute.provenance } : {}
2457
+ }));
2458
+ for (const backend of route.backends) if (!datasources.some((datasource) => datasource.type === backend) && !mappedBackendTypes.has(backend)) datasources.push({
2459
+ type: backend,
2460
+ source: route.source_file || "unknown"
2461
+ });
2462
+ return {
2463
+ schema_version: 1,
2464
+ generated_at: catalogue.generated_at,
2465
+ route: {
2466
+ method: route.method,
2467
+ path: route.path,
2468
+ version: route.version,
2469
+ source: route.source_file,
2470
+ analysis_files: route.analysis_files
2471
+ },
2472
+ datasources,
2473
+ inputs: catalogue.route_input_properties.filter((item) => item.route_key === routeKey).map(buildReviewProperty),
2474
+ outputs: catalogue.route_output_properties.filter((item) => item.route_key === routeKey).map(buildReviewProperty),
2475
+ rules: catalogue.rules.filter((item) => item.route_key === routeKey).map(({ route_key: _routeKey, source_file: source, ...rule }) => ({
2476
+ ...rule,
2477
+ source
2478
+ })),
2479
+ aggregations: catalogue.aggregations.filter((item) => item.route_key === routeKey).map(({ route_key: _routeKey, source_file: source, ...aggregation }) => ({
2480
+ ...aggregation,
2481
+ source
2482
+ }))
2483
+ };
2484
+ }
2485
+ function toRelativeFilePath(repoRoot, filePath) {
2486
+ if (!filePath) return filePath;
2487
+ if (!path.isAbsolute(filePath)) return filePath.replaceAll(path.sep, "/");
2488
+ return path.relative(repoRoot, filePath).replaceAll(path.sep, "/");
2489
+ }
2490
+ function relativizeProperty(property, repoRoot) {
2491
+ return {
2492
+ ...property,
2493
+ source: toRelativeFilePath(repoRoot, property.source),
2494
+ mapping: property.mapping && {
2495
+ ...property.mapping,
2496
+ domain: property.mapping.domain,
2497
+ backends: property.mapping.backends?.map((backend) => ({
2498
+ ...backend,
2499
+ source: toRelativeFilePath(repoRoot, backend.source) || backend.source
2500
+ }))
2501
+ },
2502
+ review: property.review && {
2503
+ ...property.review.suggestions ? { suggestions: property.review.suggestions.map((suggestion) => ({
2504
+ ...suggestion,
2505
+ source: toRelativeFilePath(repoRoot, suggestion.source) || suggestion.source
2506
+ })) } : {},
2507
+ ...property.review.unresolved_backend_candidates ? { unresolved_backend_candidates: property.review.unresolved_backend_candidates.map((candidate) => ({
2508
+ ...candidate,
2509
+ source: toRelativeFilePath(repoRoot, candidate.source) || candidate.source
2510
+ })) } : {}
2511
+ }
2512
+ };
2513
+ }
2514
+ function relativizeRouteReviewDocument(document, repoRoot) {
2515
+ return {
2516
+ ...document,
2517
+ route: {
2518
+ ...document.route,
2519
+ source: toRelativeFilePath(repoRoot, document.route.source),
2520
+ analysis_files: document.route.analysis_files.map((filePath) => toRelativeFilePath(repoRoot, filePath) || filePath)
2521
+ },
2522
+ datasources: document.datasources.map((datasource) => ({
2523
+ ...datasource,
2524
+ source: toRelativeFilePath(repoRoot, datasource.source) || datasource.source
2525
+ })),
2526
+ inputs: document.inputs.map((property) => relativizeProperty(property, repoRoot)),
2527
+ outputs: document.outputs.map((property) => relativizeProperty(property, repoRoot)),
2528
+ rules: document.rules.map((rule) => ({
2529
+ ...rule,
2530
+ source: toRelativeFilePath(repoRoot, rule.source) || rule.source
2531
+ })),
2532
+ aggregations: document.aggregations.map((aggregation) => ({
2533
+ ...aggregation,
2534
+ source: toRelativeFilePath(repoRoot, aggregation.source) || aggregation.source
2535
+ }))
2536
+ };
2537
+ }
2538
+ //#endregion
2539
+ //#region src/services/catalogueArtifactService.ts
2540
+ function stableKey$1(...parts) {
2541
+ return createHash("sha1").update(parts.join("::")).digest("hex");
2542
+ }
2543
+ async function writeRouteReviewDocument(catalogue, outputDir, repoRoot, routeKey) {
2544
+ const route = catalogue.routes.find((item) => item.key === routeKey);
2545
+ if (!route) throw new Error(`Unable to write review document for unknown route key ${routeKey}`);
2546
+ const document = relativizeRouteReviewDocument(buildRouteReviewDocument(catalogue, route.key), repoRoot);
2547
+ const filePath = path.join(outputDir, buildRouteReviewRelativePath(route.method, route.path));
2548
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
2549
+ await fs.writeFile(filePath, `${stringify(document)}\n`, "utf8");
2550
+ return filePath;
2551
+ }
2552
+ async function writeRouteReviewDocuments(catalogue, outputDir, repoRoot) {
2553
+ await fs.mkdir(outputDir, { recursive: true });
2554
+ for (const [index, route] of catalogue.routes.entries()) {
2555
+ taskProgressService.log(`[${index + 1}/${catalogue.routes.length}] - ${route.method} ${route.path} ...`);
2556
+ await writeRouteReviewDocument(catalogue, outputDir, repoRoot, route.key);
2557
+ }
2558
+ return outputDir;
2559
+ }
2560
+ async function prepareCatalogueOutputDirectory(outputDir) {
2561
+ await fs.mkdir(outputDir, { recursive: true });
2562
+ await Promise.all(["catalogue.yaml", "catalogue.yml"].map((fileName) => fs.rm(path.join(outputDir, fileName), { force: true })));
2563
+ }
2564
+ async function writeCatalogueArtifacts(catalogue, outputDir, repoRoot = process.cwd()) {
2565
+ await prepareCatalogueOutputDirectory(outputDir);
2566
+ await writeRouteReviewDocuments(catalogue, outputDir, repoRoot);
2567
+ return {
2568
+ outputDir,
2569
+ routeDocuments: catalogue.routes.length
2570
+ };
2571
+ }
2572
+ function addBackendMapping(routeKey, direction, apiPropertyKey, mapping, backendRoutesByKey, backendPropertiesByKey, mappingEvidence) {
2573
+ const backendRouteKey = stableKey$1(routeKey, mapping.backend, mapping.route);
2574
+ const backendRoute = backendRoutesByKey.get(backendRouteKey) || {
2575
+ key: backendRouteKey,
2576
+ backend: mapping.backend,
2577
+ route_key: routeKey,
2578
+ route: `${mapping.method || "CALL"} ${mapping.route}`,
2579
+ source_file: mapping.source_file,
2580
+ provenance: "code_analysis"
2581
+ };
2582
+ backendRoutesByKey.set(backendRouteKey, backendRoute);
2583
+ const backendPropertyKey = stableKey$1(backendRouteKey, direction, mapping.field);
2584
+ backendPropertiesByKey.set(backendPropertyKey, {
2585
+ key: backendPropertyKey,
2586
+ backend_route_key: backendRouteKey,
2587
+ backend: mapping.backend,
2588
+ field: mapping.field,
2589
+ direction,
2590
+ source_file: mapping.source_file,
2591
+ provenance: mapping.provenance || "code_analysis"
2592
+ });
2593
+ mappingEvidence.push({
2594
+ key: stableKey$1(apiPropertyKey, backendPropertyKey),
2595
+ evidence_type: mapping.provenance === "ai" ? "inference" : "static_analysis",
2596
+ status: mapping.confidence >= 90 ? "confirmed" : "inferred",
2597
+ confidence_score: mapping.confidence,
2598
+ comment: mapping.reason || `Reconstructed from route artifact ${path.basename(mapping.source_file)}`,
2599
+ ...direction === "input" ? { input_property_key: apiPropertyKey } : { output_property_key: apiPropertyKey },
2600
+ backend_property_key: backendPropertyKey,
2601
+ source_file: mapping.source_file
2602
+ });
2603
+ }
2604
+ function toCatalogueBackendMapping(mapping) {
2605
+ return {
2606
+ backend: mapping.type,
2607
+ method: mapping.operation?.method || null,
2608
+ route: mapping.operation?.route || "unknown",
2609
+ field: mapping.field,
2610
+ source_file: mapping.source,
2611
+ confidence: mapping.confidence,
2612
+ mapper_type: mapping.mapper_type,
2613
+ provenance: mapping.provenance,
2614
+ reason: mapping.reason
2615
+ };
2616
+ }
2617
+ function addDatasourceRoute(routeKey, datasource, backendRoutesByKey) {
2618
+ const backendRoute = datasource.operation?.route || "unknown";
2619
+ const backendRouteKey = stableKey$1(routeKey, datasource.type, backendRoute);
2620
+ backendRoutesByKey.set(backendRouteKey, {
2621
+ key: backendRouteKey,
2622
+ backend: datasource.type,
2623
+ route_key: routeKey,
2624
+ route: `${datasource.operation?.method || "CALL"} ${backendRoute}`,
2625
+ source_file: datasource.source,
2626
+ provenance: datasource.provenance || "code_analysis"
2627
+ });
2628
+ }
2629
+ function mergeRouteReviewDocuments(documents) {
2630
+ const routes = [];
2631
+ const routeInputProperties = [];
2632
+ const routeOutputProperties = [];
2633
+ const backendRoutesByKey = /* @__PURE__ */ new Map();
2634
+ const backendPropertiesByKey = /* @__PURE__ */ new Map();
2635
+ const mappingEvidence = [];
2636
+ const rules = [];
2637
+ const aggregations = [];
2638
+ for (const document of documents) {
2639
+ const routeKey = stableKey$1(document.route.method, document.route.path);
2640
+ const mappedBackendNames = [...document.inputs, ...document.outputs].flatMap((property) => property.mapping?.backends?.map((mapping) => mapping.type) || []);
2641
+ const backendNames = [.../* @__PURE__ */ new Set([...document.datasources.map((datasource) => datasource.type), ...mappedBackendNames])];
2642
+ routes.push({
2643
+ key: routeKey,
2644
+ method: document.route.method,
2645
+ path: document.route.path,
2646
+ version: document.route.version,
2647
+ source_file: document.route.source,
2648
+ analysis_files: document.route.analysis_files,
2649
+ backends: backendNames
2650
+ });
2651
+ for (const datasource of document.datasources) addDatasourceRoute(routeKey, datasource, backendRoutesByKey);
2652
+ const appendProperties = (direction, properties) => {
2653
+ for (const property of properties) {
2654
+ const apiPropertyKey = stableKey$1(routeKey, direction, property.field);
2655
+ const backendMappings = property.mapping?.backends?.map(toCatalogueBackendMapping) || [];
2656
+ const catalogueProperty = {
2657
+ key: apiPropertyKey,
2658
+ route_key: routeKey,
2659
+ direction,
2660
+ field: property.field,
2661
+ domain_field: property.mapping?.domain?.field,
2662
+ transversal: property.transversal,
2663
+ backend_names: [...new Set(backendMappings.map((mapping) => mapping.backend))],
2664
+ backend_mappings: backendMappings,
2665
+ inference_suggestions: property.review?.suggestions?.map((suggestion) => ({
2666
+ backend: suggestion.type,
2667
+ method: suggestion.operation?.method || null,
2668
+ route: suggestion.operation?.route || "unknown",
2669
+ field: suggestion.field,
2670
+ source_file: suggestion.source,
2671
+ confidence: suggestion.confidence,
2672
+ reason: suggestion.reason,
2673
+ status: suggestion.status,
2674
+ rejection_reason: suggestion.rejection_reason
2675
+ })),
2676
+ unresolved_backend_candidates: property.review?.unresolved_backend_candidates?.map((candidate) => ({
2677
+ backend: candidate.type,
2678
+ field: candidate.field,
2679
+ domain_field: candidate.domain_field,
2680
+ source_file: candidate.source,
2681
+ reason: candidate.reason
2682
+ })),
2683
+ description: property.description,
2684
+ source_file: property.source,
2685
+ analysis_files: document.route.analysis_files,
2686
+ evidence_status: property.evidence_status
2687
+ };
2688
+ if (direction === "input") routeInputProperties.push(catalogueProperty);
2689
+ else routeOutputProperties.push(catalogueProperty);
2690
+ for (const mapping of backendMappings) addBackendMapping(routeKey, direction, apiPropertyKey, mapping, backendRoutesByKey, backendPropertiesByKey, mappingEvidence);
2691
+ }
2692
+ };
2693
+ appendProperties("input", document.inputs);
2694
+ appendProperties("output", document.outputs);
2695
+ rules.push(...document.rules.map(({ source, ...rule }) => ({
2696
+ route_key: routeKey,
2697
+ ...rule,
2698
+ source_file: source
2699
+ })));
2700
+ aggregations.push(...document.aggregations.map(({ source, ...aggregation }) => ({
2701
+ route_key: routeKey,
2702
+ ...aggregation,
2703
+ source_file: source
2704
+ })));
2705
+ }
2706
+ const needsReview = routeInputProperties.filter((property) => property.evidence_status === "needs_review").length + routeOutputProperties.filter((property) => property.evidence_status === "needs_review").length;
2707
+ return {
2708
+ generated_at: documents.map((document) => document.generated_at).sort().at(-1) || (/* @__PURE__ */ new Date()).toISOString(),
2709
+ routes,
2710
+ route_input_properties: routeInputProperties,
2711
+ route_output_properties: routeOutputProperties,
2712
+ backend_routes: [...backendRoutesByKey.values()],
2713
+ backend_properties: [...backendPropertiesByKey.values()],
2714
+ mapping_evidence: mappingEvidence,
2715
+ rules,
2716
+ aggregations,
2717
+ stats: {
2718
+ routes: routes.length,
2719
+ input_properties: routeInputProperties.length,
2720
+ output_properties: routeOutputProperties.length,
2721
+ backend_routes: backendRoutesByKey.size,
2722
+ backend_properties: backendPropertiesByKey.size,
2723
+ mapping_evidence: mappingEvidence.length,
2724
+ rules: rules.length,
2725
+ aggregations: aggregations.length,
2726
+ needs_review: needsReview,
2727
+ needs_review_percentage: calculateNeedsReviewPercentage(routeInputProperties.length, routeOutputProperties.length, needsReview)
2728
+ }
2729
+ };
2730
+ }
2731
+ async function readCatalogueFromDirectory(inputDir) {
2732
+ const files = await globby("**/*.@(yaml|yml)", {
2733
+ cwd: inputDir,
2734
+ absolute: true,
2735
+ ignore: ["**/*.graph.yaml", "**/*.graph.yml"]
2736
+ });
2737
+ if (!files.length) throw new Error(`No route YAML documents found in ${inputDir}`);
2738
+ return {
2739
+ catalogue: mergeRouteReviewDocuments(await Promise.all(files.sort().map(async (file) => {
2740
+ const parsed = parse(await fs.readFile(file, "utf8"));
2741
+ if (parsed?.schema_version !== 1 || !parsed?.route?.method || !parsed?.route?.path) throw new Error(`Invalid route YAML document: ${file}`);
2742
+ return parsed;
2743
+ }))),
2744
+ files
2745
+ };
2746
+ }
2747
+ //#endregion
2748
+ //#region src/services/directusPushService.ts
2749
+ async function pushCatalogueDirectoryToDirectus(params) {
2750
+ const inputPath = path.resolve(params.cwd, params.catalogueDirectory);
2751
+ taskProgressService.report("Loading and reconstructing route YAML documents");
2752
+ const { catalogue, files } = await readCatalogueFromDirectory(inputPath);
2753
+ taskProgressService.report(`${files.length} route documents loaded, ${catalogue.backend_routes.length} backend routes reconstructed`);
2754
+ const directusUrl = params.directusUrl || process.env.DIRECTUS_URL || process.env.CMS_API_URL || getUserConfig().directusUrl;
2755
+ const directusToken = params.directusToken || process.env.DIRECTUS_TOKEN || process.env.CMS_DIRECTUS_TOKEN || process.env.CMS_API_TOKEN;
2756
+ if (!directusToken) throw new Error("Directus push requested but DIRECTUS_TOKEN/directus-token is missing");
2757
+ return {
2758
+ inputPath,
2759
+ files,
2760
+ result: await pushCatalogueToDirectus(catalogue, {
2761
+ baseUrl: directusUrl,
2762
+ token: directusToken,
2763
+ dryRun: !params.write,
2764
+ routeSelector: params.routeSelector
2765
+ })
2766
+ };
2767
+ }
2768
+ //#endregion
2769
+ //#region src/utils/routeSelector.ts
2770
+ function parseRouteSelector(value) {
2771
+ if (!value) return;
2772
+ const match = value.trim().match(/^([A-Z]+)\s+(\S+)$/i);
2773
+ if (!match) throw new Error(`Invalid route selector "${value}". Expected format: "GET /v0/products"`);
2774
+ return {
2775
+ method: match[1].toUpperCase(),
2776
+ path: match[2]
2777
+ };
2778
+ }
2779
+ //#endregion
2780
+ //#region src/commands/push.ts
2781
+ const DEFAULT_CATALOGUE_DIRECTORY = ".tmp/datasource-catalogue";
2782
+ function isRouteSelectorArgument(value) {
2783
+ return /^[A-Z]+\s+\//i.test(value.trim());
2784
+ }
2785
+ function isRecord$1(value) {
2786
+ return typeof value === "object" && value !== null && !Array.isArray(value);
2787
+ }
2788
+ function formatPushError(error) {
2789
+ if (error instanceof Error) return error.message;
2790
+ if (!isRecord$1(error)) return String(error);
2791
+ const details = (Array.isArray(error.errors) ? error.errors : []).flatMap((item) => {
2792
+ if (!isRecord$1(item) || typeof item.message !== "string") return [];
2793
+ return [`${isRecord$1(item.extensions) && typeof item.extensions.code === "string" ? `${item.extensions.code}: ` : ""}${item.message}`];
2794
+ });
2795
+ if (details.length) return `Directus API error: ${details.join("; ")}`;
2796
+ if (typeof error.message === "string") return error.message;
2797
+ return "Directus push failed with an unrecognized error response";
2798
+ }
2799
+ function createPushStepClassifier() {
2800
+ let currentRoute;
2801
+ let currentRouteIndex = 0;
2802
+ let totalRoutes = 0;
2803
+ return (message) => {
2804
+ const route = message.match(/^Synchronizing routes (\d+)\/(\d+): ([A-Z]+) (.+)$/);
2805
+ if (route) {
2806
+ currentRouteIndex = Number(route[1]);
2807
+ totalRoutes = Number(route[2]);
2808
+ currentRoute = {
2809
+ method: route[3],
2810
+ path: route[4]
2811
+ };
2812
+ return {
2813
+ id: `push-${currentRoute.method}-${currentRoute.path}`,
2814
+ title: `1 worker · Push · [${currentRouteIndex}/${totalRoutes}] routes · ${currentRoute.method} ${currentRoute.path} [Synchronizing route]`,
2815
+ completedTitle: `Push completed: ${currentRoute.method} ${currentRoute.path}`
2816
+ };
2817
+ }
2818
+ if (currentRoute && /^Synchronizing (backend routes|backend properties|input properties|output properties) \d+\//.test(message)) return {
2819
+ id: `push-${currentRoute.method}-${currentRoute.path}`,
2820
+ title: `1 worker · Push · [${currentRouteIndex}/${totalRoutes}] routes · ${currentRoute.method} ${currentRoute.path} [${message}]`,
2821
+ completedTitle: `Push completed: ${currentRoute.method} ${currentRoute.path}`
2822
+ };
2823
+ if (/Loading and reconstructing|route documents loaded/.test(message)) return {
2824
+ id: "artifacts",
2825
+ title: "Loading route artifacts",
2826
+ completedTitle: "Route artifacts loaded",
2827
+ detail: message
2828
+ };
2829
+ if (/Validating reconstructed/.test(message)) return {
2830
+ id: "validation",
2831
+ title: "Validating catalogue",
2832
+ completedTitle: "Catalogue validated",
2833
+ detail: message
2834
+ };
2835
+ if (/Connecting to Directus/.test(message)) return {
2836
+ id: "connection",
2837
+ title: "Connecting to Directus",
2838
+ completedTitle: "Connected to Directus",
2839
+ detail: message
2840
+ };
2841
+ if (/Loading predefined/.test(message)) return {
2842
+ id: "references",
2843
+ title: "Loading Directus references",
2844
+ completedTitle: "Directus references loaded",
2845
+ detail: message
2846
+ };
2847
+ return {
2848
+ id: "finalization",
2849
+ title: "Finalizing Directus synchronization",
2850
+ completedTitle: "Directus synchronization finalized",
2851
+ detail: message
2852
+ };
2853
+ };
2854
+ }
2855
+ var push_default = (program) => void program.command("push").argument("[catalogue-directory-or-route]", "Directory or route such as \"GET /v1/offers\"", DEFAULT_CATALOGUE_DIRECTORY).option("--route <route>", "Push one route only, for example \"POST /v0/accommodations_arrangement/check\"").option("--directus-url <url>", "Directus base URL").option("--directus-token <token>", "Directus bearer token").option("--write", "Persist changes to Directus", false).description("Push generated per-route YAML documents to Directus").action(async (catalogueDirectoryOrRoute, options) => {
2856
+ intro("Datasource catalogue push");
2857
+ const { cwd } = getUserConfig();
2858
+ const progress = taskProgressService.createStepProgress(createPushStepClassifier());
2859
+ try {
2860
+ const positionalRouteSelector = isRouteSelectorArgument(catalogueDirectoryOrRoute) ? parseRouteSelector(catalogueDirectoryOrRoute) : void 0;
2861
+ if (options.route && positionalRouteSelector) throw new Error("Specify the route either as the positional argument or with --route, not both");
2862
+ const routeSelector = options.route ? parseRouteSelector(options.route) : positionalRouteSelector;
2863
+ const catalogueDirectory = positionalRouteSelector ? DEFAULT_CATALOGUE_DIRECTORY : catalogueDirectoryOrRoute;
2864
+ const pushResult = await progress.execute(() => pushCatalogueDirectoryToDirectus({
2865
+ cwd,
2866
+ catalogueDirectory,
2867
+ directusUrl: options.directusUrl,
2868
+ directusToken: options.directusToken,
2869
+ routeSelector,
2870
+ write: options.write
2871
+ }));
2872
+ progress.finish(options.write ? "Directus synchronization completed" : "Directus dry-run completed");
2873
+ note([
2874
+ `Input directory: ${pushResult.inputPath}`,
2875
+ `Route documents: ${pushResult.files.length}`,
2876
+ routeSelector ? `Route selector: ${routeSelector.method} ${routeSelector.path}` : null,
2877
+ `Collections targeted: ${pushResult.result.pushedCollections.join(", ")}`,
2878
+ `Items processed: ${pushResult.result.pushedItems}`,
2879
+ `Warnings: ${pushResult.result.warnings.length}`
2880
+ ].filter(Boolean).join("\n"), "Directus");
2881
+ outro("Datasource catalogue push completed");
2882
+ } catch (error) {
2883
+ progress.fail("Directus push failed");
2884
+ cancel(formatPushError(error));
2885
+ process.exit(1);
2886
+ }
2887
+ });
2888
+ //#endregion
2889
+ //#region src/commands/cleanOrphans.ts
2890
+ var cleanOrphans_default = (program) => void program.command("clean-orphans").option("--directus-url <url>", "Directus base URL").option("--directus-token <token>", "Directus bearer token").option("--write", "Remove the orphaned links", false).description("Find and optionally remove orphaned property-to-backend-property links in Directus").action(async (options) => {
2891
+ intro("Datasource catalogue orphan cleanup");
2892
+ const progress = taskProgressService.createStepProgress();
2893
+ try {
2894
+ const config = getUserConfig();
2895
+ const directusUrl = options.directusUrl || process.env.DIRECTUS_URL || process.env.CMS_API_URL || config.directusUrl;
2896
+ const directusToken = options.directusToken || process.env.DIRECTUS_TOKEN || process.env.CMS_DIRECTUS_TOKEN || process.env.CMS_API_TOKEN;
2897
+ if (!directusToken) throw new Error("Directus cleanup requested but DIRECTUS_TOKEN/directus-token is missing");
2898
+ const result = await progress.execute(() => cleanDirectusOrphanedPropertyLinks({
2899
+ baseUrl: directusUrl,
2900
+ token: directusToken,
2901
+ dryRun: !options.write
2902
+ }));
2903
+ progress.finish(options.write ? "Orphaned property links removed" : "Orphaned property links inspected");
2904
+ note([
2905
+ `Orphaned links found: ${result.orphanedLinks}`,
2906
+ `Orphaned links removed: ${result.removedOrphanedLinks}`,
2907
+ options.write ? null : "Run again with --write to remove them."
2908
+ ].filter(Boolean).join("\n"), "Directus");
2909
+ outro("Datasource catalogue orphan cleanup completed");
2910
+ } catch (error) {
2911
+ progress.fail("Orphan cleanup failed");
2912
+ cancel(formatPushError(error));
2913
+ process.exit(1);
2914
+ }
2915
+ });
2916
+ //#endregion
2917
+ //#region src/services/analysisProfileService.ts
2918
+ function createAnalysisProfile(enabled) {
2919
+ const durations = /* @__PURE__ */ new Map();
2920
+ return {
2921
+ add: (label, durationMs) => {
2922
+ if (enabled) durations.set(label, (durations.get(label) || 0) + durationMs);
2923
+ },
2924
+ report: () => [...durations.entries()].sort((a, b) => b[1] - a[1]).map(([label, duration]) => `${label}: ${(duration / 1e3).toFixed(2)}s`)
2925
+ };
2926
+ }
2927
+ //#endregion
2928
+ //#region src/services/backendTopologyArtifactService.ts
2929
+ function buildBackendTopologyRelativePath(method, routePath) {
2930
+ return buildRouteReviewRelativePath(method, routePath).replace(/\.yaml$/, ".graph.yaml");
2931
+ }
2932
+ async function writeBackendTopologyArtifact(artifact, outputDir) {
2933
+ const filePath = path.join(outputDir, buildBackendTopologyRelativePath(artifact.route.method, artifact.route.path));
2934
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
2935
+ await fs.writeFile(filePath, `${stringify(artifact, { aliasDuplicateObjects: false })}\n`, "utf8");
2936
+ return filePath;
2937
+ }
2938
+ function isRecord(value) {
2939
+ return typeof value === "object" && value !== null && !Array.isArray(value);
2940
+ }
2941
+ function parseBackendTopologyArtifact(value, expectedMethod, expectedPath, filePath) {
2942
+ if (!isRecord(value) || value.schema_version !== 3) throw new Error(`Invalid backend graph artifact schema: ${filePath}. Run generate:graph again.`);
2943
+ const route = value.route;
2944
+ const analysisFiles = value.analysis_files;
2945
+ const backendPaths = value.backend_paths;
2946
+ const mappingContext = value.mapping_context;
2947
+ if (!isRecord(route) || route.method !== expectedMethod || route.path !== expectedPath || !Array.isArray(analysisFiles) || !analysisFiles.length || analysisFiles.some((item) => typeof item !== "string" || !item) || !Array.isArray(mappingContext) || mappingContext.some((item) => !isRecord(item) || item.layer !== "api" && item.layer !== "backend" || item.direction !== "input" && item.direction !== "output" || typeof item.symbol !== "string" || typeof item.source !== "string" || typeof item.line !== "number" || typeof item.backend_path !== "number" || typeof item.backend_type !== "string") || !Array.isArray(backendPaths) || !backendPaths.length || backendPaths.some((item) => !isRecord(item) || item.status !== "resolved" || typeof item.backend_type !== "string" || !item.backend_type)) throw new Error(`Invalid backend graph artifact for ${expectedMethod} ${expectedPath}: ${filePath}`);
2948
+ return value;
2949
+ }
2950
+ async function loadBackendTopologyAnalysisScope(outputDir, cwd, method, routePath) {
2951
+ const filePath = path.join(outputDir, buildBackendTopologyRelativePath(method, routePath));
2952
+ let content;
2953
+ try {
2954
+ content = await fs.readFile(filePath, "utf8");
2955
+ } catch (error) {
2956
+ if (isRecord(error) && error.code === "ENOENT") throw new Error(`Backend graph artifact not found for ${method} ${routePath}: ${filePath}. Run generate:graph first.`);
2957
+ throw error;
2958
+ }
2959
+ let parsed;
2960
+ try {
2961
+ parsed = parse(content);
2962
+ } catch {
2963
+ throw new Error(`Unable to parse backend graph artifact for ${method} ${routePath}: ${filePath}`);
2964
+ }
2965
+ const artifact = parseBackendTopologyArtifact(parsed, method, routePath, filePath);
2966
+ const analysisFiles = artifact.analysis_files.map((source) => {
2967
+ const absolutePath = path.resolve(cwd, source);
2968
+ const relativePath = path.relative(cwd, absolutePath);
2969
+ if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) throw new Error(`Backend graph analysis file is outside the repository: ${source}`);
2970
+ return absolutePath;
2971
+ });
2972
+ const missingFiles = [];
2973
+ await Promise.all(analysisFiles.map(async (analysisFile) => {
2974
+ try {
2975
+ await fs.access(analysisFile);
2976
+ } catch {
2977
+ missingFiles.push(path.relative(cwd, analysisFile));
2978
+ }
2979
+ }));
2980
+ if (missingFiles.length) throw new Error(`Backend graph references missing analysis files for ${method} ${routePath}: ${missingFiles.sort().join(", ")}. Run generate:graph again.`);
2981
+ return {
2982
+ analysisFiles,
2983
+ backendTypes: new Set(artifact.backend_paths.map((backendPath) => backendPath.backend_type)),
2984
+ mappingContext: artifact.mapping_context.map((context) => ({
2985
+ ...context,
2986
+ source: path.resolve(cwd, context.source)
2987
+ })),
2988
+ backendPathSources: [...new Map(artifact.backend_paths.flatMap((backendPath) => backendPath.nodes.filter((node) => node.source.includes("app/_infra/back/")).map((node) => {
2989
+ const sourceFile = path.resolve(cwd, node.source);
2990
+ return [`${backendPath.backend_type}:${sourceFile}`, {
2991
+ backend: backendPath.backend_type,
2992
+ sourceFile
2993
+ }];
2994
+ }))).values()]
2995
+ };
2996
+ }
2997
+ //#endregion
2998
+ //#region src/commands/generate.ts
2999
+ async function generateHandler(cwd, routeArgument, options) {
3000
+ intro("Datasource catalogue generation");
3001
+ const progress$3 = taskProgressService.createStepProgress();
3002
+ const routeSelector = parseRouteSelector(routeArgument);
3003
+ const profile = createAnalysisProfile(Boolean(options.profile));
3004
+ try {
3005
+ const outputDir = path.resolve(cwd, options.output);
3006
+ await progress$3.run({
3007
+ id: "artifacts",
3008
+ title: "Preparing route documents",
3009
+ completedTitle: "Route document directory prepared",
3010
+ detail: outputDir
3011
+ }, () => prepareCatalogueOutputDirectory(outputDir));
3012
+ const openApiStartedAt = performance.now();
3013
+ const openApiDocument = await progress$3.run({
3014
+ id: "openapi",
3015
+ title: "Loading OpenAPI contract",
3016
+ completedTitle: "OpenAPI contract loaded"
3017
+ }, () => loadOpenApiDocument(options.openapiUrl));
3018
+ profile.add("OpenAPI", performance.now() - openApiStartedAt);
3019
+ const selectedRouteKeys = routeSelector ? /* @__PURE__ */ new Set([buildRouteKey(routeSelector.method, routeSelector.path)]) : void 0;
3020
+ const totalRoutes = routeSelector ? 1 : Object.values(openApiDocument.paths || {}).reduce((total, pathItem) => total + Object.keys(pathItem || {}).filter((method) => /^(get|post|put|patch|delete|head|options)$/i.test(method)).length, 0);
3021
+ const analysisStartedAt = performance.now();
3022
+ progress$3.finish("OpenAPI contract loaded");
3023
+ const analysisProgress = progress({
3024
+ style: "heavy",
3025
+ max: totalRoutes,
3026
+ size: 40
3027
+ });
3028
+ let analyzedRoutes = 0;
3029
+ analysisProgress.start(`Analyzing · [0/${totalRoutes}] routes`);
3030
+ const documents = await analyzeCodebaseRouteContracts({
3031
+ cwd,
3032
+ openApiDocument,
3033
+ selectedRouteKeys,
3034
+ routeAnalysisScope: async ({ method, path: routePath }) => {
3035
+ try {
3036
+ return await loadBackendTopologyAnalysisScope(outputDir, cwd, method, routePath);
3037
+ } catch (error) {
3038
+ const message = error instanceof Error ? error.message : String(error);
3039
+ taskProgressService.log(`Warning: ${message} Route skipped.`);
3040
+ return;
3041
+ }
3042
+ },
3043
+ onDocument: async (document) => {
3044
+ const artifactStartedAt = performance.now();
3045
+ analyzedRoutes += 1;
3046
+ analysisProgress.advance(1, `Analyzing · [${analyzedRoutes}/${totalRoutes}] routes · ${document.method} ${document.path} ...`);
3047
+ await writeRouteReviewDocument(await buildCatalogue([document]), outputDir, cwd, document.key);
3048
+ profile.add("Artifacts", performance.now() - artifactStartedAt);
3049
+ }
3050
+ });
3051
+ analysisProgress.stop("Static route analysis completed");
3052
+ profile.add("Static analysis", performance.now() - analysisStartedAt);
3053
+ const catalogueStartedAt = performance.now();
3054
+ const catalogue = await progress$3.run({
3055
+ id: "catalogue",
3056
+ title: "Building catalogue",
3057
+ completedTitle: "Catalogue built",
3058
+ detail: `${documents.length} route${documents.length === 1 ? "" : "s"}`
3059
+ }, () => buildCatalogue(documents));
3060
+ profile.add("Catalogue build", performance.now() - catalogueStartedAt);
3061
+ note([
3062
+ `Output directory: ${outputDir}`,
3063
+ `Route documents: ${catalogue.routes.length}`,
3064
+ routeSelector ? `Route selector: ${routeSelector.method} ${routeSelector.path}` : null
3065
+ ].filter(Boolean).join("\n"), "Artifacts");
3066
+ note(JSON.stringify(catalogue.stats, null, 2), "Stats");
3067
+ if (options.profile) note(profile.report().join("\n"), "Performance profile");
3068
+ outro("Datasource catalogue generation completed");
3069
+ } catch (error) {
3070
+ progress$3.fail("Generation failed");
3071
+ cancel(error instanceof Error ? error.message : String(error));
3072
+ process.exit(1);
3073
+ }
3074
+ }
3075
+ var generate_default = (program) => void program.command("generate:catalogue").argument("[route]", "Route selector in the format \"GET /v1/products\"").option("-o, --output <directory>", "Directory where catalogue artifacts are generated", ".tmp/datasource-catalogue").option("--openapi-url <url>", "OpenAPI document URL").option("--profile", "Display elapsed time by generation stage").description("Analyze the codebase and generate one YAML review document per route").action((routeArgument, options) => {
3076
+ const config = getUserConfig();
3077
+ return generateHandler(config.cwd, routeArgument, {
3078
+ ...options,
3079
+ openapiUrl: options.openapiUrl ?? config.openapiUrl
3080
+ });
3081
+ });
3082
+ //#endregion
3083
+ //#region src/services/routeBackendTopologyService.ts
3084
+ function stringProperty(object, name) {
3085
+ const property = object.getProperty(name);
3086
+ if (!property || !Node.isPropertyAssignment(property)) return void 0;
3087
+ const initializer = property.getInitializer();
3088
+ return initializer && (Node.isStringLiteral(initializer) || Node.isNoSubstitutionTemplateLiteral(initializer)) ? initializer.getLiteralValue() : void 0;
3089
+ }
3090
+ function handlerProperty(object) {
3091
+ const property = object.getProperty("handler");
3092
+ if (!property || !Node.isPropertyAssignment(property)) return void 0;
3093
+ const initializer = property.getInitializer();
3094
+ return initializer && (Node.isIdentifier(initializer) || Node.isPropertyAccessExpression(initializer)) ? initializer.getText() : void 0;
3095
+ }
3096
+ function extractRouteDeclarations(sourceFile) {
3097
+ const initializer = sourceFile.getVariableDeclaration("routes")?.getInitializer();
3098
+ let expression = initializer;
3099
+ if (initializer && (Node.isAsExpression(initializer) || Node.isSatisfiesExpression(initializer))) expression = initializer.getExpression();
3100
+ if (!expression || !Node.isArrayLiteralExpression(expression)) return [];
3101
+ return expression.getElements().flatMap((element) => {
3102
+ if (!Node.isObjectLiteralExpression(element)) return [];
3103
+ const method = stringProperty(element, "method");
3104
+ const routePath = stringProperty(element, "path");
3105
+ const handlerRef = handlerProperty(element);
3106
+ return method && routePath && handlerRef ? [{
3107
+ method: method.toUpperCase(),
3108
+ path: routePath,
3109
+ sourceFile,
3110
+ handlerRef
3111
+ }] : [];
3112
+ });
3113
+ }
3114
+ async function discoverBackendTopologyRouteSelectors(cwd) {
3115
+ const project = new Project({
3116
+ skipAddingFilesFromTsConfig: true,
3117
+ compilerOptions: {
3118
+ allowJs: true,
3119
+ checkJs: false
3120
+ }
3121
+ });
3122
+ return (await globby(["app/_api/**/routes.@(js|ts)", "app/legacy/**/routes.@(js|ts)"], {
3123
+ cwd,
3124
+ absolute: true
3125
+ })).flatMap((routeFile) => extractRouteDeclarations(project.addSourceFileAtPath(routeFile))).map((route) => ({
3126
+ method: route.method,
3127
+ path: route.path
3128
+ }));
3129
+ }
3130
+ async function generateBackendTopologyArtifactsInWorkers(params) {
3131
+ const selectors = params.routeSelector ? [params.routeSelector] : await discoverBackendTopologyRouteSelectors(params.cwd);
3132
+ params.onRoutesDiscovered?.(selectors.length);
3133
+ const workerCount = Math.min(params.workers || 2, selectors.length);
3134
+ const chunks = Array.from({ length: workerCount }, () => []);
3135
+ selectors.forEach((selector, index) => chunks[index % workerCount].push(selector));
3136
+ const artifacts = [];
3137
+ let completed = 0;
3138
+ let writeQueue = Promise.resolve();
3139
+ const workers = [];
3140
+ try {
3141
+ await Promise.all(chunks.map((routeSelectors) => new Promise((resolve, reject) => {
3142
+ const worker = new Worker(new URL("./routeBackendTopologyWorker.ts", import.meta.url), {
3143
+ workerData: {
3144
+ cwd: params.cwd,
3145
+ routeSelectors
3146
+ },
3147
+ execArgv: process.execArgv
3148
+ });
3149
+ workers.push(worker);
3150
+ worker.on("message", (message) => {
3151
+ if (message.type === "error") {
3152
+ reject(new Error(message.message));
3153
+ return;
3154
+ }
3155
+ writeQueue = writeQueue.then(async () => {
3156
+ if (message.type === "artifact") {
3157
+ await params.onArtifact(message.artifact);
3158
+ artifacts.push(message.artifact);
3159
+ return;
3160
+ }
3161
+ completed += 1;
3162
+ params.onRouteProgress?.({
3163
+ current: completed,
3164
+ total: selectors.length,
3165
+ route: message.route,
3166
+ stage: "completed"
3167
+ });
3168
+ }).catch(reject);
3169
+ });
3170
+ worker.once("error", reject);
3171
+ worker.once("exit", (code) => code === 0 ? resolve() : reject(/* @__PURE__ */ new Error(`Topology worker exited with code ${code}`)));
3172
+ })));
3173
+ await writeQueue;
3174
+ return artifacts;
3175
+ } catch (error) {
3176
+ await Promise.all(workers.map((worker) => worker.terminate().catch(() => void 0)));
3177
+ throw error;
3178
+ }
3179
+ }
3180
+ //#endregion
3181
+ //#region src/commands/generateGraph.ts
3182
+ function parseWorkerCount$1(value) {
3183
+ const workers = Number(value);
3184
+ if (!Number.isInteger(workers) || workers < 1) throw new Error("Worker count must be a positive integer");
3185
+ return workers;
3186
+ }
3187
+ async function generateGraphHandler(cwd, routeArgument, options) {
3188
+ intro("Code source Graph generation");
3189
+ const progress$2 = taskProgressService.createStepProgress((message) => {
3190
+ if (message === "Discovering route files") return {
3191
+ id: "route-files",
3192
+ title: message,
3193
+ completedTitle: "Route files discovered"
3194
+ };
3195
+ if (message === "Extracting route declarations") return {
3196
+ id: "routes",
3197
+ title: message,
3198
+ completedTitle: "Route declarations extracted"
3199
+ };
3200
+ if (message.startsWith("Writing graph document ")) return {
3201
+ id: "artifacts",
3202
+ title: "Writing graph documents",
3203
+ completedTitle: "Graph YAML document written",
3204
+ detail: message.slice(23)
3205
+ };
3206
+ return {
3207
+ id: message,
3208
+ title: message
3209
+ };
3210
+ });
3211
+ try {
3212
+ const routeSelector = parseRouteSelector(routeArgument);
3213
+ const outputDir = path.resolve(cwd, options.output);
3214
+ const workerCount = options.workers ?? 2;
3215
+ const files = [];
3216
+ let routeProgress;
3217
+ const onRoutesDiscovered = (total) => {
3218
+ progress$2.finish("Route declarations extracted");
3219
+ routeProgress = progress({
3220
+ style: "heavy",
3221
+ max: total,
3222
+ size: 40
3223
+ });
3224
+ routeProgress.start(`${workerCount} worker${workerCount > 1 ? "s" : ""} · Graph · [0/${total}] routes`);
3225
+ };
3226
+ const onArtifact = async (artifact) => {
3227
+ routeProgress?.message(`${workerCount} worker${workerCount > 1 ? "s" : ""} · Graph · Writing ${artifact.route.method} ${artifact.route.path}`);
3228
+ files.push(await writeBackendTopologyArtifact(artifact, outputDir));
3229
+ };
3230
+ const onRouteProgress = ({ current, total, route }) => {
3231
+ routeProgress?.advance(1, `${workerCount} worker${workerCount > 1 ? "s" : ""} · Graph · [${current}/${total}] routes · ${route.method} ${route.path}`);
3232
+ };
3233
+ const artifacts = await progress$2.execute(() => generateBackendTopologyArtifactsInWorkers({
3234
+ cwd,
3235
+ routeSelector,
3236
+ workers: workerCount,
3237
+ onRoutesDiscovered,
3238
+ onRouteProgress,
3239
+ onArtifact
3240
+ }));
3241
+ routeProgress?.stop("API-to-backend graph generated");
3242
+ progress$2.finish("API-to-backend graph generated");
3243
+ const backendPaths = artifacts.reduce((total, artifact) => total + artifact.backend_paths.length, 0);
3244
+ note([
3245
+ `Output directory: ${outputDir}`,
3246
+ `Route documents: ${files.length}`,
3247
+ `Backend paths: ${backendPaths}`,
3248
+ routeSelector ? `Route selector: ${routeSelector.method} ${routeSelector.path}` : null
3249
+ ].filter(Boolean).join("\n"), "Artifacts");
3250
+ outro("Code source graph generation completed");
3251
+ } catch (error) {
3252
+ progress$2.fail("Graph generation failed");
3253
+ cancel(error instanceof Error ? error.message : String(error));
3254
+ process.exitCode = 1;
3255
+ }
3256
+ }
3257
+ var generateGraph_default = (program) => void program.command("generate:graph").argument("[route]", "Optional route selector in the format \"GET /v1/products\"").option("-o, --output <directory>", "Directory where backend graph artifacts are generated", ".tmp/datasource-catalogue").option("-w, --workers <count>", "Maximum number of topology workers", parseWorkerCount$1, 2).description("Generate weighted call graphs from API routes to backend types").action((routeArgument, options) => {
3258
+ const { cwd } = getUserConfig();
3259
+ return generateGraphHandler(cwd, routeArgument, options);
3260
+ });
3261
+ //#endregion
3262
+ //#region src/services/catalogueCoverageService.ts
3263
+ /**
3264
+ * Coverage is the percentage of fields that do not need manual review. A higher
3265
+ * value is an improvement, so configured values are lower bounds.
3266
+ */
3267
+ function validateCoverage(coverage, catalogues) {
3268
+ return coverage.map(({ route, minimum_coverage }) => {
3269
+ const expected = Number(minimum_coverage);
3270
+ if (!Number.isFinite(expected) || expected < 0 || expected > 100) throw new Error(`Invalid minimum coverage for ${route}: ${minimum_coverage}`);
3271
+ const catalogue = catalogues.get(route);
3272
+ if (!catalogue) throw new Error(`No generated catalogue found for configured route: ${route}`);
3273
+ const actual = 100 - catalogue.stats.needs_review_percentage;
3274
+ return {
3275
+ route,
3276
+ expected,
3277
+ actual,
3278
+ passed: actual >= expected
3279
+ };
3280
+ });
3281
+ }
3282
+ function formatCoverageFailure(results) {
3283
+ return ["Coverage regression detected:", ...results.filter((result) => !result.passed).map((result) => `- ${result.route}: ${result.actual.toFixed(2)}% is below ${result.expected.toFixed(2)}%`)].join("\n");
3284
+ }
3285
+ //#endregion
3286
+ //#region src/commands/generateTest.ts
3287
+ async function generateTestHandler(cwd) {
3288
+ const config = getUserConfig();
3289
+ const coverage = config.test.coverage;
3290
+ if (!coverage.length) throw new Error("No route coverage is configured in atlas.config.ts");
3291
+ const progress = taskProgressService.createStepProgress();
3292
+ const openApiDocument = await progress.run({
3293
+ id: "openapi",
3294
+ title: "Loading OpenAPI contract",
3295
+ completedTitle: "OpenAPI contract loaded"
3296
+ }, () => loadOpenApiDocument(config.openapiUrl));
3297
+ const catalogues = /* @__PURE__ */ new Map();
3298
+ for (const [index, expectation] of coverage.entries()) {
3299
+ const routeSelector = parseRouteSelector(expectation.route);
3300
+ const documents = await progress.run({
3301
+ id: `analysis-${index}`,
3302
+ title: `Analyzing ${expectation.route}`,
3303
+ completedTitle: `Analyzed ${expectation.route}`
3304
+ }, () => analyzeCodebaseRouteContracts({
3305
+ cwd,
3306
+ openApiDocument,
3307
+ selectedRouteKeys: /* @__PURE__ */ new Set([buildRouteKey(routeSelector.method, routeSelector.path)])
3308
+ }));
3309
+ if (documents.length !== 1) throw new Error(`Configured route was not found: ${expectation.route}`);
3310
+ catalogues.set(expectation.route, await buildCatalogue(documents));
3311
+ }
3312
+ const results = validateCoverage(coverage, catalogues);
3313
+ log.message(results.map((result) => `${result.passed ? "✓" : "✗"} ${result.route}: ${result.actual.toFixed(2)}% (minimum ${result.expected.toFixed(2)}%)`).join("\n"));
3314
+ if (results.some((result) => !result.passed)) throw new Error(formatCoverageFailure(results));
3315
+ }
3316
+ var generateTest_default = (program) => void program.command("generate:test").description("Generate configured routes and verify their resolved-field coverage does not regress").action(async () => {
3317
+ intro("Datasource catalogue generation regression check");
3318
+ const { cwd, test } = getUserConfig();
3319
+ try {
3320
+ await generateTestHandler(cwd);
3321
+ note(`Routes checked: ${test.coverage.length}`, "Coverage");
3322
+ outro("Datasource catalogue generation regression check completed");
3323
+ } catch (error) {
3324
+ cancel(error instanceof Error ? error.message : String(error));
3325
+ process.exit(1);
3326
+ }
3327
+ });
3328
+ //#endregion
3329
+ //#region src/services/aiSdkClient.ts
3330
+ var AISdkClient = class {
3331
+ #config;
3332
+ constructor(config) {
3333
+ this.#config = config;
3334
+ }
3335
+ getModel() {
3336
+ return createOpenAICompatible({
3337
+ name: "litellm",
3338
+ baseURL: this.#config.baseUrl,
3339
+ apiKey: this.#config.apiKey ?? "",
3340
+ supportsStructuredOutputs: true
3341
+ })(this.#config.model);
3342
+ }
3343
+ async generateStructuredObject({ schema, prompt, schemaName, schemaDescription, temperature, abortSignal }) {
3344
+ const outputSpec = Output.object({
3345
+ schema,
3346
+ name: schemaName,
3347
+ description: schemaDescription
3348
+ });
3349
+ const { output } = await generateText({
3350
+ model: this.getModel(),
3351
+ prompt,
3352
+ output: outputSpec,
3353
+ temperature,
3354
+ abortSignal
3355
+ });
3356
+ return output;
3357
+ }
3358
+ };
3359
+ //#endregion
3360
+ //#region src/services/inferenceService.ts
3361
+ const MAPPING_OUTPUT_SCHEMA = z.object({ mappings: z.array(z.object({
3362
+ property_index: z.number().int(),
3363
+ candidate_index: z.number().int(),
3364
+ confidence: z.number().min(0).max(100),
3365
+ reason: z.string().min(1)
3366
+ })) });
3367
+ const MAX_SOURCE_EXCERPTS = 36;
3368
+ const MAX_EXCERPT_CHARACTERS = 16e3;
3369
+ const EXCERPT_RADIUS = 7;
3370
+ const PROPERTIES_PER_BATCH = 8;
3371
+ const MAX_CANDIDATES_PER_BATCH = 48;
3372
+ function normalizeFieldForRanking(field) {
3373
+ return (field || "").replace(/\[\]/g, "").replace(/[^a-zA-Z0-9.]/g, "").toLowerCase();
3374
+ }
3375
+ function terminalFieldForRanking(field) {
3376
+ return normalizeFieldForRanking(field).split(".").filter(Boolean).at(-1) || "";
3377
+ }
3378
+ /**
3379
+ * Guards against a model picking the right reasoning but the wrong array index: the chosen
3380
+ * backend field must share a term with the API property, either as an exact terminal match
3381
+ * or as the container of a nested object property (e.g. "address.number" <-> "address").
3382
+ */
3383
+ function candidateSharesTermWithProperty(candidate, property) {
3384
+ const propertySegments = normalizeFieldForRanking(property.field).split(".").filter(Boolean);
3385
+ const domainSegments = normalizeFieldForRanking(property.domainField).split(".").filter(Boolean);
3386
+ const propertyTerminal = propertySegments.at(-1) || "";
3387
+ const propertyRoot = propertySegments[0] || "";
3388
+ const backendTerminal = terminalFieldForRanking(candidate.backendField);
3389
+ const apiPathTerminal = terminalFieldForRanking(candidate.apiPathCandidate);
3390
+ if (backendTerminal && (backendTerminal === propertyTerminal || domainSegments.includes(backendTerminal))) return true;
3391
+ if (apiPathTerminal && apiPathTerminal === propertyTerminal) return true;
3392
+ return Boolean(propertySegments.length > 1 && backendTerminal && backendTerminal === propertyRoot);
3393
+ }
3394
+ function scopeCandidatesToProperties(document, properties) {
3395
+ const candidates = document.backendFieldCandidates.filter((candidate) => !candidate.direction || properties.some((property) => property.direction === candidate.direction)).map((candidate, index) => {
3396
+ const apiPath = normalizeFieldForRanking(candidate.apiPathCandidate);
3397
+ const backendTerminal = terminalFieldForRanking(candidate.backendField);
3398
+ return {
3399
+ candidate,
3400
+ index,
3401
+ relevance: Math.max(...properties.map((property) => {
3402
+ const field = normalizeFieldForRanking(property.field);
3403
+ const terminal = terminalFieldForRanking(property.field);
3404
+ if (apiPath && (apiPath.endsWith(field) || field.endsWith(apiPath))) return 1e3;
3405
+ if (terminal && backendTerminal === terminal) return 100;
3406
+ return 0;
3407
+ }))
3408
+ };
3409
+ }).sort((left, right) => right.relevance - left.relevance || right.candidate.confidence - left.candidate.confidence || left.index - right.index).slice(0, MAX_CANDIDATES_PER_BATCH).map(({ candidate }) => candidate);
3410
+ return {
3411
+ ...document,
3412
+ backendFieldCandidates: candidates
3413
+ };
3414
+ }
3415
+ async function readSourceExcerpts(document) {
3416
+ const analysisFiles = new Set(document.analysisFiles.map((file) => path.resolve(file)));
3417
+ const locations = [
3418
+ ...(document.mappingContext || []).map((context) => ({
3419
+ sourceFile: context.source,
3420
+ line: context.line
3421
+ })),
3422
+ ...(document.apiFieldSourceCandidates || []).map((candidate) => ({
3423
+ sourceFile: candidate.sourceFile,
3424
+ line: candidate.line
3425
+ })),
3426
+ ...document.backendFieldCandidates.filter((candidate) => Boolean(candidate.sourceLine)).map((candidate) => ({
3427
+ sourceFile: candidate.sourceFile,
3428
+ line: candidate.sourceLine
3429
+ }))
3430
+ ];
3431
+ const uniqueLocations = [...new Map(locations.filter(({ sourceFile, line }) => analysisFiles.has(path.resolve(sourceFile)) && Number.isInteger(line) && line > 0).map((location) => [`${path.resolve(location.sourceFile)}:${location.line}`, location])).values()].slice(0, MAX_SOURCE_EXCERPTS);
3432
+ const excerpts = [];
3433
+ let totalCharacters = 0;
3434
+ for (const location of uniqueLocations) try {
3435
+ const lines = (await fs.readFile(location.sourceFile, "utf8")).split("\n");
3436
+ const startLine = Math.max(1, location.line - EXCERPT_RADIUS);
3437
+ const endLine = Math.min(lines.length, location.line + EXCERPT_RADIUS);
3438
+ const code = lines.slice(startLine - 1, endLine).map((line, index) => `${String(startLine + index).padStart(5)} | ${line}`).join("\n");
3439
+ if (totalCharacters + code.length > MAX_EXCERPT_CHARACTERS) break;
3440
+ excerpts.push({
3441
+ source_file: location.sourceFile,
3442
+ start_line: startLine,
3443
+ end_line: endLine,
3444
+ code
3445
+ });
3446
+ totalCharacters += code.length;
3447
+ } catch {}
3448
+ return excerpts;
3449
+ }
3450
+ async function buildPrompt(document, properties) {
3451
+ const candidates = document.backendFieldCandidates.map((candidate, index) => ({
3452
+ index,
3453
+ backend: candidate.backend,
3454
+ api_path_candidate: candidate.apiPathCandidate || null,
3455
+ backend_field: candidate.backendField,
3456
+ direction: candidate.direction || null,
3457
+ mapper_type: candidate.mapperType || null,
3458
+ deterministic_review_reason: candidate.reviewReason || null,
3459
+ source_file: candidate.sourceLine ? `${candidate.sourceFile}:${candidate.sourceLine}` : candidate.sourceFile,
3460
+ static_confidence: candidate.confidence
3461
+ }));
3462
+ const sourceExcerpts = await readSourceExcerpts(document);
3463
+ return [
3464
+ "You review API-to-backend field mappings extracted from TypeScript code.",
3465
+ "Select mappings only when the API property is semantically produced from or sent to an existing candidate.",
3466
+ "Never create a backend, field, route, property index, or candidate index.",
3467
+ "A candidate can only be selected for the listed API property and direction. Do not infer a mapping from name similarity alone.",
3468
+ "Return every defensible mapping in the structured output. Return an empty mappings array only if no supplied candidate is defensible.",
3469
+ "Omit uncertain mappings. Multiple candidates may be returned for one API property only when the code metadata supports aggregation.",
3470
+ "Double-check candidate_index points at the candidate you actually mean to select, not merely one that supports your reasoning about the surrounding code.",
3471
+ "Confidence is an integer from 0 to 100. The reason must be concise and based only on the supplied metadata.",
3472
+ JSON.stringify({
3473
+ api_route: `${document.method} ${document.path}`,
3474
+ unresolved_properties: properties.map((property, index) => ({
3475
+ index,
3476
+ direction: property.direction,
3477
+ field: property.field,
3478
+ domain_field: property.domainField || null,
3479
+ description: property.description,
3480
+ deterministic_review_reasons: property.reviewReasons || []
3481
+ })),
3482
+ backend_routes: document.backendRouteCandidates.map((route) => ({
3483
+ backend: route.backend,
3484
+ method: route.method || null,
3485
+ route: route.route,
3486
+ source_file: route.sourceFile
3487
+ })),
3488
+ backend_field_candidates: candidates,
3489
+ graph_context: {
3490
+ analysis_files: document.analysisFiles,
3491
+ mapping_context: document.mappingContext || [],
3492
+ backend_path_sources: document.backendPathSources || []
3493
+ },
3494
+ source_excerpts: sourceExcerpts,
3495
+ rules: document.rules,
3496
+ aggregations: document.aggregations
3497
+ })
3498
+ ].join("\n\n");
3499
+ }
3500
+ function parseSuggestions(content, document, properties, minimumConfidence) {
3501
+ let raw;
3502
+ try {
3503
+ raw = JSON.parse(content);
3504
+ } catch {
3505
+ throw new Error("Inference returned invalid JSON for mapping inference");
3506
+ }
3507
+ let parsed;
3508
+ if (typeof raw.mappings === "string") try {
3509
+ parsed = {
3510
+ ...raw,
3511
+ mappings: JSON.parse(raw.mappings.replace(/<UNKNOWN>/g, "null"))
3512
+ };
3513
+ } catch {
3514
+ throw new Error("Inference returned invalid mappings JSON for mapping inference");
3515
+ }
3516
+ else parsed = raw;
3517
+ const suggestions = /* @__PURE__ */ new Map();
3518
+ const reviewSuggestions = [];
3519
+ const rejectionReasons = {
3520
+ invalid_property_index: 0,
3521
+ invalid_candidate_index: 0,
3522
+ invalid_confidence: 0,
3523
+ below_confidence: 0,
3524
+ missing_reason: 0,
3525
+ duplicate: 0,
3526
+ candidate_mismatch: 0
3527
+ };
3528
+ for (const mapping of parsed.mappings || []) {
3529
+ if (typeof mapping.property_index !== "number" || !Number.isInteger(mapping.property_index)) {
3530
+ rejectionReasons.invalid_property_index += 1;
3531
+ continue;
3532
+ }
3533
+ if (typeof mapping.candidate_index !== "number" || !Number.isInteger(mapping.candidate_index)) {
3534
+ rejectionReasons.invalid_candidate_index += 1;
3535
+ continue;
3536
+ }
3537
+ if (typeof mapping.confidence !== "number" || mapping.confidence < 0 || mapping.confidence > 100) {
3538
+ rejectionReasons.invalid_confidence += 1;
3539
+ continue;
3540
+ }
3541
+ const confidence = mapping.confidence > 0 && mapping.confidence <= 1 ? mapping.confidence * 100 : mapping.confidence;
3542
+ if (typeof mapping.reason !== "string" || !mapping.reason.trim()) {
3543
+ rejectionReasons.missing_reason += 1;
3544
+ continue;
3545
+ }
3546
+ const property = properties[mapping.property_index];
3547
+ if (!property) {
3548
+ rejectionReasons.invalid_property_index += 1;
3549
+ continue;
3550
+ }
3551
+ const candidate = document.backendFieldCandidates[mapping.candidate_index];
3552
+ if (!candidate) {
3553
+ rejectionReasons.invalid_candidate_index += 1;
3554
+ continue;
3555
+ }
3556
+ if (!candidateSharesTermWithProperty(candidate, property)) {
3557
+ rejectionReasons.candidate_mismatch += 1;
3558
+ continue;
3559
+ }
3560
+ const key = `${mapping.property_index}:${mapping.candidate_index}`;
3561
+ if (suggestions.has(key)) {
3562
+ rejectionReasons.duplicate += 1;
3563
+ continue;
3564
+ }
3565
+ const suggestion = {
3566
+ apiPropertyKey: property.key,
3567
+ candidate,
3568
+ confidence,
3569
+ reason: mapping.reason.trim()
3570
+ };
3571
+ if (confidence < minimumConfidence) {
3572
+ rejectionReasons.below_confidence += 1;
3573
+ reviewSuggestions.push({
3574
+ ...suggestion,
3575
+ accepted: false,
3576
+ rejectionReason: "below_confidence"
3577
+ });
3578
+ continue;
3579
+ }
3580
+ suggestions.set(key, suggestion);
3581
+ reviewSuggestions.push({
3582
+ ...suggestion,
3583
+ accepted: true
3584
+ });
3585
+ }
3586
+ const acceptedSuggestions = [...suggestions.values()];
3587
+ return {
3588
+ suggestions: acceptedSuggestions,
3589
+ reviewSuggestions,
3590
+ diagnostics: {
3591
+ candidates: document.backendFieldCandidates.length,
3592
+ proposals: parsed.mappings?.length || 0,
3593
+ rejectedProposals: Math.max(0, (parsed.mappings?.length || 0) - acceptedSuggestions.length),
3594
+ rejectionReasons
3595
+ }
3596
+ };
3597
+ }
3598
+ function chunkProperties(properties) {
3599
+ return Array.from({ length: Math.ceil(properties.length / PROPERTIES_PER_BATCH) }, (_, index) => properties.slice(index * PROPERTIES_PER_BATCH, (index + 1) * PROPERTIES_PER_BATCH));
3600
+ }
3601
+ async function inferMissingMappings(document, properties, config) {
3602
+ if (!config.enabled || !properties.length || !document.backendFieldCandidates.length) return {
3603
+ suggestions: [],
3604
+ reviewSuggestions: [],
3605
+ diagnostics: {
3606
+ candidates: document.backendFieldCandidates.length,
3607
+ proposals: 0,
3608
+ rejectedProposals: 0,
3609
+ rejectionReasons: {
3610
+ invalid_property_index: 0,
3611
+ invalid_candidate_index: 0,
3612
+ invalid_confidence: 0,
3613
+ below_confidence: 0,
3614
+ missing_reason: 0,
3615
+ duplicate: 0,
3616
+ candidate_mismatch: 0
3617
+ }
3618
+ }
3619
+ };
3620
+ const client = new AISdkClient({
3621
+ baseUrl: config.baseUrl,
3622
+ model: config.model,
3623
+ apiKey: process.env.LITELLM_API_KEY
3624
+ });
3625
+ const results = [];
3626
+ for (const batch of chunkProperties(properties)) {
3627
+ const scopedDocument = scopeCandidatesToProperties(document, batch);
3628
+ let content;
3629
+ try {
3630
+ const object = await client.generateStructuredObject({
3631
+ schema: MAPPING_OUTPUT_SCHEMA,
3632
+ prompt: await buildPrompt(scopedDocument, batch),
3633
+ temperature: 0,
3634
+ abortSignal: AbortSignal.timeout(config.timeoutMs)
3635
+ });
3636
+ content = JSON.stringify(object);
3637
+ } catch (error) {
3638
+ if (error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError")) throw new Error(`Inference timed out after ${config.timeoutMs}ms for ${document.method} ${document.path} with model ${config.model}. Increase the infer --timeout option.`);
3639
+ if (NoObjectGeneratedError.isInstance(error)) {
3640
+ const rawText = (error.text || "").slice(0, 2e3);
3641
+ try {
3642
+ results.push(parseSuggestions(rawText, scopedDocument, batch, config.minimumConfidence));
3643
+ continue;
3644
+ } catch {}
3645
+ throw new Error(`Inference for ${document.method} ${document.path} with model ${config.model} did not return parseable structured output (finish reason: ${error.finishReason ?? "unknown"}). Raw response: ${rawText || "<empty>"}`);
3646
+ }
3647
+ throw error;
3648
+ }
3649
+ if (!content) throw new Error("Mapping inference returned no response content");
3650
+ results.push(parseSuggestions(content, scopedDocument, batch, config.minimumConfidence));
3651
+ }
3652
+ const rejectionReasons = {
3653
+ invalid_property_index: 0,
3654
+ invalid_candidate_index: 0,
3655
+ invalid_confidence: 0,
3656
+ below_confidence: 0,
3657
+ missing_reason: 0,
3658
+ duplicate: 0,
3659
+ candidate_mismatch: 0
3660
+ };
3661
+ for (const result of results) for (const [reason, count] of Object.entries(result.diagnostics.rejectionReasons)) rejectionReasons[reason] += count;
3662
+ return {
3663
+ suggestions: results.flatMap((result) => result.suggestions),
3664
+ reviewSuggestions: results.flatMap((result) => result.reviewSuggestions),
3665
+ diagnostics: {
3666
+ candidates: document.backendFieldCandidates.length,
3667
+ proposals: results.reduce((total, result) => total + result.diagnostics.proposals, 0),
3668
+ rejectedProposals: results.reduce((total, result) => total + result.diagnostics.rejectedProposals, 0),
3669
+ rejectionReasons
3670
+ }
3671
+ };
3672
+ }
3673
+ //#endregion
3674
+ //#region src/services/catalogueInferenceService.ts
3675
+ function stableKey(...parts) {
3676
+ return createHash("sha1").update(parts.join("::")).digest("hex");
3677
+ }
3678
+ function candidateSource(candidate) {
3679
+ return candidate.sourceLine ? `${candidate.sourceFile}:${candidate.sourceLine}` : candidate.sourceFile;
3680
+ }
3681
+ function findBackendRoute(document, suggestion) {
3682
+ const candidates = document.backendRouteCandidates.filter((candidate) => candidate.backend === suggestion.candidate.backend);
3683
+ return candidates.find((candidate) => candidate.sourceFile === suggestion.candidate.sourceFile) || candidates[0];
3684
+ }
3685
+ function ensureBackendRoute(catalogue, document, suggestion) {
3686
+ const candidate = findBackendRoute(document, suggestion);
3687
+ const backendRoutePath = candidate?.route || "unknown";
3688
+ const key = stableKey(document.key, suggestion.candidate.backend, backendRoutePath);
3689
+ const existing = catalogue.backend_routes.find((route) => route.key === key);
3690
+ if (existing) return existing;
3691
+ const created = {
3692
+ key,
3693
+ backend: suggestion.candidate.backend,
3694
+ route_key: document.key,
3695
+ route: `${candidate?.method || "CALL"} ${backendRoutePath}`,
3696
+ source_file: candidate?.sourceFile || suggestion.candidate.sourceFile,
3697
+ provenance: "ai"
3698
+ };
3699
+ catalogue.backend_routes.push(created);
3700
+ return created;
3701
+ }
3702
+ function removeNeedsReviewEvidence(catalogue, property) {
3703
+ catalogue.mapping_evidence = catalogue.mapping_evidence.filter((evidence) => !(evidence.status === "needs_review" && (evidence.input_property_key === property.key || evidence.output_property_key === property.key)));
3704
+ }
3705
+ function applyRefreshedStaticMappings(catalogue, staticCatalogue, unresolvedProperties) {
3706
+ let resolved = 0;
3707
+ const staticProperties = [...staticCatalogue.route_input_properties, ...staticCatalogue.route_output_properties];
3708
+ for (const property of unresolvedProperties) {
3709
+ const staticProperty = staticProperties.find((item) => item.key === property.key);
3710
+ if (!staticProperty?.backend_mappings.length || staticProperty.evidence_status === "needs_review") continue;
3711
+ property.backend_names = staticProperty.backend_names;
3712
+ property.backend_mappings = staticProperty.backend_mappings;
3713
+ property.evidence_status = staticProperty.evidence_status;
3714
+ property.inference_suggestions = [];
3715
+ removeNeedsReviewEvidence(catalogue, property);
3716
+ for (const evidence of staticCatalogue.mapping_evidence.filter((item) => item.input_property_key === property.key || item.output_property_key === property.key)) if (!catalogue.mapping_evidence.some((item) => item.key === evidence.key)) catalogue.mapping_evidence.push(evidence);
3717
+ resolved += 1;
3718
+ }
3719
+ for (const backendRoute of staticCatalogue.backend_routes) if (!catalogue.backend_routes.some((item) => item.key === backendRoute.key)) catalogue.backend_routes.push(backendRoute);
3720
+ for (const backendProperty of staticCatalogue.backend_properties) if (!catalogue.backend_properties.some((item) => item.key === backendProperty.key)) catalogue.backend_properties.push(backendProperty);
3721
+ return resolved;
3722
+ }
3723
+ function applySuggestion(catalogue, document, property, suggestion) {
3724
+ const backendRouteCandidate = findBackendRoute(document, suggestion);
3725
+ const backendRoute = ensureBackendRoute(catalogue, document, suggestion);
3726
+ const backendPropertyKey = stableKey(backendRoute.key, property.direction, suggestion.candidate.backendField);
3727
+ if (!catalogue.backend_properties.some((item) => item.key === backendPropertyKey)) catalogue.backend_properties.push({
3728
+ key: backendPropertyKey,
3729
+ backend_route_key: backendRoute.key,
3730
+ backend: suggestion.candidate.backend,
3731
+ field: suggestion.candidate.backendField,
3732
+ direction: property.direction,
3733
+ source_file: candidateSource(suggestion.candidate),
3734
+ provenance: "ai"
3735
+ });
3736
+ if (!property.backend_mappings.some((mapping) => mapping.backend === suggestion.candidate.backend && mapping.field === suggestion.candidate.backendField)) property.backend_mappings.push({
3737
+ backend: suggestion.candidate.backend,
3738
+ method: backendRouteCandidate?.method || null,
3739
+ route: backendRouteCandidate?.route || "unknown",
3740
+ field: suggestion.candidate.backendField,
3741
+ source_file: candidateSource(suggestion.candidate),
3742
+ confidence: suggestion.confidence,
3743
+ mapper_type: suggestion.candidate.mapperType,
3744
+ provenance: "ai",
3745
+ reason: suggestion.reason
3746
+ });
3747
+ if (!property.backend_names.includes(suggestion.candidate.backend)) property.backend_names.push(suggestion.candidate.backend);
3748
+ property.evidence_status = "inferred";
3749
+ removeNeedsReviewEvidence(catalogue, property);
3750
+ catalogue.mapping_evidence.push({
3751
+ key: stableKey(property.key, backendPropertyKey),
3752
+ evidence_type: "inference",
3753
+ status: "inferred",
3754
+ confidence_score: suggestion.confidence,
3755
+ comment: suggestion.reason,
3756
+ ...property.direction === "input" ? { input_property_key: property.key } : { output_property_key: property.key },
3757
+ backend_property_key: backendPropertyKey,
3758
+ source_file: candidateSource(suggestion.candidate)
3759
+ });
3760
+ }
3761
+ function recordReviewSuggestion(document, property, suggestion) {
3762
+ const backendRouteCandidate = findBackendRoute(document, suggestion);
3763
+ property.inference_suggestions ||= [];
3764
+ property.inference_suggestions.push({
3765
+ backend: suggestion.candidate.backend,
3766
+ method: backendRouteCandidate?.method || null,
3767
+ route: backendRouteCandidate?.route || "unknown",
3768
+ field: suggestion.candidate.backendField,
3769
+ source_file: candidateSource(suggestion.candidate),
3770
+ confidence: suggestion.confidence,
3771
+ reason: suggestion.reason,
3772
+ status: suggestion.accepted ? "accepted" : "needs_review",
3773
+ rejection_reason: suggestion.rejectionReason
3774
+ });
3775
+ }
3776
+ function ensureNeedsReviewEvidence(catalogue) {
3777
+ const properties = [...catalogue.route_input_properties, ...catalogue.route_output_properties];
3778
+ for (const property of properties.filter((item) => item.evidence_status === "needs_review")) {
3779
+ if (catalogue.mapping_evidence.some((evidence) => evidence.input_property_key === property.key || evidence.output_property_key === property.key)) continue;
3780
+ catalogue.mapping_evidence.push({
3781
+ key: stableKey(property.key, "needs_review"),
3782
+ evidence_type: "static_analysis",
3783
+ status: "needs_review",
3784
+ confidence_score: 0,
3785
+ comment: `No backend property candidate matched ${property.field}`,
3786
+ ...property.direction === "input" ? { input_property_key: property.key } : { output_property_key: property.key },
3787
+ backend_property_key: stableKey(property.route_key, "unmatched", property.direction, property.field),
3788
+ source_file: property.source_file
3789
+ });
3790
+ }
3791
+ }
3792
+ function refreshStats(catalogue) {
3793
+ catalogue.stats.backend_routes = catalogue.backend_routes.length;
3794
+ catalogue.stats.backend_properties = catalogue.backend_properties.length;
3795
+ catalogue.stats.mapping_evidence = catalogue.mapping_evidence.length;
3796
+ catalogue.stats.needs_review = [...catalogue.route_input_properties, ...catalogue.route_output_properties].filter((property) => property.evidence_status === "needs_review").length;
3797
+ catalogue.stats.needs_review_percentage = calculateNeedsReviewPercentage(catalogue.stats.input_properties, catalogue.stats.output_properties, catalogue.stats.needs_review);
3798
+ catalogue.generated_at = (/* @__PURE__ */ new Date()).toISOString();
3799
+ }
3800
+ function formatInferenceFailure(error) {
3801
+ return (error instanceof Error ? error.message : String(error)).split(" Raw response:")[0];
3802
+ }
3803
+ async function runWithConcurrency(items, workers, task) {
3804
+ let nextIndex = 0;
3805
+ await Promise.all(Array.from({ length: Math.min(Math.max(1, workers), items.length) }, async (_, workerId) => {
3806
+ while (nextIndex < items.length) {
3807
+ const item = items[nextIndex++];
3808
+ await task(item, workerId);
3809
+ }
3810
+ }));
3811
+ }
3812
+ async function inferCatalogueDirectory(params) {
3813
+ const inputPath = path.resolve(params.cwd, params.catalogueDirectory);
3814
+ const outputPath = path.resolve(params.cwd, params.outputDirectory || params.catalogueDirectory);
3815
+ taskProgressService.report("Loading route YAML documents");
3816
+ const { catalogue, files } = await readCatalogueFromDirectory(inputPath);
3817
+ ensureNeedsReviewEvidence(catalogue);
3818
+ const needsReviewBefore = [...catalogue.route_input_properties, ...catalogue.route_output_properties].filter((property) => property.evidence_status === "needs_review").length;
3819
+ const needsReviewPercentageBefore = calculateNeedsReviewPercentage(catalogue.stats.input_properties, catalogue.stats.output_properties, needsReviewBefore);
3820
+ const selectedRoute = params.routeSelector ? catalogue.routes.find((route) => route.method.toUpperCase() === params.routeSelector?.method.toUpperCase() && route.path === params.routeSelector?.path) : void 0;
3821
+ if (params.routeSelector && !selectedRoute) throw new Error(`Route not found in catalogue artifacts: ${params.routeSelector.method} ${params.routeSelector.path}`);
3822
+ const scopeProperties = [...catalogue.route_input_properties, ...catalogue.route_output_properties].filter((property) => !selectedRoute || property.route_key === selectedRoute.key);
3823
+ const needsReviewInScopeBefore = scopeProperties.filter((property) => property.evidence_status === "needs_review").length;
3824
+ const needsReviewPercentageInScopeBefore = calculateNeedsReviewPercentage(scopeProperties.filter((property) => property.direction === "input").length, scopeProperties.filter((property) => property.direction === "output").length, needsReviewInScopeBefore);
3825
+ const scopeStats = (needsReview, needsReviewPercentage) => ({
3826
+ routes: selectedRoute ? 1 : catalogue.routes.length,
3827
+ input_properties: scopeProperties.filter((property) => property.direction === "input").length,
3828
+ output_properties: scopeProperties.filter((property) => property.direction === "output").length,
3829
+ needs_review: needsReview,
3830
+ needs_review_percentage: needsReviewPercentage
3831
+ });
3832
+ const unresolvedProperties = [...catalogue.route_input_properties, ...catalogue.route_output_properties].filter((property) => property.evidence_status === "needs_review").filter((property) => !selectedRoute || property.route_key === selectedRoute.key);
3833
+ if (outputPath !== inputPath) {
3834
+ taskProgressService.report("Copying source YAML documents to the output directory");
3835
+ await writeCatalogueArtifacts(catalogue, outputPath, params.cwd);
3836
+ }
3837
+ if (!unresolvedProperties.length) return {
3838
+ inputPath,
3839
+ outputPath,
3840
+ files,
3841
+ catalogue,
3842
+ reviewed: 0,
3843
+ staticallyResolved: 0,
3844
+ inferred: 0,
3845
+ candidates: 0,
3846
+ proposals: 0,
3847
+ rejectedProposals: 0,
3848
+ reviewSuggestions: 0,
3849
+ rejectionReasons: {
3850
+ invalid_property_index: 0,
3851
+ invalid_candidate_index: 0,
3852
+ invalid_confidence: 0,
3853
+ below_confidence: 0,
3854
+ missing_reason: 0,
3855
+ duplicate: 0,
3856
+ candidate_mismatch: 0
3857
+ },
3858
+ remainingInScope: 0,
3859
+ graphScopedRoutes: 0,
3860
+ skippedWithoutGraph: 0,
3861
+ needsReviewBefore,
3862
+ needsReviewPercentageBefore,
3863
+ needsReviewGain: 0,
3864
+ needsReviewPercentageGain: 0,
3865
+ needsReviewInScopeBefore,
3866
+ needsReviewPercentageInScopeBefore,
3867
+ needsReviewPercentageInScopeAfter: needsReviewPercentageInScopeBefore,
3868
+ scopeStats: scopeStats(needsReviewInScopeBefore, needsReviewPercentageInScopeBefore)
3869
+ };
3870
+ const routeKeys = [...new Set(unresolvedProperties.map((property) => property.route_key))];
3871
+ const graphErrors = /* @__PURE__ */ new Map();
3872
+ const analysisDocuments = [];
3873
+ let staticallyResolved = 0;
3874
+ let inferred = 0;
3875
+ let candidates = 0;
3876
+ let proposals = 0;
3877
+ let rejectedProposals = 0;
3878
+ let reviewSuggestions = 0;
3879
+ const rejectionReasons = {
3880
+ invalid_property_index: 0,
3881
+ invalid_candidate_index: 0,
3882
+ invalid_confidence: 0,
3883
+ below_confidence: 0,
3884
+ missing_reason: 0,
3885
+ duplicate: 0,
3886
+ candidate_mismatch: 0
3887
+ };
3888
+ await runWithConcurrency(routeKeys, Math.min(params.inference.workers, 10), async (routeKey, workerId) => {
3889
+ const route = catalogue.routes.find((item) => item.key === routeKey);
3890
+ const reportRoute = (phase) => {
3891
+ params.onWorkerProgress?.({
3892
+ workerId,
3893
+ total: routeKeys.length,
3894
+ route,
3895
+ phase
3896
+ });
3897
+ if (!params.onWorkerProgress) taskProgressService.report(`Infer: ${route.method} ${route.path} [${phase}]`);
3898
+ };
3899
+ const routeProperties = unresolvedProperties.filter((property) => property.route_key === routeKey);
3900
+ reportRoute("Load artifacts");
3901
+ const routeContracts = /* @__PURE__ */ new Map([[routeKey, {
3902
+ version: route.version,
3903
+ input: catalogue.route_input_properties.filter((property) => property.route_key === routeKey).map((property) => ({
3904
+ path: property.field,
3905
+ description: property.description,
3906
+ type: "BODY"
3907
+ })),
3908
+ output: catalogue.route_output_properties.filter((property) => property.route_key === routeKey).map((property) => ({
3909
+ path: property.field,
3910
+ description: property.description,
3911
+ type: "RESPONSE_BODY"
3912
+ }))
3913
+ }]]);
3914
+ reportRoute("Static analysis refresh");
3915
+ const [document] = await analyzeCodebaseRouteContracts({
3916
+ cwd: params.cwd,
3917
+ selectedRouteKeys: /* @__PURE__ */ new Set([routeKey]),
3918
+ routeContracts,
3919
+ routeAnalysisScope: async ({ method, path: routePath }) => {
3920
+ try {
3921
+ return await loadBackendTopologyAnalysisScope(inputPath, params.cwd, method, routePath);
3922
+ } catch (error) {
3923
+ const message = error instanceof Error ? error.message : String(error);
3924
+ graphErrors.set(`${method} ${routePath}`, message);
3925
+ taskProgressService.log(`Warning: ${message} Route skipped.`);
3926
+ return;
3927
+ }
3928
+ }
3929
+ });
3930
+ if (!document) {
3931
+ params.onWorkerProgress?.({
3932
+ workerId,
3933
+ total: routeKeys.length,
3934
+ route,
3935
+ phase: "Skipped",
3936
+ completed: true
3937
+ });
3938
+ return;
3939
+ }
3940
+ analysisDocuments.push(document);
3941
+ reportRoute("Deterministic mapping refresh");
3942
+ staticallyResolved += applyRefreshedStaticMappings(catalogue, await buildCatalogue([document]), routeProperties);
3943
+ const remainingRouteProperties = routeProperties.filter((property) => property.evidence_status === "needs_review");
3944
+ for (const property of routeProperties) property.inference_suggestions = [];
3945
+ if (!remainingRouteProperties.length) {
3946
+ refreshStats(catalogue);
3947
+ reportRoute("Update artifacts");
3948
+ await writeRouteReviewDocument(catalogue, outputPath, params.cwd, routeKey);
3949
+ params.onWorkerProgress?.({
3950
+ workerId,
3951
+ total: routeKeys.length,
3952
+ route,
3953
+ phase: "Completed",
3954
+ completed: true
3955
+ });
3956
+ return;
3957
+ }
3958
+ reportRoute(`AI inference: ${remainingRouteProperties.length} fields`);
3959
+ let inferenceResult;
3960
+ try {
3961
+ inferenceResult = await inferMissingMappings(document, remainingRouteProperties.map((property) => ({
3962
+ key: property.key,
3963
+ direction: property.direction,
3964
+ field: property.field,
3965
+ domainField: property.domain_field,
3966
+ description: property.description,
3967
+ reviewReasons: property.unresolved_backend_candidates?.map((candidate) => candidate.reason)
3968
+ })), params.inference);
3969
+ } catch (error) {
3970
+ taskProgressService.log(`Warning: AI inference failed for ${route.method} ${route.path}: ${formatInferenceFailure(error)}. Route kept for review.`);
3971
+ refreshStats(catalogue);
3972
+ reportRoute("Update artifacts");
3973
+ await writeRouteReviewDocument(catalogue, outputPath, params.cwd, routeKey);
3974
+ params.onWorkerProgress?.({
3975
+ workerId,
3976
+ total: routeKeys.length,
3977
+ route,
3978
+ phase: "Completed with AI error",
3979
+ completed: true
3980
+ });
3981
+ return;
3982
+ }
3983
+ candidates += inferenceResult.diagnostics.candidates;
3984
+ proposals += inferenceResult.diagnostics.proposals;
3985
+ rejectedProposals += inferenceResult.diagnostics.rejectedProposals;
3986
+ for (const [reason, count] of Object.entries(inferenceResult.diagnostics.rejectionReasons)) rejectionReasons[reason] += count;
3987
+ for (const suggestion of inferenceResult.reviewSuggestions) {
3988
+ const property = remainingRouteProperties.find((item) => item.key === suggestion.apiPropertyKey);
3989
+ if (!property) continue;
3990
+ recordReviewSuggestion(document, property, suggestion);
3991
+ reviewSuggestions += 1;
3992
+ }
3993
+ for (const suggestion of inferenceResult.suggestions) {
3994
+ const property = remainingRouteProperties.find((item) => item.key === suggestion.apiPropertyKey);
3995
+ if (!property) continue;
3996
+ applySuggestion(catalogue, document, property, suggestion);
3997
+ inferred += 1;
3998
+ }
3999
+ refreshStats(catalogue);
4000
+ reportRoute("Update artifacts");
4001
+ await writeRouteReviewDocument(catalogue, outputPath, params.cwd, routeKey);
4002
+ params.onWorkerProgress?.({
4003
+ workerId,
4004
+ total: routeKeys.length,
4005
+ route,
4006
+ phase: "Completed",
4007
+ completed: true
4008
+ });
4009
+ });
4010
+ refreshStats(catalogue);
4011
+ const remainingInScope = [...catalogue.route_input_properties, ...catalogue.route_output_properties].filter((property) => routeKeys.includes(property.route_key) && property.evidence_status === "needs_review").length;
4012
+ const needsReviewPercentageInScopeAfter = calculateNeedsReviewPercentage(scopeProperties.filter((property) => property.direction === "input").length, scopeProperties.filter((property) => property.direction === "output").length, remainingInScope);
4013
+ taskProgressService.report(`Inference completed with ${remainingInScope} reviews remaining in selected scope`);
4014
+ return {
4015
+ inputPath,
4016
+ outputPath,
4017
+ files,
4018
+ catalogue,
4019
+ reviewed: unresolvedProperties.length,
4020
+ staticallyResolved,
4021
+ inferred,
4022
+ candidates,
4023
+ proposals,
4024
+ rejectedProposals,
4025
+ reviewSuggestions,
4026
+ rejectionReasons,
4027
+ remainingInScope,
4028
+ graphScopedRoutes: analysisDocuments.length,
4029
+ skippedWithoutGraph: graphErrors.size,
4030
+ needsReviewBefore,
4031
+ needsReviewPercentageBefore,
4032
+ needsReviewGain: needsReviewBefore - catalogue.stats.needs_review,
4033
+ needsReviewPercentageGain: Number((needsReviewPercentageBefore - catalogue.stats.needs_review_percentage).toFixed(2)),
4034
+ needsReviewInScopeBefore,
4035
+ needsReviewPercentageInScopeBefore,
4036
+ needsReviewPercentageInScopeAfter,
4037
+ scopeStats: scopeStats(remainingInScope, needsReviewPercentageInScopeAfter)
4038
+ };
4039
+ }
4040
+ //#endregion
4041
+ //#region src/commands/infer.ts
4042
+ function createInferenceStepClassifier() {
4043
+ return (message) => {
4044
+ const routePhase = message.match(/^Infer: ([A-Z]+) (.+) \[(.+)]$/);
4045
+ if (routePhase) {
4046
+ const [, method, routePath, phase] = routePhase;
4047
+ return {
4048
+ id: `infer-${method}-${routePath}`,
4049
+ title: `Infer: ${method} ${routePath} [${phase}]`,
4050
+ completedTitle: `Inference completed: ${method} ${routePath}`
4051
+ };
4052
+ }
4053
+ if (/Loading route YAML|Copying source YAML/.test(message)) return {
4054
+ id: "artifacts",
4055
+ title: "Loading route artifacts",
4056
+ completedTitle: "Route artifacts loaded",
4057
+ detail: message
4058
+ };
4059
+ return {
4060
+ id: "inference",
4061
+ title: "Processing inference",
4062
+ completedTitle: "Inference processed",
4063
+ detail: message
4064
+ };
4065
+ };
4066
+ }
4067
+ function parsePositiveNumber(value) {
4068
+ const parsed = Number(value);
4069
+ if (!Number.isFinite(parsed) || parsed <= 0) throw new InvalidArgumentError("Expected a positive number");
4070
+ return parsed;
4071
+ }
4072
+ function parseConfidence(value) {
4073
+ const parsed = parsePositiveNumber(value);
4074
+ if (parsed > 100) throw new InvalidArgumentError("Confidence must be between 1 and 100");
4075
+ return parsed;
4076
+ }
4077
+ function parseWorkerCount(value) {
4078
+ const workers = parsePositiveNumber(value);
4079
+ if (workers > 10) throw new InvalidArgumentError("A maximum of 10 inference workers is supported");
4080
+ return workers;
4081
+ }
4082
+ var infer_default = (program) => void program.command("infer").argument("[route-or-directory]", "Route selector (GET /v1/offers) or catalogue directory").option("-i, --input <directory>", "Directory containing generated route YAML documents", ".tmp/datasource-catalogue").option("-o, --output <directory>", "Output directory; defaults to updating the input directory").option("--model <name>", "AI model name").option("--ai-url <url>", "AI base URL").option("--min-confidence <number>", "Minimum confidence required to accept an ai/sdk mapping", parseConfidence).option("--timeout <milliseconds>", "Maximum duration of one route-level ai/sdk request", parsePositiveNumber).option("-w, --workers <count>", "Maximum number of concurrent route inference jobs (max 10)", parseWorkerCount).description("Use AI to review unresolved mappings in generated route YAML documents").action(async (routeOrDirectory, options) => {
4083
+ intro("Datasource catalogue inference");
4084
+ const config = getUserConfig();
4085
+ const inference = config.inference;
4086
+ const resolvedOptions = {
4087
+ model: options.model ?? process.env.AI_MODEL ?? inference.model,
4088
+ aiUrl: options.aiUrl ?? process.env.AI_URL ?? inference.baseUrl,
4089
+ minConfidence: options.minConfidence ?? inference.minimumConfidence,
4090
+ timeout: options.timeout ?? inference.timeoutMs,
4091
+ workers: options.workers ?? inference.workers
4092
+ };
4093
+ const progress$1 = taskProgressService.createStepProgress(createInferenceStepClassifier());
4094
+ try {
4095
+ const routeSelector = routeOrDirectory && /^(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\s+/i.test(routeOrDirectory) ? parseRouteSelector(routeOrDirectory) : void 0;
4096
+ const catalogueDirectory = routeSelector ? options.input : routeOrDirectory || options.input;
4097
+ let inferenceProgress;
4098
+ let completedRoutes = 0;
4099
+ const result = await progress$1.execute(() => inferCatalogueDirectory({
4100
+ cwd: config.cwd,
4101
+ catalogueDirectory,
4102
+ routeSelector,
4103
+ outputDirectory: options.output,
4104
+ inference: {
4105
+ enabled: true,
4106
+ model: resolvedOptions.model,
4107
+ baseUrl: resolvedOptions.aiUrl,
4108
+ minimumConfidence: resolvedOptions.minConfidence,
4109
+ timeoutMs: resolvedOptions.timeout,
4110
+ workers: resolvedOptions.workers
4111
+ },
4112
+ onWorkerProgress: ({ total, route, phase, completed }) => {
4113
+ const w = resolvedOptions.workers;
4114
+ if (!inferenceProgress) {
4115
+ progress$1.finish("Route artifacts loaded");
4116
+ inferenceProgress = progress({
4117
+ style: "heavy",
4118
+ max: total,
4119
+ size: 40
4120
+ });
4121
+ inferenceProgress.start(`${w} worker${w > 1 ? "s" : ""} · Infer · [0/${total}] routes`);
4122
+ }
4123
+ const label = `${w} worker${w > 1 ? "s" : ""} · Infer · [${completedRoutes}/${total}] routes · ${route.method} ${route.path} [${phase}]`;
4124
+ if (completed) {
4125
+ completedRoutes += 1;
4126
+ inferenceProgress.advance(1, label.replace(`[${completedRoutes - 1}/${total}]`, `[${completedRoutes}/${total}]`));
4127
+ return;
4128
+ }
4129
+ inferenceProgress.message(label);
4130
+ }
4131
+ }));
4132
+ inferenceProgress?.stop(`Inference completed: ${completedRoutes} route${completedRoutes > 1 ? "s" : ""} · ${resolvedOptions.workers} worker${resolvedOptions.workers > 1 ? "s" : ""}`);
4133
+ progress$1.finish("Inferrence mapping review completed");
4134
+ const rejectionSummary = Object.entries(result.rejectionReasons).filter(([, count]) => count > 0).map(([reason, count]) => `${reason}=${count}`).join(", ");
4135
+ note([
4136
+ `Input directory: ${result.inputPath}`,
4137
+ `Output directory: ${result.outputPath}`,
4138
+ routeSelector ? `Route selector: ${routeSelector.method} ${routeSelector.path}` : null,
4139
+ `Properties reviewed: ${result.reviewed}`,
4140
+ `AI workers: ${resolvedOptions.workers}`,
4141
+ `Mappings resolved by static refresh: ${result.staticallyResolved}`,
4142
+ `AI proposals received: ${result.proposals}`,
4143
+ `AI proposals rejected: ${result.rejectedProposals}`,
4144
+ rejectionSummary ? `Rejection reasons: ${rejectionSummary}` : null,
4145
+ `Mappings inferred: ${result.inferred}`,
4146
+ `Needs review remaining in scope: ${result.remainingInScope}`
4147
+ ].filter(Boolean).join("\n"), "Inference");
4148
+ outro("Datasource catalogue inference completed");
4149
+ } catch (error) {
4150
+ progress$1.fail("AI mapping review failed");
4151
+ cancel(error instanceof Error ? error.message : String(error));
4152
+ process.exit(1);
4153
+ }
4154
+ });
4155
+ //#endregion
4156
+ //#region src/commands/init.ts
4157
+ const CONFIG_FILE_NAME = "atlas.config.ts";
4158
+ const configTemplate = `import { defineConfig } from "@cmflow/atlas";
4159
+
4160
+ export default defineConfig({
4161
+ repoRoot: process.cwd(),
4162
+ backendTypesFile: "app/_infra/back/BackendTypes.ts",
4163
+ openapiUrl: "https://api.example.com/openapi.json",
4164
+ openapiTimeoutMs: 60_000,
4165
+ directusUrl: "https://cms.api.clubmed",
4166
+ resolver: {
4167
+ alias: {}
4168
+ },
4169
+ inference: {
4170
+ model: process.env.AI_MODEL ?? "gpt-4o-mini",
4171
+ baseUrl: process.env.AI_BASE_URL ?? "https://api.openai.com/v1",
4172
+ minimumConfidence: 75,
4173
+ timeoutMs: 120_000,
4174
+ workers: 2
4175
+ },
4176
+ analysis: {
4177
+ excluded: [],
4178
+ transversalInputs: [],
4179
+ ignoredOutputs: [],
4180
+ backendFieldAccess: {
4181
+ excludedPrefixes: []
4182
+ },
4183
+ mapperNaming: {
4184
+ inputPatterns: [/(?:FromApiToDomain|ApiToDomain|ToDomain)/i],
4185
+ outputPatterns: [/(?:FromDomainToApi|DomainToApi|ToApi)/i],
4186
+ domainToBackendPattern: /(?:DomainTo|To)(?:Back|Backend)$/i,
4187
+ domainNameFromToDomainPattern: /^map(.+)ToDomain$/i,
4188
+ constructedTypeToDomainPattern: (constructorName) =>
4189
+ new RegExp(\`^map\${constructorName}(?:From(?:Back|Backend))?ToDomain$|^map\${constructorName}$\`, "i")
4190
+ },
4191
+ neutralExpressionMatchers: [],
4192
+ rules: []
4193
+ },
4194
+ test: {
4195
+ coverage: []
4196
+ }
4197
+ });
4198
+ `;
4199
+ var init_default = (program, datasourceCommand) => void program.command("init").option("--force", "Overwrite an existing atlas.config.ts", false).description("Create an atlas.config.ts in the target API project").action(async (options) => {
4200
+ const configPath = path.resolve(datasourceCommand.opts().config ?? CONFIG_FILE_NAME);
4201
+ const configDirectory = path.dirname(configPath);
4202
+ intro("Initialize datasource configuration");
4203
+ try {
4204
+ await fs.mkdir(configDirectory, { recursive: true });
4205
+ if (!options.force) {
4206
+ let exists = false;
4207
+ try {
4208
+ await fs.access(configPath);
4209
+ exists = true;
4210
+ } catch (error) {
4211
+ if (error.code !== "ENOENT") throw error;
4212
+ }
4213
+ if (exists) throw new Error(`${configPath} already exists. Use --force to overwrite it.`);
4214
+ }
4215
+ await fs.writeFile(configPath, configTemplate, "utf8");
4216
+ outro(`Created ${configPath}`);
4217
+ } catch (error) {
4218
+ cancel(error instanceof Error ? error.message : String(error));
4219
+ process.exitCode = 1;
4220
+ }
4221
+ });
4222
+ //#endregion
4223
+ //#region src/services/catalogueReviewReportService.ts
4224
+ async function buildNeedsReviewRouteReport(params) {
4225
+ const inputPath = path.resolve(params.cwd, params.catalogueDirectory);
4226
+ const { catalogue, files } = await readCatalogueFromDirectory(inputPath);
4227
+ const rows = catalogue.routes.map((route) => {
4228
+ const inputs = catalogue.route_input_properties.filter((property) => property.route_key === route.key);
4229
+ const outputs = catalogue.route_output_properties.filter((property) => property.route_key === route.key);
4230
+ const inputNeedsReview = inputs.filter((property) => property.evidence_status === "needs_review").length;
4231
+ const outputNeedsReview = outputs.filter((property) => property.evidence_status === "needs_review").length;
4232
+ const needsReview = inputNeedsReview + outputNeedsReview;
4233
+ return {
4234
+ method: route.method,
4235
+ path: route.path,
4236
+ inputProperties: inputs.length,
4237
+ outputProperties: outputs.length,
4238
+ inputNeedsReview,
4239
+ outputNeedsReview,
4240
+ needsReview,
4241
+ needsReviewPercentage: calculateNeedsReviewPercentage(inputs.length, outputs.length, needsReview)
4242
+ };
4243
+ }).filter((row) => row.needsReview > 0);
4244
+ rows.sort((left, right) => {
4245
+ return (params.sort === "percentage" ? right.needsReviewPercentage - left.needsReviewPercentage : right.needsReview - left.needsReview) || right.needsReview - left.needsReview || left.path.localeCompare(right.path);
4246
+ });
4247
+ return {
4248
+ inputPath,
4249
+ files,
4250
+ totalRoutes: catalogue.routes.length,
4251
+ candidateRoutes: rows.length,
4252
+ totalProperties: catalogue.stats.input_properties + catalogue.stats.output_properties,
4253
+ needsReview: catalogue.stats.needs_review,
4254
+ needsReviewPercentage: catalogue.stats.needs_review_percentage,
4255
+ rows: params.limit ? rows.slice(0, params.limit) : rows
4256
+ };
4257
+ }
4258
+ //#endregion
4259
+ //#region src/commands/needsReview.ts
4260
+ function parsePositiveInteger(value) {
4261
+ const parsed = Number(value);
4262
+ if (!Number.isInteger(parsed) || parsed <= 0) throw new InvalidArgumentError("Expected a positive integer");
4263
+ return parsed;
4264
+ }
4265
+ function formatNeedsReviewTable(rows) {
4266
+ const headers = [
4267
+ "#",
4268
+ "Route",
4269
+ "In review",
4270
+ "Out review",
4271
+ "Properties",
4272
+ "Need review",
4273
+ "Need %"
4274
+ ];
4275
+ const values = rows.map((row, index) => [
4276
+ String(index + 1),
4277
+ `${row.method} ${row.path}`,
4278
+ String(row.inputNeedsReview),
4279
+ String(row.outputNeedsReview),
4280
+ String(row.inputProperties + row.outputProperties),
4281
+ String(row.needsReview),
4282
+ `${row.needsReviewPercentage.toFixed(2)}%`
4283
+ ]);
4284
+ const widths = headers.map((header, column) => Math.max(header.length, ...values.map((row) => row[column].length)));
4285
+ const formatRow = (row) => row.map((cell, column) => column < 2 ? cell.padEnd(widths[column]) : cell.padStart(widths[column])).join(" | ");
4286
+ return [
4287
+ formatRow(headers),
4288
+ widths.map((width) => "-".repeat(width)).join("-+-"),
4289
+ ...values.map(formatRow)
4290
+ ].join("\n");
4291
+ }
4292
+ var needsReview_default = (program) => void program.command("needs-review").argument("[catalogue-directory]", "Directory containing generated route YAML documents", ".tmp/datasource-catalogue").addOption(new Option("--sort <metric>", "Sort candidates by review count or percentage").choices(["count", "percentage"]).default("count")).option("--limit <number>", "Maximum number of candidate routes to display", parsePositiveInteger).description("Display needs_review metrics grouped by route").action(async (catalogueDirectory, options) => {
4293
+ intro("Datasource catalogue needs review report");
4294
+ const { cwd } = getUserConfig();
4295
+ const progress = taskProgressService.createStepProgress();
4296
+ try {
4297
+ const report = await progress.run({
4298
+ id: "report",
4299
+ title: "Loading route review documents",
4300
+ completedTitle: "Route review report built"
4301
+ }, () => buildNeedsReviewRouteReport({
4302
+ cwd,
4303
+ catalogueDirectory,
4304
+ sort: options.sort,
4305
+ limit: options.limit
4306
+ }));
4307
+ if (report.rows.length) {
4308
+ log.info("Candidate routes");
4309
+ log.message(formatNeedsReviewTable(report.rows));
4310
+ } else log.info("No route requires review.");
4311
+ note([
4312
+ `Input directory: ${report.inputPath}`,
4313
+ `Route documents: ${report.files.length}`,
4314
+ `Candidate routes: ${report.candidateRoutes}/${report.totalRoutes}`,
4315
+ `Properties: ${report.totalProperties}`,
4316
+ `Needs review: ${report.needsReview}`,
4317
+ `Needs review percentage: ${report.needsReviewPercentage.toFixed(2)}%`,
4318
+ options.limit ? `Rows displayed: ${report.rows.length}/${report.candidateRoutes}` : null
4319
+ ].filter(Boolean).join("\n"), "Summary");
4320
+ outro("Datasource catalogue needs review report completed");
4321
+ } catch (error) {
4322
+ progress.fail("Needs review report failed");
4323
+ cancel(error instanceof Error ? error.message : String(error));
4324
+ process.exit(1);
4325
+ }
4326
+ });
4327
+ //#endregion
4328
+ //#region src/services/changedRouteReportService.ts
4329
+ function sortPropertiesByStatus(properties) {
4330
+ return [...properties].sort((left, right) => {
4331
+ const rank = (status) => status === "needs_review" ? 0 : 1;
4332
+ return rank(left.status) - rank(right.status) || left.field.localeCompare(right.field);
4333
+ });
4334
+ }
4335
+ function normalizeFilePath(filePath) {
4336
+ return filePath.replaceAll("\\", "/").replace(/^\.\//, "");
4337
+ }
4338
+ function isGraphDocument(value) {
4339
+ if (typeof value !== "object" || value === null) return false;
4340
+ const document = value;
4341
+ return typeof document.route?.method === "string" && typeof document.route.path === "string" && Array.isArray(document.analysis_files) && document.analysis_files.every((filePath) => typeof filePath === "string");
4342
+ }
4343
+ function collectImpactedRoutes(params) {
4344
+ const changedFiles = new Set(params.changedFiles.map(normalizeFilePath));
4345
+ return params.graphs.filter((graph) => graph.analysisFiles.some((analysisFile) => changedFiles.has(normalizeFilePath(analysisFile)))).sort((left, right) => `${left.method} ${left.path}`.localeCompare(`${right.method} ${right.path}`));
4346
+ }
4347
+ async function readChangedFiles(filePath) {
4348
+ return (await fs.readFile(filePath, "utf8")).split(/\r?\n/).map((line) => normalizeFilePath(line.trim())).filter(Boolean);
4349
+ }
4350
+ async function readRouteGraphs(outputDirectory) {
4351
+ const graphFiles = await globby("**/*.graph.yaml", {
4352
+ cwd: outputDirectory,
4353
+ absolute: true
4354
+ });
4355
+ const graphs = await Promise.all(graphFiles.map(async (graphFile) => {
4356
+ const document = parse(await fs.readFile(graphFile, "utf8"));
4357
+ if (!isGraphDocument(document)) throw new Error(`Invalid graph document: ${graphFile}`);
4358
+ return {
4359
+ method: document.route.method,
4360
+ path: document.route.path,
4361
+ analysisFiles: document.analysis_files.map(normalizeFilePath)
4362
+ };
4363
+ }));
4364
+ if (!graphs.length) throw new Error(`No graph documents found in ${path.resolve(outputDirectory)}. Run generate:graph first.`);
4365
+ return graphs;
4366
+ }
4367
+ function renderChangedRouteReport(params) {
4368
+ const changedFiles = params.changedFiles.sort();
4369
+ const routeRows = params.routes.length ? params.routes.map((route) => `| ${route.method} ${route.path} | ${route.coverage.toFixed(2)}% | ${route.needsReview}/${route.fields} |`).join("\n") : "| _Aucune route impactée_ | — | — |";
4370
+ return [
4371
+ "<!-- datasource-catalogue-report -->",
4372
+ "## Datasource mapping report",
4373
+ "",
4374
+ `Fichiers modifiés analysés : **${changedFiles.length}**`,
4375
+ "",
4376
+ "<details><summary>Fichiers modifiés</summary>",
4377
+ "",
4378
+ ...changedFiles.map((filePath) => `- \`${filePath}\``),
4379
+ "",
4380
+ "</details>",
4381
+ "",
4382
+ `Routes impactées : **${params.routes.length}**`,
4383
+ "",
4384
+ "| Route | Couverture mapping | Champs à revoir |",
4385
+ "| --- | ---: | ---: |",
4386
+ routeRows,
4387
+ ...params.routes.flatMap((route) => {
4388
+ const inputs = sortPropertiesByStatus(route.properties.filter((property) => property.direction === "input"));
4389
+ const outputs = sortPropertiesByStatus(route.properties.filter((property) => property.direction === "output"));
4390
+ return [
4391
+ "",
4392
+ `### ${route.method} ${route.path}`,
4393
+ "",
4394
+ `Backends détectés : ${route.backends.length ? route.backends.map((backend) => `\`${backend}\``).join(", ") : "aucun"}`,
4395
+ "",
4396
+ "#### Routes backend",
4397
+ "",
4398
+ "| Backend | Route |",
4399
+ "| --- | --- |",
4400
+ ...route.backendRoutes.length ? route.backendRoutes.map((backendRoute) => `| ${backendRoute.backend} | \`${backendRoute.route}\` |`) : ["| — | — |"],
4401
+ "",
4402
+ `<details><summary>Inputs (${inputs.length})</summary>`,
4403
+ "",
4404
+ "| Champ | Statut |",
4405
+ "| --- | --- |",
4406
+ ...inputs.length ? inputs.map((property) => `| \`${property.field}\` | ${property.status} |`) : ["| — | — |"],
4407
+ "",
4408
+ "</details>",
4409
+ "",
4410
+ `<details><summary>Outputs (${outputs.length})</summary>`,
4411
+ "",
4412
+ "| Champ | Statut |",
4413
+ "| --- | --- |",
4414
+ ...outputs.length ? outputs.map((property) => `| \`${property.field}\` | ${property.status} |`) : ["| — | — |"],
4415
+ "",
4416
+ "</details>"
4417
+ ];
4418
+ }),
4419
+ "",
4420
+ "Couverture = part des champs dont le mapping déterministe ne nécessite pas de revue manuelle."
4421
+ ].join("\n");
4422
+ }
4423
+ //#endregion
4424
+ //#region src/commands/reportChanged.ts
4425
+ async function reportChangedHandler(cwd, options) {
4426
+ const config = getUserConfig();
4427
+ const outputDirectory = path.resolve(cwd, options.output);
4428
+ const changedFiles = await readChangedFiles(path.resolve(cwd, options.changedFiles));
4429
+ const impactedRoutes = collectImpactedRoutes({
4430
+ changedFiles,
4431
+ graphs: await readRouteGraphs(outputDirectory)
4432
+ });
4433
+ const coverages = [];
4434
+ if (impactedRoutes.length) {
4435
+ const openApiDocument = await loadOpenApiDocument(config.openapiUrl);
4436
+ for (const route of impactedRoutes) try {
4437
+ const documents = await analyzeCodebaseRouteContracts({
4438
+ cwd,
4439
+ openApiDocument,
4440
+ selectedRouteKeys: /* @__PURE__ */ new Set([buildRouteKey(route.method, route.path)]),
4441
+ routeAnalysisScope: ({ method, path: routePath }) => loadBackendTopologyAnalysisScope(outputDirectory, cwd, method, routePath)
4442
+ });
4443
+ if (documents.length !== 1) throw new Error("Route was not found in the OpenAPI contract");
4444
+ const catalogue = await buildCatalogue(documents);
4445
+ const catalogueRoute = catalogue.routes[0];
4446
+ const properties = [...catalogue.route_input_properties, ...catalogue.route_output_properties];
4447
+ coverages.push({
4448
+ ...route,
4449
+ coverage: 100 - catalogue.stats.needs_review_percentage,
4450
+ fields: properties.length,
4451
+ needsReview: properties.filter((p) => p.evidence_status === "needs_review").length,
4452
+ backends: catalogueRoute?.backends || [],
4453
+ backendRoutes: catalogue.backend_routes.filter((br) => br.route_key === catalogueRoute?.key).map((br) => ({
4454
+ backend: br.backend,
4455
+ route: br.route
4456
+ })),
4457
+ properties: [...catalogue.route_input_properties.map((p) => ({
4458
+ direction: "input",
4459
+ field: p.field,
4460
+ status: p.evidence_status
4461
+ })), ...catalogue.route_output_properties.map((p) => ({
4462
+ direction: "output",
4463
+ field: p.field,
4464
+ status: p.evidence_status
4465
+ }))]
4466
+ });
4467
+ } catch {}
4468
+ }
4469
+ const report = renderChangedRouteReport({
4470
+ changedFiles,
4471
+ routes: coverages
4472
+ });
4473
+ const reportPath = path.resolve(cwd, options.report);
4474
+ await fs.mkdir(path.dirname(reportPath), { recursive: true });
4475
+ await fs.writeFile(reportPath, `${report}\n`, "utf8");
4476
+ note(report, "Datasource mapping report");
4477
+ }
4478
+ var reportChanged_default = (program) => void program.command("report:changed").requiredOption("--changed-files <file>", "Newline-delimited list of changed repository files").option("-o, --output <directory>", "Directory containing generated graph artifacts", ".tmp/datasource-catalogue").option("--report <file>", "Markdown report output path", ".tmp/datasource-catalogue/changed-routes-report.md").description("Report deterministic mapping coverage for routes impacted by changed files").action(async (options) => {
4479
+ intro("Datasource changed-route report");
4480
+ const { cwd } = getUserConfig();
4481
+ try {
4482
+ await reportChangedHandler(cwd, options);
4483
+ outro("Datasource changed-route report completed");
4484
+ } catch (error) {
4485
+ cancel(error instanceof Error ? error.message : String(error));
4486
+ process.exit(1);
4487
+ }
4488
+ });
4489
+ //#endregion
4490
+ //#region src/bin/atlas.ts
4491
+ const program = new Command();
4492
+ program.version("3.4.0-alpha.1");
4493
+ program.name("atlas").description("Manage the API-to-backend mapping catalogue").option("--project-root <path>", "Root directory of the API project to analyze").option("-c, --config <path>", "Path to atlas.config.ts", path.resolve(process.cwd(), "atlas.config.ts")).hook("preAction", async (_thisCommand, actionCommand) => {
4494
+ if (actionCommand.name() === "init") return;
4495
+ const config = await loadAtlasConfig(program.opts().config);
4496
+ const repoRoot = program.opts().projectRoot ?? config.repoRoot;
4497
+ setUserConfig({
4498
+ ...config,
4499
+ repoRoot,
4500
+ cwd: repoRoot
4501
+ });
4502
+ });
4503
+ init_default(program, program);
4504
+ generate_default(program);
4505
+ generateGraph_default(program);
4506
+ generateTest_default(program);
4507
+ infer_default(program);
4508
+ needsReview_default(program);
4509
+ push_default(program);
4510
+ cleanOrphans_default(program);
4511
+ reportChanged_default(program);
4512
+ await program.parseAsync(process.argv);
4513
+ //#endregion
4514
+ export {};