@cmflow/atlas 3.4.0-beta.6 → 3.4.0-beta.7
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
CHANGED
|
@@ -119,7 +119,7 @@ Generate the topology first, then the route catalogue:
|
|
|
119
119
|
|
|
120
120
|
```bash
|
|
121
121
|
atlas --project-root /path/to/api generate:graph
|
|
122
|
-
atlas --project-root /path/to/api generate:
|
|
122
|
+
atlas --project-root /path/to/api generate:catalog --output .tmp/datasource-catalogue
|
|
123
123
|
```
|
|
124
124
|
|
|
125
125
|
Each route produces a YAML document and a `.graph.yaml` topology artifact. The catalogue requires a valid graph and leaves ambiguous fields marked `needs_review`.
|
|
@@ -152,7 +152,7 @@ Without `--write`, the command performs a dry run. Use `clean-orphans` to inspec
|
|
|
152
152
|
```text
|
|
153
153
|
atlas init Create atlas.config.ts
|
|
154
154
|
atlas generate:graph Trace API routes to backends
|
|
155
|
-
atlas generate:
|
|
155
|
+
atlas generate:catalog [route] Generate route review documents
|
|
156
156
|
atlas generate:test Check configured coverage baselines
|
|
157
157
|
atlas needs-review Rank unresolved routes
|
|
158
158
|
atlas infer [route|directory] Optionally enrich unresolved mappings
|
|
@@ -163,7 +163,7 @@ atlas report:changed Report coverage for changed routes
|
|
|
163
163
|
|
|
164
164
|
## CI
|
|
165
165
|
|
|
166
|
-
The recommended static CI flow is `generate:graph`, `generate:
|
|
166
|
+
The recommended static CI flow is `generate:graph`, `generate:catalog`, then `push`. Do not run optional inference in CI. Supply `DIRECTUS_URL` and `DIRECTUS_TOKEN` through the CI secret context; skip the push if either value is absent.
|
|
167
167
|
|
|
168
168
|
## Development
|
|
169
169
|
|
package/dist/bin/atlas.mjs
CHANGED
|
@@ -1,156 +1,23 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { a as isKnownBackendType, c as taskProgressService, d as setUserConfig, i as inferBackendNameFromFile, l as getUserConfig, n as generateBackendTopologyArtifactsInWorkers, o as filterAnalysisFiles, r as resolveModulePath, s as shouldKeepAnalysisFile, u as loadAtlasConfig } from "../routeBackendTopologyService-D_aIIBbX.mjs";
|
|
2
3
|
import { Command, InvalidArgumentError, Option } from "commander";
|
|
3
4
|
import path from "node:path";
|
|
4
|
-
import fs from "node:fs";
|
|
5
|
-
import { pathToFileURL } from "node:url";
|
|
6
5
|
import { Node, Project, SyntaxKind } from "ts-morph";
|
|
7
|
-
import { cancel, intro, log, note, outro, progress
|
|
8
|
-
import fs
|
|
6
|
+
import { cancel, intro, log, note, outro, progress } from "@clack/prompts";
|
|
7
|
+
import fs from "node:fs/promises";
|
|
9
8
|
import { createDirectus, createItem, deleteItem, readItems, rest, staticToken, updateItem } from "@directus/sdk";
|
|
10
|
-
import { AsyncLocalStorage } from "node:async_hooks";
|
|
11
9
|
import { globby } from "globby";
|
|
12
|
-
import { minimatch } from "minimatch";
|
|
13
10
|
import { createHash } from "node:crypto";
|
|
14
11
|
import { parse, stringify } from "yaml";
|
|
15
|
-
import { Worker } from "node:worker_threads";
|
|
16
12
|
import { NoObjectGeneratedError, Output, generateText } from "ai";
|
|
17
13
|
import { z } from "zod";
|
|
18
14
|
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
|
-
function getTsconfigAliases(repoRoot) {
|
|
29
|
-
const tsconfigPath = path.join(repoRoot, "tsconfig.json");
|
|
30
|
-
if (!fs.existsSync(tsconfigPath)) return {};
|
|
31
|
-
const paths = new Project({
|
|
32
|
-
tsConfigFilePath: tsconfigPath,
|
|
33
|
-
skipAddingFilesFromTsConfig: true
|
|
34
|
-
}).getCompilerOptions().paths ?? {};
|
|
35
|
-
return Object.fromEntries(Object.entries(paths).flatMap(([alias, targets]) => {
|
|
36
|
-
const target = targets?.[0];
|
|
37
|
-
if (!target) return [];
|
|
38
|
-
return [[alias.replace(/\/\*$/, ""), target.replace(/^\.\//, "").replace(/\/\*$/, "")]];
|
|
39
|
-
}));
|
|
40
|
-
}
|
|
41
|
-
async function loadAtlasConfig(configPath, projectRoot) {
|
|
42
|
-
const filePath = path.resolve(configPath);
|
|
43
|
-
try {
|
|
44
|
-
let mod;
|
|
45
|
-
if (filePath.endsWith(".ts")) {
|
|
46
|
-
const { tsImport } = await import("tsx/esm/api");
|
|
47
|
-
mod = await tsImport(filePath, import.meta.url);
|
|
48
|
-
} else mod = await import(pathToFileURL(filePath).href);
|
|
49
|
-
const config = mod.default ?? mod;
|
|
50
|
-
const repoRoot = projectRoot || config.repoRoot || process.cwd();
|
|
51
|
-
return {
|
|
52
|
-
...config,
|
|
53
|
-
repoRoot,
|
|
54
|
-
cwd: repoRoot,
|
|
55
|
-
resolver: { alias: {
|
|
56
|
-
...getTsconfigAliases(repoRoot),
|
|
57
|
-
...config.resolver?.alias
|
|
58
|
-
} }
|
|
59
|
-
};
|
|
60
|
-
} catch (error) {
|
|
61
|
-
throw new Error(`Unable to load Atlas configuration at ${filePath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
//#endregion
|
|
65
|
-
//#region src/services/taskProgressService.ts
|
|
66
|
-
function formatDuration(durationMs) {
|
|
67
|
-
if (durationMs < 1e3) return `${durationMs}ms`;
|
|
68
|
-
return `${(durationMs / 1e3).toFixed(1)}s`;
|
|
69
|
-
}
|
|
70
|
-
var TaskProgressService = class {
|
|
71
|
-
#storage = new AsyncLocalStorage();
|
|
72
|
-
attach(sink, task) {
|
|
73
|
-
return this.#storage.run(sink, task);
|
|
74
|
-
}
|
|
75
|
-
log(message) {
|
|
76
|
-
this.#storage.getStore()?.log(message);
|
|
77
|
-
}
|
|
78
|
-
report(message) {
|
|
79
|
-
const sink = this.#storage.getStore();
|
|
80
|
-
(sink?.report || sink?.log)?.(message);
|
|
81
|
-
}
|
|
82
|
-
createStepProgress(classify = (message) => ({
|
|
83
|
-
id: message,
|
|
84
|
-
title: message
|
|
85
|
-
})) {
|
|
86
|
-
const progress = spinner();
|
|
87
|
-
let active;
|
|
88
|
-
const finishActive = (label) => {
|
|
89
|
-
if (!active) return;
|
|
90
|
-
const duration = formatDuration(Date.now() - active.startedAt);
|
|
91
|
-
progress.stop(`${label || active.completedTitle || `${active.title} completed`} (${duration})`);
|
|
92
|
-
active = void 0;
|
|
93
|
-
};
|
|
94
|
-
const start = (step) => {
|
|
95
|
-
if (active?.id === step.id) {
|
|
96
|
-
progress.message(step.detail ? `${step.title}: ${step.detail}` : step.title);
|
|
97
|
-
return;
|
|
98
|
-
}
|
|
99
|
-
finishActive();
|
|
100
|
-
active = {
|
|
101
|
-
id: step.id,
|
|
102
|
-
title: step.title,
|
|
103
|
-
completedTitle: step.completedTitle,
|
|
104
|
-
startedAt: Date.now()
|
|
105
|
-
};
|
|
106
|
-
progress.start(step.detail ? `${step.title}: ${step.detail}` : step.title);
|
|
107
|
-
};
|
|
108
|
-
const execute = (task) => this.attach({
|
|
109
|
-
log: (message) => progress.message(active ? `${active.title}: ${message}` : message),
|
|
110
|
-
report: (message) => start(classify(message))
|
|
111
|
-
}, task);
|
|
112
|
-
return {
|
|
113
|
-
report(message) {
|
|
114
|
-
start(classify(message));
|
|
115
|
-
},
|
|
116
|
-
start,
|
|
117
|
-
execute,
|
|
118
|
-
run: async (step, task) => {
|
|
119
|
-
start(step);
|
|
120
|
-
await new Promise((resolve) => setImmediate(resolve));
|
|
121
|
-
const result = await execute(task);
|
|
122
|
-
finishActive();
|
|
123
|
-
return result;
|
|
124
|
-
},
|
|
125
|
-
finish(label) {
|
|
126
|
-
finishActive(label);
|
|
127
|
-
},
|
|
128
|
-
fail(label) {
|
|
129
|
-
if (!active) return;
|
|
130
|
-
const duration = formatDuration(Date.now() - active.startedAt);
|
|
131
|
-
progress.stop(`${label} (${duration})`);
|
|
132
|
-
active = void 0;
|
|
133
|
-
}
|
|
134
|
-
};
|
|
135
|
-
}
|
|
136
|
-
};
|
|
137
|
-
const taskProgressService = new TaskProgressService();
|
|
138
|
-
//#endregion
|
|
139
15
|
//#region src/utils/catalogueStats.ts
|
|
140
16
|
function calculateNeedsReviewPercentage(inputProperties, outputProperties, needsReview) {
|
|
141
17
|
const properties = inputProperties + outputProperties;
|
|
142
18
|
return properties ? Number((needsReview / properties * 100).toFixed(2)) : 0;
|
|
143
19
|
}
|
|
144
20
|
//#endregion
|
|
145
|
-
//#region src/services/analysisFileService.ts
|
|
146
|
-
function shouldKeepAnalysisFile(filePath) {
|
|
147
|
-
const normalizedProjectPath = filePath.replaceAll(path.sep, "/").replace(/^.*?(app\/)/, "app/");
|
|
148
|
-
return !getUserConfig().analysis.excluded.some((pattern) => minimatch(normalizedProjectPath, pattern, { dot: true }));
|
|
149
|
-
}
|
|
150
|
-
function filterAnalysisFiles(filePaths) {
|
|
151
|
-
return filePaths.filter(shouldKeepAnalysisFile);
|
|
152
|
-
}
|
|
153
|
-
//#endregion
|
|
154
21
|
//#region src/services/useCaseAnalysisService.ts
|
|
155
22
|
function normalizeCodeText(value) {
|
|
156
23
|
return value.replace(/\s+/g, " ").replace(/;$/, "").trim();
|
|
@@ -391,22 +258,6 @@ function extractOpenApiOutputProperties(operation, swagger) {
|
|
|
391
258
|
return [...map.values()];
|
|
392
259
|
}
|
|
393
260
|
//#endregion
|
|
394
|
-
//#region src/utils/isKnowBackendType.ts
|
|
395
|
-
function isKnownBackendType(value) {
|
|
396
|
-
return getUserConfig().analysis.backends.includes(value);
|
|
397
|
-
}
|
|
398
|
-
//#endregion
|
|
399
|
-
//#region src/services/backendSourceService.ts
|
|
400
|
-
function inferBackendNameFromFile(sourceFile) {
|
|
401
|
-
const declaredType = sourceFile.getFullText().match(/BackendTypes\.([A-Z0-9_]+)/)?.[1];
|
|
402
|
-
if (declaredType) return isKnownBackendType(declaredType) ? declaredType : null;
|
|
403
|
-
const parts = sourceFile.getFilePath().split(path.sep);
|
|
404
|
-
const backIndex = parts.lastIndexOf("back");
|
|
405
|
-
const infrastructureIndex = parts.lastIndexOf("_infra");
|
|
406
|
-
const candidate = backIndex >= 0 ? parts[backIndex + 1] : infrastructureIndex >= 0 ? parts[infrastructureIndex + 2] : void 0;
|
|
407
|
-
return candidate && isKnownBackendType(candidate.toUpperCase()) ? candidate.toUpperCase() : null;
|
|
408
|
-
}
|
|
409
|
-
//#endregion
|
|
410
261
|
//#region src/services/backendRouteExtractionService.ts
|
|
411
262
|
function looksLikeBackendRoute(value) {
|
|
412
263
|
return value.startsWith("/") || /^https?:\/\//.test(value) || /^graphql$/i.test(value);
|
|
@@ -457,51 +308,6 @@ function normalizeDescription(description) {
|
|
|
457
308
|
return description?.trim() || "";
|
|
458
309
|
}
|
|
459
310
|
//#endregion
|
|
460
|
-
//#region src/utils/resolveAliasPath.ts
|
|
461
|
-
function resolveAliasPath(moduleSpecifier, aliases, rootPath) {
|
|
462
|
-
for (const [alias, target] of Object.entries(aliases)) {
|
|
463
|
-
if (moduleSpecifier !== alias && !moduleSpecifier.startsWith(`${alias}/`)) continue;
|
|
464
|
-
const modulePath = moduleSpecifier.slice(alias.length).replace(/^\//, "");
|
|
465
|
-
return path.join(rootPath, target, modulePath);
|
|
466
|
-
}
|
|
467
|
-
return null;
|
|
468
|
-
}
|
|
469
|
-
//#endregion
|
|
470
|
-
//#region src/utils/tryResolveWithExtensions.ts
|
|
471
|
-
function tryResolveWithExtensions(basePath) {
|
|
472
|
-
const ext = path.extname(basePath);
|
|
473
|
-
const withoutExt = ext ? basePath.slice(0, -ext.length) : basePath;
|
|
474
|
-
const candidates = [
|
|
475
|
-
basePath,
|
|
476
|
-
ext === ".js" ? `${withoutExt}.ts` : null,
|
|
477
|
-
ext === ".ts" ? `${withoutExt}.js` : null,
|
|
478
|
-
ext === ".mjs" ? `${withoutExt}.mts` : null,
|
|
479
|
-
ext === ".mts" ? `${withoutExt}.mjs` : null,
|
|
480
|
-
`${basePath}.ts`,
|
|
481
|
-
`${basePath}.js`,
|
|
482
|
-
`${basePath}.mts`,
|
|
483
|
-
`${basePath}.mjs`,
|
|
484
|
-
path.join(withoutExt, "index.ts"),
|
|
485
|
-
path.join(withoutExt, "index.js"),
|
|
486
|
-
path.join(basePath, "index.ts"),
|
|
487
|
-
path.join(basePath, "index.js")
|
|
488
|
-
].filter((candidate) => Boolean(candidate));
|
|
489
|
-
for (const candidate of candidates) try {
|
|
490
|
-
const normalized = path.normalize(candidate);
|
|
491
|
-
if (fs.existsSync(normalized)) return normalized;
|
|
492
|
-
} catch {
|
|
493
|
-
continue;
|
|
494
|
-
}
|
|
495
|
-
return null;
|
|
496
|
-
}
|
|
497
|
-
//#endregion
|
|
498
|
-
//#region src/utils/resolveModulePath.ts
|
|
499
|
-
function resolveModulePath(sourceFilePath, moduleSpecifier, aliases, rootPath) {
|
|
500
|
-
if (moduleSpecifier.startsWith(".")) return tryResolveWithExtensions(path.resolve(path.dirname(sourceFilePath), moduleSpecifier));
|
|
501
|
-
const aliased = resolveAliasPath(moduleSpecifier, aliases, rootPath);
|
|
502
|
-
return aliased ? tryResolveWithExtensions(aliased) : null;
|
|
503
|
-
}
|
|
504
|
-
//#endregion
|
|
505
311
|
//#region src/utils/dedupeByKey.ts
|
|
506
312
|
function dedupeByKey(items, keyFn) {
|
|
507
313
|
const map = /* @__PURE__ */ new Map();
|
|
@@ -1443,11 +1249,12 @@ async function analyzeCodebaseRouteContracts(params) {
|
|
|
1443
1249
|
}) : void 0;
|
|
1444
1250
|
if (routeAnalysisScope && !topologyScope) continue;
|
|
1445
1251
|
taskProgressService.log(topologyScope ? `${routeLabel} ... Loading dependencies from backend graph` : `${routeLabel} ... Collecting dependencies`);
|
|
1446
|
-
const
|
|
1252
|
+
const dependencyFiles = topologyScope ? topologyScope.analysisFiles : handler.handlerFile ? dependencyFilesByHandler.get(handler.handlerFile) || (() => {
|
|
1447
1253
|
const files = collectDependencyFiles(project, handler.handlerFile, routeLabel, resolveCachedModulePath);
|
|
1448
1254
|
dependencyFilesByHandler.set(handler.handlerFile, files);
|
|
1449
1255
|
return files;
|
|
1450
|
-
})() : [routeDeclaration.file]
|
|
1256
|
+
})() : [routeDeclaration.file];
|
|
1257
|
+
const analysisFiles = filterAnalysisFiles(dedupeByKey(await dependencyFiles, (value) => value));
|
|
1451
1258
|
taskProgressService.log(`${routeLabel} ... Extracting mappings from ${analysisFiles.length} relevant files`);
|
|
1452
1259
|
await yieldToEventLoop();
|
|
1453
1260
|
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) => {
|
|
@@ -2356,7 +2163,7 @@ async function pushCatalogueToDirectus(catalogue, options) {
|
|
|
2356
2163
|
}
|
|
2357
2164
|
}
|
|
2358
2165
|
}
|
|
2359
|
-
if (options.outputPath) await fs
|
|
2166
|
+
if (options.outputPath) await fs.appendFile(options.outputPath, "", "utf8");
|
|
2360
2167
|
const removedOrphanedLinks = await cleanupOrphanedPropertyLinks(client);
|
|
2361
2168
|
taskProgressService.report("Finalizing Directus synchronization");
|
|
2362
2169
|
if (warnings.size) {
|
|
@@ -2552,12 +2359,12 @@ async function writeRouteReviewDocument(catalogue, outputDir, repoRoot, routeKey
|
|
|
2552
2359
|
if (!route) throw new Error(`Unable to write review document for unknown route key ${routeKey}`);
|
|
2553
2360
|
const document = relativizeRouteReviewDocument(buildRouteReviewDocument(catalogue, route.key), repoRoot);
|
|
2554
2361
|
const filePath = path.join(outputDir, buildRouteReviewRelativePath(route.method, route.path));
|
|
2555
|
-
await fs
|
|
2556
|
-
await fs
|
|
2362
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
2363
|
+
await fs.writeFile(filePath, `${stringify(document)}\n`, "utf8");
|
|
2557
2364
|
return filePath;
|
|
2558
2365
|
}
|
|
2559
2366
|
async function writeRouteReviewDocuments(catalogue, outputDir, repoRoot) {
|
|
2560
|
-
await fs
|
|
2367
|
+
await fs.mkdir(outputDir, { recursive: true });
|
|
2561
2368
|
for (const [index, route] of catalogue.routes.entries()) {
|
|
2562
2369
|
taskProgressService.log(`[${index + 1}/${catalogue.routes.length}] - ${route.method} ${route.path} ...`);
|
|
2563
2370
|
await writeRouteReviewDocument(catalogue, outputDir, repoRoot, route.key);
|
|
@@ -2565,8 +2372,8 @@ async function writeRouteReviewDocuments(catalogue, outputDir, repoRoot) {
|
|
|
2565
2372
|
return outputDir;
|
|
2566
2373
|
}
|
|
2567
2374
|
async function prepareCatalogueOutputDirectory(outputDir) {
|
|
2568
|
-
await fs
|
|
2569
|
-
await Promise.all(["catalogue.yaml", "catalogue.yml"].map((fileName) => fs
|
|
2375
|
+
await fs.mkdir(outputDir, { recursive: true });
|
|
2376
|
+
await Promise.all(["catalogue.yaml", "catalogue.yml"].map((fileName) => fs.rm(path.join(outputDir, fileName), { force: true })));
|
|
2570
2377
|
}
|
|
2571
2378
|
async function writeCatalogueArtifacts(catalogue, outputDir, repoRoot = process.cwd()) {
|
|
2572
2379
|
await prepareCatalogueOutputDirectory(outputDir);
|
|
@@ -2744,7 +2551,7 @@ async function readCatalogueFromDirectory(inputDir) {
|
|
|
2744
2551
|
if (!files.length) throw new Error(`No route YAML documents found in ${inputDir}`);
|
|
2745
2552
|
return {
|
|
2746
2553
|
catalogue: mergeRouteReviewDocuments(await Promise.all(files.sort().map(async (file) => {
|
|
2747
|
-
const parsed = parse(await fs
|
|
2554
|
+
const parsed = parse(await fs.readFile(file, "utf8"));
|
|
2748
2555
|
if (parsed?.schema_version !== 1 || !parsed?.route?.method || !parsed?.route?.path) throw new Error(`Invalid route YAML document: ${file}`);
|
|
2749
2556
|
return parsed;
|
|
2750
2557
|
}))),
|
|
@@ -2938,8 +2745,8 @@ function buildBackendTopologyRelativePath(method, routePath) {
|
|
|
2938
2745
|
}
|
|
2939
2746
|
async function writeBackendTopologyArtifact(artifact, outputDir) {
|
|
2940
2747
|
const filePath = path.join(outputDir, buildBackendTopologyRelativePath(artifact.route.method, artifact.route.path));
|
|
2941
|
-
await fs
|
|
2942
|
-
await fs
|
|
2748
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
2749
|
+
await fs.writeFile(filePath, `${stringify(artifact, { aliasDuplicateObjects: false })}\n`, "utf8");
|
|
2943
2750
|
return filePath;
|
|
2944
2751
|
}
|
|
2945
2752
|
function isRecord(value) {
|
|
@@ -2958,7 +2765,7 @@ async function loadBackendTopologyAnalysisScope(outputDir, cwd, method, routePat
|
|
|
2958
2765
|
const filePath = path.join(outputDir, buildBackendTopologyRelativePath(method, routePath));
|
|
2959
2766
|
let content;
|
|
2960
2767
|
try {
|
|
2961
|
-
content = await fs
|
|
2768
|
+
content = await fs.readFile(filePath, "utf8");
|
|
2962
2769
|
} catch (error) {
|
|
2963
2770
|
if (isRecord(error) && error.code === "ENOENT") throw new Error(`Backend graph artifact not found for ${method} ${routePath}: ${filePath}. Run generate:graph first.`);
|
|
2964
2771
|
throw error;
|
|
@@ -2979,7 +2786,7 @@ async function loadBackendTopologyAnalysisScope(outputDir, cwd, method, routePat
|
|
|
2979
2786
|
const missingFiles = [];
|
|
2980
2787
|
await Promise.all(analysisFiles.map(async (analysisFile) => {
|
|
2981
2788
|
try {
|
|
2982
|
-
await fs
|
|
2789
|
+
await fs.access(analysisFile);
|
|
2983
2790
|
} catch {
|
|
2984
2791
|
missingFiles.push(path.relative(cwd, analysisFile));
|
|
2985
2792
|
}
|
|
@@ -3079,7 +2886,7 @@ async function generateHandler(cwd, routeArgument, options) {
|
|
|
3079
2886
|
process.exit(1);
|
|
3080
2887
|
}
|
|
3081
2888
|
}
|
|
3082
|
-
var generate_default = (program) => void program.command("generate:
|
|
2889
|
+
var generate_default = (program) => void program.command("generate:catalog").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) => {
|
|
3083
2890
|
const config = getUserConfig();
|
|
3084
2891
|
return generateHandler(config.cwd, routeArgument, {
|
|
3085
2892
|
...options,
|
|
@@ -3087,104 +2894,6 @@ var generate_default = (program) => void program.command("generate:catalogue").a
|
|
|
3087
2894
|
});
|
|
3088
2895
|
});
|
|
3089
2896
|
//#endregion
|
|
3090
|
-
//#region src/services/routeBackendTopologyService.ts
|
|
3091
|
-
function stringProperty(object, name) {
|
|
3092
|
-
const property = object.getProperty(name);
|
|
3093
|
-
if (!property || !Node.isPropertyAssignment(property)) return void 0;
|
|
3094
|
-
const initializer = property.getInitializer();
|
|
3095
|
-
return initializer && (Node.isStringLiteral(initializer) || Node.isNoSubstitutionTemplateLiteral(initializer)) ? initializer.getLiteralValue() : void 0;
|
|
3096
|
-
}
|
|
3097
|
-
function handlerProperty(object) {
|
|
3098
|
-
const property = object.getProperty("handler");
|
|
3099
|
-
if (!property || !Node.isPropertyAssignment(property)) return void 0;
|
|
3100
|
-
const initializer = property.getInitializer();
|
|
3101
|
-
return initializer && (Node.isIdentifier(initializer) || Node.isPropertyAccessExpression(initializer)) ? initializer.getText() : void 0;
|
|
3102
|
-
}
|
|
3103
|
-
function extractRouteDeclarations(sourceFile) {
|
|
3104
|
-
const initializer = sourceFile.getVariableDeclaration("routes")?.getInitializer();
|
|
3105
|
-
let expression = initializer;
|
|
3106
|
-
if (initializer && (Node.isAsExpression(initializer) || Node.isSatisfiesExpression(initializer))) expression = initializer.getExpression();
|
|
3107
|
-
if (!expression || !Node.isArrayLiteralExpression(expression)) return [];
|
|
3108
|
-
return expression.getElements().flatMap((element) => {
|
|
3109
|
-
if (!Node.isObjectLiteralExpression(element)) return [];
|
|
3110
|
-
const method = stringProperty(element, "method");
|
|
3111
|
-
const routePath = stringProperty(element, "path");
|
|
3112
|
-
const handlerRef = handlerProperty(element);
|
|
3113
|
-
return method && routePath && handlerRef ? [{
|
|
3114
|
-
method: method.toUpperCase(),
|
|
3115
|
-
path: routePath,
|
|
3116
|
-
sourceFile,
|
|
3117
|
-
handlerRef
|
|
3118
|
-
}] : [];
|
|
3119
|
-
});
|
|
3120
|
-
}
|
|
3121
|
-
async function discoverBackendTopologyRouteSelectors(cwd) {
|
|
3122
|
-
const project = new Project({
|
|
3123
|
-
skipAddingFilesFromTsConfig: true,
|
|
3124
|
-
compilerOptions: {
|
|
3125
|
-
allowJs: true,
|
|
3126
|
-
checkJs: false
|
|
3127
|
-
}
|
|
3128
|
-
});
|
|
3129
|
-
return (await globby(["app/_api/**/routes.@(js|ts)", "app/legacy/**/routes.@(js|ts)"], {
|
|
3130
|
-
cwd,
|
|
3131
|
-
absolute: true
|
|
3132
|
-
})).flatMap((routeFile) => extractRouteDeclarations(project.addSourceFileAtPath(routeFile))).map((route) => ({
|
|
3133
|
-
method: route.method,
|
|
3134
|
-
path: route.path
|
|
3135
|
-
}));
|
|
3136
|
-
}
|
|
3137
|
-
async function generateBackendTopologyArtifactsInWorkers(params) {
|
|
3138
|
-
const selectors = params.routeSelector ? [params.routeSelector] : await discoverBackendTopologyRouteSelectors(params.cwd);
|
|
3139
|
-
params.onRoutesDiscovered?.(selectors.length);
|
|
3140
|
-
const workerCount = Math.min(params.workers || 2, selectors.length);
|
|
3141
|
-
const chunks = Array.from({ length: workerCount }, () => []);
|
|
3142
|
-
selectors.forEach((selector, index) => chunks[index % workerCount].push(selector));
|
|
3143
|
-
const artifacts = [];
|
|
3144
|
-
let completed = 0;
|
|
3145
|
-
let writeQueue = Promise.resolve();
|
|
3146
|
-
const workers = [];
|
|
3147
|
-
try {
|
|
3148
|
-
await Promise.all(chunks.map((routeSelectors) => new Promise((resolve, reject) => {
|
|
3149
|
-
const worker = new Worker(new URL("./routeBackendTopologyWorker.ts", import.meta.url), {
|
|
3150
|
-
workerData: {
|
|
3151
|
-
cwd: params.cwd,
|
|
3152
|
-
routeSelectors
|
|
3153
|
-
},
|
|
3154
|
-
execArgv: process.execArgv
|
|
3155
|
-
});
|
|
3156
|
-
workers.push(worker);
|
|
3157
|
-
worker.on("message", (message) => {
|
|
3158
|
-
if (message.type === "error") {
|
|
3159
|
-
reject(new Error(message.message));
|
|
3160
|
-
return;
|
|
3161
|
-
}
|
|
3162
|
-
writeQueue = writeQueue.then(async () => {
|
|
3163
|
-
if (message.type === "artifact") {
|
|
3164
|
-
await params.onArtifact(message.artifact);
|
|
3165
|
-
artifacts.push(message.artifact);
|
|
3166
|
-
return;
|
|
3167
|
-
}
|
|
3168
|
-
completed += 1;
|
|
3169
|
-
params.onRouteProgress?.({
|
|
3170
|
-
current: completed,
|
|
3171
|
-
total: selectors.length,
|
|
3172
|
-
route: message.route,
|
|
3173
|
-
stage: "completed"
|
|
3174
|
-
});
|
|
3175
|
-
}).catch(reject);
|
|
3176
|
-
});
|
|
3177
|
-
worker.once("error", reject);
|
|
3178
|
-
worker.once("exit", (code) => code === 0 ? resolve() : reject(/* @__PURE__ */ new Error(`Topology worker exited with code ${code}`)));
|
|
3179
|
-
})));
|
|
3180
|
-
await writeQueue;
|
|
3181
|
-
return artifacts;
|
|
3182
|
-
} catch (error) {
|
|
3183
|
-
await Promise.all(workers.map((worker) => worker.terminate().catch(() => void 0)));
|
|
3184
|
-
throw error;
|
|
3185
|
-
}
|
|
3186
|
-
}
|
|
3187
|
-
//#endregion
|
|
3188
2897
|
//#region src/commands/generateGraph.ts
|
|
3189
2898
|
function parseWorkerCount$1(value) {
|
|
3190
2899
|
const workers = Number(value);
|
|
@@ -3439,7 +3148,7 @@ async function readSourceExcerpts(document) {
|
|
|
3439
3148
|
const excerpts = [];
|
|
3440
3149
|
let totalCharacters = 0;
|
|
3441
3150
|
for (const location of uniqueLocations) try {
|
|
3442
|
-
const lines = (await fs
|
|
3151
|
+
const lines = (await fs.readFile(location.sourceFile, "utf8")).split("\n");
|
|
3443
3152
|
const startLine = Math.max(1, location.line - EXCERPT_RADIUS);
|
|
3444
3153
|
const endLine = Math.min(lines.length, location.line + EXCERPT_RADIUS);
|
|
3445
3154
|
const code = lines.slice(startLine - 1, endLine).map((line, index) => `${String(startLine + index).padStart(5)} | ${line}`).join("\n");
|
|
@@ -4205,18 +3914,18 @@ var init_default = (program, datasourceCommand) => void program.command("init").
|
|
|
4205
3914
|
const configDirectory = path.dirname(configPath);
|
|
4206
3915
|
intro("Initialize datasource configuration");
|
|
4207
3916
|
try {
|
|
4208
|
-
await fs
|
|
3917
|
+
await fs.mkdir(configDirectory, { recursive: true });
|
|
4209
3918
|
if (!options.force) {
|
|
4210
3919
|
let exists = false;
|
|
4211
3920
|
try {
|
|
4212
|
-
await fs
|
|
3921
|
+
await fs.access(configPath);
|
|
4213
3922
|
exists = true;
|
|
4214
3923
|
} catch (error) {
|
|
4215
3924
|
if (error.code !== "ENOENT") throw error;
|
|
4216
3925
|
}
|
|
4217
3926
|
if (exists) throw new Error(`${configPath} already exists. Use --force to overwrite it.`);
|
|
4218
3927
|
}
|
|
4219
|
-
await fs
|
|
3928
|
+
await fs.writeFile(configPath, configTemplate, "utf8");
|
|
4220
3929
|
outro(`Created ${configPath}`);
|
|
4221
3930
|
} catch (error) {
|
|
4222
3931
|
cancel(error instanceof Error ? error.message : String(error));
|
|
@@ -4349,7 +4058,7 @@ function collectImpactedRoutes(params) {
|
|
|
4349
4058
|
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}`));
|
|
4350
4059
|
}
|
|
4351
4060
|
async function readChangedFiles(filePath) {
|
|
4352
|
-
return (await fs
|
|
4061
|
+
return (await fs.readFile(filePath, "utf8")).split(/\r?\n/).map((line) => normalizeFilePath(line.trim())).filter(Boolean);
|
|
4353
4062
|
}
|
|
4354
4063
|
async function readRouteGraphs(outputDirectory) {
|
|
4355
4064
|
const graphFiles = await globby("**/*.graph.yaml", {
|
|
@@ -4357,7 +4066,7 @@ async function readRouteGraphs(outputDirectory) {
|
|
|
4357
4066
|
absolute: true
|
|
4358
4067
|
});
|
|
4359
4068
|
const graphs = await Promise.all(graphFiles.map(async (graphFile) => {
|
|
4360
|
-
const document = parse(await fs
|
|
4069
|
+
const document = parse(await fs.readFile(graphFile, "utf8"));
|
|
4361
4070
|
if (!isGraphDocument(document)) throw new Error(`Invalid graph document: ${graphFile}`);
|
|
4362
4071
|
return {
|
|
4363
4072
|
method: document.route.method,
|
|
@@ -4475,8 +4184,8 @@ async function reportChangedHandler(cwd, options) {
|
|
|
4475
4184
|
routes: coverages
|
|
4476
4185
|
});
|
|
4477
4186
|
const reportPath = path.resolve(cwd, options.report);
|
|
4478
|
-
await fs
|
|
4479
|
-
await fs
|
|
4187
|
+
await fs.mkdir(path.dirname(reportPath), { recursive: true });
|
|
4188
|
+
await fs.writeFile(reportPath, `${report}\n`, "utf8");
|
|
4480
4189
|
note(report, "Datasource mapping report");
|
|
4481
4190
|
}
|
|
4482
4191
|
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) => {
|
|
@@ -4496,7 +4205,8 @@ const program = new Command();
|
|
|
4496
4205
|
program.version("3.4.0-alpha.1");
|
|
4497
4206
|
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) => {
|
|
4498
4207
|
if (actionCommand.name() === "init") return;
|
|
4499
|
-
|
|
4208
|
+
const config = await loadAtlasConfig(program.opts().config, program.opts().projectRoot);
|
|
4209
|
+
setUserConfig(config);
|
|
4500
4210
|
});
|
|
4501
4211
|
init_default(program, program);
|
|
4502
4212
|
generate_default(program);
|
|
@@ -0,0 +1,792 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
4
|
+
import { Node, Project, SyntaxKind } from "ts-morph";
|
|
5
|
+
import { spinner } from "@clack/prompts";
|
|
6
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
7
|
+
import { globby } from "globby";
|
|
8
|
+
import { minimatch } from "minimatch";
|
|
9
|
+
import { Worker } from "node:worker_threads";
|
|
10
|
+
//#region src/utils/config.ts
|
|
11
|
+
let _config;
|
|
12
|
+
function setUserConfig(config) {
|
|
13
|
+
_config = config;
|
|
14
|
+
}
|
|
15
|
+
function getUserConfig() {
|
|
16
|
+
if (!_config) throw new Error("Atlas config not loaded. Run Atlas from a directory containing atlas.config.ts or pass --config.");
|
|
17
|
+
return _config;
|
|
18
|
+
}
|
|
19
|
+
function getTsconfigAliases(repoRoot) {
|
|
20
|
+
const tsconfigPath = path.join(repoRoot, "tsconfig.json");
|
|
21
|
+
if (!fs.existsSync(tsconfigPath)) return {};
|
|
22
|
+
const paths = new Project({
|
|
23
|
+
tsConfigFilePath: tsconfigPath,
|
|
24
|
+
skipAddingFilesFromTsConfig: true
|
|
25
|
+
}).getCompilerOptions().paths ?? {};
|
|
26
|
+
return Object.fromEntries(Object.entries(paths).flatMap(([alias, targets]) => {
|
|
27
|
+
const target = targets?.[0];
|
|
28
|
+
if (!target) return [];
|
|
29
|
+
return [[alias.replace(/\/\*$/, ""), target.replace(/^\.\//, "").replace(/\/\*$/, "")]];
|
|
30
|
+
}));
|
|
31
|
+
}
|
|
32
|
+
async function loadAtlasConfig(configPath, projectRoot) {
|
|
33
|
+
const filePath = path.resolve(configPath);
|
|
34
|
+
try {
|
|
35
|
+
let mod;
|
|
36
|
+
if (filePath.endsWith(".ts")) {
|
|
37
|
+
const { tsImport } = await import("tsx/esm/api");
|
|
38
|
+
mod = await tsImport(filePath, import.meta.url);
|
|
39
|
+
} else mod = await import(pathToFileURL(filePath).href);
|
|
40
|
+
const config = mod.default ?? mod;
|
|
41
|
+
const repoRoot = projectRoot || config.repoRoot || process.cwd();
|
|
42
|
+
return {
|
|
43
|
+
...config,
|
|
44
|
+
repoRoot,
|
|
45
|
+
cwd: repoRoot,
|
|
46
|
+
resolver: { alias: {
|
|
47
|
+
...getTsconfigAliases(repoRoot),
|
|
48
|
+
...config.resolver?.alias
|
|
49
|
+
} }
|
|
50
|
+
};
|
|
51
|
+
} catch (error) {
|
|
52
|
+
throw new Error(`Unable to load Atlas configuration at ${filePath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
//#endregion
|
|
56
|
+
//#region src/services/taskProgressService.ts
|
|
57
|
+
function formatDuration(durationMs) {
|
|
58
|
+
if (durationMs < 1e3) return `${durationMs}ms`;
|
|
59
|
+
return `${(durationMs / 1e3).toFixed(1)}s`;
|
|
60
|
+
}
|
|
61
|
+
var TaskProgressService = class {
|
|
62
|
+
#storage = new AsyncLocalStorage();
|
|
63
|
+
attach(sink, task) {
|
|
64
|
+
return this.#storage.run(sink, task);
|
|
65
|
+
}
|
|
66
|
+
log(message) {
|
|
67
|
+
this.#storage.getStore()?.log(message);
|
|
68
|
+
}
|
|
69
|
+
report(message) {
|
|
70
|
+
const sink = this.#storage.getStore();
|
|
71
|
+
(sink?.report || sink?.log)?.(message);
|
|
72
|
+
}
|
|
73
|
+
createStepProgress(classify = (message) => ({
|
|
74
|
+
id: message,
|
|
75
|
+
title: message
|
|
76
|
+
})) {
|
|
77
|
+
const progress = spinner();
|
|
78
|
+
let active;
|
|
79
|
+
const finishActive = (label) => {
|
|
80
|
+
if (!active) return;
|
|
81
|
+
const duration = formatDuration(Date.now() - active.startedAt);
|
|
82
|
+
progress.stop(`${label || active.completedTitle || `${active.title} completed`} (${duration})`);
|
|
83
|
+
active = void 0;
|
|
84
|
+
};
|
|
85
|
+
const start = (step) => {
|
|
86
|
+
if (active?.id === step.id) {
|
|
87
|
+
progress.message(step.detail ? `${step.title}: ${step.detail}` : step.title);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
finishActive();
|
|
91
|
+
active = {
|
|
92
|
+
id: step.id,
|
|
93
|
+
title: step.title,
|
|
94
|
+
completedTitle: step.completedTitle,
|
|
95
|
+
startedAt: Date.now()
|
|
96
|
+
};
|
|
97
|
+
progress.start(step.detail ? `${step.title}: ${step.detail}` : step.title);
|
|
98
|
+
};
|
|
99
|
+
const execute = (task) => this.attach({
|
|
100
|
+
log: (message) => progress.message(active ? `${active.title}: ${message}` : message),
|
|
101
|
+
report: (message) => start(classify(message))
|
|
102
|
+
}, task);
|
|
103
|
+
return {
|
|
104
|
+
report(message) {
|
|
105
|
+
start(classify(message));
|
|
106
|
+
},
|
|
107
|
+
start,
|
|
108
|
+
execute,
|
|
109
|
+
run: async (step, task) => {
|
|
110
|
+
start(step);
|
|
111
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
112
|
+
const result = await execute(task);
|
|
113
|
+
finishActive();
|
|
114
|
+
return result;
|
|
115
|
+
},
|
|
116
|
+
finish(label) {
|
|
117
|
+
finishActive(label);
|
|
118
|
+
},
|
|
119
|
+
fail(label) {
|
|
120
|
+
if (!active) return;
|
|
121
|
+
const duration = formatDuration(Date.now() - active.startedAt);
|
|
122
|
+
progress.stop(`${label} (${duration})`);
|
|
123
|
+
active = void 0;
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
const taskProgressService = new TaskProgressService();
|
|
129
|
+
//#endregion
|
|
130
|
+
//#region src/services/analysisFileService.ts
|
|
131
|
+
function shouldKeepAnalysisFile(filePath) {
|
|
132
|
+
const normalizedProjectPath = filePath.replaceAll(path.sep, "/").replace(/^.*?(app\/)/, "app/");
|
|
133
|
+
return !getUserConfig().analysis.excluded.some((pattern) => minimatch(normalizedProjectPath, pattern, { dot: true }));
|
|
134
|
+
}
|
|
135
|
+
function filterAnalysisFiles(filePaths) {
|
|
136
|
+
return filePaths.filter(shouldKeepAnalysisFile);
|
|
137
|
+
}
|
|
138
|
+
//#endregion
|
|
139
|
+
//#region src/utils/isKnowBackendType.ts
|
|
140
|
+
function isKnownBackendType(value) {
|
|
141
|
+
return getUserConfig().analysis.backends.includes(value);
|
|
142
|
+
}
|
|
143
|
+
//#endregion
|
|
144
|
+
//#region src/services/backendSourceService.ts
|
|
145
|
+
function inferBackendNameFromFile(sourceFile) {
|
|
146
|
+
const declaredType = sourceFile.getFullText().match(/BackendTypes\.([A-Z0-9_]+)/)?.[1];
|
|
147
|
+
if (declaredType) return isKnownBackendType(declaredType) ? declaredType : null;
|
|
148
|
+
const parts = sourceFile.getFilePath().split(path.sep);
|
|
149
|
+
const backIndex = parts.lastIndexOf("back");
|
|
150
|
+
const infrastructureIndex = parts.lastIndexOf("_infra");
|
|
151
|
+
const candidate = backIndex >= 0 ? parts[backIndex + 1] : infrastructureIndex >= 0 ? parts[infrastructureIndex + 2] : void 0;
|
|
152
|
+
return candidate && isKnownBackendType(candidate.toUpperCase()) ? candidate.toUpperCase() : null;
|
|
153
|
+
}
|
|
154
|
+
//#endregion
|
|
155
|
+
//#region src/utils/resolveAliasPath.ts
|
|
156
|
+
function resolveAliasPath(moduleSpecifier, aliases, rootPath) {
|
|
157
|
+
for (const [alias, target] of Object.entries(aliases)) {
|
|
158
|
+
if (moduleSpecifier !== alias && !moduleSpecifier.startsWith(`${alias}/`)) continue;
|
|
159
|
+
const modulePath = moduleSpecifier.slice(alias.length).replace(/^\//, "");
|
|
160
|
+
return path.join(rootPath, target, modulePath);
|
|
161
|
+
}
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
//#endregion
|
|
165
|
+
//#region src/utils/tryResolveWithExtensions.ts
|
|
166
|
+
function tryResolveWithExtensions(basePath) {
|
|
167
|
+
const ext = path.extname(basePath);
|
|
168
|
+
const withoutExt = ext ? basePath.slice(0, -ext.length) : basePath;
|
|
169
|
+
const candidates = [
|
|
170
|
+
basePath,
|
|
171
|
+
ext === ".js" ? `${withoutExt}.ts` : null,
|
|
172
|
+
ext === ".ts" ? `${withoutExt}.js` : null,
|
|
173
|
+
ext === ".mjs" ? `${withoutExt}.mts` : null,
|
|
174
|
+
ext === ".mts" ? `${withoutExt}.mjs` : null,
|
|
175
|
+
`${basePath}.ts`,
|
|
176
|
+
`${basePath}.js`,
|
|
177
|
+
`${basePath}.mts`,
|
|
178
|
+
`${basePath}.mjs`,
|
|
179
|
+
path.join(withoutExt, "index.ts"),
|
|
180
|
+
path.join(withoutExt, "index.js"),
|
|
181
|
+
path.join(basePath, "index.ts"),
|
|
182
|
+
path.join(basePath, "index.js")
|
|
183
|
+
].filter((candidate) => Boolean(candidate));
|
|
184
|
+
for (const candidate of candidates) try {
|
|
185
|
+
const normalized = path.normalize(candidate);
|
|
186
|
+
if (fs.existsSync(normalized)) return normalized;
|
|
187
|
+
} catch {
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
//#endregion
|
|
193
|
+
//#region src/utils/resolveModulePath.ts
|
|
194
|
+
function resolveModulePath(sourceFilePath, moduleSpecifier, aliases, rootPath) {
|
|
195
|
+
if (moduleSpecifier.startsWith(".")) return tryResolveWithExtensions(path.resolve(path.dirname(sourceFilePath), moduleSpecifier));
|
|
196
|
+
const aliased = resolveAliasPath(moduleSpecifier, aliases, rootPath);
|
|
197
|
+
return aliased ? tryResolveWithExtensions(aliased) : null;
|
|
198
|
+
}
|
|
199
|
+
//#endregion
|
|
200
|
+
//#region src/services/routeBackendTopologyService.ts
|
|
201
|
+
const backendTopologyWeights = {
|
|
202
|
+
local_call: 1,
|
|
203
|
+
imported_call: 2,
|
|
204
|
+
local_callback: 2,
|
|
205
|
+
imported_callback: 3
|
|
206
|
+
};
|
|
207
|
+
function normalizeSourcePath(cwd, filePath) {
|
|
208
|
+
const relative = path.relative(cwd, filePath);
|
|
209
|
+
return relative.startsWith("..") ? filePath : relative.replaceAll(path.sep, "/");
|
|
210
|
+
}
|
|
211
|
+
function isFilePath(filePath) {
|
|
212
|
+
try {
|
|
213
|
+
return fs.statSync(filePath).isFile();
|
|
214
|
+
} catch {
|
|
215
|
+
return false;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
function callableLine(callable) {
|
|
219
|
+
return callable.declaration?.getStartLineNumber() || 1;
|
|
220
|
+
}
|
|
221
|
+
function callableKey(callable) {
|
|
222
|
+
return `${callable.sourceFile.getFilePath()}:${callable.symbol}:${callableLine(callable)}`;
|
|
223
|
+
}
|
|
224
|
+
function isCallableVariable(declaration) {
|
|
225
|
+
const initializer = declaration.getInitializer();
|
|
226
|
+
return Boolean(initializer && (Node.isArrowFunction(initializer) || Node.isFunctionExpression(initializer) || Node.isCallExpression(initializer) || Node.isNewExpression(initializer)));
|
|
227
|
+
}
|
|
228
|
+
function findLocalCallable(sourceFile, symbol) {
|
|
229
|
+
const functionDeclaration = sourceFile.getFunctions().find((item) => item.getName() === symbol);
|
|
230
|
+
if (functionDeclaration) return functionDeclaration;
|
|
231
|
+
const variableDeclaration = sourceFile.getVariableDeclarations().find((item) => item.getName() === symbol && isCallableVariable(item));
|
|
232
|
+
if (variableDeclaration) return variableDeclaration;
|
|
233
|
+
return sourceFile.getDescendantsOfKind(SyntaxKind.MethodDeclaration).find((item) => item.getName() === symbol);
|
|
234
|
+
}
|
|
235
|
+
function resolveSourceFile(project, owner, moduleSpecifier, resolvePath) {
|
|
236
|
+
const resolvedPath = resolvePath(owner.getFilePath(), moduleSpecifier);
|
|
237
|
+
if (!resolvedPath) return void 0;
|
|
238
|
+
const existing = project.getSourceFile(resolvedPath);
|
|
239
|
+
if (existing) return existing;
|
|
240
|
+
return isFilePath(resolvedPath) ? project.addSourceFileAtPathIfExists(resolvedPath) : void 0;
|
|
241
|
+
}
|
|
242
|
+
function resolveExportedCallable(project, sourceFile, symbol, resolvePath, seen = /* @__PURE__ */ new Set()) {
|
|
243
|
+
const key = `${sourceFile.getFilePath()}:${symbol}`;
|
|
244
|
+
if (seen.has(key)) return void 0;
|
|
245
|
+
seen.add(key);
|
|
246
|
+
const local = findLocalCallable(sourceFile, symbol);
|
|
247
|
+
if (local) return {
|
|
248
|
+
declaration: local,
|
|
249
|
+
sourceFile,
|
|
250
|
+
symbol,
|
|
251
|
+
imported: true
|
|
252
|
+
};
|
|
253
|
+
for (const exportDeclaration of sourceFile.getExportDeclarations()) {
|
|
254
|
+
const moduleSpecifier = exportDeclaration.getModuleSpecifierValue();
|
|
255
|
+
if (!moduleSpecifier) continue;
|
|
256
|
+
const namedExport = exportDeclaration.getNamedExports().find((item) => (item.getAliasNode()?.getText() || item.getName()) === symbol);
|
|
257
|
+
if (exportDeclaration.getNamedExports().length && !namedExport) continue;
|
|
258
|
+
const targetFile = resolveSourceFile(project, sourceFile, moduleSpecifier, resolvePath);
|
|
259
|
+
if (!targetFile) continue;
|
|
260
|
+
const resolved = resolveExportedCallable(project, targetFile, namedExport?.getName() || symbol, resolvePath, seen);
|
|
261
|
+
if (resolved) return resolved;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
function resolveConstructedMember(project, sourceFile, variableName, memberName, resolvePath) {
|
|
265
|
+
const initializer = sourceFile.getVariableDeclaration(variableName)?.getInitializer();
|
|
266
|
+
if (!initializer || !Node.isNewExpression(initializer)) return void 0;
|
|
267
|
+
const constructorName = initializer.getExpression().getText();
|
|
268
|
+
const localMethod = sourceFile.getClass(constructorName)?.getInstanceMethod(memberName);
|
|
269
|
+
if (localMethod) return {
|
|
270
|
+
declaration: localMethod,
|
|
271
|
+
sourceFile,
|
|
272
|
+
symbol: memberName,
|
|
273
|
+
imported: true
|
|
274
|
+
};
|
|
275
|
+
for (const importDeclaration of sourceFile.getImportDeclarations()) {
|
|
276
|
+
const namedImport = importDeclaration.getNamedImports().find((item) => (item.getAliasNode()?.getText() || item.getName()) === constructorName);
|
|
277
|
+
if (!namedImport) continue;
|
|
278
|
+
const targetFile = resolveSourceFile(project, sourceFile, importDeclaration.getModuleSpecifierValue(), resolvePath);
|
|
279
|
+
const method = targetFile?.getClass(namedImport.getName())?.getInstanceMethod(memberName);
|
|
280
|
+
if (targetFile && method) return {
|
|
281
|
+
declaration: method,
|
|
282
|
+
sourceFile: targetFile,
|
|
283
|
+
symbol: memberName,
|
|
284
|
+
imported: true
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
function resolveImportedReference(project, sourceFile, expressionText, resolvePath) {
|
|
289
|
+
const [root, member] = expressionText.split(".");
|
|
290
|
+
for (const importDeclaration of sourceFile.getImportDeclarations()) {
|
|
291
|
+
const targetFile = resolveSourceFile(project, sourceFile, importDeclaration.getModuleSpecifierValue(), resolvePath);
|
|
292
|
+
if (!targetFile) continue;
|
|
293
|
+
if (importDeclaration.getNamespaceImport()?.getText() === root && member) return resolveExportedCallable(project, targetFile, member, resolvePath) || {
|
|
294
|
+
sourceFile: targetFile,
|
|
295
|
+
symbol: expressionText,
|
|
296
|
+
imported: true
|
|
297
|
+
};
|
|
298
|
+
const namedImport = importDeclaration.getNamedImports().find((item) => (item.getAliasNode()?.getText() || item.getName()) === root);
|
|
299
|
+
if (namedImport) {
|
|
300
|
+
const importedSymbol = namedImport.getName();
|
|
301
|
+
if (member) {
|
|
302
|
+
if (inferBackendNameFromFile(targetFile)) return {
|
|
303
|
+
sourceFile: targetFile,
|
|
304
|
+
symbol: expressionText,
|
|
305
|
+
imported: true
|
|
306
|
+
};
|
|
307
|
+
const constructedMember = resolveConstructedMember(project, targetFile, importedSymbol, member, resolvePath);
|
|
308
|
+
if (constructedMember) return constructedMember;
|
|
309
|
+
}
|
|
310
|
+
return resolveExportedCallable(project, targetFile, member || importedSymbol, resolvePath) || {
|
|
311
|
+
sourceFile: targetFile,
|
|
312
|
+
symbol: expressionText,
|
|
313
|
+
imported: true
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
if (importDeclaration.getDefaultImport()?.getText() === root) return resolveExportedCallable(project, targetFile, member || "default", resolvePath) || {
|
|
317
|
+
sourceFile: targetFile,
|
|
318
|
+
symbol: expressionText,
|
|
319
|
+
imported: true
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
function resolveTypedPropertyMethod(project, sourceFile, expression, resolvePath) {
|
|
324
|
+
const receiver = expression.getExpression();
|
|
325
|
+
if (!Node.isPropertyAccessExpression(receiver) || receiver.getExpression().getText() !== "this") return;
|
|
326
|
+
const propertyType = expression.getFirstAncestorByKind(SyntaxKind.ClassDeclaration)?.getProperty(receiver.getName())?.getTypeNode()?.getText().match(/[A-Za-z_$][A-Za-z0-9_$]*/)?.[0];
|
|
327
|
+
if (!propertyType) return void 0;
|
|
328
|
+
for (const importDeclaration of sourceFile.getImportDeclarations()) {
|
|
329
|
+
const namedImport = importDeclaration.getNamedImports().find((item) => (item.getAliasNode()?.getText() || item.getName()) === propertyType);
|
|
330
|
+
if (!namedImport) continue;
|
|
331
|
+
const targetFile = resolveSourceFile(project, sourceFile, importDeclaration.getModuleSpecifierValue(), resolvePath);
|
|
332
|
+
if (!targetFile) continue;
|
|
333
|
+
const importedType = namedImport.getName();
|
|
334
|
+
const method = targetFile.getClasses().find((declaration) => declaration.getName() === importedType)?.getInstanceMethod(expression.getName());
|
|
335
|
+
if (method) return {
|
|
336
|
+
declaration: method,
|
|
337
|
+
sourceFile: targetFile,
|
|
338
|
+
symbol: expression.getText(),
|
|
339
|
+
imported: true
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
function resolveReference(project, sourceFile, expression, resolvePath) {
|
|
344
|
+
const expressionText = expression.getText();
|
|
345
|
+
const localSymbol = Node.isPropertyAccessExpression(expression) ? expression.getName() : Node.isIdentifier(expression) ? expression.getText() : void 0;
|
|
346
|
+
if (Node.isPropertyAccessExpression(expression)) {
|
|
347
|
+
const imported = resolveImportedReference(project, sourceFile, expressionText, resolvePath);
|
|
348
|
+
if (imported) return imported;
|
|
349
|
+
const typedPropertyMethod = resolveTypedPropertyMethod(project, sourceFile, expression, resolvePath);
|
|
350
|
+
if (typedPropertyMethod) return typedPropertyMethod;
|
|
351
|
+
}
|
|
352
|
+
if (localSymbol) {
|
|
353
|
+
const local = findLocalCallable(sourceFile, localSymbol);
|
|
354
|
+
if (local) return {
|
|
355
|
+
declaration: local,
|
|
356
|
+
sourceFile,
|
|
357
|
+
symbol: localSymbol,
|
|
358
|
+
imported: false
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
const imported = resolveImportedReference(project, sourceFile, expressionText, resolvePath);
|
|
362
|
+
if (imported) return imported;
|
|
363
|
+
const declaration = expression.getSymbol()?.getAliasedSymbol()?.getDeclarations()[0] || expression.getSymbol()?.getDeclarations()[0];
|
|
364
|
+
if (!declaration) return void 0;
|
|
365
|
+
const callable = Node.isFunctionDeclaration(declaration) || Node.isMethodDeclaration(declaration) || Node.isVariableDeclaration(declaration) ? declaration : void 0;
|
|
366
|
+
if (!callable) return void 0;
|
|
367
|
+
if (Node.isVariableDeclaration(callable) && !isCallableVariable(callable)) return void 0;
|
|
368
|
+
const targetFile = callable.getSourceFile();
|
|
369
|
+
return {
|
|
370
|
+
declaration: callable,
|
|
371
|
+
sourceFile: targetFile,
|
|
372
|
+
symbol: localSymbol || expressionText,
|
|
373
|
+
imported: targetFile.getFilePath() !== sourceFile.getFilePath()
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
function referenceExpressions(call) {
|
|
377
|
+
const references = [{
|
|
378
|
+
expression: call.getExpression(),
|
|
379
|
+
type: "call"
|
|
380
|
+
}];
|
|
381
|
+
for (const argument of call.getArguments()) if (Node.isIdentifier(argument) || Node.isPropertyAccessExpression(argument)) references.push({
|
|
382
|
+
expression: argument,
|
|
383
|
+
type: "callback"
|
|
384
|
+
});
|
|
385
|
+
return references;
|
|
386
|
+
}
|
|
387
|
+
function edgeWeight(type, imported) {
|
|
388
|
+
if (type === "callback") return imported ? backendTopologyWeights.imported_callback : backendTopologyWeights.local_callback;
|
|
389
|
+
return imported ? backendTopologyWeights.imported_call : backendTopologyWeights.local_call;
|
|
390
|
+
}
|
|
391
|
+
function callableCalls(callable) {
|
|
392
|
+
return callable.declaration?.getDescendantsOfKind(SyntaxKind.CallExpression) || [];
|
|
393
|
+
}
|
|
394
|
+
function mappingDirection(symbol, layer) {
|
|
395
|
+
const mapperNaming = getUserConfig().analysis.mapperNaming;
|
|
396
|
+
if (layer === "api") {
|
|
397
|
+
if (mapperNaming.inputPatterns.some((pattern) => pattern.test(symbol))) return "input";
|
|
398
|
+
if (mapperNaming.outputPatterns.some((pattern) => pattern.test(symbol))) return "output";
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
if (mapperNaming.domainToBackendPattern.test(symbol)) return "input";
|
|
402
|
+
if (mapperNaming.domainNameFromToDomainPattern.test(symbol) || /(?:From(?:Back|Backend)ToDomain|ToDomain)$/i.test(symbol)) return "output";
|
|
403
|
+
}
|
|
404
|
+
function callableFromTopologyNode(cwd, project, node) {
|
|
405
|
+
const filePath = path.resolve(cwd, node.source);
|
|
406
|
+
const sourceFile = project.getSourceFile(filePath) || project.addSourceFileAtPathIfExists(filePath);
|
|
407
|
+
if (!sourceFile) return void 0;
|
|
408
|
+
const declaration = [
|
|
409
|
+
...sourceFile.getFunctions(),
|
|
410
|
+
...sourceFile.getDescendantsOfKind(SyntaxKind.MethodDeclaration),
|
|
411
|
+
...sourceFile.getVariableDeclarations().filter(isCallableVariable)
|
|
412
|
+
].find((item) => item.getStartLineNumber() === node.line);
|
|
413
|
+
return declaration ? {
|
|
414
|
+
declaration,
|
|
415
|
+
sourceFile,
|
|
416
|
+
symbol: node.symbol,
|
|
417
|
+
imported: true
|
|
418
|
+
} : void 0;
|
|
419
|
+
}
|
|
420
|
+
function collectBackendTopologyMappingContext(params) {
|
|
421
|
+
const handlerDeclaration = findLocalCallable(params.handlerFile, params.handlerName);
|
|
422
|
+
if (!handlerDeclaration) return [];
|
|
423
|
+
const handler = {
|
|
424
|
+
declaration: handlerDeclaration,
|
|
425
|
+
sourceFile: params.handlerFile,
|
|
426
|
+
symbol: params.handlerName,
|
|
427
|
+
imported: false
|
|
428
|
+
};
|
|
429
|
+
const contexts = [];
|
|
430
|
+
const visitedMapperCalls = /* @__PURE__ */ new Set();
|
|
431
|
+
const appendMapper = (callable, layer, direction, backendPath, backendType) => {
|
|
432
|
+
const key = [
|
|
433
|
+
callableKey(callable),
|
|
434
|
+
layer,
|
|
435
|
+
direction,
|
|
436
|
+
backendPath,
|
|
437
|
+
backendType
|
|
438
|
+
].join(":");
|
|
439
|
+
if (visitedMapperCalls.has(key)) return;
|
|
440
|
+
visitedMapperCalls.add(key);
|
|
441
|
+
contexts.push({
|
|
442
|
+
layer,
|
|
443
|
+
direction,
|
|
444
|
+
symbol: callable.declaration?.getSymbol()?.getName() || callable.symbol.split(".").at(-1),
|
|
445
|
+
source: normalizeSourcePath(params.cwd, callable.sourceFile.getFilePath()),
|
|
446
|
+
line: callableLine(callable),
|
|
447
|
+
backend_path: backendPath,
|
|
448
|
+
backend_type: backendType
|
|
449
|
+
});
|
|
450
|
+
const expectedLayerPath = layer === "api" ? "app/_api/" : "app/_infra/back/";
|
|
451
|
+
for (const call of callableCalls(callable)) {
|
|
452
|
+
const dependency = resolveReference(params.project, callable.sourceFile, call.getExpression(), params.resolvePath);
|
|
453
|
+
if (!dependency?.declaration || !normalizeSourcePath(params.cwd, dependency.sourceFile.getFilePath()).includes(expectedLayerPath)) continue;
|
|
454
|
+
appendMapper(dependency, layer, direction, backendPath, backendType);
|
|
455
|
+
}
|
|
456
|
+
};
|
|
457
|
+
const appendMapperCalls = (callable, layer, backendPath, backendType) => {
|
|
458
|
+
for (const call of callableCalls(callable)) {
|
|
459
|
+
const resolved = resolveReference(params.project, callable.sourceFile, call.getExpression(), params.resolvePath);
|
|
460
|
+
if (!resolved?.declaration) continue;
|
|
461
|
+
const direction = mappingDirection(resolved.declaration.getSymbol()?.getName() || resolved.symbol.split(".").at(-1), layer);
|
|
462
|
+
if (!direction) continue;
|
|
463
|
+
appendMapper(resolved, layer, direction, backendPath, backendType);
|
|
464
|
+
}
|
|
465
|
+
};
|
|
466
|
+
for (const [backendPath, topologyPath] of params.backendPaths.entries()) {
|
|
467
|
+
appendMapperCalls(handler, "api", backendPath, topologyPath.backend_type);
|
|
468
|
+
for (const node of topologyPath.nodes) {
|
|
469
|
+
if (!node.source.includes("app/_infra/back/")) continue;
|
|
470
|
+
const callable = callableFromTopologyNode(params.cwd, params.project, node);
|
|
471
|
+
if (callable) appendMapperCalls(callable, "backend", backendPath, topologyPath.backend_type);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
return [...new Map(contexts.map((context) => [[
|
|
475
|
+
context.layer,
|
|
476
|
+
context.direction,
|
|
477
|
+
context.symbol,
|
|
478
|
+
context.source,
|
|
479
|
+
context.line,
|
|
480
|
+
context.backend_path,
|
|
481
|
+
context.backend_type
|
|
482
|
+
].join(":"), context])).values()];
|
|
483
|
+
}
|
|
484
|
+
function traceBackendPaths(params) {
|
|
485
|
+
const handlerDeclaration = findLocalCallable(params.handlerFile, params.handlerName);
|
|
486
|
+
if (!handlerDeclaration) throw new Error(`Handler declaration not found: ${params.handlerName} in ${params.handlerFile.getFilePath()}`);
|
|
487
|
+
const handler = {
|
|
488
|
+
declaration: handlerDeclaration,
|
|
489
|
+
sourceFile: params.handlerFile,
|
|
490
|
+
symbol: params.handlerName,
|
|
491
|
+
imported: false
|
|
492
|
+
};
|
|
493
|
+
const queue = [{
|
|
494
|
+
callable: handler,
|
|
495
|
+
weight: 0,
|
|
496
|
+
nodes: [{
|
|
497
|
+
symbol: handler.symbol,
|
|
498
|
+
source: normalizeSourcePath(params.cwd, handler.sourceFile.getFilePath()),
|
|
499
|
+
line: callableLine(handler),
|
|
500
|
+
depth: 0
|
|
501
|
+
}],
|
|
502
|
+
edges: [],
|
|
503
|
+
visited: /* @__PURE__ */ new Set([callableKey(handler)])
|
|
504
|
+
}];
|
|
505
|
+
const paths = [];
|
|
506
|
+
const shortestWeightByCallable = /* @__PURE__ */ new Map([[callableKey(handler), 0]]);
|
|
507
|
+
const shortestWeightByBackendBoundary = /* @__PURE__ */ new Map();
|
|
508
|
+
while (queue.length) {
|
|
509
|
+
queue.sort((left, right) => left.weight - right.weight);
|
|
510
|
+
const current = queue.shift();
|
|
511
|
+
for (const call of callableCalls(current.callable)) for (const reference of referenceExpressions(call)) {
|
|
512
|
+
const resolved = resolveReference(params.project, current.callable.sourceFile, reference.expression, params.resolvePath);
|
|
513
|
+
if (!resolved) continue;
|
|
514
|
+
const key = callableKey(resolved);
|
|
515
|
+
if (current.visited.has(key)) continue;
|
|
516
|
+
const weight = edgeWeight(reference.type, resolved.imported);
|
|
517
|
+
const totalWeight = current.weight + weight;
|
|
518
|
+
const backendType = inferBackendNameFromFile(resolved.sourceFile) || void 0;
|
|
519
|
+
const node = {
|
|
520
|
+
symbol: resolved.symbol,
|
|
521
|
+
source: normalizeSourcePath(params.cwd, resolved.sourceFile.getFilePath()),
|
|
522
|
+
line: callableLine(resolved),
|
|
523
|
+
depth: totalWeight,
|
|
524
|
+
backend_type: backendType
|
|
525
|
+
};
|
|
526
|
+
const edge = {
|
|
527
|
+
type: reference.type,
|
|
528
|
+
from: current.callable.symbol,
|
|
529
|
+
to: resolved.symbol,
|
|
530
|
+
source: normalizeSourcePath(params.cwd, current.callable.sourceFile.getFilePath()),
|
|
531
|
+
line: call.getStartLineNumber(),
|
|
532
|
+
weight
|
|
533
|
+
};
|
|
534
|
+
const nodes = [...current.nodes, node];
|
|
535
|
+
const edges = [...current.edges, edge];
|
|
536
|
+
if (backendType) {
|
|
537
|
+
const boundaryKey = `${backendType}:${node.source}:${node.symbol}:${node.line}`;
|
|
538
|
+
const shortestWeight = shortestWeightByBackendBoundary.get(boundaryKey);
|
|
539
|
+
if (shortestWeight !== void 0 && totalWeight > shortestWeight) continue;
|
|
540
|
+
shortestWeightByBackendBoundary.set(boundaryKey, totalWeight);
|
|
541
|
+
paths.push({
|
|
542
|
+
backend_type: backendType,
|
|
543
|
+
total_weight: totalWeight,
|
|
544
|
+
status: "resolved",
|
|
545
|
+
nodes,
|
|
546
|
+
edges
|
|
547
|
+
});
|
|
548
|
+
continue;
|
|
549
|
+
}
|
|
550
|
+
if (!resolved.declaration) continue;
|
|
551
|
+
const shortestWeight = shortestWeightByCallable.get(key);
|
|
552
|
+
if (shortestWeight !== void 0 && totalWeight >= shortestWeight) continue;
|
|
553
|
+
shortestWeightByCallable.set(key, totalWeight);
|
|
554
|
+
queue.push({
|
|
555
|
+
callable: resolved,
|
|
556
|
+
weight: totalWeight,
|
|
557
|
+
nodes,
|
|
558
|
+
edges,
|
|
559
|
+
visited: /* @__PURE__ */ new Set([...current.visited, key])
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
const unique = /* @__PURE__ */ new Map();
|
|
564
|
+
for (const topologyPath of paths.filter((item) => {
|
|
565
|
+
const terminalNode = item.nodes.at(-1);
|
|
566
|
+
const boundaryKey = `${item.backend_type}:${terminalNode.source}:${terminalNode.symbol}:${terminalNode.line}`;
|
|
567
|
+
return item.total_weight === shortestWeightByBackendBoundary.get(boundaryKey);
|
|
568
|
+
})) {
|
|
569
|
+
const key = `${topologyPath.backend_type}:${topologyPath.nodes.map((node) => `${node.source}:${node.symbol}:${node.line}`).join("->")}`;
|
|
570
|
+
const existing = unique.get(key);
|
|
571
|
+
if (!existing || topologyPath.total_weight < existing.total_weight) unique.set(key, topologyPath);
|
|
572
|
+
}
|
|
573
|
+
return [...unique.values()].sort((left, right) => left.total_weight - right.total_weight || left.backend_type.localeCompare(right.backend_type));
|
|
574
|
+
}
|
|
575
|
+
function stringProperty(object, name) {
|
|
576
|
+
const property = object.getProperty(name);
|
|
577
|
+
if (!property || !Node.isPropertyAssignment(property)) return void 0;
|
|
578
|
+
const initializer = property.getInitializer();
|
|
579
|
+
return initializer && (Node.isStringLiteral(initializer) || Node.isNoSubstitutionTemplateLiteral(initializer)) ? initializer.getLiteralValue() : void 0;
|
|
580
|
+
}
|
|
581
|
+
function handlerProperty(object) {
|
|
582
|
+
const property = object.getProperty("handler");
|
|
583
|
+
if (!property || !Node.isPropertyAssignment(property)) return void 0;
|
|
584
|
+
const initializer = property.getInitializer();
|
|
585
|
+
return initializer && (Node.isIdentifier(initializer) || Node.isPropertyAccessExpression(initializer)) ? initializer.getText() : void 0;
|
|
586
|
+
}
|
|
587
|
+
function extractRouteDeclarations(sourceFile) {
|
|
588
|
+
const initializer = sourceFile.getVariableDeclaration("routes")?.getInitializer();
|
|
589
|
+
let expression = initializer;
|
|
590
|
+
if (initializer && (Node.isAsExpression(initializer) || Node.isSatisfiesExpression(initializer))) expression = initializer.getExpression();
|
|
591
|
+
if (!expression || !Node.isArrayLiteralExpression(expression)) return [];
|
|
592
|
+
return expression.getElements().flatMap((element) => {
|
|
593
|
+
if (!Node.isObjectLiteralExpression(element)) return [];
|
|
594
|
+
const method = stringProperty(element, "method");
|
|
595
|
+
const routePath = stringProperty(element, "path");
|
|
596
|
+
const handlerRef = handlerProperty(element);
|
|
597
|
+
return method && routePath && handlerRef ? [{
|
|
598
|
+
method: method.toUpperCase(),
|
|
599
|
+
path: routePath,
|
|
600
|
+
sourceFile,
|
|
601
|
+
handlerRef
|
|
602
|
+
}] : [];
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
function resolveHandler(project, route, resolvePath) {
|
|
606
|
+
const resolved = resolveImportedReference(project, route.sourceFile, route.handlerRef, resolvePath);
|
|
607
|
+
if (resolved) return resolved;
|
|
608
|
+
const local = findLocalCallable(route.sourceFile, route.handlerRef);
|
|
609
|
+
return local ? {
|
|
610
|
+
declaration: local,
|
|
611
|
+
sourceFile: route.sourceFile,
|
|
612
|
+
symbol: route.handlerRef,
|
|
613
|
+
imported: false
|
|
614
|
+
} : void 0;
|
|
615
|
+
}
|
|
616
|
+
async function collectBackendTopologyDependencyFiles(project, entryFilePath, resolvePath) {
|
|
617
|
+
const queue = [entryFilePath];
|
|
618
|
+
const visited = /* @__PURE__ */ new Set();
|
|
619
|
+
while (queue.length) {
|
|
620
|
+
const current = queue.shift();
|
|
621
|
+
if (visited.has(current)) continue;
|
|
622
|
+
visited.add(current);
|
|
623
|
+
const sourceFile = project.getSourceFile(current) || (isFilePath(current) ? project.addSourceFileAtPathIfExists(current) : void 0);
|
|
624
|
+
if (!sourceFile) continue;
|
|
625
|
+
const moduleSpecifiers = [...sourceFile.getImportDeclarations().map((item) => item.getModuleSpecifierValue()), ...sourceFile.getExportDeclarations().map((item) => item.getModuleSpecifierValue()).filter((value) => Boolean(value))];
|
|
626
|
+
for (const moduleSpecifier of moduleSpecifiers) {
|
|
627
|
+
const resolved = resolvePath(sourceFile.getFilePath(), moduleSpecifier);
|
|
628
|
+
if (resolved && !visited.has(resolved) && shouldKeepAnalysisFile(resolved)) queue.push(resolved);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
return [...visited];
|
|
632
|
+
}
|
|
633
|
+
async function generateBackendTopologyArtifacts(params) {
|
|
634
|
+
const resolvePath = (sourceFilePath, moduleSpecifier) => resolveModulePath(sourceFilePath, moduleSpecifier, getUserConfig().resolver.alias, params.cwd);
|
|
635
|
+
const project = new Project({
|
|
636
|
+
skipAddingFilesFromTsConfig: true,
|
|
637
|
+
compilerOptions: {
|
|
638
|
+
allowJs: true,
|
|
639
|
+
checkJs: false,
|
|
640
|
+
target: 99,
|
|
641
|
+
module: 99
|
|
642
|
+
}
|
|
643
|
+
});
|
|
644
|
+
taskProgressService.report("Discovering route files");
|
|
645
|
+
const routeFiles = await globby(["app/_api/**/routes.@(js|ts)", "app/legacy/**/routes.@(js|ts)"], {
|
|
646
|
+
cwd: params.cwd,
|
|
647
|
+
absolute: true
|
|
648
|
+
});
|
|
649
|
+
taskProgressService.log(`${routeFiles.length} route file${routeFiles.length === 1 ? "" : "s"} discovered`);
|
|
650
|
+
taskProgressService.report("Extracting route declarations");
|
|
651
|
+
const routes = routeFiles.flatMap((routeFile) => extractRouteDeclarations(project.addSourceFileAtPath(routeFile))).filter((route) => {
|
|
652
|
+
if (params.routeSelector) return route.method === params.routeSelector.method && route.path === params.routeSelector.path;
|
|
653
|
+
return !params.routeSelectors || params.routeSelectors.some((selector) => route.method === selector.method && route.path === selector.path);
|
|
654
|
+
});
|
|
655
|
+
if (params.routeSelector && !routes.length) throw new Error(`Route not found: ${params.routeSelector.method} ${params.routeSelector.path}`);
|
|
656
|
+
params.onRoutesDiscovered?.(routes.length);
|
|
657
|
+
if (!params.onRoutesDiscovered) taskProgressService.log(`${routes.length} API route${routes.length === 1 ? "" : "s"} selected`);
|
|
658
|
+
const artifacts = [];
|
|
659
|
+
for (const [index, route] of routes.entries()) {
|
|
660
|
+
const routeProgress = (stage) => params.onRouteProgress?.({
|
|
661
|
+
current: index + 1,
|
|
662
|
+
total: routes.length,
|
|
663
|
+
route: {
|
|
664
|
+
method: route.method,
|
|
665
|
+
path: route.path
|
|
666
|
+
},
|
|
667
|
+
stage
|
|
668
|
+
});
|
|
669
|
+
if (!params.onRouteProgress) {
|
|
670
|
+
taskProgressService.report(`Tracing route ${index + 1}/${routes.length}: ${route.method} ${route.path}`);
|
|
671
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
672
|
+
}
|
|
673
|
+
const handler = resolveHandler(project, route, resolvePath);
|
|
674
|
+
if (!handler?.declaration) {
|
|
675
|
+
taskProgressService.log(`Warning: handler not resolved for ${route.method} ${route.path} (${route.handlerRef}); route skipped`);
|
|
676
|
+
routeProgress("completed");
|
|
677
|
+
continue;
|
|
678
|
+
}
|
|
679
|
+
routeProgress("collecting_dependencies");
|
|
680
|
+
if (!params.onRouteProgress) taskProgressService.log(`${route.method} ${route.path} ... Collecting dependencies`);
|
|
681
|
+
const dependencyFiles = await collectBackendTopologyDependencyFiles(project, handler.sourceFile.getFilePath(), resolvePath);
|
|
682
|
+
routeProgress("tracing_paths");
|
|
683
|
+
if (!params.onRouteProgress) taskProgressService.log(`${route.method} ${route.path} ... Tracing callable paths`);
|
|
684
|
+
const backendPaths = traceBackendPaths({
|
|
685
|
+
cwd: params.cwd,
|
|
686
|
+
project,
|
|
687
|
+
handlerFile: handler.sourceFile,
|
|
688
|
+
handlerName: handler.symbol,
|
|
689
|
+
resolvePath
|
|
690
|
+
});
|
|
691
|
+
const mappingContext = collectBackendTopologyMappingContext({
|
|
692
|
+
cwd: params.cwd,
|
|
693
|
+
project,
|
|
694
|
+
handlerFile: handler.sourceFile,
|
|
695
|
+
handlerName: handler.symbol,
|
|
696
|
+
backendPaths,
|
|
697
|
+
resolvePath
|
|
698
|
+
});
|
|
699
|
+
if (!params.onRouteProgress) taskProgressService.log(`${route.method} ${route.path} ... ${backendPaths.length} backend path${backendPaths.length === 1 ? "" : "s"}`);
|
|
700
|
+
const artifact = {
|
|
701
|
+
schema_version: 3,
|
|
702
|
+
generated_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
703
|
+
route: {
|
|
704
|
+
method: route.method,
|
|
705
|
+
path: route.path,
|
|
706
|
+
source: normalizeSourcePath(params.cwd, route.sourceFile.getFilePath()),
|
|
707
|
+
handler: {
|
|
708
|
+
symbol: handler.symbol,
|
|
709
|
+
source: normalizeSourcePath(params.cwd, handler.sourceFile.getFilePath()),
|
|
710
|
+
line: callableLine(handler)
|
|
711
|
+
}
|
|
712
|
+
},
|
|
713
|
+
analysis_files: [.../* @__PURE__ */ new Set([...dependencyFiles.map((filePath) => normalizeSourcePath(params.cwd, filePath)), ...backendPaths.flatMap((backendPath) => backendPath.nodes.map((node) => node.source))])].sort(),
|
|
714
|
+
mapping_context: mappingContext,
|
|
715
|
+
weights: backendTopologyWeights,
|
|
716
|
+
backend_paths: backendPaths
|
|
717
|
+
};
|
|
718
|
+
artifacts.push(artifact);
|
|
719
|
+
await params.onArtifact?.(artifact);
|
|
720
|
+
routeProgress("completed");
|
|
721
|
+
}
|
|
722
|
+
return artifacts;
|
|
723
|
+
}
|
|
724
|
+
async function discoverBackendTopologyRouteSelectors(cwd) {
|
|
725
|
+
const project = new Project({
|
|
726
|
+
skipAddingFilesFromTsConfig: true,
|
|
727
|
+
compilerOptions: {
|
|
728
|
+
allowJs: true,
|
|
729
|
+
checkJs: false
|
|
730
|
+
}
|
|
731
|
+
});
|
|
732
|
+
return (await globby(["app/_api/**/routes.@(js|ts)", "app/legacy/**/routes.@(js|ts)"], {
|
|
733
|
+
cwd,
|
|
734
|
+
absolute: true
|
|
735
|
+
})).flatMap((routeFile) => extractRouteDeclarations(project.addSourceFileAtPath(routeFile))).map((route) => ({
|
|
736
|
+
method: route.method,
|
|
737
|
+
path: route.path
|
|
738
|
+
}));
|
|
739
|
+
}
|
|
740
|
+
async function generateBackendTopologyArtifactsInWorkers(params) {
|
|
741
|
+
const selectors = params.routeSelector ? [params.routeSelector] : await discoverBackendTopologyRouteSelectors(params.cwd);
|
|
742
|
+
params.onRoutesDiscovered?.(selectors.length);
|
|
743
|
+
const workerCount = Math.min(params.workers || 2, selectors.length);
|
|
744
|
+
const chunks = Array.from({ length: workerCount }, () => []);
|
|
745
|
+
selectors.forEach((selector, index) => chunks[index % workerCount].push(selector));
|
|
746
|
+
const artifacts = [];
|
|
747
|
+
let completed = 0;
|
|
748
|
+
let writeQueue = Promise.resolve();
|
|
749
|
+
const workers = [];
|
|
750
|
+
try {
|
|
751
|
+
await Promise.all(chunks.map((routeSelectors) => new Promise((resolve, reject) => {
|
|
752
|
+
const workerFile = import.meta.url.endsWith(".ts") ? "../workers/routeBackendTopologyWorker.ts" : "./workers/routeBackendTopologyWorker.mjs";
|
|
753
|
+
const worker = new Worker(new URL(workerFile, import.meta.url), {
|
|
754
|
+
workerData: {
|
|
755
|
+
cwd: params.cwd,
|
|
756
|
+
routeSelectors
|
|
757
|
+
},
|
|
758
|
+
execArgv: process.execArgv
|
|
759
|
+
});
|
|
760
|
+
workers.push(worker);
|
|
761
|
+
worker.on("message", (message) => {
|
|
762
|
+
if (message.type === "error") {
|
|
763
|
+
reject(new Error(message.message));
|
|
764
|
+
return;
|
|
765
|
+
}
|
|
766
|
+
writeQueue = writeQueue.then(async () => {
|
|
767
|
+
if (message.type === "artifact") {
|
|
768
|
+
await params.onArtifact(message.artifact);
|
|
769
|
+
artifacts.push(message.artifact);
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
completed += 1;
|
|
773
|
+
params.onRouteProgress?.({
|
|
774
|
+
current: completed,
|
|
775
|
+
total: selectors.length,
|
|
776
|
+
route: message.route,
|
|
777
|
+
stage: "completed"
|
|
778
|
+
});
|
|
779
|
+
}).catch(reject);
|
|
780
|
+
});
|
|
781
|
+
worker.once("error", reject);
|
|
782
|
+
worker.once("exit", (code) => code === 0 ? resolve() : reject(/* @__PURE__ */ new Error(`Topology worker exited with code ${code}`)));
|
|
783
|
+
})));
|
|
784
|
+
await writeQueue;
|
|
785
|
+
return artifacts;
|
|
786
|
+
} catch (error) {
|
|
787
|
+
await Promise.all(workers.map((worker) => worker.terminate().catch(() => void 0)));
|
|
788
|
+
throw error;
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
//#endregion
|
|
792
|
+
export { isKnownBackendType as a, taskProgressService as c, setUserConfig as d, inferBackendNameFromFile as i, getUserConfig as l, generateBackendTopologyArtifactsInWorkers as n, filterAnalysisFiles as o, resolveModulePath as r, shouldKeepAnalysisFile as s, generateBackendTopologyArtifacts as t, loadAtlasConfig as u };
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { t as generateBackendTopologyArtifacts } from "../routeBackendTopologyService-D_aIIBbX.mjs";
|
|
2
|
+
import { parentPort, workerData } from "node:worker_threads";
|
|
3
|
+
//#region src/workers/routeBackendTopologyWorker.ts
|
|
4
|
+
const data = workerData;
|
|
5
|
+
generateBackendTopologyArtifacts({
|
|
6
|
+
cwd: data.cwd,
|
|
7
|
+
routeSelectors: data.routeSelectors,
|
|
8
|
+
onRouteProgress: ({ route, stage }) => {
|
|
9
|
+
if (stage !== "completed") return;
|
|
10
|
+
parentPort?.postMessage({
|
|
11
|
+
type: "progress",
|
|
12
|
+
route
|
|
13
|
+
});
|
|
14
|
+
},
|
|
15
|
+
onArtifact: async (artifact) => {
|
|
16
|
+
parentPort?.postMessage({
|
|
17
|
+
type: "artifact",
|
|
18
|
+
artifact
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
}).catch((error) => {
|
|
22
|
+
parentPort?.postMessage({
|
|
23
|
+
type: "error",
|
|
24
|
+
message: error instanceof Error ? error.message : String(error)
|
|
25
|
+
});
|
|
26
|
+
process.exitCode = 1;
|
|
27
|
+
});
|
|
28
|
+
//#endregion
|
|
29
|
+
export {};
|