archsync 1.0.0
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/bin/cli.js +91 -0
- package/package.json +57 -0
- package/src/__tests__/e2e-workflow.test.js +66 -0
- package/src/__tests__/hashEngine.test.js +109 -0
- package/src/__tests__/impact.test.js +137 -0
- package/src/__tests__/parsers.test.js +496 -0
- package/src/__tests__/scan-pipeline.test.js +332 -0
- package/src/__tests__/schemaBuilder.test.js +145 -0
- package/src/__tests__/workspace.test.js +178 -0
- package/src/commands/backup.js +54 -0
- package/src/commands/connect.js +129 -0
- package/src/commands/diff.js +228 -0
- package/src/commands/export.js +125 -0
- package/src/commands/impactReport.js +50 -0
- package/src/commands/import.js +126 -0
- package/src/commands/init.js +80 -0
- package/src/commands/login.js +116 -0
- package/src/commands/plugin.js +28 -0
- package/src/commands/push.js +194 -0
- package/src/commands/register.js +127 -0
- package/src/commands/scan.js +498 -0
- package/src/commands/serve.js +133 -0
- package/src/commands/setup.js +233 -0
- package/src/commands/status.js +56 -0
- package/src/commands/validate.js +245 -0
- package/src/commands/watch.js +70 -0
- package/src/core/credentialStore.js +76 -0
- package/src/core/hashEngine.js +34 -0
- package/src/core/impactEngine.js +192 -0
- package/src/core/monorepoDetector.js +41 -0
- package/src/core/pluginManager.js +40 -0
- package/src/core/relationshipEngine.js +917 -0
- package/src/core/requestSigning.js +16 -0
- package/src/core/schemaBuilder.js +230 -0
- package/src/core/schemaDeduplicator.js +54 -0
- package/src/core/supabaseClient.js +68 -0
- package/src/core/workspaceDetector.js +113 -0
- package/src/parsers/astParser.js +274 -0
- package/src/parsers/configParser.js +49 -0
- package/src/parsers/dependencyGraph.js +31 -0
- package/src/parsers/flutterParser.js +98 -0
- package/src/parsers/goParser.js +99 -0
- package/src/parsers/index.js +211 -0
- package/src/parsers/javaParser.js +89 -0
- package/src/parsers/nodeParser.js +429 -0
- package/src/parsers/pythonParser.js +109 -0
- package/src/parsers/reactParser.js +368 -0
- package/src/parsers/smartComment.js +144 -0
|
@@ -0,0 +1,917 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Relationship Engine — post-process phase that connects entities.
|
|
6
|
+
*
|
|
7
|
+
* This runs AFTER all files are parsed and entities collected.
|
|
8
|
+
* It builds edges by analyzing:
|
|
9
|
+
* 1. Route → Controller (from handler references in route files)
|
|
10
|
+
* 2. Controller → Service (from service instantiation/calls in controller files)
|
|
11
|
+
* 3. Service → Model/DB (from DB calls in service files)
|
|
12
|
+
* 4. Route mounting (app.use prefix + sub-router)
|
|
13
|
+
* 5. Middleware attachments
|
|
14
|
+
* 6. Frontend → API (matching endpoints across systems)
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
// ─── PATTERN: import resolution ────────────────────────────────────
|
|
18
|
+
// Try a base path with every known source extension (and index files).
|
|
19
|
+
function tryResolveFile(base) {
|
|
20
|
+
const extensions = ['.ts', '.js', '.tsx', '.jsx', '.dart', ''];
|
|
21
|
+
for (const ext of extensions) {
|
|
22
|
+
const candidate = base + ext;
|
|
23
|
+
if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) return candidate;
|
|
24
|
+
const indexCandidate = path.join(base, 'index' + ext);
|
|
25
|
+
if (fs.existsSync(indexCandidate)) return indexCandidate;
|
|
26
|
+
}
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Resolves `import { X } from '../controllers/foo.controller'` to an absolute path.
|
|
31
|
+
function resolveImportPath(importSource, fromFile) {
|
|
32
|
+
if (!importSource.startsWith('.')) return null;
|
|
33
|
+
const dir = path.dirname(fromFile);
|
|
34
|
+
return tryResolveFile(path.resolve(dir, importSource));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Resolves aliased imports like `@/components/Button` or `~/lib/utils`
|
|
38
|
+
// against candidate src roots (detected from the scanned file set).
|
|
39
|
+
function resolveAliasImport(importSource, srcRoots) {
|
|
40
|
+
const m = importSource.match(/^[@~]\/(.+)$/);
|
|
41
|
+
if (!m) return null;
|
|
42
|
+
for (const root of srcRoots) {
|
|
43
|
+
const resolved = tryResolveFile(path.join(root, m[1]));
|
|
44
|
+
if (resolved) return resolved;
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
50
|
+
// UNIVERSAL MODULE RESOLUTION
|
|
51
|
+
// Language-specific import statements all reduce to a module path, and
|
|
52
|
+
// every module path resolves the same way: match it against the suffix
|
|
53
|
+
// of a scanned file. No per-language manifest parsing required —
|
|
54
|
+
// `package:app/foo/bar.dart`, `from app.services.user import X`,
|
|
55
|
+
// `import com.foo.Bar;`, `"mymodule/internal/auth"` all land on the
|
|
56
|
+
// scanned file whose path ends with the candidate.
|
|
57
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
58
|
+
|
|
59
|
+
/** Index scanned files by basename for fast suffix matching. */
|
|
60
|
+
function buildSuffixIndex(parsedFiles) {
|
|
61
|
+
const byBase = new Map(); // basename → [normalized absolute paths]
|
|
62
|
+
for (const f of parsedFiles) {
|
|
63
|
+
const norm = path.resolve(f.filePath).replace(/\\/g, '/');
|
|
64
|
+
const base = norm.split('/').pop();
|
|
65
|
+
if (!byBase.has(base)) byBase.set(base, []);
|
|
66
|
+
byBase.get(base).push(norm);
|
|
67
|
+
}
|
|
68
|
+
return byBase;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Find a scanned file whose path ends with `/cand`. Dir candidates (no
|
|
72
|
+
* extension) match any file directly inside that directory. */
|
|
73
|
+
function resolveBySuffix(cand, suffixIndex) {
|
|
74
|
+
if (!cand) return null;
|
|
75
|
+
const norm = cand.replace(/\\/g, '/').replace(/^\/+/, '');
|
|
76
|
+
const base = norm.split('/').pop();
|
|
77
|
+
|
|
78
|
+
if (base.includes('.')) {
|
|
79
|
+
// File candidate
|
|
80
|
+
const matches = suffixIndex.get(base) || [];
|
|
81
|
+
for (const p of matches) {
|
|
82
|
+
if (p.endsWith('/' + norm) || p === norm) return p;
|
|
83
|
+
}
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
// Directory candidate (Go packages, Java wildcard imports)
|
|
87
|
+
for (const paths of suffixIndex.values()) {
|
|
88
|
+
for (const p of paths) {
|
|
89
|
+
const dir = p.slice(0, p.lastIndexOf('/'));
|
|
90
|
+
if (dir.endsWith('/' + norm)) return p;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Common source extensions tried when an import omits the extension
|
|
97
|
+
const MODULE_EXTS = ['.js', '.ts', '.jsx', '.tsx', '.dart', '.py', '.go', '.java'];
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Turn a raw import specifier into candidate path suffixes, based on the
|
|
101
|
+
* importing file's language. Unknown languages fall through to the generic
|
|
102
|
+
* "path with or without extension" treatment.
|
|
103
|
+
*/
|
|
104
|
+
function moduleCandidates(spec, fromExt) {
|
|
105
|
+
if (!spec) return [];
|
|
106
|
+
|
|
107
|
+
// Dart / Swift-style package URI: strip the scheme + package segment
|
|
108
|
+
const pkg = spec.match(/^[a-z]+:[^/]+\/(.+)$/i);
|
|
109
|
+
if (pkg) return [pkg[1]];
|
|
110
|
+
|
|
111
|
+
// Dotted module path (Python, Java, Kotlin, …)
|
|
112
|
+
if (fromExt === '.py') {
|
|
113
|
+
const p = spec.replace(/\./g, '/');
|
|
114
|
+
return [`${p}.py`, `${p}/__init__.py`];
|
|
115
|
+
}
|
|
116
|
+
if (fromExt === '.java') {
|
|
117
|
+
if (spec.endsWith('.*')) return [spec.slice(0, -2).replace(/\./g, '/')];
|
|
118
|
+
return [spec.replace(/\./g, '/') + '.java'];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Path-style specifier (Go modules, monorepo deep imports). The module
|
|
122
|
+
// prefix often isn't a directory on disk ("mymodule/internal/auth" where
|
|
123
|
+
// "mymodule" comes from go.mod), so progressively strip leading segments
|
|
124
|
+
// and retry — keeping at least two segments to avoid false positives.
|
|
125
|
+
// Bare single-word JS imports ('react', 'lodash') are external packages —
|
|
126
|
+
// never resolved to local files.
|
|
127
|
+
if (spec.includes('/')) {
|
|
128
|
+
const out = [];
|
|
129
|
+
const parts = spec.split('/');
|
|
130
|
+
for (let i = 0; i <= parts.length - 2; i++) {
|
|
131
|
+
const sub = parts.slice(i).join('/');
|
|
132
|
+
out.push(sub);
|
|
133
|
+
if (!/\.\w+$/.test(sub)) {
|
|
134
|
+
for (const ext of MODULE_EXTS) out.push(sub + ext);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return out;
|
|
138
|
+
}
|
|
139
|
+
return [];
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Universal import resolution chain:
|
|
144
|
+
* 1. relative path (any language)
|
|
145
|
+
* 2. JS-style alias roots (@/, ~/)
|
|
146
|
+
* 3. language-aware candidates suffix-matched against the scanned file set
|
|
147
|
+
*/
|
|
148
|
+
function resolveModuleImport(imp, fromFile, ctx) {
|
|
149
|
+
if (imp.resolvedFile) return imp.resolvedFile;
|
|
150
|
+
|
|
151
|
+
const alias = resolveAliasImport(imp.sourcePath, ctx.srcRoots);
|
|
152
|
+
if (alias) return alias;
|
|
153
|
+
|
|
154
|
+
const fromExt = path.extname(fromFile).toLowerCase();
|
|
155
|
+
for (const cand of moduleCandidates(imp.sourcePath, fromExt)) {
|
|
156
|
+
const hit = resolveBySuffix(cand, ctx.suffixIndex);
|
|
157
|
+
if (hit) return hit;
|
|
158
|
+
}
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ─── EXTRACT imports from a source file ────────────────────────────
|
|
163
|
+
const IMPORT_ES = /import\s+(?:\{([^}]+)\}|(\w+)|\*\s+as\s+(\w+))\s*(?:,\s*\{([^}]+)\})?\s+from\s+['"`]([^'"`]+)['"`]/g;
|
|
164
|
+
const REQUIRE_CJS = /(?:const|let|var)\s+(?:\{([^}]+)\}|(\w+))\s*=\s*require\s*\(\s*['"`]([^'"`]+)['"`]\s*\)/g;
|
|
165
|
+
// Side-effect imports (`import './styles.css'`, Dart `import 'foo.dart';`)
|
|
166
|
+
const IMPORT_BARE = /(?:^|[^\w.])import\s+['"`]([^'"`]+)['"`]/g;
|
|
167
|
+
// Re-exports (`export * from './x'`, `export { A } from './x'`)
|
|
168
|
+
const EXPORT_FROM = /export\s+(?:\*(?:\s+as\s+\w+)?|\{[^}]*\})\s+from\s+['"`]([^'"`]+)['"`]/g;
|
|
169
|
+
|
|
170
|
+
function extractImports(source, filePath) {
|
|
171
|
+
const imports = []; // { localName, importedName, sourcePath, resolvedFile }
|
|
172
|
+
let m;
|
|
173
|
+
|
|
174
|
+
IMPORT_ES.lastIndex = 0;
|
|
175
|
+
while ((m = IMPORT_ES.exec(source)) !== null) {
|
|
176
|
+
const namedImports = m[1] || m[4]; // { A, B } (or `Default, { A, B }`)
|
|
177
|
+
const defaultImport = m[2]; // X
|
|
178
|
+
const namespaceImport = m[3]; // * as Y
|
|
179
|
+
const sourcePath = m[5];
|
|
180
|
+
|
|
181
|
+
if (namedImports) {
|
|
182
|
+
for (const part of namedImports.split(',')) {
|
|
183
|
+
const [imported, local] = part.trim().split(/\s+as\s+/).map(s => s.trim());
|
|
184
|
+
if (imported) {
|
|
185
|
+
imports.push({
|
|
186
|
+
localName: local || imported,
|
|
187
|
+
importedName: imported,
|
|
188
|
+
sourcePath,
|
|
189
|
+
resolvedFile: resolveImportPath(sourcePath, filePath),
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
if (defaultImport) {
|
|
195
|
+
imports.push({ localName: defaultImport, importedName: 'default', sourcePath, resolvedFile: resolveImportPath(sourcePath, filePath) });
|
|
196
|
+
}
|
|
197
|
+
if (namespaceImport) {
|
|
198
|
+
imports.push({ localName: namespaceImport, importedName: '*', sourcePath, resolvedFile: resolveImportPath(sourcePath, filePath) });
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
REQUIRE_CJS.lastIndex = 0;
|
|
203
|
+
while ((m = REQUIRE_CJS.exec(source)) !== null) {
|
|
204
|
+
const destructured = m[1];
|
|
205
|
+
const varName = m[2];
|
|
206
|
+
const sourcePath = m[3];
|
|
207
|
+
|
|
208
|
+
if (destructured) {
|
|
209
|
+
for (const part of destructured.split(',')) {
|
|
210
|
+
const name = part.trim().split(/\s+as\s+/)[0]?.trim();
|
|
211
|
+
if (name) {
|
|
212
|
+
imports.push({ localName: name, importedName: name, sourcePath, resolvedFile: resolveImportPath(sourcePath, filePath) });
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
if (varName) {
|
|
217
|
+
imports.push({ localName: varName, importedName: 'default', sourcePath, resolvedFile: resolveImportPath(sourcePath, filePath) });
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Side-effect imports + re-exports — no local binding, but still a
|
|
222
|
+
// file-level dependency (needed for the import graph pass).
|
|
223
|
+
const already = new Set(imports.map(i => i.sourcePath));
|
|
224
|
+
const pushModule = (sourcePath) => {
|
|
225
|
+
if (!sourcePath || already.has(sourcePath)) return;
|
|
226
|
+
already.add(sourcePath);
|
|
227
|
+
imports.push({ localName: null, importedName: '*', sourcePath, resolvedFile: resolveImportPath(sourcePath, filePath) });
|
|
228
|
+
};
|
|
229
|
+
for (const regex of [IMPORT_BARE, EXPORT_FROM]) {
|
|
230
|
+
regex.lastIndex = 0;
|
|
231
|
+
while ((m = regex.exec(source)) !== null) pushModule(m[1]);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ── Language-specific import syntax (universal module paths) ────
|
|
235
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
236
|
+
|
|
237
|
+
if (ext === '.py') {
|
|
238
|
+
const PY_FROM = /^\s*from\s+([\w.]+)\s+import\b/gm;
|
|
239
|
+
const PY_IMPORT = /^\s*import\s+([\w.]+(?:\s*,\s*[\w.]+)*)/gm;
|
|
240
|
+
PY_FROM.lastIndex = 0;
|
|
241
|
+
while ((m = PY_FROM.exec(source)) !== null) pushModule(m[1]);
|
|
242
|
+
PY_IMPORT.lastIndex = 0;
|
|
243
|
+
while ((m = PY_IMPORT.exec(source)) !== null) {
|
|
244
|
+
for (const mod of m[1].split(',')) pushModule(mod.trim());
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (ext === '.go') {
|
|
249
|
+
// Single import + grouped import blocks
|
|
250
|
+
const GO_SINGLE = /import\s+(?:\w+\s+)?"([^"]+)"/g;
|
|
251
|
+
const GO_BLOCK = /import\s*\(([\s\S]*?)\)/g;
|
|
252
|
+
GO_SINGLE.lastIndex = 0;
|
|
253
|
+
while ((m = GO_SINGLE.exec(source)) !== null) pushModule(m[1]);
|
|
254
|
+
GO_BLOCK.lastIndex = 0;
|
|
255
|
+
while ((m = GO_BLOCK.exec(source)) !== null) {
|
|
256
|
+
const inner = m[1];
|
|
257
|
+
const Q = /"([^"]+)"/g;
|
|
258
|
+
let q;
|
|
259
|
+
while ((q = Q.exec(inner)) !== null) pushModule(q[1]);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (ext === '.java' || ext === '.kt' || ext === '.kts') {
|
|
264
|
+
const JAVA_IMPORT = /^\s*import\s+(?:static\s+)?([\w.]+(?:\.\*)?)\s*;?/gm;
|
|
265
|
+
JAVA_IMPORT.lastIndex = 0;
|
|
266
|
+
while ((m = JAVA_IMPORT.exec(source)) !== null) pushModule(m[1]);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return imports;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Join an app.use() mount prefix with a route's local path:
|
|
273
|
+
// joinPaths('/api/users', '/:id') → '/api/users/:id'
|
|
274
|
+
function joinPaths(prefix, routePath) {
|
|
275
|
+
const p = String(prefix || '').replace(/\/+$/, '');
|
|
276
|
+
const r = String(routePath || '').replace(/^\/+/, '');
|
|
277
|
+
return r ? `${p}/${r}` : (p || '/');
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Frontend parsers capture absolute URLs (https://api.example.com/api/login);
|
|
281
|
+
// only the pathname is comparable to backend route paths.
|
|
282
|
+
function stripOrigin(urlOrPath) {
|
|
283
|
+
return String(urlOrPath || '').replace(/^[a-z][a-z0-9+.-]*:\/\/[^/]+/i, '') || '/';
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// ─── MAIN ENGINE ───────────────────────────────────────────────────
|
|
287
|
+
export function buildRelationships(parsedFiles, debug = false) {
|
|
288
|
+
const relations = [];
|
|
289
|
+
const log = debug ? (...args) => console.log(' [rel]', ...args) : () => { };
|
|
290
|
+
|
|
291
|
+
// ═══════════════════════════════════════════════════════════
|
|
292
|
+
// PASS 0 — Compose externally-visible route paths from mounts
|
|
293
|
+
// app.use('/api/users', userRoutes) + router.get('/:id', …)
|
|
294
|
+
// → route "GET /api/users/:id"
|
|
295
|
+
// Mutates route entities (data.fullPath + name) BEFORE the node
|
|
296
|
+
// map is built, so every later pass and the schema builder see
|
|
297
|
+
// the public path. Renames are recorded so PASS 1's source-regex
|
|
298
|
+
// route names stay resolvable.
|
|
299
|
+
// ═══════════════════════════════════════════════════════════
|
|
300
|
+
const routeRenames = new Map(); // `${filePath}|${oldName}` → newName
|
|
301
|
+
|
|
302
|
+
for (const file of parsedFiles) {
|
|
303
|
+
const mounts = (file.entities || []).filter(e => e.entityType === 'mount');
|
|
304
|
+
if (!mounts.length) continue;
|
|
305
|
+
|
|
306
|
+
let source;
|
|
307
|
+
try { source = fs.readFileSync(file.filePath, 'utf-8'); } catch { continue; }
|
|
308
|
+
const imports = extractImports(source, file.filePath);
|
|
309
|
+
|
|
310
|
+
for (const mount of mounts) {
|
|
311
|
+
const routerVar = mount.data?.routerVariable;
|
|
312
|
+
const prefix = mount.data?.prefix;
|
|
313
|
+
if (!routerVar || !prefix) continue;
|
|
314
|
+
|
|
315
|
+
const imp = imports.find(i => i.localName === routerVar);
|
|
316
|
+
if (!imp?.resolvedFile) continue;
|
|
317
|
+
|
|
318
|
+
const routedFile = parsedFiles.find(
|
|
319
|
+
f => path.resolve(f.filePath) === path.resolve(imp.resolvedFile)
|
|
320
|
+
);
|
|
321
|
+
if (!routedFile) continue;
|
|
322
|
+
|
|
323
|
+
for (const e of routedFile.entities || []) {
|
|
324
|
+
if (e.entityType !== 'route' || !e.data?.path || e.data.fullPath) continue;
|
|
325
|
+
const fullPath = joinPaths(prefix, e.data.path);
|
|
326
|
+
const oldName = e.name;
|
|
327
|
+
e.data.fullPath = fullPath;
|
|
328
|
+
e.name = `${e.data.method || ''} ${fullPath}`.trim();
|
|
329
|
+
routeRenames.set(`${routedFile.filePath}|${oldName}`, e.name);
|
|
330
|
+
log(`Mount: "${mount.name}" composes "${oldName}" → "${e.name}"`);
|
|
331
|
+
relations.push({
|
|
332
|
+
source: mount.name,
|
|
333
|
+
target: e.name,
|
|
334
|
+
sourceType: 'mount',
|
|
335
|
+
targetType: 'route',
|
|
336
|
+
sourceSystem: mount.system || 'backend',
|
|
337
|
+
targetSystem: e.system || 'backend',
|
|
338
|
+
relation: 'mounts',
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Resolve a route name derived from raw source (local path) to its
|
|
345
|
+
// post-PASS-0 public name.
|
|
346
|
+
const renamedRoute = (filePath, routeName) =>
|
|
347
|
+
routeRenames.get(`${filePath}|${routeName}`) || routeName;
|
|
348
|
+
|
|
349
|
+
// ── Collect all entities into lookups ─────────────────────
|
|
350
|
+
// entityByFile: filePath → [entities]
|
|
351
|
+
// entityByName: name → entity (first match)
|
|
352
|
+
// entityByTypeAndName: "controller:getRoutes" → entity
|
|
353
|
+
const entityByFile = new Map();
|
|
354
|
+
const entityByName = new Map();
|
|
355
|
+
const entityByTypeAndName = new Map();
|
|
356
|
+
const allEntities = [];
|
|
357
|
+
|
|
358
|
+
for (const file of parsedFiles) {
|
|
359
|
+
const fileEntities = file.entities || [];
|
|
360
|
+
entityByFile.set(file.filePath, fileEntities);
|
|
361
|
+
for (const e of fileEntities) {
|
|
362
|
+
allEntities.push({ ...e, _filePath: file.filePath });
|
|
363
|
+
if (!entityByName.has(e.name)) entityByName.set(e.name, e);
|
|
364
|
+
const key = `${e.entityType}:${e.name}`;
|
|
365
|
+
if (!entityByTypeAndName.has(key)) entityByTypeAndName.set(key, e);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Pre-index: which files are controllers, services, etc.
|
|
370
|
+
const controllerFiles = new Set();
|
|
371
|
+
const serviceFiles = new Set();
|
|
372
|
+
const middlewareFiles = new Set();
|
|
373
|
+
const routeFiles = new Set();
|
|
374
|
+
|
|
375
|
+
for (const [filePath, ents] of entityByFile) {
|
|
376
|
+
for (const e of ents) {
|
|
377
|
+
if (e.entityType === 'controller') controllerFiles.add(filePath);
|
|
378
|
+
if (e.entityType === 'service') serviceFiles.add(filePath);
|
|
379
|
+
if (e.entityType === 'middleware') middlewareFiles.add(filePath);
|
|
380
|
+
if (e.entityType === 'route') routeFiles.add(filePath);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// ═══════════════════════════════════════════════════════════
|
|
385
|
+
// PASS 1 — Route → Controller (from route file source code)
|
|
386
|
+
// ═══════════════════════════════════════════════════════════
|
|
387
|
+
for (const file of parsedFiles) {
|
|
388
|
+
const fp = file.filePath;
|
|
389
|
+
const isRouteFile = /routes?[/\\]/i.test(fp) || routeFiles.has(fp);
|
|
390
|
+
if (!isRouteFile) continue;
|
|
391
|
+
|
|
392
|
+
let source;
|
|
393
|
+
try { source = fs.readFileSync(fp, 'utf-8'); } catch { continue; }
|
|
394
|
+
|
|
395
|
+
const imports = extractImports(source, fp);
|
|
396
|
+
const routes = (file.entities || []).filter(e => e.entityType === 'route');
|
|
397
|
+
|
|
398
|
+
// Pattern 1: router.get('/path', controllerRef.method)
|
|
399
|
+
// Pattern 2: router.get('/path', namedImport)
|
|
400
|
+
// Pattern 3: router.get('/path', verifyToken, controllerRef.method)
|
|
401
|
+
const ROUTE_HANDLER = /(?:router|app)\.(get|post|put|patch|delete|all)\s*\(\s*['"`]([^'"`]+)['"`]\s*,([^)]+)\)/gi;
|
|
402
|
+
let match;
|
|
403
|
+
ROUTE_HANDLER.lastIndex = 0;
|
|
404
|
+
while ((match = ROUTE_HANDLER.exec(source)) !== null) {
|
|
405
|
+
const method = match[1].toUpperCase();
|
|
406
|
+
const routePath = match[2];
|
|
407
|
+
const handlerChain = match[3];
|
|
408
|
+
const routeName = renamedRoute(fp, `${method} ${routePath}`);
|
|
409
|
+
|
|
410
|
+
// Extract all identifiers from the handler chain (last one is the actual handler)
|
|
411
|
+
const identifiers = handlerChain.match(/\b(\w+(?:\.\w+)?)\b/g) || [];
|
|
412
|
+
|
|
413
|
+
// The actual handler is typically the last identifier that isn't middleware
|
|
414
|
+
for (const ident of identifiers) {
|
|
415
|
+
const parts = ident.split('.');
|
|
416
|
+
let resolvedControllerName = null;
|
|
417
|
+
|
|
418
|
+
if (parts.length === 2) {
|
|
419
|
+
// e.g., routeController.createRoute
|
|
420
|
+
const [objName, methodName] = parts;
|
|
421
|
+
// Find what objName imports from
|
|
422
|
+
const imp = imports.find(i => i.localName === objName);
|
|
423
|
+
if (imp?.resolvedFile) {
|
|
424
|
+
const fileEnts = entityByFile.get(imp.resolvedFile) || [];
|
|
425
|
+
// Look for the method as a controller entity
|
|
426
|
+
resolvedControllerName = fileEnts.find(e =>
|
|
427
|
+
e.entityType === 'controller' && e.name === methodName
|
|
428
|
+
)?.name;
|
|
429
|
+
// If not found by name, the import target itself is the controller file
|
|
430
|
+
if (!resolvedControllerName) {
|
|
431
|
+
resolvedControllerName = fileEnts.find(e => e.entityType === 'controller')?.name;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
// Even if we can't resolve the file, create the relation using the method name
|
|
435
|
+
if (!resolvedControllerName) resolvedControllerName = methodName;
|
|
436
|
+
} else if (parts.length === 1) {
|
|
437
|
+
// e.g., getSubscriptionPlans (direct import)
|
|
438
|
+
const funcName = parts[0];
|
|
439
|
+
// Skip known middleware patterns and common non-handler identifiers
|
|
440
|
+
if (/^(verify|decrypt|encrypt|auth|require|check|validate|cors|json|async|await|req|res|next|err|true|false|upload|single|array|fields|none)/i.test(funcName)) continue;
|
|
441
|
+
// Skip known Express/middleware identifiers
|
|
442
|
+
if (['Router', 'router', 'app', 'express', 'middleware', 'multer', 'upload'].includes(funcName)) continue;
|
|
443
|
+
// Check if this is an imported controller function
|
|
444
|
+
const imp = imports.find(i => i.localName === funcName);
|
|
445
|
+
if (imp?.resolvedFile) {
|
|
446
|
+
const fileEnts = entityByFile.get(imp.resolvedFile) || [];
|
|
447
|
+
const isControllerFile = /controller/i.test(imp.resolvedFile);
|
|
448
|
+
if (isControllerFile || fileEnts.some(e => e.entityType === 'controller')) {
|
|
449
|
+
resolvedControllerName = funcName;
|
|
450
|
+
}
|
|
451
|
+
} else if (entityByTypeAndName.has(`controller:${funcName}`)) {
|
|
452
|
+
resolvedControllerName = funcName;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
if (resolvedControllerName) {
|
|
457
|
+
log(`Route→Controller: "${routeName}" → "${resolvedControllerName}"`);
|
|
458
|
+
relations.push({
|
|
459
|
+
source: routeName,
|
|
460
|
+
target: resolvedControllerName,
|
|
461
|
+
sourceType: 'route',
|
|
462
|
+
targetType: 'controller',
|
|
463
|
+
sourceSystem: 'backend',
|
|
464
|
+
targetSystem: 'backend',
|
|
465
|
+
relation: 'handles',
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// Also detect middleware in the chain
|
|
471
|
+
for (const ident of identifiers) {
|
|
472
|
+
const name = ident.split('.')[0];
|
|
473
|
+
if (/^(verify|decrypt|encrypt|auth|require|check|validate|optional)/i.test(name)) {
|
|
474
|
+
log(`Route→Middleware: "${routeName}" → "${name}"`);
|
|
475
|
+
relations.push({
|
|
476
|
+
source: routeName,
|
|
477
|
+
target: name,
|
|
478
|
+
sourceType: 'route',
|
|
479
|
+
targetType: 'middleware',
|
|
480
|
+
sourceSystem: 'backend',
|
|
481
|
+
targetSystem: 'backend',
|
|
482
|
+
relation: 'protected_by',
|
|
483
|
+
});
|
|
484
|
+
} else if (name === 'encryptedRoute' || /middleware/i.test(name)) {
|
|
485
|
+
log(`Route→Middleware: "${routeName}" → "${name}"`);
|
|
486
|
+
relations.push({
|
|
487
|
+
source: routeName,
|
|
488
|
+
target: name,
|
|
489
|
+
sourceType: 'route',
|
|
490
|
+
targetType: 'middleware',
|
|
491
|
+
sourceSystem: 'backend',
|
|
492
|
+
targetSystem: 'backend',
|
|
493
|
+
relation: 'protected_by',
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// Pattern 4: inline handler — router.post('/path', async (req, res) => { ... })
|
|
500
|
+
// If route has NO controller relation yet, the handler is inline in the route file
|
|
501
|
+
for (const route of routes) {
|
|
502
|
+
const hasControllerEdge = relations.some(r =>
|
|
503
|
+
r.source === route.name && r.sourceType === 'route' && r.targetType === 'controller'
|
|
504
|
+
);
|
|
505
|
+
if (!hasControllerEdge) {
|
|
506
|
+
// Route file itself acts as controller — link to file-level controller entity if any
|
|
507
|
+
const fileControllers = (file.entities || []).filter(e => e.entityType === 'controller');
|
|
508
|
+
if (fileControllers.length > 0) {
|
|
509
|
+
log(`Route→Controller (inline): "${route.name}" → "${fileControllers[0].name}"`);
|
|
510
|
+
relations.push({
|
|
511
|
+
source: route.name,
|
|
512
|
+
target: fileControllers[0].name,
|
|
513
|
+
sourceType: 'route',
|
|
514
|
+
targetType: 'controller',
|
|
515
|
+
sourceSystem: 'backend',
|
|
516
|
+
targetSystem: 'backend',
|
|
517
|
+
relation: 'handles',
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// ═══════════════════════════════════════════════════════════
|
|
525
|
+
// PASS 2 — Controller → Service (from controller source code)
|
|
526
|
+
// ═══════════════════════════════════════════════════════════
|
|
527
|
+
for (const file of parsedFiles) {
|
|
528
|
+
const fp = file.filePath;
|
|
529
|
+
const isControllerFile = /controller/i.test(fp) || controllerFiles.has(fp);
|
|
530
|
+
if (!isControllerFile) continue;
|
|
531
|
+
|
|
532
|
+
let source;
|
|
533
|
+
try { source = fs.readFileSync(fp, 'utf-8'); } catch { continue; }
|
|
534
|
+
|
|
535
|
+
const imports = extractImports(source, fp);
|
|
536
|
+
const controllers = (file.entities || []).filter(e => e.entityType === 'controller');
|
|
537
|
+
const controllerName = controllers[0]?.name || path.basename(fp, path.extname(fp));
|
|
538
|
+
|
|
539
|
+
// Find service imports
|
|
540
|
+
for (const imp of imports) {
|
|
541
|
+
if (/service/i.test(imp.sourcePath) || /service/i.test(imp.localName)) {
|
|
542
|
+
// This controller imports a service
|
|
543
|
+
const serviceName = imp.importedName !== 'default' && imp.importedName !== '*'
|
|
544
|
+
? imp.importedName
|
|
545
|
+
: imp.localName;
|
|
546
|
+
|
|
547
|
+
// Find actual service entity
|
|
548
|
+
const serviceEntity = entityByName.get(serviceName)
|
|
549
|
+
|| entityByTypeAndName.get(`service:${serviceName}`)
|
|
550
|
+
|| (imp.resolvedFile ? (entityByFile.get(imp.resolvedFile) || []).find(e => e.entityType === 'service') : null);
|
|
551
|
+
|
|
552
|
+
const targetName = serviceEntity?.name || serviceName;
|
|
553
|
+
|
|
554
|
+
// Create relation from each controller function → service
|
|
555
|
+
for (const ctrl of controllers) {
|
|
556
|
+
log(`Controller→Service: "${ctrl.name}" → "${targetName}"`);
|
|
557
|
+
relations.push({
|
|
558
|
+
source: ctrl.name,
|
|
559
|
+
target: targetName,
|
|
560
|
+
sourceType: 'controller',
|
|
561
|
+
targetType: 'service',
|
|
562
|
+
sourceSystem: 'backend',
|
|
563
|
+
targetSystem: 'backend',
|
|
564
|
+
relation: 'uses',
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// If no individual controller functions found, use file-level
|
|
569
|
+
if (controllers.length === 0) {
|
|
570
|
+
log(`Controller(file)→Service: "${controllerName}" → "${targetName}"`);
|
|
571
|
+
relations.push({
|
|
572
|
+
source: controllerName,
|
|
573
|
+
target: targetName,
|
|
574
|
+
sourceType: 'controller',
|
|
575
|
+
targetType: 'service',
|
|
576
|
+
sourceSystem: 'backend',
|
|
577
|
+
targetSystem: 'backend',
|
|
578
|
+
relation: 'uses',
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// Also detect service method calls: xxxService.method()
|
|
585
|
+
const SERVICE_CALL = /(\w+Service)\s*\.\s*(\w+)\s*\(/gi;
|
|
586
|
+
let scMatch;
|
|
587
|
+
const seenServiceCalls = new Set();
|
|
588
|
+
SERVICE_CALL.lastIndex = 0;
|
|
589
|
+
while ((scMatch = SERVICE_CALL.exec(source)) !== null) {
|
|
590
|
+
const svcVar = scMatch[1];
|
|
591
|
+
const svcMethod = scMatch[2];
|
|
592
|
+
const callKey = `${controllerName}→${svcVar}`;
|
|
593
|
+
if (seenServiceCalls.has(callKey)) continue;
|
|
594
|
+
seenServiceCalls.add(callKey);
|
|
595
|
+
|
|
596
|
+
// Resolve service variable to imported class
|
|
597
|
+
const imp = imports.find(i => i.localName === svcVar);
|
|
598
|
+
const serviceEntity = imp?.resolvedFile
|
|
599
|
+
? (entityByFile.get(imp.resolvedFile) || []).find(e => e.entityType === 'service')
|
|
600
|
+
: entityByName.get(svcVar);
|
|
601
|
+
|
|
602
|
+
if (serviceEntity) {
|
|
603
|
+
for (const ctrl of controllers) {
|
|
604
|
+
log(`Controller→Service (call): "${ctrl.name}" → "${serviceEntity.name}"`);
|
|
605
|
+
relations.push({
|
|
606
|
+
source: ctrl.name,
|
|
607
|
+
target: serviceEntity.name,
|
|
608
|
+
sourceType: 'controller',
|
|
609
|
+
targetType: 'service',
|
|
610
|
+
sourceSystem: 'backend',
|
|
611
|
+
targetSystem: 'backend',
|
|
612
|
+
relation: 'uses',
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// Detect `new XService(...)` constructor calls
|
|
619
|
+
const NEW_SERVICE = /new\s+(\w+Service)\s*\(/gi;
|
|
620
|
+
let nsMatch;
|
|
621
|
+
const seenNewService = new Set();
|
|
622
|
+
NEW_SERVICE.lastIndex = 0;
|
|
623
|
+
while ((nsMatch = NEW_SERVICE.exec(source)) !== null) {
|
|
624
|
+
const svcClass = nsMatch[1];
|
|
625
|
+
if (seenNewService.has(`${controllerName}→${svcClass}`)) continue;
|
|
626
|
+
seenNewService.add(`${controllerName}→${svcClass}`);
|
|
627
|
+
|
|
628
|
+
const serviceEntity = entityByName.get(svcClass) || entityByTypeAndName.get(`service:${svcClass}`);
|
|
629
|
+
const targetName = serviceEntity?.name || svcClass;
|
|
630
|
+
|
|
631
|
+
for (const ctrl of controllers) {
|
|
632
|
+
log(`Controller→Service (new): "${ctrl.name}" → "${targetName}"`);
|
|
633
|
+
relations.push({
|
|
634
|
+
source: ctrl.name,
|
|
635
|
+
target: targetName,
|
|
636
|
+
sourceType: 'controller',
|
|
637
|
+
targetType: 'service',
|
|
638
|
+
sourceSystem: 'backend',
|
|
639
|
+
targetSystem: 'backend',
|
|
640
|
+
relation: 'uses',
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// ═══════════════════════════════════════════════════════════
|
|
647
|
+
// PASS 3 — Service → DB / Model (from service source code)
|
|
648
|
+
// ═══════════════════════════════════════════════════════════
|
|
649
|
+
for (const file of parsedFiles) {
|
|
650
|
+
const fp = file.filePath;
|
|
651
|
+
const isServiceFile = /service/i.test(fp) || serviceFiles.has(fp);
|
|
652
|
+
if (!isServiceFile) continue;
|
|
653
|
+
|
|
654
|
+
let source;
|
|
655
|
+
try { source = fs.readFileSync(fp, 'utf-8'); } catch { continue; }
|
|
656
|
+
|
|
657
|
+
const services = (file.entities || []).filter(e => e.entityType === 'service');
|
|
658
|
+
const serviceName = services[0]?.name || path.basename(fp, path.extname(fp));
|
|
659
|
+
|
|
660
|
+
// Detect Firestore collection access: db.collection('users'), .collection('routes')
|
|
661
|
+
const COLLECTION = /\.collection\s*\(\s*['"`](\w+)['"`]\s*\)/gi;
|
|
662
|
+
let cMatch;
|
|
663
|
+
const seenCollections = new Set();
|
|
664
|
+
COLLECTION.lastIndex = 0;
|
|
665
|
+
while ((cMatch = COLLECTION.exec(source)) !== null) {
|
|
666
|
+
const collection = cMatch[1];
|
|
667
|
+
if (seenCollections.has(collection)) continue;
|
|
668
|
+
seenCollections.add(collection);
|
|
669
|
+
|
|
670
|
+
const target = serviceName;
|
|
671
|
+
for (const svc of services) {
|
|
672
|
+
log(`Service→Database: "${svc.name}" → "collection:${collection}"`);
|
|
673
|
+
relations.push({
|
|
674
|
+
source: svc.name,
|
|
675
|
+
target: `collection:${collection}`,
|
|
676
|
+
sourceType: 'service',
|
|
677
|
+
targetType: 'database',
|
|
678
|
+
sourceSystem: 'backend',
|
|
679
|
+
targetSystem: 'backend',
|
|
680
|
+
relation: 'queries',
|
|
681
|
+
});
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// Detect Mongoose model calls: Model.find(), Model.findById(), new Model()
|
|
686
|
+
const MODEL_CALL = /(\b[A-Z]\w+)\.(find|findOne|findById|create|save|update|deleteOne|deleteMany|aggregate|count|distinct)\s*\(/gi;
|
|
687
|
+
let mMatch;
|
|
688
|
+
const seenModels = new Set();
|
|
689
|
+
MODEL_CALL.lastIndex = 0;
|
|
690
|
+
while ((mMatch = MODEL_CALL.exec(source)) !== null) {
|
|
691
|
+
const modelName = mMatch[1];
|
|
692
|
+
if (['Date', 'Math', 'JSON', 'Object', 'Array', 'String', 'Number', 'Promise', 'Error', 'Buffer', 'FieldValue', 'RegExp'].includes(modelName)) continue;
|
|
693
|
+
if (seenModels.has(`${serviceName}→${modelName}`)) continue;
|
|
694
|
+
seenModels.add(`${serviceName}→${modelName}`);
|
|
695
|
+
|
|
696
|
+
for (const svc of services) {
|
|
697
|
+
log(`Service→Model: "${svc.name}" → "${modelName}"`);
|
|
698
|
+
relations.push({
|
|
699
|
+
source: svc.name,
|
|
700
|
+
target: modelName,
|
|
701
|
+
sourceType: 'service',
|
|
702
|
+
targetType: 'model',
|
|
703
|
+
sourceSystem: 'backend',
|
|
704
|
+
targetSystem: 'backend',
|
|
705
|
+
relation: 'queries',
|
|
706
|
+
});
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
// Detect getDatabase/getFirestore usage → database node
|
|
711
|
+
if (/getFirestore|getDatabase|mongoose\.connect|createPool|pg\.connect/i.test(source)) {
|
|
712
|
+
for (const svc of services) {
|
|
713
|
+
log(`Service→Database: "${svc.name}" → "Database"`);
|
|
714
|
+
relations.push({
|
|
715
|
+
source: svc.name,
|
|
716
|
+
target: 'Database',
|
|
717
|
+
sourceType: 'service',
|
|
718
|
+
targetType: 'database',
|
|
719
|
+
sourceSystem: 'backend',
|
|
720
|
+
targetSystem: 'backend',
|
|
721
|
+
relation: 'stored_in',
|
|
722
|
+
});
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
// ═══════════════════════════════════════════════════════════
|
|
728
|
+
// PASS 4 — Frontend → API matching
|
|
729
|
+
// ═══════════════════════════════════════════════════════════
|
|
730
|
+
// Server-side routes are anything with a concrete method+path that is
|
|
731
|
+
// not a frontend reference — the system label may be 'backend' or a
|
|
732
|
+
// workspace-specific name, so don't filter on it.
|
|
733
|
+
const backendRoutes = allEntities.filter(e =>
|
|
734
|
+
e.entityType === 'route' && e.data?.method && e.data?.path && e.data?.scope !== 'frontend-ref'
|
|
735
|
+
);
|
|
736
|
+
const frontendAPIs = allEntities.filter(e => e.entityType === 'api' && e.data?.scope === 'frontend-ref');
|
|
737
|
+
|
|
738
|
+
for (const feAPI of frontendAPIs) {
|
|
739
|
+
const apiPath = stripOrigin(feAPI.data?.path || '');
|
|
740
|
+
const apiMethod = feAPI.data?.method || 'GET';
|
|
741
|
+
|
|
742
|
+
// Find screen/widget that this API belongs to (same file)
|
|
743
|
+
const sameFileEntities = allEntities.filter(e =>
|
|
744
|
+
e._filePath === feAPI._filePath && (e.entityType === 'screen' || e.entityType === 'widget')
|
|
745
|
+
);
|
|
746
|
+
const screenName = sameFileEntities[0]?.name;
|
|
747
|
+
|
|
748
|
+
if (screenName) {
|
|
749
|
+
log(`Frontend→API: "${screenName}" → "${feAPI.name}"`);
|
|
750
|
+
relations.push({
|
|
751
|
+
source: screenName,
|
|
752
|
+
target: feAPI.name,
|
|
753
|
+
sourceType: sameFileEntities[0].entityType,
|
|
754
|
+
targetType: 'api',
|
|
755
|
+
sourceSystem: feAPI.system,
|
|
756
|
+
targetSystem: feAPI.system,
|
|
757
|
+
relation: 'consumes',
|
|
758
|
+
});
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
// Match to backend route — prefer the mount-composed public path
|
|
762
|
+
for (const beRoute of backendRoutes) {
|
|
763
|
+
const routePath = beRoute.data?.fullPath || beRoute.data?.path || '';
|
|
764
|
+
const routeMethod = beRoute.data?.method || 'GET';
|
|
765
|
+
|
|
766
|
+
if (routeMethod === apiMethod && pathsMatch(routePath, apiPath)) {
|
|
767
|
+
log(`API Match: Frontend "${feAPI.name}" ↔ Backend "${beRoute.name}"`);
|
|
768
|
+
relations.push({
|
|
769
|
+
source: feAPI.name,
|
|
770
|
+
target: beRoute.name,
|
|
771
|
+
sourceType: 'api',
|
|
772
|
+
targetType: 'route',
|
|
773
|
+
sourceSystem: feAPI.system,
|
|
774
|
+
targetSystem: beRoute.system || 'backend',
|
|
775
|
+
relation: 'calls',
|
|
776
|
+
});
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
// ═══════════════════════════════════════════════════════════
|
|
782
|
+
// PASS 5 — Route mount relationships (app.use prefix → sub-routes)
|
|
783
|
+
// ═══════════════════════════════════════════════════════════
|
|
784
|
+
// Handled by the nodeParser which detects app.use patterns.
|
|
785
|
+
|
|
786
|
+
// ═══════════════════════════════════════════════════════════
|
|
787
|
+
// PASS 6 — File-level import graph (CodeSee-style arrows)
|
|
788
|
+
// Every resolvable import between two scanned files becomes an
|
|
789
|
+
// `imports` edge connecting the files' primary entities. This is
|
|
790
|
+
// what makes arrows appear automatically across the whole map,
|
|
791
|
+
// exactly like CodeSee's codebase maps.
|
|
792
|
+
// ═══════════════════════════════════════════════════════════
|
|
793
|
+
const filePathIndex = new Map(
|
|
794
|
+
parsedFiles.map(f => [path.resolve(f.filePath), f])
|
|
795
|
+
);
|
|
796
|
+
|
|
797
|
+
// Detect alias roots (`@/x` → <project>/src/x) from the scanned file set.
|
|
798
|
+
const srcRoots = new Set();
|
|
799
|
+
for (const f of parsedFiles) {
|
|
800
|
+
const norm = path.resolve(f.filePath).replace(/\\/g, '/');
|
|
801
|
+
const i = norm.lastIndexOf('/src/');
|
|
802
|
+
if (i !== -1) srcRoots.add(norm.slice(0, i + 4));
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
// Universal module resolution context — works for any language whose
|
|
806
|
+
// imports name a module path (Dart package:, Python dotted, Java dotted,
|
|
807
|
+
// Go module paths, JS monorepo deep imports, …).
|
|
808
|
+
const resolveCtx = { srcRoots, suffixIndex: buildSuffixIndex(parsedFiles) };
|
|
809
|
+
|
|
810
|
+
// Primary entity of a file: name matching the filename wins, then the
|
|
811
|
+
// most "architectural" entity type, then the first one parsed.
|
|
812
|
+
const ENTITY_PRIORITY = ['screen', 'layout', 'widget', 'controller', 'service', 'route', 'api', 'mount', 'middleware', 'model', 'database', 'config'];
|
|
813
|
+
const priorityOf = (e) => {
|
|
814
|
+
const i = ENTITY_PRIORITY.indexOf(e.entityType);
|
|
815
|
+
return i === -1 ? ENTITY_PRIORITY.length : i;
|
|
816
|
+
};
|
|
817
|
+
const primaryEntityOf = (file) => {
|
|
818
|
+
const ents = file.entities || [];
|
|
819
|
+
if (ents.length === 0) return null;
|
|
820
|
+
const base = path.basename(file.filePath).replace(/\.[^.]+$/, '').toLowerCase();
|
|
821
|
+
return ents.find(e => (e.name || '').toLowerCase() === base)
|
|
822
|
+
|| [...ents].sort((a, b) => priorityOf(a) - priorityOf(b))[0];
|
|
823
|
+
};
|
|
824
|
+
|
|
825
|
+
// Don't double-draw pairs already connected by a richer relation —
|
|
826
|
+
// including relations the per-file parsers emitted (merged in buildSchema).
|
|
827
|
+
const connectedPairs = new Set([
|
|
828
|
+
...relations.map(r => `${r.source}→${r.target}`),
|
|
829
|
+
...parsedFiles.flatMap(f => (f.relations || []).map(r => `${r.source}→${r.target}`)),
|
|
830
|
+
]);
|
|
831
|
+
let importEdgeCount = 0;
|
|
832
|
+
|
|
833
|
+
for (const file of parsedFiles) {
|
|
834
|
+
const srcEntity = primaryEntityOf(file);
|
|
835
|
+
if (!srcEntity) continue;
|
|
836
|
+
|
|
837
|
+
let source;
|
|
838
|
+
try { source = fs.readFileSync(file.filePath, 'utf-8'); } catch { continue; }
|
|
839
|
+
|
|
840
|
+
const imports = extractImports(source, file.filePath);
|
|
841
|
+
const seenTargetFiles = new Set();
|
|
842
|
+
|
|
843
|
+
for (const imp of imports) {
|
|
844
|
+
const resolved = resolveModuleImport(imp, file.filePath, resolveCtx);
|
|
845
|
+
if (!resolved) continue;
|
|
846
|
+
|
|
847
|
+
const tgtFile = filePathIndex.get(path.resolve(resolved));
|
|
848
|
+
if (!tgtFile || tgtFile.filePath === file.filePath) continue;
|
|
849
|
+
if (seenTargetFiles.has(tgtFile.filePath)) continue; // one edge per file pair
|
|
850
|
+
seenTargetFiles.add(tgtFile.filePath);
|
|
851
|
+
|
|
852
|
+
const tgtEntity = primaryEntityOf(tgtFile);
|
|
853
|
+
if (!tgtEntity || tgtEntity.name === srcEntity.name) continue;
|
|
854
|
+
|
|
855
|
+
const pairKey = `${srcEntity.name}→${tgtEntity.name}`;
|
|
856
|
+
const reverseKey = `${tgtEntity.name}→${srcEntity.name}`;
|
|
857
|
+
if (connectedPairs.has(pairKey) || connectedPairs.has(reverseKey)) continue;
|
|
858
|
+
connectedPairs.add(pairKey);
|
|
859
|
+
|
|
860
|
+
importEdgeCount++;
|
|
861
|
+
log(`Import: "${srcEntity.name}" → "${tgtEntity.name}"`);
|
|
862
|
+
relations.push({
|
|
863
|
+
source: srcEntity.name,
|
|
864
|
+
target: tgtEntity.name,
|
|
865
|
+
sourceType: srcEntity.entityType,
|
|
866
|
+
targetType: tgtEntity.entityType,
|
|
867
|
+
sourceSystem: srcEntity.system,
|
|
868
|
+
targetSystem: tgtEntity.system,
|
|
869
|
+
// File scoping — disambiguates same-named entities (two
|
|
870
|
+
// `main` modules) when the schema builder resolves nodes.
|
|
871
|
+
sourceFile: file.filePath,
|
|
872
|
+
targetFile: tgtFile.filePath,
|
|
873
|
+
relation: 'imports',
|
|
874
|
+
});
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
if (importEdgeCount > 0) log(`Import graph: ${importEdgeCount} file-dependency edges`);
|
|
878
|
+
|
|
879
|
+
// Deduplicate relations. The system is part of identity — the admin and
|
|
880
|
+
// the mobile app may both call "POST /api/auth/login" and each deserves
|
|
881
|
+
// its own cross-system edge.
|
|
882
|
+
const seen = new Set();
|
|
883
|
+
const unique = [];
|
|
884
|
+
for (const rel of relations) {
|
|
885
|
+
const key = `${rel.sourceSystem || ''}:${rel.sourceType}:${rel.source}:${rel.sourceFile || ''}:${rel.targetSystem || ''}:${rel.targetType}:${rel.target}:${rel.targetFile || ''}:${rel.relation}`;
|
|
886
|
+
if (!seen.has(key)) {
|
|
887
|
+
seen.add(key);
|
|
888
|
+
unique.push(rel);
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
return unique;
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
// Check if two API paths match (handles path params like :id)
|
|
896
|
+
function pathsMatch(backendPath, frontendPath) {
|
|
897
|
+
// Normalize both paths
|
|
898
|
+
const normalize = (p) => p.replace(/^\/+|\/+$/g, '').toLowerCase();
|
|
899
|
+
const bp = normalize(backendPath);
|
|
900
|
+
const fp = normalize(frontendPath);
|
|
901
|
+
|
|
902
|
+
if (bp === fp) return true;
|
|
903
|
+
|
|
904
|
+
// Handle path params: /routes/:routeId matches /routes/abc123
|
|
905
|
+
const bParts = bp.split('/');
|
|
906
|
+
const fParts = fp.split('/');
|
|
907
|
+
|
|
908
|
+
if (bParts.length !== fParts.length) return false;
|
|
909
|
+
|
|
910
|
+
for (let i = 0; i < bParts.length; i++) {
|
|
911
|
+
if (bParts[i].startsWith(':') || bParts[i].startsWith('{')) continue;
|
|
912
|
+
if (fParts[i].startsWith(':') || fParts[i].startsWith('{')) continue;
|
|
913
|
+
if (bParts[i] !== fParts[i]) return false;
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
return true;
|
|
917
|
+
}
|