@cmflow/atlas 3.4.0-beta.5 → 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 +7 -5
- package/dist/bin/atlas.mjs +29 -322
- package/dist/index.d.mts +1 -1
- package/dist/routeBackendTopologyService-D_aIIBbX.mjs +792 -0
- package/dist/rules/cleanObjectRule.d.mts +1 -1
- package/dist/rules/cmsI18nFieldRule.d.mts +1 -1
- package/dist/rules/dateConversionRule.d.mts +1 -1
- package/dist/rules/lodashGetRule.d.mts +1 -1
- package/dist/rules/mappingUtilityRule.d.mts +1 -1
- package/dist/rules/memberGetFieldRule.d.mts +1 -1
- package/dist/rules/quableI18nFieldRule.d.mts +1 -1
- package/dist/{types-DcvdOZ2j.d.mts → types-3y34Gf8R.d.mts} +3 -3
- package/dist/workers/routeBackendTopologyWorker.mjs +29 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -50,10 +50,12 @@ The configuration imports `defineConfig` from Atlas:
|
|
|
50
50
|
import { defineConfig } from "@cmflow/atlas";
|
|
51
51
|
|
|
52
52
|
export default defineConfig({
|
|
53
|
-
backends: ["ICC", "CMS", "CMS_B2C"],
|
|
54
53
|
openapiUrl: "https://api.example.com/openapi.json",
|
|
55
54
|
openapiTimeoutMs: 60_000,
|
|
56
|
-
directusUrl: "https://cms.api.clubmed"
|
|
55
|
+
directusUrl: "https://cms.api.clubmed",
|
|
56
|
+
analysis: {
|
|
57
|
+
backends: ["ICC", "CMS", "CMS_B2C"]
|
|
58
|
+
}
|
|
57
59
|
});
|
|
58
60
|
```
|
|
59
61
|
|
|
@@ -117,7 +119,7 @@ Generate the topology first, then the route catalogue:
|
|
|
117
119
|
|
|
118
120
|
```bash
|
|
119
121
|
atlas --project-root /path/to/api generate:graph
|
|
120
|
-
atlas --project-root /path/to/api generate:
|
|
122
|
+
atlas --project-root /path/to/api generate:catalog --output .tmp/datasource-catalogue
|
|
121
123
|
```
|
|
122
124
|
|
|
123
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`.
|
|
@@ -150,7 +152,7 @@ Without `--write`, the command performs a dry run. Use `clean-orphans` to inspec
|
|
|
150
152
|
```text
|
|
151
153
|
atlas init Create atlas.config.ts
|
|
152
154
|
atlas generate:graph Trace API routes to backends
|
|
153
|
-
atlas generate:
|
|
155
|
+
atlas generate:catalog [route] Generate route review documents
|
|
154
156
|
atlas generate:test Check configured coverage baselines
|
|
155
157
|
atlas needs-review Rank unresolved routes
|
|
156
158
|
atlas infer [route|directory] Optionally enrich unresolved mappings
|
|
@@ -161,7 +163,7 @@ atlas report:changed Report coverage for changed routes
|
|
|
161
163
|
|
|
162
164
|
## CI
|
|
163
165
|
|
|
164
|
-
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.
|
|
165
167
|
|
|
166
168
|
## Development
|
|
167
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,25 +258,6 @@ function extractOpenApiOutputProperties(operation, swagger) {
|
|
|
391
258
|
return [...map.values()];
|
|
392
259
|
}
|
|
393
260
|
//#endregion
|
|
394
|
-
//#region src/services/backendTypeService.ts
|
|
395
|
-
function getBackendTypes() {
|
|
396
|
-
return Object.fromEntries(getUserConfig().backends.map((backend) => [backend, backend]));
|
|
397
|
-
}
|
|
398
|
-
function isKnownBackendType(value) {
|
|
399
|
-
return Object.hasOwn(getBackendTypes(), value);
|
|
400
|
-
}
|
|
401
|
-
//#endregion
|
|
402
|
-
//#region src/services/backendSourceService.ts
|
|
403
|
-
function inferBackendNameFromFile(sourceFile) {
|
|
404
|
-
const declaredType = sourceFile.getFullText().match(/BackendTypes\.([A-Z0-9_]+)/)?.[1];
|
|
405
|
-
if (declaredType) return isKnownBackendType(declaredType) ? declaredType : null;
|
|
406
|
-
const parts = sourceFile.getFilePath().split(path.sep);
|
|
407
|
-
const backIndex = parts.lastIndexOf("back");
|
|
408
|
-
const infrastructureIndex = parts.lastIndexOf("_infra");
|
|
409
|
-
const candidate = backIndex >= 0 ? parts[backIndex + 1] : infrastructureIndex >= 0 ? parts[infrastructureIndex + 2] : void 0;
|
|
410
|
-
return candidate && isKnownBackendType(candidate.toUpperCase()) ? candidate.toUpperCase() : null;
|
|
411
|
-
}
|
|
412
|
-
//#endregion
|
|
413
261
|
//#region src/services/backendRouteExtractionService.ts
|
|
414
262
|
function looksLikeBackendRoute(value) {
|
|
415
263
|
return value.startsWith("/") || /^https?:\/\//.test(value) || /^graphql$/i.test(value);
|
|
@@ -460,51 +308,6 @@ function normalizeDescription(description) {
|
|
|
460
308
|
return description?.trim() || "";
|
|
461
309
|
}
|
|
462
310
|
//#endregion
|
|
463
|
-
//#region src/utils/resolveAliasPath.ts
|
|
464
|
-
function resolveAliasPath(moduleSpecifier, aliases, rootPath) {
|
|
465
|
-
for (const [alias, target] of Object.entries(aliases)) {
|
|
466
|
-
if (moduleSpecifier !== alias && !moduleSpecifier.startsWith(`${alias}/`)) continue;
|
|
467
|
-
const modulePath = moduleSpecifier.slice(alias.length).replace(/^\//, "");
|
|
468
|
-
return path.join(rootPath, target, modulePath);
|
|
469
|
-
}
|
|
470
|
-
return null;
|
|
471
|
-
}
|
|
472
|
-
//#endregion
|
|
473
|
-
//#region src/utils/tryResolveWithExtensions.ts
|
|
474
|
-
function tryResolveWithExtensions(basePath) {
|
|
475
|
-
const ext = path.extname(basePath);
|
|
476
|
-
const withoutExt = ext ? basePath.slice(0, -ext.length) : basePath;
|
|
477
|
-
const candidates = [
|
|
478
|
-
basePath,
|
|
479
|
-
ext === ".js" ? `${withoutExt}.ts` : null,
|
|
480
|
-
ext === ".ts" ? `${withoutExt}.js` : null,
|
|
481
|
-
ext === ".mjs" ? `${withoutExt}.mts` : null,
|
|
482
|
-
ext === ".mts" ? `${withoutExt}.mjs` : null,
|
|
483
|
-
`${basePath}.ts`,
|
|
484
|
-
`${basePath}.js`,
|
|
485
|
-
`${basePath}.mts`,
|
|
486
|
-
`${basePath}.mjs`,
|
|
487
|
-
path.join(withoutExt, "index.ts"),
|
|
488
|
-
path.join(withoutExt, "index.js"),
|
|
489
|
-
path.join(basePath, "index.ts"),
|
|
490
|
-
path.join(basePath, "index.js")
|
|
491
|
-
].filter((candidate) => Boolean(candidate));
|
|
492
|
-
for (const candidate of candidates) try {
|
|
493
|
-
const normalized = path.normalize(candidate);
|
|
494
|
-
if (fs.existsSync(normalized)) return normalized;
|
|
495
|
-
} catch {
|
|
496
|
-
continue;
|
|
497
|
-
}
|
|
498
|
-
return null;
|
|
499
|
-
}
|
|
500
|
-
//#endregion
|
|
501
|
-
//#region src/utils/resolveModulePath.ts
|
|
502
|
-
function resolveModulePath(sourceFilePath, moduleSpecifier, aliases, rootPath) {
|
|
503
|
-
if (moduleSpecifier.startsWith(".")) return tryResolveWithExtensions(path.resolve(path.dirname(sourceFilePath), moduleSpecifier));
|
|
504
|
-
const aliased = resolveAliasPath(moduleSpecifier, aliases, rootPath);
|
|
505
|
-
return aliased ? tryResolveWithExtensions(aliased) : null;
|
|
506
|
-
}
|
|
507
|
-
//#endregion
|
|
508
311
|
//#region src/utils/dedupeByKey.ts
|
|
509
312
|
function dedupeByKey(items, keyFn) {
|
|
510
313
|
const map = /* @__PURE__ */ new Map();
|
|
@@ -1446,11 +1249,12 @@ async function analyzeCodebaseRouteContracts(params) {
|
|
|
1446
1249
|
}) : void 0;
|
|
1447
1250
|
if (routeAnalysisScope && !topologyScope) continue;
|
|
1448
1251
|
taskProgressService.log(topologyScope ? `${routeLabel} ... Loading dependencies from backend graph` : `${routeLabel} ... Collecting dependencies`);
|
|
1449
|
-
const
|
|
1252
|
+
const dependencyFiles = topologyScope ? topologyScope.analysisFiles : handler.handlerFile ? dependencyFilesByHandler.get(handler.handlerFile) || (() => {
|
|
1450
1253
|
const files = collectDependencyFiles(project, handler.handlerFile, routeLabel, resolveCachedModulePath);
|
|
1451
1254
|
dependencyFilesByHandler.set(handler.handlerFile, files);
|
|
1452
1255
|
return files;
|
|
1453
|
-
})() : [routeDeclaration.file]
|
|
1256
|
+
})() : [routeDeclaration.file];
|
|
1257
|
+
const analysisFiles = filterAnalysisFiles(dedupeByKey(await dependencyFiles, (value) => value));
|
|
1454
1258
|
taskProgressService.log(`${routeLabel} ... Extracting mappings from ${analysisFiles.length} relevant files`);
|
|
1455
1259
|
await yieldToEventLoop();
|
|
1456
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) => {
|
|
@@ -2359,7 +2163,7 @@ async function pushCatalogueToDirectus(catalogue, options) {
|
|
|
2359
2163
|
}
|
|
2360
2164
|
}
|
|
2361
2165
|
}
|
|
2362
|
-
if (options.outputPath) await fs
|
|
2166
|
+
if (options.outputPath) await fs.appendFile(options.outputPath, "", "utf8");
|
|
2363
2167
|
const removedOrphanedLinks = await cleanupOrphanedPropertyLinks(client);
|
|
2364
2168
|
taskProgressService.report("Finalizing Directus synchronization");
|
|
2365
2169
|
if (warnings.size) {
|
|
@@ -2555,12 +2359,12 @@ async function writeRouteReviewDocument(catalogue, outputDir, repoRoot, routeKey
|
|
|
2555
2359
|
if (!route) throw new Error(`Unable to write review document for unknown route key ${routeKey}`);
|
|
2556
2360
|
const document = relativizeRouteReviewDocument(buildRouteReviewDocument(catalogue, route.key), repoRoot);
|
|
2557
2361
|
const filePath = path.join(outputDir, buildRouteReviewRelativePath(route.method, route.path));
|
|
2558
|
-
await fs
|
|
2559
|
-
await fs
|
|
2362
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
2363
|
+
await fs.writeFile(filePath, `${stringify(document)}\n`, "utf8");
|
|
2560
2364
|
return filePath;
|
|
2561
2365
|
}
|
|
2562
2366
|
async function writeRouteReviewDocuments(catalogue, outputDir, repoRoot) {
|
|
2563
|
-
await fs
|
|
2367
|
+
await fs.mkdir(outputDir, { recursive: true });
|
|
2564
2368
|
for (const [index, route] of catalogue.routes.entries()) {
|
|
2565
2369
|
taskProgressService.log(`[${index + 1}/${catalogue.routes.length}] - ${route.method} ${route.path} ...`);
|
|
2566
2370
|
await writeRouteReviewDocument(catalogue, outputDir, repoRoot, route.key);
|
|
@@ -2568,8 +2372,8 @@ async function writeRouteReviewDocuments(catalogue, outputDir, repoRoot) {
|
|
|
2568
2372
|
return outputDir;
|
|
2569
2373
|
}
|
|
2570
2374
|
async function prepareCatalogueOutputDirectory(outputDir) {
|
|
2571
|
-
await fs
|
|
2572
|
-
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 })));
|
|
2573
2377
|
}
|
|
2574
2378
|
async function writeCatalogueArtifacts(catalogue, outputDir, repoRoot = process.cwd()) {
|
|
2575
2379
|
await prepareCatalogueOutputDirectory(outputDir);
|
|
@@ -2747,7 +2551,7 @@ async function readCatalogueFromDirectory(inputDir) {
|
|
|
2747
2551
|
if (!files.length) throw new Error(`No route YAML documents found in ${inputDir}`);
|
|
2748
2552
|
return {
|
|
2749
2553
|
catalogue: mergeRouteReviewDocuments(await Promise.all(files.sort().map(async (file) => {
|
|
2750
|
-
const parsed = parse(await fs
|
|
2554
|
+
const parsed = parse(await fs.readFile(file, "utf8"));
|
|
2751
2555
|
if (parsed?.schema_version !== 1 || !parsed?.route?.method || !parsed?.route?.path) throw new Error(`Invalid route YAML document: ${file}`);
|
|
2752
2556
|
return parsed;
|
|
2753
2557
|
}))),
|
|
@@ -2941,8 +2745,8 @@ function buildBackendTopologyRelativePath(method, routePath) {
|
|
|
2941
2745
|
}
|
|
2942
2746
|
async function writeBackendTopologyArtifact(artifact, outputDir) {
|
|
2943
2747
|
const filePath = path.join(outputDir, buildBackendTopologyRelativePath(artifact.route.method, artifact.route.path));
|
|
2944
|
-
await fs
|
|
2945
|
-
await fs
|
|
2748
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
2749
|
+
await fs.writeFile(filePath, `${stringify(artifact, { aliasDuplicateObjects: false })}\n`, "utf8");
|
|
2946
2750
|
return filePath;
|
|
2947
2751
|
}
|
|
2948
2752
|
function isRecord(value) {
|
|
@@ -2961,7 +2765,7 @@ async function loadBackendTopologyAnalysisScope(outputDir, cwd, method, routePat
|
|
|
2961
2765
|
const filePath = path.join(outputDir, buildBackendTopologyRelativePath(method, routePath));
|
|
2962
2766
|
let content;
|
|
2963
2767
|
try {
|
|
2964
|
-
content = await fs
|
|
2768
|
+
content = await fs.readFile(filePath, "utf8");
|
|
2965
2769
|
} catch (error) {
|
|
2966
2770
|
if (isRecord(error) && error.code === "ENOENT") throw new Error(`Backend graph artifact not found for ${method} ${routePath}: ${filePath}. Run generate:graph first.`);
|
|
2967
2771
|
throw error;
|
|
@@ -2982,7 +2786,7 @@ async function loadBackendTopologyAnalysisScope(outputDir, cwd, method, routePat
|
|
|
2982
2786
|
const missingFiles = [];
|
|
2983
2787
|
await Promise.all(analysisFiles.map(async (analysisFile) => {
|
|
2984
2788
|
try {
|
|
2985
|
-
await fs
|
|
2789
|
+
await fs.access(analysisFile);
|
|
2986
2790
|
} catch {
|
|
2987
2791
|
missingFiles.push(path.relative(cwd, analysisFile));
|
|
2988
2792
|
}
|
|
@@ -3082,7 +2886,7 @@ async function generateHandler(cwd, routeArgument, options) {
|
|
|
3082
2886
|
process.exit(1);
|
|
3083
2887
|
}
|
|
3084
2888
|
}
|
|
3085
|
-
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) => {
|
|
3086
2890
|
const config = getUserConfig();
|
|
3087
2891
|
return generateHandler(config.cwd, routeArgument, {
|
|
3088
2892
|
...options,
|
|
@@ -3090,104 +2894,6 @@ var generate_default = (program) => void program.command("generate:catalogue").a
|
|
|
3090
2894
|
});
|
|
3091
2895
|
});
|
|
3092
2896
|
//#endregion
|
|
3093
|
-
//#region src/services/routeBackendTopologyService.ts
|
|
3094
|
-
function stringProperty(object, name) {
|
|
3095
|
-
const property = object.getProperty(name);
|
|
3096
|
-
if (!property || !Node.isPropertyAssignment(property)) return void 0;
|
|
3097
|
-
const initializer = property.getInitializer();
|
|
3098
|
-
return initializer && (Node.isStringLiteral(initializer) || Node.isNoSubstitutionTemplateLiteral(initializer)) ? initializer.getLiteralValue() : void 0;
|
|
3099
|
-
}
|
|
3100
|
-
function handlerProperty(object) {
|
|
3101
|
-
const property = object.getProperty("handler");
|
|
3102
|
-
if (!property || !Node.isPropertyAssignment(property)) return void 0;
|
|
3103
|
-
const initializer = property.getInitializer();
|
|
3104
|
-
return initializer && (Node.isIdentifier(initializer) || Node.isPropertyAccessExpression(initializer)) ? initializer.getText() : void 0;
|
|
3105
|
-
}
|
|
3106
|
-
function extractRouteDeclarations(sourceFile) {
|
|
3107
|
-
const initializer = sourceFile.getVariableDeclaration("routes")?.getInitializer();
|
|
3108
|
-
let expression = initializer;
|
|
3109
|
-
if (initializer && (Node.isAsExpression(initializer) || Node.isSatisfiesExpression(initializer))) expression = initializer.getExpression();
|
|
3110
|
-
if (!expression || !Node.isArrayLiteralExpression(expression)) return [];
|
|
3111
|
-
return expression.getElements().flatMap((element) => {
|
|
3112
|
-
if (!Node.isObjectLiteralExpression(element)) return [];
|
|
3113
|
-
const method = stringProperty(element, "method");
|
|
3114
|
-
const routePath = stringProperty(element, "path");
|
|
3115
|
-
const handlerRef = handlerProperty(element);
|
|
3116
|
-
return method && routePath && handlerRef ? [{
|
|
3117
|
-
method: method.toUpperCase(),
|
|
3118
|
-
path: routePath,
|
|
3119
|
-
sourceFile,
|
|
3120
|
-
handlerRef
|
|
3121
|
-
}] : [];
|
|
3122
|
-
});
|
|
3123
|
-
}
|
|
3124
|
-
async function discoverBackendTopologyRouteSelectors(cwd) {
|
|
3125
|
-
const project = new Project({
|
|
3126
|
-
skipAddingFilesFromTsConfig: true,
|
|
3127
|
-
compilerOptions: {
|
|
3128
|
-
allowJs: true,
|
|
3129
|
-
checkJs: false
|
|
3130
|
-
}
|
|
3131
|
-
});
|
|
3132
|
-
return (await globby(["app/_api/**/routes.@(js|ts)", "app/legacy/**/routes.@(js|ts)"], {
|
|
3133
|
-
cwd,
|
|
3134
|
-
absolute: true
|
|
3135
|
-
})).flatMap((routeFile) => extractRouteDeclarations(project.addSourceFileAtPath(routeFile))).map((route) => ({
|
|
3136
|
-
method: route.method,
|
|
3137
|
-
path: route.path
|
|
3138
|
-
}));
|
|
3139
|
-
}
|
|
3140
|
-
async function generateBackendTopologyArtifactsInWorkers(params) {
|
|
3141
|
-
const selectors = params.routeSelector ? [params.routeSelector] : await discoverBackendTopologyRouteSelectors(params.cwd);
|
|
3142
|
-
params.onRoutesDiscovered?.(selectors.length);
|
|
3143
|
-
const workerCount = Math.min(params.workers || 2, selectors.length);
|
|
3144
|
-
const chunks = Array.from({ length: workerCount }, () => []);
|
|
3145
|
-
selectors.forEach((selector, index) => chunks[index % workerCount].push(selector));
|
|
3146
|
-
const artifacts = [];
|
|
3147
|
-
let completed = 0;
|
|
3148
|
-
let writeQueue = Promise.resolve();
|
|
3149
|
-
const workers = [];
|
|
3150
|
-
try {
|
|
3151
|
-
await Promise.all(chunks.map((routeSelectors) => new Promise((resolve, reject) => {
|
|
3152
|
-
const worker = new Worker(new URL("./routeBackendTopologyWorker.ts", import.meta.url), {
|
|
3153
|
-
workerData: {
|
|
3154
|
-
cwd: params.cwd,
|
|
3155
|
-
routeSelectors
|
|
3156
|
-
},
|
|
3157
|
-
execArgv: process.execArgv
|
|
3158
|
-
});
|
|
3159
|
-
workers.push(worker);
|
|
3160
|
-
worker.on("message", (message) => {
|
|
3161
|
-
if (message.type === "error") {
|
|
3162
|
-
reject(new Error(message.message));
|
|
3163
|
-
return;
|
|
3164
|
-
}
|
|
3165
|
-
writeQueue = writeQueue.then(async () => {
|
|
3166
|
-
if (message.type === "artifact") {
|
|
3167
|
-
await params.onArtifact(message.artifact);
|
|
3168
|
-
artifacts.push(message.artifact);
|
|
3169
|
-
return;
|
|
3170
|
-
}
|
|
3171
|
-
completed += 1;
|
|
3172
|
-
params.onRouteProgress?.({
|
|
3173
|
-
current: completed,
|
|
3174
|
-
total: selectors.length,
|
|
3175
|
-
route: message.route,
|
|
3176
|
-
stage: "completed"
|
|
3177
|
-
});
|
|
3178
|
-
}).catch(reject);
|
|
3179
|
-
});
|
|
3180
|
-
worker.once("error", reject);
|
|
3181
|
-
worker.once("exit", (code) => code === 0 ? resolve() : reject(/* @__PURE__ */ new Error(`Topology worker exited with code ${code}`)));
|
|
3182
|
-
})));
|
|
3183
|
-
await writeQueue;
|
|
3184
|
-
return artifacts;
|
|
3185
|
-
} catch (error) {
|
|
3186
|
-
await Promise.all(workers.map((worker) => worker.terminate().catch(() => void 0)));
|
|
3187
|
-
throw error;
|
|
3188
|
-
}
|
|
3189
|
-
}
|
|
3190
|
-
//#endregion
|
|
3191
2897
|
//#region src/commands/generateGraph.ts
|
|
3192
2898
|
function parseWorkerCount$1(value) {
|
|
3193
2899
|
const workers = Number(value);
|
|
@@ -3442,7 +3148,7 @@ async function readSourceExcerpts(document) {
|
|
|
3442
3148
|
const excerpts = [];
|
|
3443
3149
|
let totalCharacters = 0;
|
|
3444
3150
|
for (const location of uniqueLocations) try {
|
|
3445
|
-
const lines = (await fs
|
|
3151
|
+
const lines = (await fs.readFile(location.sourceFile, "utf8")).split("\n");
|
|
3446
3152
|
const startLine = Math.max(1, location.line - EXCERPT_RADIUS);
|
|
3447
3153
|
const endLine = Math.min(lines.length, location.line + EXCERPT_RADIUS);
|
|
3448
3154
|
const code = lines.slice(startLine - 1, endLine).map((line, index) => `${String(startLine + index).padStart(5)} | ${line}`).join("\n");
|
|
@@ -4169,7 +3875,6 @@ const configTemplate = `import { defineConfig } from "@cmflow/atlas";
|
|
|
4169
3875
|
|
|
4170
3876
|
export default defineConfig({
|
|
4171
3877
|
repoRoot: process.cwd(),
|
|
4172
|
-
backends: [],
|
|
4173
3878
|
openapiUrl: "https://api.example.com/openapi.json",
|
|
4174
3879
|
openapiTimeoutMs: 60_000,
|
|
4175
3880
|
directusUrl: "https://cms.api.clubmed",
|
|
@@ -4181,6 +3886,7 @@ export default defineConfig({
|
|
|
4181
3886
|
workers: 2
|
|
4182
3887
|
},
|
|
4183
3888
|
analysis: {
|
|
3889
|
+
backends: [],
|
|
4184
3890
|
excluded: [],
|
|
4185
3891
|
transversalInputs: [],
|
|
4186
3892
|
ignoredOutputs: [],
|
|
@@ -4208,18 +3914,18 @@ var init_default = (program, datasourceCommand) => void program.command("init").
|
|
|
4208
3914
|
const configDirectory = path.dirname(configPath);
|
|
4209
3915
|
intro("Initialize datasource configuration");
|
|
4210
3916
|
try {
|
|
4211
|
-
await fs
|
|
3917
|
+
await fs.mkdir(configDirectory, { recursive: true });
|
|
4212
3918
|
if (!options.force) {
|
|
4213
3919
|
let exists = false;
|
|
4214
3920
|
try {
|
|
4215
|
-
await fs
|
|
3921
|
+
await fs.access(configPath);
|
|
4216
3922
|
exists = true;
|
|
4217
3923
|
} catch (error) {
|
|
4218
3924
|
if (error.code !== "ENOENT") throw error;
|
|
4219
3925
|
}
|
|
4220
3926
|
if (exists) throw new Error(`${configPath} already exists. Use --force to overwrite it.`);
|
|
4221
3927
|
}
|
|
4222
|
-
await fs
|
|
3928
|
+
await fs.writeFile(configPath, configTemplate, "utf8");
|
|
4223
3929
|
outro(`Created ${configPath}`);
|
|
4224
3930
|
} catch (error) {
|
|
4225
3931
|
cancel(error instanceof Error ? error.message : String(error));
|
|
@@ -4352,7 +4058,7 @@ function collectImpactedRoutes(params) {
|
|
|
4352
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}`));
|
|
4353
4059
|
}
|
|
4354
4060
|
async function readChangedFiles(filePath) {
|
|
4355
|
-
return (await fs
|
|
4061
|
+
return (await fs.readFile(filePath, "utf8")).split(/\r?\n/).map((line) => normalizeFilePath(line.trim())).filter(Boolean);
|
|
4356
4062
|
}
|
|
4357
4063
|
async function readRouteGraphs(outputDirectory) {
|
|
4358
4064
|
const graphFiles = await globby("**/*.graph.yaml", {
|
|
@@ -4360,7 +4066,7 @@ async function readRouteGraphs(outputDirectory) {
|
|
|
4360
4066
|
absolute: true
|
|
4361
4067
|
});
|
|
4362
4068
|
const graphs = await Promise.all(graphFiles.map(async (graphFile) => {
|
|
4363
|
-
const document = parse(await fs
|
|
4069
|
+
const document = parse(await fs.readFile(graphFile, "utf8"));
|
|
4364
4070
|
if (!isGraphDocument(document)) throw new Error(`Invalid graph document: ${graphFile}`);
|
|
4365
4071
|
return {
|
|
4366
4072
|
method: document.route.method,
|
|
@@ -4478,8 +4184,8 @@ async function reportChangedHandler(cwd, options) {
|
|
|
4478
4184
|
routes: coverages
|
|
4479
4185
|
});
|
|
4480
4186
|
const reportPath = path.resolve(cwd, options.report);
|
|
4481
|
-
await fs
|
|
4482
|
-
await fs
|
|
4187
|
+
await fs.mkdir(path.dirname(reportPath), { recursive: true });
|
|
4188
|
+
await fs.writeFile(reportPath, `${report}\n`, "utf8");
|
|
4483
4189
|
note(report, "Datasource mapping report");
|
|
4484
4190
|
}
|
|
4485
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) => {
|
|
@@ -4499,7 +4205,8 @@ const program = new Command();
|
|
|
4499
4205
|
program.version("3.4.0-alpha.1");
|
|
4500
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) => {
|
|
4501
4207
|
if (actionCommand.name() === "init") return;
|
|
4502
|
-
|
|
4208
|
+
const config = await loadAtlasConfig(program.opts().config, program.opts().projectRoot);
|
|
4209
|
+
setUserConfig(config);
|
|
4503
4210
|
});
|
|
4504
4211
|
init_default(program, program);
|
|
4505
4212
|
generate_default(program);
|
package/dist/index.d.mts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { r as defineExpressionRule, t as UserConfig } from "./types-
|
|
1
|
+
import { r as defineExpressionRule, t as UserConfig } from "./types-3y34Gf8R.mjs";
|
|
2
2
|
//#region src/utils/defineConfig.d.ts
|
|
3
3
|
declare function defineConfig(config: UserConfig): UserConfig;
|
|
4
4
|
//#endregion
|