@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:catalogue --output .tmp/datasource-catalogue
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:catalogue [route] Generate route review documents
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:catalogue`, 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.
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
 
@@ -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, spinner } from "@clack/prompts";
8
- import fs$1 from "node:fs/promises";
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 analysisFiles = filterAnalysisFiles(dedupeByKey(await (topologyScope ? topologyScope.analysisFiles : handler.handlerFile ? dependencyFilesByHandler.get(handler.handlerFile) || (() => {
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]), (value) => value));
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$1.appendFile(options.outputPath, "", "utf8");
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$1.mkdir(path.dirname(filePath), { recursive: true });
2556
- await fs$1.writeFile(filePath, `${stringify(document)}\n`, "utf8");
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$1.mkdir(outputDir, { recursive: true });
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$1.mkdir(outputDir, { recursive: true });
2569
- await Promise.all(["catalogue.yaml", "catalogue.yml"].map((fileName) => fs$1.rm(path.join(outputDir, fileName), { force: true })));
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$1.readFile(file, "utf8"));
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$1.mkdir(path.dirname(filePath), { recursive: true });
2942
- await fs$1.writeFile(filePath, `${stringify(artifact, { aliasDuplicateObjects: false })}\n`, "utf8");
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$1.readFile(filePath, "utf8");
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$1.access(analysisFile);
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:catalogue").argument("[route]", "Route selector in the format \"GET /v1/products\"").option("-o, --output <directory>", "Directory where catalogue artifacts are generated", ".tmp/datasource-catalogue").option("--openapi-url <url>", "OpenAPI document URL").option("--profile", "Display elapsed time by generation stage").description("Analyze the codebase and generate one YAML review document per route").action((routeArgument, options) => {
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$1.readFile(location.sourceFile, "utf8")).split("\n");
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$1.mkdir(configDirectory, { recursive: true });
3917
+ await fs.mkdir(configDirectory, { recursive: true });
4209
3918
  if (!options.force) {
4210
3919
  let exists = false;
4211
3920
  try {
4212
- await fs$1.access(configPath);
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$1.writeFile(configPath, configTemplate, "utf8");
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$1.readFile(filePath, "utf8")).split(/\r?\n/).map((line) => normalizeFilePath(line.trim())).filter(Boolean);
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$1.readFile(graphFile, "utf8"));
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$1.mkdir(path.dirname(reportPath), { recursive: true });
4479
- await fs$1.writeFile(reportPath, `${report}\n`, "utf8");
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
- setUserConfig(await loadAtlasConfig(program.opts().config, program.opts().projectRoot));
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 {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cmflow/atlas",
3
- "version": "3.4.0-beta.6",
3
+ "version": "3.4.0-beta.7",
4
4
  "description": "API-to-backend mapping catalogue for Club Med Flow",
5
5
  "license": "MIT",
6
6
  "author": "romakita",