archsync 1.0.0 → 1.0.2

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.
Files changed (50) hide show
  1. package/README.md +67 -0
  2. package/dist/archsync.cjs +2 -0
  3. package/package.json +11 -7
  4. package/bin/cli.js +0 -91
  5. package/src/__tests__/e2e-workflow.test.js +0 -66
  6. package/src/__tests__/hashEngine.test.js +0 -109
  7. package/src/__tests__/impact.test.js +0 -137
  8. package/src/__tests__/parsers.test.js +0 -496
  9. package/src/__tests__/scan-pipeline.test.js +0 -332
  10. package/src/__tests__/schemaBuilder.test.js +0 -145
  11. package/src/__tests__/workspace.test.js +0 -178
  12. package/src/commands/backup.js +0 -54
  13. package/src/commands/connect.js +0 -129
  14. package/src/commands/diff.js +0 -228
  15. package/src/commands/export.js +0 -125
  16. package/src/commands/impactReport.js +0 -50
  17. package/src/commands/import.js +0 -126
  18. package/src/commands/init.js +0 -80
  19. package/src/commands/login.js +0 -116
  20. package/src/commands/plugin.js +0 -28
  21. package/src/commands/push.js +0 -194
  22. package/src/commands/register.js +0 -127
  23. package/src/commands/scan.js +0 -498
  24. package/src/commands/serve.js +0 -133
  25. package/src/commands/setup.js +0 -233
  26. package/src/commands/status.js +0 -56
  27. package/src/commands/validate.js +0 -245
  28. package/src/commands/watch.js +0 -70
  29. package/src/core/credentialStore.js +0 -76
  30. package/src/core/hashEngine.js +0 -34
  31. package/src/core/impactEngine.js +0 -192
  32. package/src/core/monorepoDetector.js +0 -41
  33. package/src/core/pluginManager.js +0 -40
  34. package/src/core/relationshipEngine.js +0 -917
  35. package/src/core/requestSigning.js +0 -16
  36. package/src/core/schemaBuilder.js +0 -230
  37. package/src/core/schemaDeduplicator.js +0 -54
  38. package/src/core/supabaseClient.js +0 -68
  39. package/src/core/workspaceDetector.js +0 -113
  40. package/src/parsers/astParser.js +0 -274
  41. package/src/parsers/configParser.js +0 -49
  42. package/src/parsers/dependencyGraph.js +0 -31
  43. package/src/parsers/flutterParser.js +0 -98
  44. package/src/parsers/goParser.js +0 -99
  45. package/src/parsers/index.js +0 -211
  46. package/src/parsers/javaParser.js +0 -89
  47. package/src/parsers/nodeParser.js +0 -429
  48. package/src/parsers/pythonParser.js +0 -109
  49. package/src/parsers/reactParser.js +0 -368
  50. package/src/parsers/smartComment.js +0 -144
@@ -1,917 +0,0 @@
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
- }