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/LICENSE +21 -0
- package/README.md +179 -0
- package/bin/tecton.js +417 -0
- package/brand/tecton-favicon.svg +2 -0
- package/package.json +60 -0
- package/src/diff.js +93 -0
- package/src/graph.js +130 -0
- package/src/layout.js +191 -0
- package/src/report/dist/client.js +3 -0
- package/src/report/fonts/Geist-Variable.woff2 +0 -0
- package/src/report/fonts/GeistMono-Variable.woff2 +0 -0
- package/src/report/fonts/OFL-LICENSE.txt +92 -0
- package/src/report/template.html +619 -0
- package/src/report.js +110 -0
- package/src/rules.js +82 -0
- package/src/scan.js +306 -0
- package/src/system.js +672 -0
- package/src/types.d.ts +207 -0
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
|
+
}
|