tecton 0.4.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/src/system.js ADDED
@@ -0,0 +1,672 @@
1
+ // The "system" view: a C4-style architecture picture read straight from the code.
2
+ // apps each package.json is an app (Next.js site, API server, worker…)
3
+ // entry pts pages, API routes, HTTP endpoints (with router prefixes), npm-script jobs and cron schedules
4
+ // data databases, storage and queues the code talks to (from imports, queue names and env files)
5
+ // outside external services: hosts in URL literals, well-known SDKs and URLs in .env.example files
6
+ // links app → app (e.g. BACKEND_URL), app → data, app → outside services
7
+ import path from 'node:path';
8
+ import { stripComments, parseJsonc, codeOf, makeResolver, loadAliases } from './scan.js';
9
+ import { globToRegex } from './graph.js';
10
+
11
+ const MAX_EVIDENCE = 25;
12
+ const MAX_PREFIXES = 4;
13
+ const EXCLUDE = ['**/*.test.*', '**/*.spec.*', '**/__tests__/**', '**/__mocks__/**', '**/*.stories.*', '**/*.config.*',
14
+ '**/test/**', '**/tests/**'].map(globToRegex);
15
+
16
+ // ---------------------------------------------------------------- knowledge tables
17
+ const STORES = [
18
+ { match: /^(node:sqlite|better-sqlite3|sqlite3|sqlite|@libsql\/client)$/, id: 'sqlite', name: 'SQLite', kind: 'Database' },
19
+ { match: /^(pg|postgres|@neondatabase\/serverless|@vercel\/postgres|pg-promise|slonik)$/, id: 'postgres', name: 'PostgreSQL', kind: 'Database' },
20
+ { match: /^(mysql|mysql2|@planetscale\/database)$/, id: 'mysql', name: 'MySQL', kind: 'Database' },
21
+ { match: /^(mongodb|mongoose)$/, id: 'mongodb', name: 'MongoDB', kind: 'Database' },
22
+ { match: /^(redis|ioredis|@upstash\/redis|@vercel\/kv)$/, id: 'redis', name: 'Redis', kind: 'Cache / store' },
23
+ { match: /^@prisma\/client$/, id: 'prisma', name: 'Database via Prisma', kind: 'Database' },
24
+ { match: /^drizzle-orm(\/.*)?$/, id: 'drizzle', name: 'Database via Drizzle', kind: 'Database' },
25
+ { match: /^(typeorm|sequelize|knex|kysely|@mikro-orm\/core)$/, id: 'sql-orm', name: 'SQL database (ORM)', kind: 'Database' },
26
+ { match: /^@supabase\/supabase-js$/, id: 'supabase', name: 'Supabase', kind: 'Database' },
27
+ { match: /^(firebase-admin|firebase)(\/firestore)?$|^@google-cloud\/firestore$/, id: 'firestore', name: 'Firebase / Firestore', kind: 'Database' },
28
+ { match: /^@aws-sdk\/client-dynamodb$|^@aws-sdk\/lib-dynamodb$/, id: 'dynamodb', name: 'DynamoDB', kind: 'Database' },
29
+ { match: /^@aws-sdk\/client-s3$|^@vercel\/blob$|^@google-cloud\/storage$/, id: 'blob', name: 'File storage', kind: 'Storage' },
30
+ { match: /^@elastic\/elasticsearch$|^@opensearch-project\/opensearch$/, id: 'search', name: 'Elasticsearch', kind: 'Search' },
31
+ // queues (BullMQ, Bull and Bee-Queue are handled by name below)
32
+ { match: /^@aws-sdk\/client-sqs$|^sqs-consumer$|^sqs-producer$/, id: 'sqs', name: 'Amazon SQS', kind: 'Queue' },
33
+ { match: /^(amqplib|amqp-connection-manager|rascal)$/, id: 'rabbitmq', name: 'RabbitMQ', kind: 'Queue' },
34
+ { match: /^(kafkajs|node-rdkafka|@confluentinc\/kafka-javascript)$/, id: 'kafka', name: 'Kafka', kind: 'Queue' },
35
+ { match: /^@google-cloud\/pubsub$/, id: 'pubsub', name: 'Google Pub/Sub', kind: 'Queue' },
36
+ { match: /^@azure\/service-bus$/, id: 'servicebus', name: 'Azure Service Bus', kind: 'Queue' },
37
+ { match: /^@upstash\/qstash$/, id: 'qstash', name: 'QStash', kind: 'Queue' },
38
+ { match: /^(agenda|@hokify\/agenda)$/, id: 'agenda', name: 'Agenda jobs', kind: 'Queue' },
39
+ { match: /^inngest$/, id: 'inngest', name: 'Inngest', kind: 'Queue' },
40
+ { match: /^@trigger\.dev\/sdk$/, id: 'trigger', name: 'Trigger.dev', kind: 'Queue' },
41
+ ];
42
+ const NAMED_QUEUE_PKGS = new Set(['bullmq', 'bull', 'bee-queue']);
43
+ // connection strings in env files: scheme -> store
44
+ const SCHEME_STORES = {
45
+ postgres: 'postgres', postgresql: 'postgres', mysql: 'mysql', mongodb: 'mongodb', 'mongodb+srv': 'mongodb',
46
+ redis: 'redis', rediss: 'redis', amqp: 'rabbitmq', amqps: 'rabbitmq', libsql: 'sqlite',
47
+ };
48
+ const ORM_STORES = new Set(['prisma', 'drizzle', 'sql-orm']);
49
+ /** @type {[RegExp, string, string][]} SDK import pattern, service name, kind */
50
+ const SDKS = [
51
+ [/^stripe$/, 'Stripe', 'Payments'], [/^razorpay$/, 'Razorpay', 'Payments'],
52
+ [/^openai$/, 'OpenAI', 'AI'], [/^@anthropic-ai\/sdk$/, 'Anthropic', 'AI'], [/^@google\/generative-ai$|^@google\/genai$/, 'Google Gemini', 'AI'],
53
+ [/^nodemailer$/, 'SMTP email', 'Email'], [/^resend$/, 'Resend', 'Email'], [/^@sendgrid\/mail$/, 'SendGrid', 'Email'], [/^postmark$/, 'Postmark', 'Email'],
54
+ [/^twilio$/, 'Twilio', 'SMS'], [/^@sentry\//, 'Sentry', 'Monitoring'], [/^posthog-(js|node)$/, 'PostHog', 'Analytics'],
55
+ [/^@vercel\/analytics/, 'Vercel Analytics', 'Analytics'], [/^mixpanel/, 'Mixpanel', 'Analytics'],
56
+ [/^@clerk\//, 'Clerk', 'Auth'], [/^@auth0\//, 'Auth0', 'Auth'], [/^next-auth$|^@auth\/core$/, 'Auth.js', 'Auth'],
57
+ [/^algoliasearch$/, 'Algolia', 'Search'], [/^@pinecone-database\//, 'Pinecone', 'Vector DB'], [/^cloudinary$/, 'Cloudinary', 'Media'],
58
+ [/^pusher(-js)?$/, 'Pusher', 'Realtime'], [/^ably$/, 'Ably', 'Realtime'], [/^@slack\/web-api$/, 'Slack', 'Messaging'],
59
+ [/^googleapis$/, 'Google APIs', 'API'], [/^@octokit\//, 'GitHub API', 'API'],
60
+ ];
61
+ const FRAMEWORKS = [
62
+ ['next', 'Next.js', 'web'], ['nuxt', 'Nuxt', 'web'], ['@remix-run/react', 'Remix', 'web'], ['@sveltejs/kit', 'SvelteKit', 'web'],
63
+ ['astro', 'Astro', 'web'], ['@angular/core', 'Angular', 'web'], ['vue', 'Vue', 'web'], ['svelte', 'Svelte', 'web'],
64
+ ['react-native', 'React Native', 'mobile'], ['expo', 'Expo', 'mobile'],
65
+ ['@nestjs/core', 'NestJS', 'server'], ['express', 'Express', 'server'], ['fastify', 'Fastify', 'server'], ['koa', 'Koa', 'server'],
66
+ ['hono', 'Hono', 'server'], ['@hapi/hapi', 'hapi', 'server'], ['electron', 'Electron', 'desktop'],
67
+ ['react', 'React', 'web'],
68
+ ];
69
+ const NOTABLE = ['typescript', 'tailwindcss', '@tanstack/react-query', 'zod', 'prisma', 'drizzle-orm', 'trpc', '@trpc/server', 'graphql',
70
+ 'socket.io', 'bullmq', 'node-cron', 'recharts', 'framer-motion', 'next-themes'];
71
+ const IGNORE_HOSTS = /^(localhost|127\.|0\.0\.0\.0|\[::1\]|.*\.local$|.*\.test$|.*\.example$|(www\.)?example\.(com|org|net)$|(www\.)?w3\.org$|schema\.org$|json-schema\.org$|(www\.)?schemas\.|.*\.xmlsoap\.org$|purl\.org$)/;
72
+ // names that look like `x.get('/path')` but are HTTP clients or plain objects, not servers
73
+ const NOT_ROUTERS = new Set(['axios', 'got', 'ky', 'superagent', 'request', 'http', 'https', 'fetch', 'client', 'httpClient', 'apiClient',
74
+ '$http', 'instance', 'res', 'req', 'ctx', 'headers', 'map', 'cache', 'params', 'searchParams', 'url', 'store', 'redis', 'db',
75
+ 'this', 'window', 'document', 'localStorage', 'sessionStorage', 'config', 'cookies', 'formData', 'supertest', 'agent']);
76
+ // env vars that name a URL, and ones that point back at the app itself
77
+ const URL_VAR = /_(URL|URI|HOST|ENDPOINT|ORIGIN)$/;
78
+ const SELF_VAR = /(^|_)(SITE|APP|PUBLIC_URL|VERCEL|NEXTAUTH|AUTH_URL|CALLBACK|REDIRECT|FRONTEND|CLIENT|CORS|SELF|WEB)(_|$)|^(URL|HOST|PORT)$/;
79
+ const STORE_VAR = /(DATABASE|(^|_)DB(_|$)|POSTGRES|PG|MYSQL|MONGO|REDIS|KV|CACHE|QUEUE|AMQP|RABBIT|KAFKA|SUPABASE|PRISMA|SQLITE)/;
80
+ const UPPER_WORDS = new Set(['API', 'AI', 'SMS', 'S3', 'CDN', 'ID', 'URL', 'HTTP', 'GRPC', 'ML', 'LLM', 'CRM', 'ERP', 'SSO', 'OCR']);
81
+
82
+ // ---------------------------------------------------------------- helpers
83
+ const rel = (root, f) => (root ? f.slice(root.length + 1) : f);
84
+ function lineAt(text, idx) { let n = 1; for (let i = 0; i < idx; i++) if (text.charCodeAt(i) === 10) n++; return n; }
85
+ function domainOf(host) {
86
+ const parts = host.split('.');
87
+ const two = parts.slice(-2).join('.');
88
+ if (parts.length > 2 && /^(co|com|org|net|gov|ac|edu)\.[a-z]{2}$/.test(two)) return parts.slice(-3).join('.');
89
+ return two;
90
+ }
91
+ function importSpecs(code) {
92
+ const out = [];
93
+ const re = /(?:^|[^.\w$])(?:import|export)\s+(?:type\s+)?(?:[\w*{}\s,$]+?\s+from\s*)?['"]([^'"\n]+)['"]|(?:^|[^.\w$])(?:require|import)\s*\(\s*['"]([^'"\n]+)['"]\s*\)/g;
94
+ let m;
95
+ while ((m = re.exec(code))) out.push({ spec: m[1] || m[2], idx: m.index });
96
+ return out;
97
+ }
98
+ const pkgName = (spec) => (spec.startsWith('@') ? spec.split('/').slice(0, 2).join('/') : spec.split('/')[0]);
99
+ const uniq = (arr) => [...new Set(arr)];
100
+ const pushTo = (m, k, v) => { if (!m.has(k)) m.set(k, []); m.get(k).push(v); };
101
+
102
+ /** Join URL path pieces: ('/api', 'users/', '/:id') -> '/api/users/:id' */
103
+ export function joinPath(...parts) {
104
+ const p = parts.map((x) => (x || '').replace(/^\/+|\/+$/g, '')).filter(Boolean).join('/');
105
+ return `/${p}`;
106
+ }
107
+
108
+ /** Turn a file-system route (Next app/pages router, Nuxt, SvelteKit) into its URL. Route groups "(x)" and slots "@x" vanish. */
109
+ function fsRoute(segs) {
110
+ return joinPath(...segs.filter((s) => s && s !== 'index' && !/^\(.*\)$/.test(s) && !s.startsWith('@')));
111
+ }
112
+ function nextRoute(relPath, kind) {
113
+ const segs = relPath.split('/');
114
+ const i = segs.indexOf('app') >= 0 ? segs.indexOf('app') : segs.indexOf('pages');
115
+ const parts = segs.slice(i + 1, -1);
116
+ if (kind === 'pages-router') parts.push(segs[segs.length - 1].replace(/\.(t|j)sx?$/, ''));
117
+ return fsRoute(parts);
118
+ }
119
+ const exportedMethods = (code) => uniq([...code.matchAll(/export\s+(?:async\s+)?(?:function|const|let)\s+(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\b/g)].map((m) => m[1]));
120
+
121
+ /** Split the arguments of the call whose "(" is at `open`. Returns top-level argument strings. */
122
+ export function callArgs(code, open) {
123
+ const args = [];
124
+ let depth = 0;
125
+ let start = open + 1;
126
+ let quote = null;
127
+ for (let i = open; i < code.length && i < open + 6000; i++) {
128
+ const c = code[i];
129
+ if (quote) { if (c === '\\') i++; else if (c === quote) quote = null; continue; }
130
+ if (c === '"' || c === "'" || c === '`') { quote = c; continue; }
131
+ if (c === '(' || c === '[' || c === '{') { depth++; continue; }
132
+ if (c === ')' || c === ']' || c === '}') {
133
+ depth--;
134
+ if (depth === 0) { args.push(code.slice(start, i).trim()); return { args: args.filter(Boolean), end: i + 1 }; }
135
+ continue;
136
+ }
137
+ if (c === ',' && depth === 1) { args.push(code.slice(start, i).trim()); start = i + 1; }
138
+ }
139
+ return { args: [], end: open + 1 };
140
+ }
141
+ const strLit = (s) => { const m = /^(['"`])([^'"`]*)\1$/.exec(s || ''); return m ? m[2] : null; };
142
+
143
+ /** Which names a file imports, and from where: { name -> spec } */
144
+ function bindingsOf(code) {
145
+ const b = new Map();
146
+ const names = (list, sep) => list.split(',').map((p) => p.trim().split(sep).pop().trim()).filter((n) => /^[\w$]+$/.test(n));
147
+ for (const m of code.matchAll(/\bimport\s+(?:type\s+)?([\w$]+)?\s*,?\s*(?:\{([^}]*)\})?\s*(?:\*\s*as\s+([\w$]+))?\s*from\s*['"]([^'"]+)['"]/g)) {
148
+ if (m[1]) b.set(m[1], m[4]);
149
+ if (m[3]) b.set(m[3], m[4]);
150
+ if (m[2]) names(m[2], /\s+as\s+/).forEach((n) => b.set(n, m[4]));
151
+ }
152
+ for (const m of code.matchAll(/\b(?:const|let|var)\s+(?:([\w$]+)|\{([^}]*)\})\s*=\s*(?:await\s+)?(?:require|import)\s*\(\s*['"]([^'"]+)['"]\s*\)/g)) {
153
+ if (m[1]) b.set(m[1], m[3]);
154
+ if (m[2]) names(m[2], ':').forEach((n) => b.set(n, m[3]));
155
+ }
156
+ return b;
157
+ }
158
+
159
+ /** What a mount's router argument refers to: another file (spec) or a local variable (ident). */
160
+ function targetOf(arg) {
161
+ let m = /^(?:await\s+)?(?:require|import)\s*\(\s*['"]([^'"]+)['"]\s*\)/.exec(arg);
162
+ if (m) return { spec: m[1] };
163
+ m = /^(?:await\s+)?([\w$]+)(?:\s*\.\s*[\w$]+)*(?:\s*\(\s*\))?$/.exec(arg);
164
+ if (m) return { ident: m[1] };
165
+ return null;
166
+ }
167
+
168
+ /**
169
+ * Read one server file: its routes, where it mounts sub-routers, and base paths set on a router.
170
+ * Understands Express/Koa/Hono/Fastify style `x.get('/p')`, Express `x.route('/p').get()`,
171
+ * Fastify `x.route({ method, url })`, mounts `x.use('/p', r)` / `x.route('/p', r)` / `x.register(r, { prefix })`,
172
+ * Hono `.basePath('/p')`, koa-router `new Router({ prefix })` and NestJS `@Controller` + `@Get` decorators.
173
+ */
174
+ export function scanServerCode(code) {
175
+ const routes = [];
176
+ const mounts = [];
177
+ const bases = new Map();
178
+ const METHOD = '(get|post|put|patch|delete|all|head|options)';
179
+ for (const m of code.matchAll(new RegExp(`([\\w$]+)\\s*\\.\\s*${METHOD}\\s*\\(\\s*['"\`](\\/[^'"\`\\s]*)['"\`]`, 'g'))) {
180
+ if (NOT_ROUTERS.has(m[1])) continue;
181
+ routes.push({ recv: m[1], methods: [m[2].toUpperCase()], path: m[3], idx: m.index });
182
+ }
183
+ for (const m of code.matchAll(/([\w$]+)\s*\.\s*route\s*\(/g)) {
184
+ if (NOT_ROUTERS.has(m[1])) continue;
185
+ const open = m.index + m[0].length - 1;
186
+ const { args, end } = callArgs(code, open);
187
+ const p = strLit(args[0]);
188
+ if (args.length === 1 && p != null && p.startsWith('/')) {
189
+ // Express: router.route('/p').get(a).post(b)
190
+ let pos = end;
191
+ const methods = [];
192
+ for (;;) {
193
+ const mm = new RegExp(`^\\s*\\.\\s*${METHOD}\\s*\\(`).exec(code.slice(pos, pos + 40));
194
+ if (!mm) break;
195
+ methods.push(mm[1].toUpperCase());
196
+ pos = callArgs(code, pos + mm[0].length - 1).end;
197
+ }
198
+ if (methods.length) routes.push({ recv: m[1], methods, path: p, idx: m.index });
199
+ } else if (args.length === 1 && args[0].startsWith('{')) {
200
+ // Fastify: fastify.route({ method: ['GET', 'HEAD'], url: '/p', handler })
201
+ const url = /\b(?:url|path)\s*:\s*['"`](\/[^'"`]*)['"`]/.exec(args[0]);
202
+ const meth = /\bmethod\s*:\s*(\[[^\]]*\]|['"`]\w+['"`])/.exec(args[0]);
203
+ if (url) routes.push({ recv: m[1], methods: meth ? [...meth[1].matchAll(/\w+/g)].map((x) => x[0].toUpperCase()) : ['ALL'], path: url[1], idx: m.index });
204
+ } else if (args.length >= 2 && p != null) {
205
+ const t = targetOf(args[args.length - 1]);
206
+ if (t) mounts.push({ recv: m[1], path: p, ...t, idx: m.index });
207
+ }
208
+ }
209
+ for (const m of code.matchAll(/([\w$]+)\s*\.\s*(use|register)\s*\(/g)) {
210
+ if (NOT_ROUTERS.has(m[1])) continue;
211
+ const { args } = callArgs(code, m.index + m[0].length - 1);
212
+ if (!args.length) continue;
213
+ if (m[2] === 'use') {
214
+ const p = strLit(args[0]);
215
+ const rest = p != null ? args.slice(1) : args;
216
+ if (p != null && !p.startsWith('/')) continue;
217
+ for (let i = rest.length - 1; i >= 0; i--) {
218
+ const t = targetOf(rest[i]);
219
+ if (t) { mounts.push({ recv: m[1], path: p || '', ...t, idx: m.index }); break; }
220
+ }
221
+ } else {
222
+ const t = targetOf(args[0]);
223
+ const pre = args[1] ? /\bprefix\s*:\s*['"`]([^'"`]*)['"`]/.exec(args[1]) : null;
224
+ if (t) mounts.push({ recv: m[1], path: pre ? pre[1] : '', ...t, idx: m.index });
225
+ }
226
+ }
227
+ for (const m of code.matchAll(/([\w$]+)\s*=\s*new\s+Hono\s*(?:<[^>]*>)?\s*\([^)]*\)\s*\.\s*basePath\s*\(\s*['"`]([^'"`]*)['"`]/g)) bases.set(m[1], m[2]);
228
+ for (const m of code.matchAll(/([\w$]+)\s*=\s*new\s+(?:Koa)?Router\s*\(\s*\{[^}]*?\bprefix\s*:\s*['"`]([^'"`]*)['"`]/g)) bases.set(m[1], m[2]);
229
+ for (const m of code.matchAll(/([\w$]+)\s*\.\s*prefix\s*\(\s*['"`](\/[^'"`]*)['"`]\s*\)/g)) bases.set(m[1], m[2]);
230
+
231
+ // NestJS decorators
232
+ if (/@Controller\s*\(/.test(code)) {
233
+ let ctrl = null;
234
+ const re = /@(Controller|Get|Post|Put|Patch|Delete|All|Head|Options)\s*\(\s*(?:(['"`])([^'"`]*)\2|\{[^}]*?\bpath\s*:\s*(['"`])([^'"`]*)\4[^}]*\})?\s*[,)]/g;
235
+ for (const m of code.matchAll(re)) {
236
+ const p = m[3] ?? m[5] ?? '';
237
+ if (m[1] === 'Controller') { ctrl = p; continue; }
238
+ if (ctrl == null) continue;
239
+ routes.push({ recv: '@nest', methods: [m[1] === 'All' ? 'ALL' : m[1].toUpperCase()], path: joinPath(ctrl, p), idx: m.index });
240
+ }
241
+ }
242
+ const global = /\.\s*setGlobalPrefix\s*\(\s*['"`]([^'"`]*)['"`]/.exec(code);
243
+ return { routes, mounts, bases, nestPrefix: global ? global[1] : null };
244
+ }
245
+
246
+ /** Full URL prefixes for routes defined on `recv` in `file`, following mounts across files. */
247
+ function makePrefixer(scans) {
248
+ const into = new Map(); // file -> mounts elsewhere that point at it
249
+ for (const [file, sc] of scans) for (const m of sc.mounts) if (m.targetFile) pushTo(into, m.targetFile, { file, recv: m.recv, path: m.path });
250
+ const filePrefixes = (f, seen) => {
251
+ const list = into.get(f);
252
+ if (!list || seen.has(f)) return [''];
253
+ const next = new Set(seen).add(f);
254
+ return uniq(list.flatMap((m) => recvPrefixes(m.file, m.recv, next, 0).map((p) => joinPath(p, m.path)))).slice(0, MAX_PREFIXES);
255
+ };
256
+ const recvPrefixes = (f, recv, seen, depth) => {
257
+ const sc = scans.get(f);
258
+ const base = (sc && sc.bases.get(recv)) || '';
259
+ const local = sc && depth < 6 ? sc.mounts.filter((m) => m.targetLocal === recv && m.recv !== recv) : [];
260
+ const outer = local.length
261
+ ? local.flatMap((m) => recvPrefixes(f, m.recv, seen, depth + 1).map((p) => joinPath(p, m.path)))
262
+ : filePrefixes(f, seen);
263
+ return uniq(outer.map((p) => joinPath(p, base))).slice(0, MAX_PREFIXES);
264
+ };
265
+ return (f, recv) => recvPrefixes(f, recv, new Set(), 0);
266
+ }
267
+
268
+ // ---------------------------------------------------------------- cron schedules
269
+ const DAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
270
+ const isCron = (s) => /^@(yearly|annually|monthly|weekly|daily|midnight|hourly)$/.test(s)
271
+ || (/^[\d*/,?LW#A-Za-z-]+(\s+[\d*/,?LW#A-Za-z-]+){4,6}$/.test(s.trim()) && s.trim().split(/\s+/).filter((f) => /[\d*]/.test(f)).length >= 3);
272
+
273
+ /** Plain words for common cron expressions ("0 3 * * *" -> "every day at 03:00"); null when unusual. */
274
+ export function describeCron(expr) {
275
+ const nick = { '@yearly': 'once a year', '@annually': 'once a year', '@monthly': 'once a month', '@weekly': 'once a week', '@daily': 'every day at midnight', '@midnight': 'every day at midnight', '@hourly': 'every hour' }[expr];
276
+ if (nick) return nick;
277
+ let f = expr.trim().split(/\s+/);
278
+ if (f.length === 6) {
279
+ const sec = /^\*\/(\d+)$/.exec(f[0]);
280
+ if (sec && f.slice(1).every((x) => x === '*')) return `every ${sec[1]} seconds`;
281
+ if (f[0] !== '0' && f[0] !== '*') return null;
282
+ f = f.slice(1);
283
+ }
284
+ if (f.length !== 5) return null;
285
+ const [mi, hr, dom, mon, dow] = f;
286
+ const num = (x) => /^\d+$/.test(x);
287
+ const at = () => `${hr.padStart(2, '0')}:${mi.padStart(2, '0')}`;
288
+ if (dom === '*' && mon === '*' && dow === '*') {
289
+ if (mi === '*' && hr === '*') return 'every minute';
290
+ let m = /^\*\/(\d+)$/.exec(mi);
291
+ if (m && hr === '*') return `every ${m[1]} minutes`;
292
+ if (num(mi) && hr === '*') return mi === '0' ? 'every hour' : `every hour at :${mi.padStart(2, '0')}`;
293
+ m = /^\*\/(\d+)$/.exec(hr);
294
+ if (m && num(mi)) return `every ${m[1]} hours`;
295
+ if (num(mi) && num(hr)) return `every day at ${at()}`;
296
+ }
297
+ if (num(mi) && num(hr) && mon === '*') {
298
+ if (dom === '*' && /^[0-7]$/.test(dow)) return `every ${DAYS[Number(dow) % 7]} at ${at()}`;
299
+ if (dom === '*' && dow === '1-5') return `weekdays at ${at()}`;
300
+ if (num(dom) && dow === '*') return `every month on day ${dom} at ${at()}`;
301
+ }
302
+ return null;
303
+ }
304
+ const CRON_RES = [
305
+ /\bschedule(?:Job)?\s*\(\s*(?:['"`][\w:.-]+['"`]\s*,\s*)?['"`]([^'"`]+)['"`]/g, // node-cron, node-schedule
306
+ /\bnew\s+CronJob\s*\(\s*['"`]([^'"`]+)['"`]/g, // cron
307
+ /\bcronTime\s*:\s*['"`]([^'"`]+)['"`]/g, // CronJob.from({ cronTime })
308
+ /\brepeat\s*:\s*\{[^}]*?\b(?:pattern|cron)\s*:\s*['"`]([^'"`]+)['"`]/g, // BullMQ / Bull repeatable jobs
309
+ /@Cron\s*\(\s*['"`]([^'"`]+)['"`]/g, // NestJS
310
+ ];
311
+ function findCrons(code) {
312
+ const out = [];
313
+ for (const re of CRON_RES) {
314
+ for (const m of code.matchAll(re)) if (isCron(m[1])) out.push({ expr: m[1].trim(), human: describeCron(m[1].trim()), idx: m.index });
315
+ }
316
+ for (const m of code.matchAll(/@Cron\s*\(\s*CronExpression\.(\w+)/g)) {
317
+ out.push({ expr: `CronExpression.${m[1]}`, human: m[1].toLowerCase().replace(/_/g, ' ').replace(/(\d+)(am|pm)/, '$1 $2'), idx: m.index });
318
+ }
319
+ return out;
320
+ }
321
+
322
+ /** "PAYMENTS_API_URL" -> "Payments API" */
323
+ function envServiceName(name) {
324
+ const core = name.replace(/^(NEXT_PUBLIC|VITE|PUBLIC|REACT_APP|NUXT_PUBLIC|EXPO_PUBLIC)_/, '').replace(/_(BASE_)?(URL|URI|HOST|ENDPOINT|ORIGIN)$/, '');
325
+ return core.split('_').filter(Boolean).map((w) => (UPPER_WORDS.has(w) ? w : w[0] + w.slice(1).toLowerCase())).join(' ') || name;
326
+ }
327
+
328
+ // ---------------------------------------------------------------- main
329
+ /**
330
+ * @param {import('./types.js').Source} source
331
+ * @param {import('./types.js').Graph | null} graph
332
+ * @param {{ project?: string, aliases?: import('./types.js').Config['aliases'] }} [cfg]
333
+ * @returns {import('./types.js').SystemSnapshot}
334
+ */
335
+ export function detectSystem(source, graph, cfg = {}) {
336
+ // 1. apps = folders with a package.json (deepest one owns a file)
337
+ const manifests = (source.manifests || []).filter((m) => !m.includes('node_modules/'));
338
+ const apps = [];
339
+ for (const m of manifests) {
340
+ let pkg;
341
+ try { pkg = parseJsonc(source.read(m) || '{}'); } catch { continue; }
342
+ const root = path.posix.dirname(m) === '.' ? '' : path.posix.dirname(m);
343
+ const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
344
+ const fw = FRAMEWORKS.find(([d]) => deps[d]);
345
+ apps.push({
346
+ id: root || '.', root, name: root ? root.split('/').pop() : (pkg.name || 'app'), pkgName: pkg.name || null,
347
+ framework: fw ? fw[1] : 'Node.js', role: fw ? fw[2] : 'service',
348
+ tech: NOTABLE.filter((d) => deps[d]).map((d) => d.replace(/^@[^/]+\//, '').replace(/^@/, '')),
349
+ scripts: pkg.scripts || {}, files: [],
350
+ });
351
+ }
352
+ const workspaceOnly = (a) => a.root === '' && apps.length > 1 && !apps.some((b) => b !== a && b.root === '') && a.role === 'service' && a.files.length === 0;
353
+ if (!apps.length) apps.push({ id: '.', root: '', name: cfg.project || 'app', framework: 'Node.js', role: 'service', tech: [], scripts: {}, files: [] });
354
+ const byDepth = [...apps].sort((a, b) => b.root.length - a.root.length);
355
+ const appOf = (f) => byDepth.find((a) => !a.root || f.startsWith(`${a.root}/`));
356
+ const files = source.files.filter((f) => !EXCLUDE.some((re) => re.test(f)));
357
+ for (const f of files) { const a = appOf(f); if (a) a.files.push(f); }
358
+ const resolve = makeResolver(new Set(source.files), loadAliases(source, cfg.aliases));
359
+
360
+ const stores = new Map();
361
+ const outside = new Map();
362
+ const links = new Map();
363
+ const addEv = (list, ev) => { if (list.length < MAX_EVIDENCE) list.push(ev); };
364
+ const link = (from, to, kind, label, ev) => {
365
+ const key = `${from}→${to}`;
366
+ if (!links.has(key)) links.set(key, { id: key, from, to, kind, labels: new Set(), evidence: [], count: 0 });
367
+ const l = links.get(key); l.count++; if (label) l.labels.add(label); if (ev) addEv(l.evidence, ev);
368
+ };
369
+ const addStore = (st, appId, via, ev, label) => {
370
+ if (!stores.has(st.id)) stores.set(st.id, { id: `store:${st.id}`, name: st.name, kind: st.kind, via: new Set(), evidence: [], apps: new Set() });
371
+ const S = stores.get(st.id); S.via.add(via); S.apps.add(appId); if (ev) addEv(S.evidence, ev);
372
+ link(appId, `store:${st.id}`, 'data', label || null, ev);
373
+ };
374
+ const addOutside = (id, name, kind, appId, via, ev, label, host) => {
375
+ if (!outside.has(id)) outside.set(id, { id, name, kind, hosts: new Set(), via: new Set(), evidence: [], apps: new Set() });
376
+ const O = outside.get(id); if (via) O.via.add(via); if (host) O.hosts.add(host); O.apps.add(appId); if (ev) addEv(O.evidence, ev);
377
+ link(appId, id, 'outside', label, ev);
378
+ };
379
+ const envRefs = new Map(); // app id -> [{name, file, line}] for *_URL-style vars that may point at another app
380
+ const envUse = new Map(); // env var name -> Set of app ids whose code reads it
381
+
382
+ for (const app of apps) {
383
+ app.groups = { pages: [], api: [], endpoints: [], jobs: [], schedules: [] };
384
+ const tsx = app.files.some((f) => /\.tsx?$/.test(f));
385
+ if (tsx && !app.tech.includes('typescript')) app.tech.unshift('typescript');
386
+ const fwk = app.framework;
387
+ const scans = new Map();
388
+ let namedQueuePkg = null;
389
+ let namedQueues = 0;
390
+
391
+ for (const f of app.files) {
392
+ const text = source.read(f);
393
+ if (text == null) continue;
394
+ const code = stripComments(codeOf(f, text));
395
+ const r = rel(app.root, f);
396
+ const mod = graph && graph.moduleOf ? graph.moduleOf.get(f) : null;
397
+ let m;
398
+
399
+ // entry points from file-system routing
400
+ if (fwk === 'Next.js') {
401
+ if (/(^|\/)app\/(.*\/)?page\.(t|j)sx?$/.test(r)) app.groups.pages.push({ key: nextRoute(r, 'app'), label: nextRoute(r, 'app'), file: f, line: 1 });
402
+ else if (/(^|\/)app\/(.*\/)?route\.(t|j)sx?$/.test(r)) {
403
+ const route = nextRoute(r, 'app');
404
+ app.groups.api.push({ key: route, label: route, methods: exportedMethods(code), file: f, line: 1 });
405
+ } else if (/(^|\/)pages\/api\//.test(r)) app.groups.api.push({ key: nextRoute(r, 'pages-router'), label: nextRoute(r, 'pages-router'), methods: [], file: f, line: 1 });
406
+ else if (/(^|\/)pages\/(?!_)[^/]+/.test(r) && /\.(t|j)sx?$/.test(r)) app.groups.pages.push({ key: nextRoute(r, 'pages-router'), label: nextRoute(r, 'pages-router'), file: f, line: 1 });
407
+ } else if (fwk === 'Nuxt') {
408
+ if ((m = /(?:^|\/)pages\/(.+)\.vue$/.exec(r))) { const route = fsRoute(m[1].split('/')); app.groups.pages.push({ key: route, label: route, file: f, line: 1 }); }
409
+ else if ((m = /(?:^|\/)server\/(api|routes)\/(.+?)(?:\.(get|post|put|patch|delete|head|options))?\.(t|j)s$/.exec(r))) {
410
+ const route = fsRoute([m[1] === 'api' ? 'api' : '', ...m[2].split('/')]);
411
+ app.groups.api.push({ key: m[3] ? `${m[3].toUpperCase()} ${route}` : route, label: route, methods: m[3] ? [m[3].toUpperCase()] : [], file: f, line: 1 });
412
+ }
413
+ } else if (fwk === 'SvelteKit') {
414
+ if ((m = /(?:^|\/)routes\/(?:(.*)\/)?\+page\.svelte$/.exec(r))) { const route = fsRoute((m[1] || '').split('/')); app.groups.pages.push({ key: route, label: route, file: f, line: 1 }); }
415
+ else if ((m = /(?:^|\/)routes\/(?:(.*)\/)?\+server\.(t|j)s$/.exec(r))) {
416
+ const route = fsRoute((m[1] || '').split('/'));
417
+ app.groups.api.push({ key: route, label: route, methods: exportedMethods(code), file: f, line: 1 });
418
+ }
419
+ }
420
+ if (app.role === 'server') {
421
+ const sc = scanServerCode(code);
422
+ if (sc.routes.length || sc.mounts.length || sc.nestPrefix != null) {
423
+ const binds = bindingsOf(code);
424
+ for (const mt of sc.mounts) {
425
+ const spec = mt.spec || (mt.ident && binds.get(mt.ident));
426
+ if (spec) { const res = resolve(f, spec); if (res.kind === 'file') mt.targetFile = res.file; } else if (mt.ident) mt.targetLocal = mt.ident;
427
+ }
428
+ scans.set(f, { ...sc, code });
429
+ }
430
+ }
431
+
432
+ // cron schedules
433
+ for (const c of findCrons(code)) {
434
+ app.groups.schedules.push({ key: `${c.expr} · ${f}`, label: c.expr, human: c.human, file: f, line: lineAt(code, c.idx) });
435
+ }
436
+
437
+ // imports -> data stores, queues and SDK services
438
+ let queuesRead = false;
439
+ for (const { spec, idx } of importSpecs(code)) {
440
+ if (spec.startsWith('.') || spec.startsWith('/') || spec.startsWith('@/') || spec.startsWith('~/')) continue;
441
+ const bare = spec.startsWith('node:') ? spec : pkgName(spec);
442
+ const ev = { file: f, line: lineAt(code, idx), text: spec, module: mod };
443
+ if (NAMED_QUEUE_PKGS.has(bare)) {
444
+ namedQueuePkg = namedQueuePkg || bare;
445
+ if (queuesRead) continue;
446
+ queuesRead = true;
447
+ for (const q of code.matchAll(/\b(?:new\s+)?(Queue|Worker|QueueEvents|Bull|BeeQueue)\s*(?:<[^>()]*>)?\s*\(\s*(?:(['"`])([\w:./-]+)\2|([A-Za-z_$][\w$]*))/g)) {
448
+ let name = q[3];
449
+ if (!name && q[4]) { const c = new RegExp(`\\b(?:const|let|var)\\s+${q[4].replace(/\$/g, '\\$')}\\s*=\\s*['"\`]([\\w:./-]+)['"\`]`).exec(code); name = c && c[1]; }
450
+ if (!name) continue;
451
+ namedQueues++;
452
+ addStore({ id: `queue-${name}`, name: `${name} queue`, kind: 'Queue' }, app.id, bare, { ...ev, line: lineAt(code, q.index), text: q[0].slice(0, 90) }, q[1] === 'Worker' ? 'runs jobs' : q[1] === 'QueueEvents' ? 'listens' : 'adds jobs');
453
+ }
454
+ continue;
455
+ }
456
+ const st = STORES.find((x) => x.match.test(bare) || x.match.test(spec));
457
+ if (st) { addStore(st, app.id, bare, ev, st.kind === 'Queue' ? 'messages' : null); continue; }
458
+ const sdk = SDKS.find(([re]) => re.test(bare));
459
+ if (sdk) addOutside(`svc:${sdk[1].toLowerCase().replace(/[^a-z0-9]+/g, '-')}`, sdk[1], sdk[2], app.id, bare, ev, sdk[2]);
460
+ }
461
+
462
+ // URL literals -> outside services (skip JSX href/src links, which are just links for users)
463
+ for (const u of code.matchAll(/https?:\/\/([a-z0-9.-]+\.[a-z]{2,}|localhost|\[[^\]]+\])(?::\d+)?[^\s'"`)]*/gi)) {
464
+ const host = u[1].toLowerCase();
465
+ if (IGNORE_HOSTS.test(host)) continue;
466
+ const before = code.slice(Math.max(0, u.index - 24), u.index);
467
+ if (/(href|src|action|srcSet|xmlns(:\w+)?)\s*=\s*\{?\s*["'`]?$/.test(before)) continue;
468
+ const dom = domainOf(host.replace(/^www\./, ''));
469
+ addOutside(`web:${dom}`, dom, 'Web / HTTP', app.id, null, { file: f, line: lineAt(code, u.index), text: u[0].slice(0, 90), module: mod }, 'HTTPS', host);
470
+ }
471
+
472
+ // env vars: which app reads what, and *_URL vars that may point at another service (BACKEND_URL, API_BASE_URL, …)
473
+ for (const e of code.matchAll(/(?:process\.env\.|import\.meta\.env\.|process\.env\[\s*['"])([A-Z][A-Z0-9_]*)/g)) {
474
+ if (!envUse.has(e[1])) envUse.set(e[1], new Set());
475
+ envUse.get(e[1]).add(app.id);
476
+ if (/(BACKEND|API|SERVER|SERVICE|GATEWAY)[A-Z0-9_]*(URL|ORIGIN|HOST|BASE|ENDPOINT)/.test(e[1])) {
477
+ pushTo(envRefs, app.id, { name: e[1], file: f, line: lineAt(code, e.index), text: `process.env.${e[1]}`, module: mod });
478
+ }
479
+ }
480
+ }
481
+
482
+ // HTTP endpoints, with the prefixes of the routers they are mounted under
483
+ if (scans.size) {
484
+ const prefixes = makePrefixer(scans);
485
+ const nest = [...scans.values()].map((s) => s.nestPrefix).find((p) => p != null) || '';
486
+ for (const [f, sc] of scans) {
487
+ for (const rt of sc.routes) {
488
+ const pres = rt.recv === '@nest' ? [nest] : prefixes(f, rt.recv);
489
+ for (const pre of pres) {
490
+ const full = joinPath(pre, rt.path);
491
+ for (const method of rt.methods) app.groups.endpoints.push({ key: `${method} ${full}`, label: full, methods: [method], file: f, line: lineAt(sc.code, rt.idx) });
492
+ }
493
+ }
494
+ }
495
+ }
496
+ if (namedQueuePkg && !namedQueues) addStore({ id: namedQueuePkg, name: `${namedQueuePkg === 'bee-queue' ? 'Bee-Queue' : namedQueuePkg === 'bull' ? 'Bull' : 'BullMQ'} queue`, kind: 'Queue' }, app.id, namedQueuePkg, null, 'jobs');
497
+
498
+ // npm scripts that run a file = jobs / CLIs
499
+ for (const [name, cmd] of Object.entries(app.scripts)) {
500
+ if (/^(start|dev|build|test|lint|format|prepare|postinstall|preview|typecheck|type-check|serve)$/.test(name)) continue;
501
+ const sm = String(cmd).match(/\b(?:node|tsx|ts-node|bun|deno run)\b[^&|;]*?\s((?:\.\/)?[\w./-]+\.(?:m?js|cjs|ts|mts))/);
502
+ if (!sm) continue;
503
+ const file = path.posix.normalize(path.posix.join(app.root, sm[1]));
504
+ app.groups.jobs.push({ key: name, label: name, file, line: 1, cmd: String(cmd).slice(0, 120) });
505
+ }
506
+ }
507
+
508
+ // 2. extra files: vercel.json cron schedules and .env.example service URLs
509
+ const servers = apps.filter((a) => a.role === 'server' || a.groups.endpoints.length);
510
+ const serverNamed = (name, appId) => servers.find((s) => s.id !== appId && name.toLowerCase().includes(s.name.toLowerCase()));
511
+ const namesOwnServer = (name, users) => users.some((u) => serverNamed(name, u));
512
+ const envExample = new Map(); // var name -> value in an example env file
513
+ for (const f of source.extras || []) {
514
+ const text = source.read(f);
515
+ const app = appOf(f);
516
+ if (text == null || !app) continue;
517
+ if (f.endsWith('vercel.json')) {
518
+ let json;
519
+ try { json = parseJsonc(text); } catch { continue; }
520
+ for (const c of json.crons || []) {
521
+ if (!c || typeof c.schedule !== 'string') continue;
522
+ const line = Math.max(1, text.split('\n').findIndex((l) => l.includes(c.schedule)) + 1);
523
+ app.groups.schedules.push({ key: `${c.schedule} · ${f}${c.path ? ` · ${c.path}` : ''}`, label: c.schedule, human: describeCron(c.schedule), file: f, line, cmd: c.path ? `calls ${c.path}` : null });
524
+ }
525
+ continue;
526
+ }
527
+ text.split('\n').forEach((raw, i) => {
528
+ const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(raw);
529
+ if (!m) return;
530
+ const name = m[1];
531
+ const value = m[2].replace(/\s+#.*$/, '').trim().replace(/^(['"])(.*)\1$/, '$2');
532
+ envExample.set(name, value);
533
+ const users = envUse.has(name) ? [...envUse.get(name)] : [app.id];
534
+ const ev = { file: f, line: i + 1, text: `${name}=${value}`.slice(0, 90), module: null };
535
+ const scheme = (/^([a-z][a-z0-9+.-]*):\/\//i.exec(value) || [])[1];
536
+ const storeId = scheme && SCHEME_STORES[scheme.toLowerCase()];
537
+ if (storeId) {
538
+ const base = STORES.find((x) => x.id === storeId);
539
+ for (const u of users) {
540
+ // DATABASE_URL=postgres://… next to Prisma/Drizzle: name the engine on the ORM box instead of adding a second one
541
+ const orm = [...stores.values()].find((s) => ORM_STORES.has(s.id.slice(6)) && s.apps.has(u));
542
+ if (orm && base.kind === 'Database') {
543
+ if (/^Database via /.test(orm.name) || orm.name === 'SQL database (ORM)') orm.name = orm.name === 'SQL database (ORM)' ? `${base.name} (ORM)` : `${base.name} via ${orm.name.slice(13)}`;
544
+ orm.via.add(name); addEv(orm.evidence, ev); link(u, orm.id, 'data', null, ev);
545
+ } else addStore(base, u, name, ev, base.kind === 'Queue' ? 'messages' : null);
546
+ }
547
+ return;
548
+ }
549
+ const url = /^https?:\/\/([a-z0-9.-]+\.[a-z]{2,}|localhost|\[[^\]]+\])/i.exec(value);
550
+ if (url) {
551
+ const host = url[1].toLowerCase();
552
+ if (IGNORE_HOSTS.test(host) || SELF_VAR.test(name)) return;
553
+ const dom = domainOf(host.replace(/^www\./, ''));
554
+ users.forEach((u) => addOutside(`web:${dom}`, dom, 'Web / HTTP', u, name, ev, 'HTTPS', host));
555
+ return;
556
+ }
557
+ // a URL that is only set per environment: we know the name, not the host
558
+ // (unless its name points at one of our own servers, e.g. API_URL next to an app called "api": that's an app link)
559
+ if (!value && URL_VAR.test(name) && !SELF_VAR.test(name) && !STORE_VAR.test(name) && !namesOwnServer(name, users)) {
560
+ users.forEach((u) => addOutside(`env:${name}`, envServiceName(name), 'Set by env var', u, name, ev, name, null));
561
+ }
562
+ });
563
+ }
564
+
565
+ // 3. app -> app over HTTP, matched through env vars like BACKEND_URL
566
+ const external = (n) => { const v = envExample.get(n); return !!v && /^https?:\/\//i.test(v) && !/^https?:\/\/(localhost|127\.|0\.0\.0\.0|\[::1\])/i.test(v); };
567
+ for (const [appId, refs] of envRefs) {
568
+ const candidates = servers.filter((s) => s.id !== appId);
569
+ if (!candidates.length) continue;
570
+ const byName = new Map();
571
+ for (const r of refs) {
572
+ if (external(r.name) || outside.has(`env:${r.name}`)) continue; // points at a service outside this repo
573
+ const target = serverNamed(r.name, appId) || candidates[0];
574
+ pushTo(byName, target.id, r);
575
+ }
576
+ for (const [target, rs] of byName) {
577
+ const names = uniq(rs.map((r) => r.name));
578
+ rs.forEach((r) => link(appId, target, 'app', `HTTP · ${names.join(', ')}`, r));
579
+ }
580
+ }
581
+
582
+ // tidy entry points: one entry per key, sorted
583
+ for (const app of apps) {
584
+ for (const g of Object.values(app.groups)) {
585
+ const seen = new Set();
586
+ const kept = g.filter((x) => (seen.has(x.key) ? false : seen.add(x.key)));
587
+ g.length = 0; g.push(...kept.sort((a, b) => a.key.localeCompare(b.key)));
588
+ }
589
+ }
590
+
591
+ // 4. who is the front door? web apps get "Users"; servers nobody in the repo calls get "Clients"
592
+ const called = new Set([...links.values()].filter((l) => l.kind === 'app').map((l) => l.to));
593
+ const finalApps = apps.filter((a) => a.files.length || !workspaceOnly(a));
594
+ const entry = [];
595
+ for (const a of finalApps) {
596
+ if (['web', 'mobile', 'desktop'].includes(a.role)) entry.push(a.id);
597
+ else if (a.groups.endpoints.length && !called.has(a.id)) entry.push(a.id);
598
+ }
599
+ for (const id of entry) link('users', id, 'users', 'HTTPS', null);
600
+
601
+ // module summary per app (from the dependency graph)
602
+ const modsByApp = new Map();
603
+ if (graph && graph.moduleOf) {
604
+ for (const [f, m] of graph.moduleOf) {
605
+ const a = appOf(f); if (!a) continue;
606
+ if (!modsByApp.has(a.id)) modsByApp.set(a.id, new Map());
607
+ const mm = modsByApp.get(a.id); mm.set(m, (mm.get(m) || 0) + 1);
608
+ }
609
+ }
610
+
611
+ return {
612
+ apps: finalApps.map((a) => ({
613
+ id: a.id, root: a.root, name: a.name, pkgName: a.pkgName, framework: a.framework, role: a.role, tech: a.tech.slice(0, 5),
614
+ fileCount: a.files.length,
615
+ modules: [...(modsByApp.get(a.id) || new Map())].sort((x, y) => y[1] - x[1]).map(([name, n]) => ({ name, files: n })),
616
+ groups: a.groups,
617
+ })),
618
+ stores: [...stores.values()].map((s) => ({ ...s, via: [...s.via], apps: [...s.apps] }))
619
+ .sort((a, b) => Number(a.kind === 'Queue') - Number(b.kind === 'Queue') || a.name.localeCompare(b.name)),
620
+ outside: [...outside.values()].map((o) => ({ ...o, hosts: [...o.hosts].sort(), via: [...o.via], apps: [...o.apps] }))
621
+ .sort((a, b) => b.evidence.length - a.evidence.length || a.name.localeCompare(b.name)),
622
+ links: [...links.values()].map((l) => ({ ...l, labels: [...l.labels] })),
623
+ hasUsers: entry.length > 0,
624
+ };
625
+ }
626
+
627
+ // ---------------------------------------------------------------- base vs current
628
+ export const ENTRY_GROUPS = ['pages', 'api', 'endpoints', 'jobs', 'schedules'];
629
+ const st = (b, c) => (b && c ? 'same' : c ? 'added' : 'removed');
630
+
631
+ function mergeList(bl = [], cl = [], key = (x) => x.key) {
632
+ const bm = new Map(bl.map((x) => [key(x), x]));
633
+ const cm = new Map(cl.map((x) => [key(x), x]));
634
+ const keys = [...new Set([...bm.keys(), ...cm.keys()])];
635
+ return keys.map((k) => ({ ...(cm.get(k) || bm.get(k)), status: st(bm.has(k), cm.has(k)) }));
636
+ }
637
+
638
+ /**
639
+ * @param {import('./types.js').SystemSnapshot} base
640
+ * @param {import('./types.js').SystemSnapshot} cur
641
+ * @returns {import('./types.js').System}
642
+ */
643
+ export function diffSystems(base, cur) {
644
+ const apps = mergeList(base.apps, cur.apps, (a) => a.id).map((a) => {
645
+ const b = base.apps.find((x) => x.id === a.id);
646
+ const c = cur.apps.find((x) => x.id === a.id);
647
+ const groups = {};
648
+ for (const g of ENTRY_GROUPS) {
649
+ groups[g] = mergeList(b ? b.groups[g] : [], c ? c.groups[g] : []).sort((x, y) => x.key.localeCompare(y.key));
650
+ }
651
+ return { ...a, groups, fileCountBase: b ? b.fileCount : 0, fileCount: c ? c.fileCount : 0 };
652
+ });
653
+ const stores = mergeList(base.stores, cur.stores, (s) => s.id);
654
+ const outside = mergeList(base.outside, cur.outside, (o) => o.id).map((o) => {
655
+ const b = base.outside.find((x) => x.id === o.id);
656
+ const c = cur.outside.find((x) => x.id === o.id);
657
+ const bh = new Set(b ? b.hosts : []); const ch = new Set(c ? c.hosts : []);
658
+ return { ...o, hosts: [...new Set([...bh, ...ch])].sort().map((h) => ({ host: h, status: st(bh.has(h), ch.has(h)) })) };
659
+ });
660
+ const links = mergeList(base.links, cur.links, (l) => l.id);
661
+ const count = (arr, s) => arr.filter((x) => x.status === s).length;
662
+ const allItems = apps.flatMap((a) => Object.values(a.groups).flat());
663
+ return {
664
+ apps, stores, outside, links, hasUsers: base.hasUsers || cur.hasUsers,
665
+ summary: {
666
+ apps: { total: cur.apps.length, added: count(apps, 'added'), removed: count(apps, 'removed') },
667
+ entry: { total: allItems.filter((x) => x.status !== 'removed').length, added: count(allItems, 'added'), removed: count(allItems, 'removed') },
668
+ stores: { total: cur.stores.length, added: count(stores, 'added'), removed: count(stores, 'removed') },
669
+ outside: { total: cur.outside.length, added: count(outside, 'added'), removed: count(outside, 'removed') },
670
+ },
671
+ };
672
+ }