@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.
- package/README.md +132 -0
- package/dist/atlas.config-BvV9t_Ma.mjs +384 -0
- package/dist/atlas.config-BvV9t_Ma.mjs.map +1 -0
- package/dist/bin/atlas.mjs +4514 -0
- package/dist/defineConfig-Dfvzj6n2.mjs +2 -0
- package/dist/defineConfig-Dfvzj6n2.mjs.map +1 -0
- package/dist/defineRule-Dfvzj6n2.mjs +2 -0
- package/dist/defineRule-Dfvzj6n2.mjs.map +1 -0
- package/dist/magic-string.es-oX5dCR5w.mjs +15 -0
- package/dist/magic-string.es-oX5dCR5w.mjs.map +1 -0
- package/knowledges/cms-and-directus-indirect-routes.md +205 -0
- package/package.json +42 -0
- package/tsdown.config.ts +42 -0
|
@@ -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 {};
|