feat(steps): cross-tier channels — a client's fetch onto its own route, queue jobs onto consumers, bus and socket events onto handlers
- resolution/tier-synthesizer.ts: http-client (literal fetch/axios/ky/got/$fetch paths, axios.create baseURL instances, template holes as :params, base-URL holes by a two-segment tail; unique match only), queue-job (BullMQ/Bull add ↔ @Process/@Processor, WorkerHost process, new Worker, queue.process), event-bus (EventEmitter2 emit ↔ @OnEvent with globs; socket emit ↔ @SubscribeMessage / socket.on both ways with tier); channel, tier, callee, registeredAt on every edge; generic transport events never pair; test and generated files never sources; registered before the emitter pass
- steps.ts: crossing() reads tier/channel before languages; an endpoint reached across a tier is a bridge box and a boundary like a screen (through=1 enters it); a channel's call is not also an effect; sites read as written; a Next 'use server' action is a crossing by its directive (when.ts directive); a function-valued constant handler (asyncHandler(...)) is a route root and borrows the file-scope calls and refs within its lines
- express.ts: app.use('/prefix', router) mounts composed onto route names in postExtract (nested, by import or require); chained router.route('/x').get(h).put(h2) extracted, across lines
- frameworks/package-deps.ts: dependencies read from workspace package.json files too (Express, React, Expo Router, NestJS detect)
- routing manifest names constant handlers; e2e/ is a test directory; explore's Flow section labels the new channels
- tests: ui-steps-cross-tier (monorepo fixture: Next client + Express/Nest API), servers test updated for the queue landing
- docs: CHANGELOG, spec §3.13 cross-tier paragraph, CLAUDE.md, callback-edge-synthesis.md, plan P3 built
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REFyW9hmNrxhwN5wxRoAkC
This commit is contained in:
1 parent
950686def4
commit
b1f40c57dd
25 files changed
+2051
-130
No files matched your search
@@ -412,6 +412,12 @@ export class ContextBuilder {
|
||||
? `renders <${String(m.via || 'child')}>`
|
||||
: m.synthesizedBy === 'vue-handler'
|
||||
? `Vue @${String(m.event || 'event')} handler`
|
||||
: m.synthesizedBy === 'http-client'
|
||||
? `HTTP ${String(m.method || 'GET')} ${String(m.href || '')} — the client's call onto its own route${at}`
|
||||
: m.synthesizedBy === 'queue-job'
|
||||
? `queue job ${m.event ? `\`${String(m.event)}\`` : ''}${m.queue ? ` on \`${String(m.queue)}\`` : ''}${at}`
|
||||
: m.synthesizedBy === 'event-bus' && m.channel === 'socket'
|
||||
? `socket message ${m.event ? `\`${String(m.event)}\`` : ''}${m.tier === 'client→server' ? ' → server' : m.tier === 'server→client' ? ' → client' : ''}${at}`
|
||||
: `event ${m.event ? `\`${String(m.event)}\`` : ''}${at}`;
|
||||
synthByPair.set(`${e.source}>${e.target}`, label);
|
||||
}
|
||||
|
||||
+1
-1
@@ -1058,7 +1058,7 @@ export class QueryBuilder {
|
||||
JOIN nodes h ON e.target = h.id
|
||||
WHERE r.kind = 'route'
|
||||
AND e.kind IN ('references', 'calls')
|
||||
AND h.kind IN ('function', 'method', 'class')
|
||||
AND h.kind IN ('function', 'method', 'class', 'constant', 'variable')
|
||||
ORDER BY r.file_path, r.start_line
|
||||
LIMIT ?
|
||||
`);
|
||||
|
||||
@@ -2478,6 +2478,33 @@ export class ToolHandler {
|
||||
registeredAt,
|
||||
};
|
||||
}
|
||||
if (m?.synthesizedBy === 'http-client') {
|
||||
const req = `${String(m.method ?? 'GET')} ${String(m.href ?? '')}`.trim();
|
||||
return {
|
||||
label: `HTTP request \`${req}\` — the client's call onto its own route (cross-tier)`,
|
||||
compact: `dynamic: HTTP ${req}${at}`,
|
||||
registeredAt,
|
||||
};
|
||||
}
|
||||
if (m?.synthesizedBy === 'queue-job') {
|
||||
const job = m.event ? `\`${String(m.event)}\`` : 'a job';
|
||||
const queue = m.queue ? ` on queue \`${String(m.queue)}\`` : '';
|
||||
return {
|
||||
label: `queue job ${job}${queue} — producer → consumer (cross-tier)`,
|
||||
compact: `dynamic: queue job ${job}${at}`,
|
||||
registeredAt,
|
||||
};
|
||||
}
|
||||
if (m?.synthesizedBy === 'event-bus') {
|
||||
const ev = m.event ? `\`${String(m.event)}\`` : 'an event';
|
||||
const what = m.channel === 'socket' ? 'socket message' : 'bus event';
|
||||
const dir = m.tier === 'client→server' ? ', client → server' : m.tier === 'server→client' ? ', server → client' : '';
|
||||
return {
|
||||
label: `${what} ${ev} — emit → handler${dir} (dynamic dispatch)`,
|
||||
compact: `dynamic: ${what} ${ev}${at}`,
|
||||
registeredAt,
|
||||
};
|
||||
}
|
||||
if (m?.synthesizedBy === 'event-emitter') {
|
||||
const ev = m.event ? `\`${String(m.event)}\`` : 'an event';
|
||||
return {
|
||||
|
||||
@@ -30,6 +30,8 @@ import { cFnPointerDispatchEdges } from './c-fnptr-synthesizer';
|
||||
import { goframeRouteEdges } from './goframe-synthesizer';
|
||||
import { expoRouterReturnEdges } from './expo-router-synthesizer';
|
||||
import { createYielder, type MaybeYield } from './cooperative-yield';
|
||||
import { crossTierEdges } from './tier-synthesizer';
|
||||
import { enclosingFn, makeLineAt } from './synth-utils';
|
||||
|
||||
const REGISTRAR_NAME = /^(on[A-Z]\w*|subscribe|addListener|addEventListener|register|watch|listen|addCallback)$/;
|
||||
const DISPATCHER_NAME = /(emit|trigger|notify|dispatch|fire|publish|flush)/i;
|
||||
@@ -110,33 +112,6 @@ function sliceLines(content: string, startLine?: number, endLine?: number): stri
|
||||
return content.split('\n').slice(startLine - 1, endLine).join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-match line resolver over `src`, 1-based at `baseLine`. The inline
|
||||
* `src.slice(0, idx).split('\n').length` idiom is O(source-length) PER MATCH,
|
||||
* which goes quadratic on a match-dense source (a generated function full of
|
||||
* `.push(` calls re-scanned tens of thousands of times was most of the #1235
|
||||
* indexing wedge). Builds the newline index once — lazily, since most sources
|
||||
* never produce a match — then answers each call with a binary search.
|
||||
*/
|
||||
function makeLineAt(src: string, baseLine: number): (idx: number) => number {
|
||||
let nl: number[] | null = null;
|
||||
return (idx: number) => {
|
||||
if (!nl) {
|
||||
nl = [];
|
||||
for (let i = src.indexOf('\n'); i !== -1; i = src.indexOf('\n', i + 1)) nl.push(i);
|
||||
}
|
||||
// Count newlines strictly before idx.
|
||||
let lo = 0;
|
||||
let hi = nl.length;
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi) >> 1;
|
||||
if (nl[mid]! < idx) lo = mid + 1;
|
||||
else hi = mid;
|
||||
}
|
||||
return baseLine + lo;
|
||||
};
|
||||
}
|
||||
|
||||
function registrarField(src: string): string | null {
|
||||
const m = src.match(/this\.(\w+)\.(?:add|push|set)\(/);
|
||||
return m ? m[1]! : null;
|
||||
@@ -150,21 +125,6 @@ function dispatcherField(src: string): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
const FN_KINDS = new Set(['method', 'function', 'component']);
|
||||
|
||||
/** Innermost function/method node whose line range contains `line`. */
|
||||
function enclosingFn(nodesInFile: Node[], line: number): Node | null {
|
||||
let best: Node | null = null;
|
||||
for (const n of nodesInFile) {
|
||||
if (!FN_KINDS.has(n.kind)) continue;
|
||||
const end = n.endLine ?? n.startLine;
|
||||
if (n.startLine <= line && end >= line) {
|
||||
if (!best || n.startLine >= best.startLine) best = n; // prefer the tightest (latest-starting) encloser
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream method + function nodes lazily. The synthesizers only scan-and-filter
|
||||
* down to a tiny matched subset, so materializing every function/method (which
|
||||
@@ -3578,6 +3538,11 @@ const ALWAYS = (): boolean => true;
|
||||
export const SYNTH_PASSES: SynthPassDef[] = [
|
||||
{ name: 'fieldEdges', gate: ALWAYS, run: (q, c, y) => fieldChannelEdges(q, c, y) },
|
||||
{ name: 'closureCollEdges', gate: ALWAYS, run: (q, c, y) => closureCollectionEdges(q, c, y) },
|
||||
// Cross-tier channels — a client's `fetch('/api/x')` onto its own route,
|
||||
// a queue job onto its consumer, a bus / socket event onto its handler.
|
||||
// Before the in-process emitter pass: the same (source, target) pair
|
||||
// keeps the more specific edge — the one that says which tier it crosses.
|
||||
{ name: 'tierEdges', gate: (has) => has(...JS_FAMILY), run: (_q, c, y) => crossTierEdges(c, y) },
|
||||
{ name: 'emitterEdges', gate: ALWAYS, run: (_q, c, y) => eventEmitterEdges(c, y) },
|
||||
{ name: 'renderEdges', gate: ALWAYS, run: (q, c, y) => reactRenderEdges(q, c, y) },
|
||||
{ name: 'jsxEdges', gate: (has) => has(...JS_FAMILY), run: (_q, c, y) => reactJsxChildEdges(c, y) },
|
||||
|
||||
@@ -44,6 +44,7 @@ import type {
|
||||
UnresolvedRef,
|
||||
} from '../types';
|
||||
import { stripCommentsForRegex } from '../strip-comments';
|
||||
import { dependsOn } from './package-deps';
|
||||
|
||||
// =============================================================================
|
||||
// Route files
|
||||
@@ -143,8 +144,8 @@ export function navVerb(name: string): string | null {
|
||||
/** Lines a single navigation call is allowed to span. */
|
||||
const MAX_CALL_LINES = 12;
|
||||
|
||||
/** Placeholder for an interpolated `${…}` inside a template-literal href. */
|
||||
const HOLE = '\u0000';
|
||||
/** Placeholder for an interpolated `${…}` inside a template-literal href (shared with the cross-tier synthesizer). */
|
||||
export const HOLE = '\u0000';
|
||||
|
||||
/** Index of the `)` matching the `(` at `open`, skipping string bodies; -1 if unbalanced. */
|
||||
function matchParen(s: string, open: number): number {
|
||||
@@ -573,16 +574,7 @@ export const expoRouterResolver: FrameworkResolver = {
|
||||
languages: [...ROUTE_LANGUAGES],
|
||||
|
||||
detect(context: ResolutionContext): boolean {
|
||||
const packageJson = context.readFile('package.json');
|
||||
if (packageJson) {
|
||||
try {
|
||||
const pkg = JSON.parse(packageJson);
|
||||
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
||||
if (deps['expo-router']) return true;
|
||||
} catch {
|
||||
// Not JSON — fall through to the layout check.
|
||||
}
|
||||
}
|
||||
if (dependsOn(context, 'expo-router')) return true;
|
||||
const files = context.getAllFiles();
|
||||
const hasLayout = files.some((f) => /(?:^|\/)(?:src\/)?app\/_layout\.(?:tsx|jsx|ts|js)$/.test(f));
|
||||
const hasExpoConfig = files.some((f) => /^app\.(?:json|config\.(?:js|ts))$/.test(f));
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
import { Node } from '../../types';
|
||||
import { FrameworkResolver, UnresolvedRef, ResolvedRef, ResolutionContext } from '../types';
|
||||
import { stripCommentsForRegex } from '../strip-comments';
|
||||
import { resolveImportPath } from '../import-resolver';
|
||||
import { dependsOn } from './package-deps';
|
||||
|
||||
function extractTailIdent(expr: string): string | null {
|
||||
const cleaned = expr.replace(/\s+/g, '').replace(/\(\)$/, '');
|
||||
@@ -52,19 +54,8 @@ export const expressResolver: FrameworkResolver = {
|
||||
languages: ['javascript', 'typescript'],
|
||||
|
||||
detect(context: ResolutionContext): boolean {
|
||||
// Check for Express in package.json
|
||||
const packageJson = context.readFile('package.json');
|
||||
if (packageJson) {
|
||||
try {
|
||||
const pkg = JSON.parse(packageJson);
|
||||
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
||||
if (deps.express || deps.fastify || deps.koa || deps.hapi) {
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// Invalid JSON
|
||||
}
|
||||
}
|
||||
// Express in a package.json — the root's, or a workspace's (`backend/`, `apps/api/`).
|
||||
if (dependsOn(context, 'express', 'fastify', 'koa', 'hapi', '@hapi/hapi')) return true;
|
||||
|
||||
// Check for common Express patterns
|
||||
const allFiles = context.getAllFiles();
|
||||
@@ -141,7 +132,7 @@ export const expressResolver: FrameworkResolver = {
|
||||
// Match the route head up to the first arg: (app|router).METHOD('/path',
|
||||
// (NOT the whole call — handlers are often inline arrows whose `)`/`{}` the
|
||||
// old single-regex couldn't span, so inline-handler routes connected to nothing.)
|
||||
const head = /\b(app|router)\.(get|post|put|patch|delete|all|use)\s*\(\s*['"]([^'"]+)['"]\s*,/g;
|
||||
const head = /\b(app|router)\s*\.\s*(get|post|put|patch|delete|all|use)\s*\(\s*['"]([^'"]+)['"]\s*,/g;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = head.exec(safe)) !== null) {
|
||||
const method = match[2]!;
|
||||
@@ -217,10 +208,198 @@ export const expressResolver: FrameworkResolver = {
|
||||
}
|
||||
}
|
||||
}
|
||||
// The chained form: `router.route('/:id').get(getProduct).put(protect, updateProduct)`
|
||||
// — one path, several methods, each with its own handler. One route node
|
||||
// per method, at the line of its `.method(`, bound like the plain form.
|
||||
const chainHead = /\b(?:app|router)\s*\.\s*route\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
|
||||
while ((match = chainHead.exec(safe)) !== null) {
|
||||
const routePath = match[1]!;
|
||||
let at = match.index + match[0].length;
|
||||
for (;;) {
|
||||
const link = /^\s*\.\s*(get|post|put|patch|delete|all)\s*\(/.exec(safe.slice(at, at + 64));
|
||||
if (!link) break;
|
||||
const openParen = at + link[0].length - 1;
|
||||
const closeParen = matchDelim(safe, openParen, '(', ')');
|
||||
if (closeParen < 0) break;
|
||||
const method = link[1]!;
|
||||
const line = safe.slice(0, openParen).split('\n').length;
|
||||
const args = safe.slice(openParen + 1, closeParen);
|
||||
const routeNode: Node = {
|
||||
id: `route:${filePath}:${line}:${method.toUpperCase()}:${routePath}`,
|
||||
kind: 'route',
|
||||
name: `${method.toUpperCase()} ${routePath}`,
|
||||
qualifiedName: `${filePath}::${method.toUpperCase()}:${routePath}`,
|
||||
filePath,
|
||||
startLine: line,
|
||||
endLine: line,
|
||||
startColumn: 0,
|
||||
endColumn: link[0].length,
|
||||
language: lang,
|
||||
updatedAt: now,
|
||||
};
|
||||
nodes.push(routeNode);
|
||||
if (args.includes('=>')) {
|
||||
const callRe = /\b([A-Za-z_$][\w$]*)\s*\(/g;
|
||||
const seen = new Set<string>();
|
||||
let cm: RegExpExecArray | null;
|
||||
while ((cm = callRe.exec(args)) !== null) {
|
||||
const name = cm[1]!;
|
||||
if (seen.has(name) || RESERVED_CALLS.has(name)) continue;
|
||||
seen.add(name);
|
||||
references.push({ fromNodeId: routeNode.id, referenceName: name, referenceKind: 'calls', line, column: 0, filePath, language: lang });
|
||||
}
|
||||
} else {
|
||||
const parts = splitTopLevel(args).map((s) => s.trim()).filter(Boolean);
|
||||
const last = parts[parts.length - 1];
|
||||
const handlerName = last ? extractTailIdent(last) : null;
|
||||
if (handlerName) {
|
||||
references.push({ fromNodeId: routeNode.id, referenceName: handlerName, referenceKind: 'references', line, column: 0, filePath, language: lang });
|
||||
}
|
||||
}
|
||||
at = closeParen + 1;
|
||||
}
|
||||
}
|
||||
return { nodes, references };
|
||||
},
|
||||
|
||||
/**
|
||||
* Cross-file finalization for mounts. A router's routes are written
|
||||
* relative to where it is mounted —
|
||||
*
|
||||
* app.use('/api', routes) // app.js
|
||||
* router.use('/users', usersRouter) // routes/index.js
|
||||
* router.post('/', createUser) // routes/users.js → POST /api/users
|
||||
*
|
||||
* — and per-file `extract()` can only see `POST /`. This pass reads every
|
||||
* `X.use('/prefix', …, router)` whose last argument names a router file
|
||||
* (an import, or an inline `require('./x')`), composes the prefixes down
|
||||
* the mount tree, and renames the routes of each mounted file to the path
|
||||
* a request actually takes. A file mounted at two different prefixes is
|
||||
* left alone: one name cannot be two paths.
|
||||
*
|
||||
* The route node's `id` and `qualifiedName` are preserved (`qualifiedName`
|
||||
* still encodes the in-file `METHOD:path`), so the pass is idempotent on
|
||||
* every sync, exactly as the NestJS `RouterModule` pass is.
|
||||
*/
|
||||
postExtract(context: ResolutionContext): Node[] {
|
||||
const files = context.getAllFiles().filter((f) => /\.(m?js|tsx?|cjs)$/.test(f));
|
||||
const mounts = new Map<string, Array<{ prefix: string; target: string }>>();
|
||||
for (const file of files) {
|
||||
const content = context.readFile(file);
|
||||
if (!content || !content.includes('.use(')) continue;
|
||||
const lang = detectLanguage(file);
|
||||
const safe = stripCommentsForRegex(content, lang);
|
||||
const mount = /\b[A-Za-z_$][\w$]*\.use\s*\(\s*(['"`])(\/[^'"`]*)\1\s*,/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = mount.exec(safe)) !== null) {
|
||||
const open = safe.indexOf('(', m.index);
|
||||
const close = open >= 0 ? matchDelim(safe, open, '(', ')') : -1;
|
||||
if (close < 0) continue;
|
||||
const args = splitTopLevel(safe.slice(open + 1, close));
|
||||
const last = args[args.length - 1]?.trim() ?? '';
|
||||
const target = mountTarget(last, safe, file, lang, context);
|
||||
if (!target || target === file) continue;
|
||||
const list = mounts.get(file) ?? [];
|
||||
list.push({ prefix: m[2]!, target });
|
||||
mounts.set(file, list);
|
||||
}
|
||||
}
|
||||
if (mounts.size === 0) return [];
|
||||
|
||||
// Compose prefixes down the mount tree until nothing changes; a file
|
||||
// reached at two different paths is ambiguous and dropped.
|
||||
let prefixOf = new Map<string, string>();
|
||||
for (let round = 0; round < 8; round++) {
|
||||
const next = new Map<string, string>();
|
||||
const ambiguous = new Set<string>();
|
||||
for (const [file, list] of mounts) {
|
||||
const base = prefixOf.get(file) ?? '';
|
||||
for (const { prefix, target } of list) {
|
||||
const full = joinPaths(base, prefix);
|
||||
const seen = next.get(target);
|
||||
if (seen !== undefined && seen !== full) ambiguous.add(target);
|
||||
else next.set(target, full);
|
||||
}
|
||||
}
|
||||
for (const a of ambiguous) next.delete(a);
|
||||
let changed = next.size !== prefixOf.size;
|
||||
if (!changed) for (const [k, v] of next) if (prefixOf.get(k) !== v) changed = true;
|
||||
prefixOf = next;
|
||||
if (!changed) break;
|
||||
}
|
||||
|
||||
const updates: Node[] = [];
|
||||
for (const [file, prefix] of prefixOf) {
|
||||
if (prefix === '' || prefix === '/') continue;
|
||||
for (const route of context.getNodesInFile(file)) {
|
||||
if (route.kind !== 'route') continue;
|
||||
const sep = route.qualifiedName.indexOf('::');
|
||||
if (sep < 0) continue;
|
||||
const colon = route.qualifiedName.indexOf(':', sep + 2);
|
||||
if (colon < 0) continue;
|
||||
const method = route.qualifiedName.slice(sep + 2, colon);
|
||||
const original = route.qualifiedName.slice(colon + 1);
|
||||
if (!original.startsWith('/')) continue;
|
||||
const name = `${method} ${joinPaths(prefix, original)}`;
|
||||
if (name !== route.name) updates.push({ ...route, name });
|
||||
}
|
||||
}
|
||||
return updates;
|
||||
},
|
||||
};
|
||||
|
||||
/** Top-level comma split of an argument list, strings and brackets respected. */
|
||||
function splitTopLevel(args: string): string[] {
|
||||
const out: string[] = [];
|
||||
let depth = 0;
|
||||
let start = 0;
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const ch = args[i];
|
||||
if (ch === '"' || ch === "'" || ch === '`') {
|
||||
const q = ch;
|
||||
i++;
|
||||
while (i < args.length && args[i] !== q) {
|
||||
if (args[i] === '\\') i++;
|
||||
i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (ch === '(' || ch === '[' || ch === '{') depth++;
|
||||
else if (ch === ')' || ch === ']' || ch === '}') depth--;
|
||||
else if (ch === ',' && depth === 0) {
|
||||
out.push(args.slice(start, i));
|
||||
start = i + 1;
|
||||
}
|
||||
}
|
||||
out.push(args.slice(start));
|
||||
return out;
|
||||
}
|
||||
|
||||
/** `/api` + `/users` → `/api/users`; `/api/` + `/` → `/api`. */
|
||||
function joinPaths(prefix: string, path: string): string {
|
||||
const a = prefix.replace(/\/+$/, '');
|
||||
const b = path.replace(/^\/+/, '');
|
||||
const joined = b ? `${a}/${b}` : a;
|
||||
return joined === '' ? '/' : joined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The file a mount's last argument names: an inline `require('./x')`, an
|
||||
* identifier imported from a project file, or one bound to `require('./x')`
|
||||
* in the same file. `x.default` / `x.router` count as `x`.
|
||||
*/
|
||||
function mountTarget(expr: string, safe: string, file: string, lang: 'typescript' | 'javascript', context: ResolutionContext): string | null {
|
||||
const inline = /^require\s*\(\s*(['"])([^'"]+)\1\s*\)(?:\.\w+)?$/.exec(expr);
|
||||
if (inline) return resolveImportPath(inline[2]!, file, lang, context);
|
||||
const ident = /^([A-Za-z_$][\w$]*)(?:\.(?:default|router|routes))?$/.exec(expr);
|
||||
if (!ident) return null;
|
||||
const name = ident[1]!;
|
||||
const mapping = context.getImportMappings(file, lang).find((im) => im.localName === name);
|
||||
if (mapping) return resolveImportPath(mapping.source, file, lang, context);
|
||||
const required = new RegExp(`\\b(?:const|let|var)\\s+${name.replace(/\$/g, '\\$')}\\s*=\\s*require\\s*\\(\\s*(['"])([^'"]+)\\1\\s*\\)`).exec(safe);
|
||||
return required ? resolveImportPath(required[2]!, file, lang, context) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a name looks like middleware
|
||||
*/
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
ResolutionContext,
|
||||
} from '../types';
|
||||
import { stripCommentsForRegex } from '../strip-comments';
|
||||
import { declaredDependencies } from './package-deps';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public surface — see comment at top of file. This file owns four NestJS
|
||||
@@ -47,19 +48,9 @@ export const nestjsResolver: FrameworkResolver = {
|
||||
languages: ['typescript', 'javascript'],
|
||||
|
||||
detect(context: ResolutionContext): boolean {
|
||||
// Primary, fast path: any @nestjs/* dependency in package.json.
|
||||
const packageJson = context.readFile('package.json');
|
||||
if (packageJson) {
|
||||
try {
|
||||
const pkg = JSON.parse(packageJson);
|
||||
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
||||
if (Object.keys(deps).some((k) => k.startsWith('@nestjs/'))) {
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// Invalid JSON — fall through to the source scan.
|
||||
}
|
||||
}
|
||||
// Primary, fast path: any @nestjs/* dependency in a package.json — the
|
||||
// root's, or a workspace's (`apps/api/`, `server/`).
|
||||
for (const name of declaredDependencies(context)) if (name.startsWith('@nestjs/')) return true;
|
||||
|
||||
// Fallback: NestJS-specific decorators in conventionally named files.
|
||||
const allFiles = context.getAllFiles();
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* The dependencies a project declares — in its root `package.json` and in
|
||||
* the ones one or two directories down (`apps/web/package.json`,
|
||||
* `frontend/package.json`, `packages/api/package.json`). A framework detector
|
||||
* that reads only the root misses every monorepo: proshop keeps `react` in
|
||||
* `frontend/`, a Turborepo keeps `next` in `apps/web/`, and the resolver for
|
||||
* that framework then never runs, so its routes never exist.
|
||||
*/
|
||||
|
||||
import type { ResolutionContext } from '../types';
|
||||
|
||||
/** Nested manifests read per project, at most — a monorepo with hundreds of packages is sampled, not scanned. */
|
||||
const MAX_MANIFESTS = 24;
|
||||
|
||||
const cache = new WeakMap<ResolutionContext, Set<string>>();
|
||||
|
||||
/** Every dependency name declared at the root or up to two directories down, de-duplicated. */
|
||||
export function declaredDependencies(context: ResolutionContext): Set<string> {
|
||||
const cached = cache.get(context);
|
||||
if (cached) return cached;
|
||||
const names = new Set<string>();
|
||||
const manifests = ['package.json'];
|
||||
for (const file of context.getAllFiles()) {
|
||||
if (manifests.length > MAX_MANIFESTS) break;
|
||||
if (/^(?:[^/]+\/){1,2}package\.json$/.test(file) && !file.includes('node_modules/')) manifests.push(file);
|
||||
}
|
||||
for (const manifest of manifests) {
|
||||
const content = context.readFile(manifest);
|
||||
if (!content) continue;
|
||||
try {
|
||||
const pkg = JSON.parse(content) as { dependencies?: Record<string, string>; devDependencies?: Record<string, string>; peerDependencies?: Record<string, string> };
|
||||
for (const group of [pkg.dependencies, pkg.devDependencies, pkg.peerDependencies]) {
|
||||
if (group && typeof group === 'object') for (const name of Object.keys(group)) names.add(name);
|
||||
}
|
||||
} catch {
|
||||
// Not JSON — a template, a broken manifest; nothing to read.
|
||||
}
|
||||
}
|
||||
cache.set(context, names);
|
||||
return names;
|
||||
}
|
||||
|
||||
/** True when any of `deps` is declared anywhere the project's manifests are read. */
|
||||
export function dependsOn(context: ResolutionContext, ...deps: string[]): boolean {
|
||||
const names = declaredDependencies(context);
|
||||
return deps.some((d) => names.has(d));
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
import { Node } from '../../types';
|
||||
import { FrameworkResolver, UnresolvedRef, ResolvedRef, ResolutionContext } from '../types';
|
||||
import { dependsOn } from './package-deps';
|
||||
|
||||
export const reactResolver: FrameworkResolver = {
|
||||
name: 'react',
|
||||
@@ -17,19 +18,8 @@ export const reactResolver: FrameworkResolver = {
|
||||
languages: ['javascript', 'typescript', 'tsx', 'jsx'],
|
||||
|
||||
detect(context: ResolutionContext): boolean {
|
||||
// Check for React in package.json
|
||||
const packageJson = context.readFile('package.json');
|
||||
if (packageJson) {
|
||||
try {
|
||||
const pkg = JSON.parse(packageJson);
|
||||
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
||||
if (deps.react || deps.next || deps['react-native']) {
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// Invalid JSON
|
||||
}
|
||||
}
|
||||
// React in a package.json — the root's, or a workspace's (`frontend/`, `apps/web/`).
|
||||
if (dependsOn(context, 'react', 'next', 'react-native')) return true;
|
||||
|
||||
// Check for .jsx/.tsx files
|
||||
const allFiles = context.getAllFiles();
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Small helpers the dynamic-edge synthesizers share: a per-match line
|
||||
* resolver over one source string, and "the function this line is in".
|
||||
*/
|
||||
|
||||
import type { Node } from '../types';
|
||||
|
||||
/** The kinds a call site can be attributed to. */
|
||||
export const FN_KINDS: ReadonlySet<string> = new Set(['method', 'function', 'component']);
|
||||
|
||||
/**
|
||||
* Per-match line resolver over `src`, 1-based at `baseLine`. The inline
|
||||
* `src.slice(0, idx).split('\n').length` idiom is O(source-length) PER MATCH,
|
||||
* which goes quadratic on a match-dense source (a generated function full of
|
||||
* `.push(` calls re-scanned tens of thousands of times was most of the #1235
|
||||
* indexing wedge). Builds the newline index once — lazily, since most sources
|
||||
* never produce a match — then answers each call with a binary search.
|
||||
*/
|
||||
export function makeLineAt(src: string, baseLine: number): (idx: number) => number {
|
||||
let nl: number[] | null = null;
|
||||
return (idx: number) => {
|
||||
if (!nl) {
|
||||
nl = [];
|
||||
for (let i = src.indexOf('\n'); i !== -1; i = src.indexOf('\n', i + 1)) nl.push(i);
|
||||
}
|
||||
// Count newlines strictly before idx.
|
||||
let lo = 0;
|
||||
let hi = nl.length;
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi) >> 1;
|
||||
if (nl[mid]! < idx) lo = mid + 1;
|
||||
else hi = mid;
|
||||
}
|
||||
return baseLine + lo;
|
||||
};
|
||||
}
|
||||
|
||||
/** Innermost function/method node whose line range contains `line`. */
|
||||
export function enclosingFn(nodesInFile: readonly Node[], line: number): Node | null {
|
||||
let best: Node | null = null;
|
||||
for (const n of nodesInFile) {
|
||||
if (!FN_KINDS.has(n.kind)) continue;
|
||||
const end = n.endLine ?? n.startLine;
|
||||
if (n.startLine <= line && end >= line) {
|
||||
if (!best || n.startLine >= best.startLine) best = n; // prefer the tightest (latest-starting) encloser
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* The smallest `constant` / `variable` node whose lines contain `line` — the
|
||||
* value a top-level registration is written inside (`const worker = new
|
||||
* Worker('q', async (job) => …)`): what a caller imports and a walk can start
|
||||
* from when no function encloses the site.
|
||||
*/
|
||||
export function enclosingValue(nodesInFile: readonly Node[], line: number): Node | null {
|
||||
let best: Node | null = null;
|
||||
for (const n of nodesInFile) {
|
||||
if (n.kind !== 'constant' && n.kind !== 'variable') continue;
|
||||
const end = n.endLine ?? n.startLine;
|
||||
if (n.startLine <= line && end >= line) {
|
||||
if (!best || n.startLine >= best.startLine) best = n;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
@@ -0,0 +1,938 @@
|
||||
/**
|
||||
* Cross-tier channels — the web's equivalent of the React Native bridge.
|
||||
*
|
||||
* A web app is two programs that talk over a wire the graph cannot see: the
|
||||
* page calls `fetch('/api/users', { method: 'POST' })` and the API's
|
||||
* `app.post('/api/users', createUser)` answers; a service puts `'welcome'` on
|
||||
* the `email` queue and a `@Process('welcome')` method picks it up; a
|
||||
* gateway's `this.server.emit('message')` lands in the component that wrote
|
||||
* `socket.on('message', …)`. Each hop is a string on both sides, which is the
|
||||
* evidence that lets a synthesizer close it — exactly as the RN event channel
|
||||
* pairs `sendEvent(withName: "x")` with `addListener('x')`.
|
||||
*
|
||||
* Three channels, one scan:
|
||||
*
|
||||
* 1. **`http-client`** — a literal path in a client call (`fetch`, `axios.post`,
|
||||
* `ky`, `got`, `$fetch`, `useFetch`, `useSWR`, or a project instance made by
|
||||
* `axios.create(…)` / `ky.extend(…)`) → the ONE route node `METHOD path` it
|
||||
* denotes. Template holes match a `:param`; a hole in front of the path
|
||||
* (`${API_URL}/users`) matches a route by its tail; a variable url, a path
|
||||
* no route serves, or a path two routes serve alike produce nothing.
|
||||
* Edge: enclosing function → route, `tier: 'client→server'`.
|
||||
* 2. **`queue-job`** — `queue.add('job', …)` where the queue is named (`new
|
||||
* Queue('email')`, `@InjectQueue('email')`) → the `@Process('job')` method
|
||||
* of the `@Processor('email')` class, a WorkerHost's `process`, a
|
||||
* `new Worker('email', handler)`, or Bull's `queue.process('job', handler)`.
|
||||
* 3. **`event-bus`** — `eventEmitter.emit('user.created')` → `@OnEvent('user.created')`
|
||||
* (globs honoured); and sockets in both directions: a client's
|
||||
* `socket.emit('x')` → the server's `@SubscribeMessage('x')` / `socket.on('x')`
|
||||
* (`tier: 'client→server'`), the server's `server.emit('x')` → the client's
|
||||
* `socket.on('x', …)` (`tier: 'server→client'`). The in-process
|
||||
* `.on('x', fn)` ↔ `.emit('x')` pairing stays the emitter pass's.
|
||||
*
|
||||
* Every edge is `kind: 'calls'`, `provenance: 'heuristic'`, and carries
|
||||
* `synthesizedBy`, `channel` (`http` | `queue` | `event` | `socket`), the
|
||||
* `tier` when the direction is known, the `event` / `queue` / `method` /
|
||||
* `href` it was paired on, and `registeredAt` — the route registration, the
|
||||
* decorator, the `.on` — so a reader can check the pairing. Fan-out is capped
|
||||
* per event as the emitter pass caps it; an HTTP pairing needs no cap because
|
||||
* it is exact. Test suites and generated files are never sources: a supertest
|
||||
* call is the test's story, and forty of them would make the route a hub.
|
||||
*/
|
||||
|
||||
import type { Edge, Language, Node } from '../types';
|
||||
import type { ResolutionContext } from './types';
|
||||
import type { MaybeYield } from './cooperative-yield';
|
||||
import { stripCommentsForRegex } from './strip-comments';
|
||||
import { resolveImportPath } from './import-resolver';
|
||||
import { isGeneratedFile } from '../extraction/generated-detection';
|
||||
import { isTestPath } from '../search/query-utils';
|
||||
import { HOLE, readStringAt } from './frameworks/expo-router';
|
||||
import { enclosingFn, enclosingValue, makeLineAt } from './synth-utils';
|
||||
|
||||
const JS_FILE = /\.(?:[cm]?[jt]sx?)$/;
|
||||
|
||||
/** Events with more handlers or dispatchers than this are too generic to pair without type information. */
|
||||
const EVENT_FANOUT_CAP = 6;
|
||||
|
||||
const HTTP_VERBS: ReadonlySet<string> = new Set(['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS', 'ALL', 'ANY']);
|
||||
|
||||
export const TIER_CLIENT_TO_SERVER = 'client→server';
|
||||
export const TIER_SERVER_TO_CLIENT = 'server→client';
|
||||
|
||||
// =============================================================================
|
||||
// Source reading
|
||||
// =============================================================================
|
||||
|
||||
/** Index just past the `)` that closes the `(` at `open`, skipping strings; -1 if unbalanced. */
|
||||
function closeParen(s: string, open: number): number {
|
||||
let depth = 0;
|
||||
for (let i = open; i < s.length; i++) {
|
||||
const ch = s[i];
|
||||
if (ch === '"' || ch === "'") {
|
||||
const q = ch;
|
||||
i++;
|
||||
while (i < s.length && s[i] !== q) {
|
||||
if (s[i] === '\\') i++;
|
||||
i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (ch === '`') {
|
||||
i = templateEnd(s, i);
|
||||
continue;
|
||||
}
|
||||
if (ch === '(') depth++;
|
||||
else if (ch === ')') {
|
||||
depth--;
|
||||
if (depth === 0) return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/** Index of the backtick closing the template opening at `open`. */
|
||||
function templateEnd(s: string, open: number): number {
|
||||
let i = open + 1;
|
||||
while (i < s.length) {
|
||||
const ch = s[i];
|
||||
if (ch === '\\') {
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (ch === '`') return i;
|
||||
if (ch === '$' && s[i + 1] === '{') {
|
||||
let depth = 0;
|
||||
for (i = i + 1; i < s.length; i++) {
|
||||
if (s[i] === '{') depth++;
|
||||
else if (s[i] === '}') {
|
||||
depth--;
|
||||
if (depth === 0) break;
|
||||
} else if (s[i] === '`') i = templateEnd(s, i);
|
||||
}
|
||||
}
|
||||
i++;
|
||||
}
|
||||
return s.length;
|
||||
}
|
||||
|
||||
/** The arguments of the call whose `(` is at `open`, split at depth-0 commas. */
|
||||
function argumentsAt(s: string, open: number): string[] | null {
|
||||
const close = closeParen(s, open);
|
||||
if (close < 0) return null;
|
||||
const inner = s.slice(open + 1, close);
|
||||
const out: string[] = [];
|
||||
let depth = 0;
|
||||
let start = 0;
|
||||
for (let i = 0; i < inner.length; i++) {
|
||||
const c = inner[i];
|
||||
if (c === '"' || c === "'") {
|
||||
const q = c;
|
||||
i++;
|
||||
while (i < inner.length && inner[i] !== q) {
|
||||
if (inner[i] === '\\') i++;
|
||||
i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (c === '`') {
|
||||
i = templateEnd(inner, i);
|
||||
continue;
|
||||
}
|
||||
if (c === '(' || c === '[' || c === '{') depth++;
|
||||
else if (c === ')' || c === ']' || c === '}') depth--;
|
||||
else if (c === ',' && depth === 0) {
|
||||
out.push(inner.slice(start, i));
|
||||
start = i + 1;
|
||||
}
|
||||
}
|
||||
out.push(inner.slice(start));
|
||||
return out.map((a) => a.trim()).filter((a, i) => a.length > 0 || i === 0);
|
||||
}
|
||||
|
||||
/** The first string literal in `text`, holes kept; null when it opens with anything else. */
|
||||
function leadingString(text: string): string | null {
|
||||
const t = text.trim().replace(/^\(\s*/, '');
|
||||
// `new URL('/x', base)` — the path is the first argument of the URL.
|
||||
const url = /^new\s+URL\s*\(/.exec(t);
|
||||
if (url) {
|
||||
const args = argumentsAt(t, url[0].length - 1);
|
||||
return args && args[0] ? leadingString(args[0]) : null;
|
||||
}
|
||||
if (t[0] === '"' || t[0] === "'" || t[0] === '`') return readStringAt(t, 0);
|
||||
return null;
|
||||
}
|
||||
|
||||
/** `method: 'POST'` inside an options object / config, upper-cased; null when absent or computed. */
|
||||
function methodIn(text: string): string | null {
|
||||
const m = /\bmethod\s*:\s*(['"`])([A-Za-z]+)\1/.exec(text);
|
||||
return m ? m[2]!.toUpperCase() : null;
|
||||
}
|
||||
|
||||
/** The first string literal anywhere in a decorator's arguments (`'x'`, `{ name: 'x' }`). */
|
||||
function firstLiteral(args: string): string | null {
|
||||
const m = /(['"`])([^'"`]+)\1/.exec(args);
|
||||
return m ? m[2]! : null;
|
||||
}
|
||||
|
||||
/** Every string literal in a decorator's arguments, for `@OnEvent(['a', 'b'])`. */
|
||||
function allLiterals(args: string): string[] {
|
||||
const out: string[] = [];
|
||||
const re = /(['"`])([^'"`]+)\1/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(args)) !== null) out.push(m[2]!);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The name of the method a decorator sits on: skip further stacked decorators
|
||||
* and modifiers after the decorator's `)`, then take the identifier before `(`.
|
||||
*/
|
||||
function methodNameAfter(safe: string, from: number): { name: string; index: number } | null {
|
||||
let i = from;
|
||||
const ws = /\s*/y;
|
||||
const deco = /@[\w.]+/y;
|
||||
const modifier = /(?:public|private|protected|async|static|readonly|override)\b/y;
|
||||
const ident = /([A-Za-z_$][\w$]*)\s*[<(]/y;
|
||||
const eat = (): void => {
|
||||
ws.lastIndex = i;
|
||||
if (ws.exec(safe)) i = ws.lastIndex;
|
||||
};
|
||||
for (;;) {
|
||||
eat();
|
||||
if (safe[i] !== '@') break;
|
||||
deco.lastIndex = i;
|
||||
if (!deco.exec(safe)) break;
|
||||
i = deco.lastIndex;
|
||||
eat();
|
||||
if (safe[i] === '(') {
|
||||
const close = closeParen(safe, i);
|
||||
if (close < 0) return null;
|
||||
i = close + 1;
|
||||
}
|
||||
}
|
||||
for (;;) {
|
||||
eat();
|
||||
modifier.lastIndex = i;
|
||||
if (modifier.exec(safe) && modifier.lastIndex > i) {
|
||||
i = modifier.lastIndex;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
eat();
|
||||
ident.lastIndex = i;
|
||||
const m = ident.exec(safe);
|
||||
return m ? { name: m[1]!, index: i } : null;
|
||||
}
|
||||
|
||||
/** Every `@Name(` decorator in `safe` with its arguments and where it ends. */
|
||||
function decorators(safe: string, name: string): Array<{ args: string; index: number; end: number }> {
|
||||
const out: Array<{ args: string; index: number; end: number }> = [];
|
||||
const re = new RegExp(`@${name}\\s*\\(`, 'g');
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(safe)) !== null) {
|
||||
const open = m.index + m[0].length - 1;
|
||||
const close = closeParen(safe, open);
|
||||
if (close < 0) continue;
|
||||
out.push({ args: safe.slice(open + 1, close), index: m.index, end: close + 1 });
|
||||
re.lastIndex = close + 1;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Per-file facts, read once
|
||||
// =============================================================================
|
||||
|
||||
interface FileFacts {
|
||||
file: string;
|
||||
safe: string;
|
||||
nodes: Node[];
|
||||
lineOf: (idx: number) => number;
|
||||
/** The 0-based column of an index on its line — where the site reader looks for the call. */
|
||||
columnOf: (idx: number) => number;
|
||||
/** Lines a framework resolver made a route node on — registrations, never client calls. */
|
||||
routeLines: Set<number>;
|
||||
/** Local names bound to an HTTP client instance, with their literal base URL when written. */
|
||||
clients: Map<string, { baseURL: string | null }>;
|
||||
/** The module's default export is a client instance. */
|
||||
defaultClient: { baseURL: string | null } | null;
|
||||
/** Local names bound to a named queue (`new Queue('email')`, `@InjectQueue('email') x`). */
|
||||
queues: Map<string, string>;
|
||||
/** The file holds a socket server (a gateway, `io.on('connection')`, `new Server(…)`). */
|
||||
socketServer: boolean;
|
||||
}
|
||||
|
||||
const CLIENT_FACTORY =
|
||||
/\b(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*(?::[^=;]*?)?=\s*(?:await\s+)?(?:(?:axios|ky|got|ofetch|\$fetch|wretch|redaxios)\s*\.\s*(?:create|extend)|new\s+Axios|wretch)\s*\(/g;
|
||||
const DEFAULT_CLIENT_FACTORY = /\bexport\s+default\s+(?:(?:axios|ky|got|ofetch|\$fetch|wretch|redaxios)\s*\.\s*(?:create|extend)|new\s+Axios|wretch)\s*\(/;
|
||||
const QUEUE_BINDING =
|
||||
/\b(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*(?::[^=;]*?)?=\s*(?:new\s+)?(?:Queue|Bull|BullQueue)\s*(?:<[^>]*>)?\s*\(\s*(['"`])([^'"`]+)\2/g;
|
||||
const INJECT_QUEUE = /@InjectQueue\s*\(\s*(['"`])([^'"`]+)\1\s*\)\s*(?:(?:private|public|protected|readonly)\s+)*([A-Za-z_$][\w$]*)/g;
|
||||
const SOCKET_SERVER_FILE = /@WebSocketGateway\s*\(|@SubscribeMessage\s*\(|@WebSocketServer\s*\(|\bnew\s+(?:Server|SocketIOServer|WebSocketServer|WebSocket\.Server|WSServer)\b|\.on\s*\(\s*['"]connection['"]/;
|
||||
|
||||
function baseUrlIn(safe: string, open: number): string | null {
|
||||
const args = argumentsAt(safe, open);
|
||||
const config = args?.[0] ?? '';
|
||||
const m = /\b(?:baseURL|baseUrl|prefixUrl|baseURI)\s*:\s*/.exec(config);
|
||||
if (!m) return null;
|
||||
return readStringAt(config, m.index + m[0].length);
|
||||
}
|
||||
|
||||
function readFacts(ctx: ResolutionContext, file: string): FileFacts | null {
|
||||
const content = ctx.readFile(file);
|
||||
if (!content) return null;
|
||||
const safe = stripCommentsForRegex(content, 'typescript');
|
||||
const nodes = ctx.getNodesInFile(file);
|
||||
const routeLines = new Set<number>();
|
||||
for (const n of nodes) if (n.kind === 'route') routeLines.add(n.startLine);
|
||||
const clients = new Map<string, { baseURL: string | null }>();
|
||||
CLIENT_FACTORY.lastIndex = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = CLIENT_FACTORY.exec(safe)) !== null) {
|
||||
clients.set(m[1]!, { baseURL: baseUrlIn(safe, m.index + m[0].length - 1) });
|
||||
}
|
||||
const dm = DEFAULT_CLIENT_FACTORY.exec(safe);
|
||||
const defaultClient = dm ? { baseURL: baseUrlIn(safe, dm.index + dm[0].length - 1) } : null;
|
||||
const queues = new Map<string, string>();
|
||||
QUEUE_BINDING.lastIndex = 0;
|
||||
while ((m = QUEUE_BINDING.exec(safe)) !== null) queues.set(m[1]!, m[3]!);
|
||||
INJECT_QUEUE.lastIndex = 0;
|
||||
while ((m = INJECT_QUEUE.exec(safe)) !== null) queues.set(m[3]!, m[2]!);
|
||||
return {
|
||||
file,
|
||||
safe,
|
||||
nodes,
|
||||
lineOf: makeLineAt(safe, 1),
|
||||
columnOf: (idx: number) => idx - (safe.lastIndexOf('\n', idx - 1) + 1),
|
||||
routeLines,
|
||||
clients,
|
||||
defaultClient,
|
||||
queues,
|
||||
socketServer: SOCKET_SERVER_FILE.test(safe),
|
||||
};
|
||||
}
|
||||
|
||||
/** Facts for the file a local name is imported from, when the import resolves to a project file. */
|
||||
function importedFacts(
|
||||
ctx: ResolutionContext,
|
||||
facts: FileFacts,
|
||||
localName: string,
|
||||
cache: Map<string, FileFacts | null>
|
||||
): { facts: FileFacts; exportedName: string; isDefault: boolean } | null {
|
||||
const lang: Language = facts.file.endsWith('x') ? 'tsx' : 'typescript';
|
||||
const im = ctx.getImportMappings(facts.file, lang).find((i) => i.localName === localName);
|
||||
if (!im) return null;
|
||||
// The mappings name the module as written; the file it is comes from the
|
||||
// same resolution the import resolver uses (aliases, extensions, index files).
|
||||
const resolved = im.resolvedPath ?? resolveImportPath(im.source, facts.file, lang, ctx);
|
||||
if (!resolved) return null;
|
||||
let target = cache.get(resolved);
|
||||
if (target === undefined) {
|
||||
target = JS_FILE.test(resolved) ? readFacts(ctx, resolved) : null;
|
||||
cache.set(resolved, target);
|
||||
}
|
||||
return target ? { facts: target, exportedName: im.exportedName, isDefault: im.isDefault } : null;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 1. HTTP client → route
|
||||
// =============================================================================
|
||||
|
||||
/** A receiver that is an HTTP client by name alone. */
|
||||
const CLIENT_NAMES =
|
||||
/^(?:axios|ky|got|superagent|http|https|httpClient|httpService|api|apiClient|client|restClient|request|agent|fetcher|instance|\$api|\$http|\$axios|axiosInstance|Axios|HttpClient|backend|server)$/;
|
||||
/** A receiver that registers routes, never a client — unless it was made by a client factory. */
|
||||
const SERVER_NAMES = /^(?:app|router|route|routes|express|fastify|koa|hono|elysia|apiRouter|v1|v2|r)$/;
|
||||
const BARE_CLIENT_CALL = /(?:(?:window|globalThis|global)\s*\.\s*)?\b(fetch|\$fetch|ofetch|axios|ky|got|useFetch|useSWR)\s*\(/g;
|
||||
const MEMBER_CLIENT_CALL = /((?:this\s*\.\s*)?[A-Za-z_$][\w$]*(?:\s*\.\s*[A-Za-z_$][\w$]*)*)\s*\.\s*(get|post|put|patch|delete|head|options|request|\$get|\$post|\$put|\$patch|\$delete)\s*\(/g;
|
||||
|
||||
interface HttpRoute {
|
||||
node: Node;
|
||||
method: string;
|
||||
segs: string[];
|
||||
}
|
||||
|
||||
interface HttpSite {
|
||||
fn: Node;
|
||||
file: string;
|
||||
line: number;
|
||||
column: number;
|
||||
/** The call as written, `fetch` / `api.get` — what the Steps walk must not also draw as an effect. */
|
||||
callee: string;
|
||||
method: string;
|
||||
segs: string[];
|
||||
/** The path began with a hole — a base URL — and matches a route by its tail. */
|
||||
suffix: boolean;
|
||||
display: string;
|
||||
}
|
||||
|
||||
function httpRoutes(ctx: ResolutionContext): HttpRoute[] {
|
||||
const out: HttpRoute[] = [];
|
||||
for (const node of ctx.getNodesByKind('route')) {
|
||||
const space = node.name.indexOf(' ');
|
||||
if (space <= 0) continue;
|
||||
const method = node.name.slice(0, space).toUpperCase();
|
||||
if (!HTTP_VERBS.has(method)) continue;
|
||||
const path = node.name.slice(space + 1).trim();
|
||||
if (!path.startsWith('/')) continue;
|
||||
out.push({ node, method, segs: path.split('/').filter((s) => s.length > 0) });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const PARAM_SEG = /^(?::|\{|\[|<|\*)|\?$/;
|
||||
const CATCH_ALL = /^(?:\*|\[\.\.\.|\{\*|:[\w$]+\*$|\{[\w$]+:\*\}|\*[\w$]*$)/;
|
||||
|
||||
/** How well the client's segments match a route's; null when they do not. A literal match beats a parameter's. */
|
||||
function scorePath(client: readonly string[], route: readonly string[]): number | null {
|
||||
let score = 0;
|
||||
let i = 0;
|
||||
for (let r = 0; r < route.length; r++) {
|
||||
const seg = route[r]!;
|
||||
if (CATCH_ALL.test(seg)) {
|
||||
if (i >= client.length) return null;
|
||||
score += client.length - i;
|
||||
i = client.length;
|
||||
continue;
|
||||
}
|
||||
if (i >= client.length) return null;
|
||||
const c = client[i]!;
|
||||
// A hole (`${id}`) fills a route's parameter; it never stands in for a
|
||||
// literal segment — `/api/products/${id}` is not `/api/products/top`.
|
||||
if (PARAM_SEG.test(seg)) score += 2;
|
||||
else if (c === seg) score += 3;
|
||||
else return null;
|
||||
i++;
|
||||
}
|
||||
return i === client.length ? score : null;
|
||||
}
|
||||
|
||||
function matchHttp(site: HttpSite, routes: readonly HttpRoute[]): HttpRoute | null {
|
||||
let best: HttpRoute | null = null;
|
||||
let bestScore = -1;
|
||||
let tied = false;
|
||||
for (const r of routes) {
|
||||
if (r.method !== 'ALL' && r.method !== 'ANY' && r.method !== site.method) continue;
|
||||
let score: number | null;
|
||||
if (site.suffix) {
|
||||
// A base URL hides a prefix the route may spell out (`${API}/users` for
|
||||
// `GET /api/users`) — but a one-segment tail names half the routes in
|
||||
// an index, and a long hidden prefix is a different API. Two segments
|
||||
// of tail at least, two of prefix at most.
|
||||
if (site.segs.length < 2 || r.segs.length < site.segs.length || r.segs.length - site.segs.length > 2) continue;
|
||||
score = scorePath(site.segs, r.segs.slice(r.segs.length - site.segs.length));
|
||||
} else score = scorePath(site.segs, r.segs);
|
||||
if (score === null) continue;
|
||||
if (score > bestScore) {
|
||||
best = r;
|
||||
bestScore = score;
|
||||
tied = false;
|
||||
} else if (score === bestScore) tied = true;
|
||||
}
|
||||
return tied ? null : best;
|
||||
}
|
||||
|
||||
/**
|
||||
* The path a client call names, as segments, with `${…}` as `*`; `suffix`
|
||||
* when a base URL came first. Null when the path is not literal enough: a
|
||||
* relative path with no base, or nothing but holes.
|
||||
*/
|
||||
function clientPath(raw: string, baseURL: string | null): { segs: string[]; suffix: boolean; display: string } | null {
|
||||
let p = raw;
|
||||
const cut = p.search(/[?#]/);
|
||||
if (cut >= 0) p = p.slice(0, cut);
|
||||
let suffix = false;
|
||||
const absolute = /^(?:[a-z][a-z0-9+.-]*:)?\/\/[^/]*(\/.*)?$/i.exec(p);
|
||||
if (absolute) p = absolute[1] ?? '/';
|
||||
else if (!p.startsWith('/')) {
|
||||
if (p.startsWith(HOLE)) {
|
||||
const rest = p.slice(1);
|
||||
if (!rest.startsWith('/')) return null;
|
||||
p = rest;
|
||||
suffix = true;
|
||||
} else if (baseURL !== null) {
|
||||
const base = clientPath(baseURL, null);
|
||||
if (!base) {
|
||||
// A base that is itself a hole: match by the tail.
|
||||
if (!baseURL.includes(HOLE)) return null;
|
||||
suffix = true;
|
||||
p = '/' + p;
|
||||
} else {
|
||||
suffix = base.suffix;
|
||||
p = '/' + [...base.segs, ...p.split('/')].filter(Boolean).join('/');
|
||||
}
|
||||
} else return null;
|
||||
} else if (baseURL !== null) {
|
||||
// An instance with a literal path base: axios joins `baseURL + url`.
|
||||
const base = clientPath(baseURL, null);
|
||||
if (base && base.segs.length > 0) {
|
||||
suffix = base.suffix;
|
||||
p = '/' + [...base.segs, ...p.split('/')].filter(Boolean).join('/');
|
||||
} else if (!base && baseURL.includes(HOLE)) suffix = true;
|
||||
}
|
||||
const segs = p
|
||||
.split('/')
|
||||
.filter((s) => s.length > 0)
|
||||
.map((s) => (s.includes(HOLE) ? '*' : s));
|
||||
if (segs.length > 0 && segs.every((s) => s === '*')) return null;
|
||||
return { segs, suffix, display: '/' + segs.map((s) => (s === '*' ? '${…}' : s)).join('/') };
|
||||
}
|
||||
|
||||
/** What a member-call receiver is: a client (with its base URL), or nothing. */
|
||||
function clientFor(
|
||||
ctx: ResolutionContext,
|
||||
facts: FileFacts,
|
||||
receiver: string,
|
||||
cache: Map<string, FileFacts | null>
|
||||
): { baseURL: string | null } | null {
|
||||
const chain = receiver.replace(/\s+/g, '').replace(/^this\./, '').split('.');
|
||||
const head = chain[0]!;
|
||||
const last = chain[chain.length - 1]!;
|
||||
const local = facts.clients.get(head);
|
||||
if (local) return local;
|
||||
const imported = importedFacts(ctx, facts, head, cache);
|
||||
if (imported) {
|
||||
const bound = imported.isDefault ? imported.facts.defaultClient : imported.facts.clients.get(imported.exportedName) ?? null;
|
||||
if (bound) return bound;
|
||||
}
|
||||
if (SERVER_NAMES.test(last) || SERVER_NAMES.test(head)) return null;
|
||||
if (CLIENT_NAMES.test(last)) return { baseURL: null };
|
||||
return null;
|
||||
}
|
||||
|
||||
function collectHttpSites(ctx: ResolutionContext, facts: FileFacts, sites: HttpSite[], cache: Map<string, FileFacts | null>): void {
|
||||
const { safe, nodes, lineOf } = facts;
|
||||
const add = (index: number, open: number, verb: string | null, baseURL: string | null): void => {
|
||||
const line = lineOf(index);
|
||||
const callee = safe.slice(index, open).replace(/\s+/g, '');
|
||||
if (facts.routeLines.has(line)) return; // a registration the resolver already read
|
||||
const fn = enclosingFn(nodes, line);
|
||||
if (!fn) return;
|
||||
const args = argumentsAt(safe, open);
|
||||
if (!args || !args[0]) return;
|
||||
let first = args[0];
|
||||
let method = verb;
|
||||
// `axios({ url, method })`, `.request({ url, method })`, `ky(url, { method })`.
|
||||
if (first.trimStart().startsWith('{')) {
|
||||
const url = /\burl\s*:\s*/.exec(first);
|
||||
if (!url) return;
|
||||
method = method ?? methodIn(first) ?? 'GET';
|
||||
first = first.slice(url.index + url[0].length);
|
||||
} else if (method === null) {
|
||||
method = methodIn(args.slice(1).join(',')) ?? 'GET';
|
||||
}
|
||||
const literal = leadingString(first);
|
||||
if (literal === null) return;
|
||||
const path = clientPath(literal, baseURL);
|
||||
if (!path) return;
|
||||
sites.push({ fn, file: facts.file, line, column: facts.columnOf(index), callee, method, segs: path.segs, suffix: path.suffix, display: path.display });
|
||||
};
|
||||
|
||||
BARE_CLIENT_CALL.lastIndex = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = BARE_CLIENT_CALL.exec(safe)) !== null) {
|
||||
// `this.fetch(…)` / `repo.fetch(…)` is a project method, not the platform's.
|
||||
const before = safe[m.index - 1];
|
||||
if (before === '.' && !/^(?:window|globalThis|global)\s*\./.test(m[0])) continue;
|
||||
add(m.index, m.index + m[0].length - 1, null, null);
|
||||
}
|
||||
MEMBER_CLIENT_CALL.lastIndex = 0;
|
||||
while ((m = MEMBER_CLIENT_CALL.exec(safe)) !== null) {
|
||||
const client = clientFor(ctx, facts, m[1]!, cache);
|
||||
if (!client) continue;
|
||||
const verb = m[2]!.replace(/^\$/, '').toUpperCase();
|
||||
add(m.index, m.index + m[0].length - 1, verb === 'REQUEST' ? null : verb, client.baseURL);
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 2. Queue job → consumer
|
||||
// =============================================================================
|
||||
|
||||
const QUEUE_ADD = /((?:this\s*\.\s*)?[A-Za-z_$][\w$]*(?:\s*\.\s*[A-Za-z_$][\w$]*)*)\s*\.\s*add\s*\(\s*(['"`])([^'"`]+)\2/g;
|
||||
const QUEUE_SHAPED = /queue|jobs?$|worker|bull|flow|producer/i;
|
||||
const NEW_WORKER = /\bnew\s+Worker\s*(?:<[^>]*>)?\s*\(\s*(['"`])([^'"`]+)\1\s*,\s*/g;
|
||||
const QUEUE_PROCESS = /((?:this\s*\.\s*)?[A-Za-z_$][\w$]*(?:\s*\.\s*[A-Za-z_$][\w$]*)*)\s*\.\s*process\s*\(\s*(?:(['"`])([^'"`]+)\2\s*,\s*)?(?:\d+\s*,\s*)?/g;
|
||||
/** A handler argument: a named function (group 1), or an inline function. */
|
||||
const HANDLER_ARG = /^(?:(?:async\s+)?([A-Za-z_$][\w$.]*)\s*(?:[,)]|$)|(?:async\s*)?(?:\(|function\b|[A-Za-z_$][\w$]*\s*=>))/;
|
||||
|
||||
interface QueueProducer {
|
||||
fn: Node;
|
||||
file: string;
|
||||
line: number;
|
||||
column: number;
|
||||
callee: string;
|
||||
queue: string | null;
|
||||
job: string;
|
||||
}
|
||||
|
||||
interface QueueConsumer {
|
||||
node: Node;
|
||||
file: string;
|
||||
line: number;
|
||||
queue: string | null;
|
||||
/** Null: every job on the queue (`@Process()` with no name, a WorkerHost's `process`, `new Worker`). */
|
||||
job: string | null;
|
||||
}
|
||||
|
||||
/** The queue a receiver is bound to, by its binding in this file or the file it is imported from. */
|
||||
function queueFor(ctx: ResolutionContext, facts: FileFacts, receiver: string, cache: Map<string, FileFacts | null>): string | null {
|
||||
const chain = receiver.replace(/\s+/g, '').replace(/^this\./, '').split('.');
|
||||
const head = chain[0]!;
|
||||
const own = facts.queues.get(head);
|
||||
if (own) return own;
|
||||
const imported = importedFacts(ctx, facts, head, cache);
|
||||
if (imported) {
|
||||
const bound = imported.facts.queues.get(imported.exportedName);
|
||||
if (bound) return bound;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The function a handler argument names or encloses; null when it is neither. */
|
||||
function handlerNode(ctx: ResolutionContext, facts: FileFacts, text: string, line: number, cache: Map<string, FileFacts | null>): Node | null {
|
||||
const m = HANDLER_ARG.exec(text.trimStart());
|
||||
if (!m) return null;
|
||||
if (m[1]) {
|
||||
const name = m[1].split('.').pop()!;
|
||||
const candidates = ctx.getNodesByName(name).filter((n) => n.kind === 'function' || n.kind === 'method');
|
||||
const local = candidates.filter((n) => n.filePath === facts.file);
|
||||
if (local.length === 1) return local[0]!;
|
||||
if (local.length > 1) return null;
|
||||
const imported = importedFacts(ctx, facts, m[1].split('.')[0]!, cache);
|
||||
if (imported) {
|
||||
const viaImport = candidates.filter((n) => n.filePath === imported.facts.file);
|
||||
if (viaImport.length === 1) return viaImport[0]!;
|
||||
}
|
||||
return candidates.length === 1 ? candidates[0]! : null;
|
||||
}
|
||||
return enclosingFn(facts.nodes, line) ?? enclosingValue(facts.nodes, line);
|
||||
}
|
||||
|
||||
/** The nearest class declared at or after `index`, as its node. */
|
||||
function classAfter(facts: FileFacts, index: number): Node | null {
|
||||
const m = /\bclass\s+([A-Za-z_$][\w$]*)/g;
|
||||
m.lastIndex = index;
|
||||
const hit = m.exec(facts.safe);
|
||||
if (!hit) return null;
|
||||
const line = facts.lineOf(hit.index);
|
||||
return facts.nodes.find((n) => n.kind === 'class' && n.name === hit[1] && n.startLine >= line - 2) ?? null;
|
||||
}
|
||||
|
||||
/** The method node a decorator at `end` sits on, inside `cls` when given. */
|
||||
function decoratedMethod(facts: FileFacts, end: number, cls: Node | null): Node | null {
|
||||
const named = methodNameAfter(facts.safe, end);
|
||||
if (!named) return null;
|
||||
const line = facts.lineOf(named.index);
|
||||
return (
|
||||
facts.nodes.find(
|
||||
(n) =>
|
||||
(n.kind === 'method' || n.kind === 'function') &&
|
||||
n.name === named.name &&
|
||||
n.startLine >= line - 1 &&
|
||||
n.startLine <= line + 1 &&
|
||||
(!cls || (n.startLine >= cls.startLine && n.endLine <= cls.endLine))
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
function collectQueue(ctx: ResolutionContext, facts: FileFacts, producers: QueueProducer[], consumers: QueueConsumer[], cache: Map<string, FileFacts | null>): void {
|
||||
const { safe, nodes, lineOf } = facts;
|
||||
let m: RegExpExecArray | null;
|
||||
QUEUE_ADD.lastIndex = 0;
|
||||
while ((m = QUEUE_ADD.exec(safe)) !== null) {
|
||||
const receiver = m[1]!;
|
||||
const queue = queueFor(ctx, facts, receiver, cache);
|
||||
const last = receiver.replace(/\s+/g, '').split('.').pop()!;
|
||||
if (queue === null && !QUEUE_SHAPED.test(last)) continue;
|
||||
const line = lineOf(m.index);
|
||||
const fn = enclosingFn(nodes, line);
|
||||
if (!fn) continue;
|
||||
producers.push({ fn, file: facts.file, line, column: facts.columnOf(m.index), callee: `${receiver.replace(/\s+/g, '')}.add`, queue, job: m[3]! });
|
||||
}
|
||||
|
||||
// Nest: `@Processor('email')` on a class; `@Process('welcome')` on its methods,
|
||||
// or a WorkerHost's `process(job)`.
|
||||
for (const proc of decorators(safe, 'Processor')) {
|
||||
const queue = firstLiteral(proc.args);
|
||||
const cls = classAfter(facts, proc.end);
|
||||
if (!cls) continue;
|
||||
let any = false;
|
||||
for (const job of decorators(safe, 'Process')) {
|
||||
const line = lineOf(job.index);
|
||||
if (line < cls.startLine || line > cls.endLine) continue;
|
||||
const method = decoratedMethod(facts, job.end, cls);
|
||||
if (!method) continue;
|
||||
any = true;
|
||||
consumers.push({ node: method, file: facts.file, line, queue, job: firstLiteral(job.args) });
|
||||
}
|
||||
if (!any) {
|
||||
const process = nodes.find((n) => n.kind === 'method' && n.name === 'process' && n.startLine >= cls.startLine && n.endLine <= cls.endLine);
|
||||
if (process) consumers.push({ node: process, file: facts.file, line: process.startLine, queue, job: null });
|
||||
}
|
||||
}
|
||||
|
||||
// BullMQ: `new Worker('email', handler)`.
|
||||
NEW_WORKER.lastIndex = 0;
|
||||
while ((m = NEW_WORKER.exec(safe)) !== null) {
|
||||
const line = lineOf(m.index);
|
||||
const node = handlerNode(ctx, facts, safe.slice(m.index + m[0].length, m.index + m[0].length + 200), line, cache);
|
||||
if (!node) continue;
|
||||
consumers.push({ node, file: facts.file, line, queue: m[2]!, job: null });
|
||||
}
|
||||
|
||||
// Bull: `queue.process('welcome', handler)` / `queue.process(handler)`.
|
||||
QUEUE_PROCESS.lastIndex = 0;
|
||||
while ((m = QUEUE_PROCESS.exec(safe)) !== null) {
|
||||
const receiver = m[1]!;
|
||||
const queue = queueFor(ctx, facts, receiver, cache);
|
||||
const last = receiver.replace(/\s+/g, '').split('.').pop()!;
|
||||
if (queue === null && !QUEUE_SHAPED.test(last)) continue;
|
||||
const line = lineOf(m.index);
|
||||
const node = handlerNode(ctx, facts, safe.slice(m.index + m[0].length, m.index + m[0].length + 200), line, cache);
|
||||
if (!node) continue;
|
||||
consumers.push({ node, file: facts.file, line, queue, job: m[3] ?? null });
|
||||
}
|
||||
}
|
||||
|
||||
function pairQueue(producers: readonly QueueProducer[], consumers: readonly QueueConsumer[], edges: Edge[], seen: Set<string>): void {
|
||||
for (const p of producers) {
|
||||
let candidates = consumers.filter((c) => (p.queue === null || c.queue === null || c.queue === p.queue) && (c.job === null || c.job === p.job));
|
||||
// The most specific pairing wins: the job by name on the named queue,
|
||||
// then the job by name, then the queue's default consumer.
|
||||
const exact = candidates.filter((c) => c.job === p.job && c.queue === p.queue && p.queue !== null);
|
||||
if (exact.length > 0) candidates = exact;
|
||||
else {
|
||||
const byJob = candidates.filter((c) => c.job === p.job);
|
||||
if (byJob.length > 0) candidates = byJob;
|
||||
else if (p.queue === null) continue; // an unnamed queue and no consumer naming the job: a guess
|
||||
else candidates = candidates.filter((c) => c.queue === p.queue);
|
||||
}
|
||||
if (candidates.length === 0 || candidates.length > EVENT_FANOUT_CAP) continue;
|
||||
for (const c of candidates) {
|
||||
if (c.node.id === p.fn.id) continue;
|
||||
const key = `${p.fn.id}>${c.node.id}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
edges.push({
|
||||
source: p.fn.id,
|
||||
target: c.node.id,
|
||||
kind: 'calls',
|
||||
line: p.line,
|
||||
column: p.column,
|
||||
provenance: 'heuristic',
|
||||
metadata: {
|
||||
synthesizedBy: 'queue-job',
|
||||
channel: 'queue',
|
||||
callee: p.callee,
|
||||
event: p.job,
|
||||
...(p.queue ?? c.queue ? { queue: p.queue ?? c.queue } : {}),
|
||||
registeredAt: `${c.file}:${c.line}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 3. Events: a bus, and sockets both ways
|
||||
// =============================================================================
|
||||
|
||||
const EMIT = /((?:[\w$]+(?:\([^()]*\))?\s*\.\s*)*[\w$]+)\s*\.\s*(emit|emitAsync)\s*\(\s*(['"`])([^'"`\n]+)\3/g;
|
||||
const SOCKET_ON = /((?:[\w$]+(?:\([^()]*\))?\s*\.\s*)*[\w$]+)\s*\.\s*(?:on|once)\s*\(\s*(['"`])([^'"`\n]+)\2\s*,\s*/g;
|
||||
const SOCKET_WORDS = /^(?:socket|io|ws|wss|client|server|namespace|nsp|conn|connection|gateway|broadcast|to|in|of|except|volatile|local|sockets|socketServer|wsServer|room|channel|pusher|ably|ioClient|socketClient|sock)$/;
|
||||
const BUS_WORDS = /^(?:eventEmitter|emitter|events|eventBus|bus|dispatcher|pubsub|publisher|eventPublisher|ee|hub|mediator|broker|messageBus|appEvents|domainEvents|eventsService|eventService)$/;
|
||||
/** The transport's own events — every socket emits and handles them; pairing them says nothing. */
|
||||
const GENERIC_EVENT =
|
||||
/^(?:error|connect|connect_error|connect_failed|connection|disconnect|disconnecting|reconnect|reconnect_attempt|reconnecting|reconnect_error|reconnect_failed|close|open|end|data|ready|drain|finish|pipe|unpipe|listening|timeout|ping|pong|upgrade|newListener|removeListener)$/;
|
||||
|
||||
interface Dispatch {
|
||||
fn: Node;
|
||||
file: string;
|
||||
line: number;
|
||||
column: number;
|
||||
callee: string;
|
||||
event: string;
|
||||
shape: 'bus' | 'socket';
|
||||
side: 'server' | 'client';
|
||||
}
|
||||
|
||||
interface Handler {
|
||||
node: Node;
|
||||
file: string;
|
||||
line: number;
|
||||
/** An event name, or an `@OnEvent` glob. */
|
||||
pattern: string;
|
||||
kind: 'bus' | 'socket';
|
||||
side: 'server' | 'client';
|
||||
}
|
||||
|
||||
function shapeOf(receiver: string): 'bus' | 'socket' | null {
|
||||
const segs = receiver.replace(/\([^()]*\)/g, '').replace(/\s+/g, '').split('.').filter((s) => s !== 'this');
|
||||
if (segs.some((s) => SOCKET_WORDS.test(s))) return 'socket';
|
||||
if (segs.some((s) => BUS_WORDS.test(s))) return 'bus';
|
||||
return null;
|
||||
}
|
||||
|
||||
function collectEvents(ctx: ResolutionContext, facts: FileFacts, dispatches: Dispatch[], handlers: Handler[], cache: Map<string, FileFacts | null>): void {
|
||||
const { safe, nodes, lineOf } = facts;
|
||||
const side: 'server' | 'client' = facts.socketServer ? 'server' : 'client';
|
||||
let m: RegExpExecArray | null;
|
||||
EMIT.lastIndex = 0;
|
||||
while ((m = EMIT.exec(safe)) !== null) {
|
||||
const shape = shapeOf(m[1]!);
|
||||
if (!shape || GENERIC_EVENT.test(m[4]!)) continue;
|
||||
const line = lineOf(m.index);
|
||||
const fn = enclosingFn(nodes, line);
|
||||
if (!fn) continue;
|
||||
dispatches.push({ fn, file: facts.file, line, column: facts.columnOf(m.index), callee: `${m[1]!.replace(/\s+/g, '')}.${m[2]!}`, event: m[4]!, shape, side });
|
||||
}
|
||||
for (const d of decorators(safe, 'OnEvent')) {
|
||||
const method = decoratedMethod(facts, d.end, null);
|
||||
if (!method) continue;
|
||||
const patterns = d.args.trimStart().startsWith('[') ? allLiterals(d.args) : [firstLiteral(d.args)].filter((x): x is string => x !== null);
|
||||
for (const pattern of patterns) handlers.push({ node: method, file: facts.file, line: lineOf(d.index), pattern, kind: 'bus', side });
|
||||
}
|
||||
for (const d of decorators(safe, 'SubscribeMessage')) {
|
||||
const method = decoratedMethod(facts, d.end, null);
|
||||
const event = firstLiteral(d.args);
|
||||
if (!method || event === null) continue;
|
||||
handlers.push({ node: method, file: facts.file, line: lineOf(d.index), pattern: event, kind: 'socket', side: 'server' });
|
||||
}
|
||||
SOCKET_ON.lastIndex = 0;
|
||||
while ((m = SOCKET_ON.exec(safe)) !== null) {
|
||||
if (shapeOf(m[1]!) !== 'socket' || GENERIC_EVENT.test(m[3]!)) continue;
|
||||
const line = lineOf(m.index);
|
||||
const node = handlerNode(ctx, facts, safe.slice(m.index + m[0].length, m.index + m[0].length + 200), line, cache);
|
||||
if (!node) continue;
|
||||
handlers.push({ node, file: facts.file, line, pattern: m[3]!, kind: 'socket', side });
|
||||
}
|
||||
}
|
||||
|
||||
/** `user.*` matches one segment, `**` any; anything else is exact. */
|
||||
function eventMatches(pattern: string, event: string): boolean {
|
||||
if (pattern === event) return true;
|
||||
if (!pattern.includes('*')) return false;
|
||||
const re = new RegExp('^' + pattern.split('**').map((part) => part.split('*').map(escapeRe).join('[^.]+')).join('.*') + '$');
|
||||
return re.test(event);
|
||||
}
|
||||
|
||||
function escapeRe(s: string): string {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function pairEvents(dispatches: readonly Dispatch[], handlers: readonly Handler[], edges: Edge[], seen: Set<string>): void {
|
||||
// Fan-out is judged per event name on each side, as the emitter pass does.
|
||||
const dispatchesByEvent = new Map<string, Dispatch[]>();
|
||||
for (const d of dispatches) dispatchesByEvent.set(`${d.shape}:${d.event}`, [...(dispatchesByEvent.get(`${d.shape}:${d.event}`) ?? []), d]);
|
||||
for (const [, group] of dispatchesByEvent) {
|
||||
if (group.length > EVENT_FANOUT_CAP) continue;
|
||||
for (const d of group) {
|
||||
let matched: Handler[];
|
||||
let tier: string | null = null;
|
||||
if (d.shape === 'bus') matched = handlers.filter((h) => h.kind === 'bus' && eventMatches(h.pattern, d.event));
|
||||
else if (d.side === 'client') {
|
||||
matched = handlers.filter((h) => h.kind === 'socket' && h.side === 'server' && h.pattern === d.event);
|
||||
tier = TIER_CLIENT_TO_SERVER;
|
||||
} else {
|
||||
matched = handlers.filter((h) => h.kind === 'socket' && h.side === 'client' && h.pattern === d.event);
|
||||
tier = TIER_SERVER_TO_CLIENT;
|
||||
}
|
||||
if (matched.length === 0 || matched.length > EVENT_FANOUT_CAP) continue;
|
||||
for (const h of matched) {
|
||||
if (h.node.id === d.fn.id) continue;
|
||||
const key = `${d.fn.id}>${h.node.id}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
edges.push({
|
||||
source: d.fn.id,
|
||||
target: h.node.id,
|
||||
kind: 'calls',
|
||||
line: d.line,
|
||||
column: d.column,
|
||||
provenance: 'heuristic',
|
||||
metadata: {
|
||||
synthesizedBy: 'event-bus',
|
||||
channel: d.shape === 'bus' ? 'event' : 'socket',
|
||||
callee: d.callee,
|
||||
event: d.event,
|
||||
...(tier ? { tier } : {}),
|
||||
registeredAt: `${h.file}:${h.line}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// The pass
|
||||
// =============================================================================
|
||||
|
||||
const HTTP_GATE = /\b(?:fetch|\$fetch|ofetch|axios|ky|got|useFetch|useSWR)\s*\(|\.\s*(?:get|post|put|patch|delete|head|options|request|\$get|\$post)\s*\(/;
|
||||
const QUEUE_GATE = /\.\s*add\s*\(|@Processor\s*\(|\bnew\s+Worker\s*[<(]|\.\s*process\s*\(/;
|
||||
const EVENT_GATE = /\.\s*(?:emit|emitAsync|on|once)\s*\(|@OnEvent\s*\(|@SubscribeMessage\s*\(/;
|
||||
|
||||
export async function crossTierEdges(ctx: ResolutionContext, onYield: MaybeYield): Promise<Edge[]> {
|
||||
const routes = httpRoutes(ctx);
|
||||
const httpSites: HttpSite[] = [];
|
||||
const producers: QueueProducer[] = [];
|
||||
const consumers: QueueConsumer[] = [];
|
||||
const dispatches: Dispatch[] = [];
|
||||
const handlers: Handler[] = [];
|
||||
const cache = new Map<string, FileFacts | null>();
|
||||
|
||||
let scanned = 0;
|
||||
for (const file of ctx.getAllFiles()) {
|
||||
if (!JS_FILE.test(file) || isTestPath(file) || isGeneratedFile(file)) continue;
|
||||
if ((++scanned & 63) === 0) await onYield();
|
||||
const content = ctx.readFile(file);
|
||||
if (!content) continue;
|
||||
const wantsHttp = routes.length > 0 && HTTP_GATE.test(content);
|
||||
const wantsQueue = QUEUE_GATE.test(content);
|
||||
const wantsEvents = EVENT_GATE.test(content);
|
||||
if (!wantsHttp && !wantsQueue && !wantsEvents) continue;
|
||||
let facts = cache.get(file);
|
||||
if (facts === undefined) {
|
||||
facts = readFacts(ctx, file);
|
||||
cache.set(file, facts);
|
||||
}
|
||||
if (!facts) continue;
|
||||
if (wantsHttp) collectHttpSites(ctx, facts, httpSites, cache);
|
||||
if (wantsQueue) collectQueue(ctx, facts, producers, consumers, cache);
|
||||
if (wantsEvents) collectEvents(ctx, facts, dispatches, handlers, cache);
|
||||
}
|
||||
|
||||
const edges: Edge[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const site of httpSites) {
|
||||
const route = matchHttp(site, routes);
|
||||
if (!route || route.node.id === site.fn.id) continue;
|
||||
const key = `${site.fn.id}>${route.node.id}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
edges.push({
|
||||
source: site.fn.id,
|
||||
target: route.node.id,
|
||||
kind: 'calls',
|
||||
line: site.line,
|
||||
column: site.column,
|
||||
provenance: 'heuristic',
|
||||
metadata: {
|
||||
synthesizedBy: 'http-client',
|
||||
channel: 'http',
|
||||
callee: site.callee,
|
||||
tier: TIER_CLIENT_TO_SERVER,
|
||||
method: site.method,
|
||||
href: site.display,
|
||||
registeredAt: `${route.node.filePath}:${route.node.startLine}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
await onYield();
|
||||
pairQueue(producers, consumers, edges, seen);
|
||||
pairEvents(dispatches, handlers, edges, seen);
|
||||
return edges;
|
||||
}
|
||||
@@ -332,9 +332,10 @@ export function isTestPath(filePath: string): boolean {
|
||||
lower.includes('/tests/') || lower.includes('/test/') ||
|
||||
lower.includes('/__tests__/') || lower.includes('/spec/') ||
|
||||
lower.includes('/specs/') || lower.includes('/testlib/') ||
|
||||
lower.includes('/testing/') ||
|
||||
lower.includes('/testing/') || lower.includes('/e2e/') ||
|
||||
lower.startsWith('test/') || lower.startsWith('tests/') ||
|
||||
lower.startsWith('spec/') || lower.startsWith('specs/') ||
|
||||
lower.startsWith('e2e/') ||
|
||||
// CamelCase test source-set dirs (Kotlin Multiplatform / Gradle / Xcode):
|
||||
// jvmTest/, commonTest/, androidTest/, iosTest/, integrationTest/. Capital-led
|
||||
// so "latest/" / "manifest/" are not matched.
|
||||
|
||||
@@ -35,7 +35,14 @@ export interface RouteRoot {
|
||||
inline: boolean;
|
||||
}
|
||||
|
||||
const HANDLER_KINDS: ReadonlySet<Node['kind']> = new Set(['function', 'method', 'class', 'component']);
|
||||
/**
|
||||
* What a resolver's `references` edge may name as the handler. A constant or
|
||||
* a variable counts: `const authUser = asyncHandler(async (req, res) => …)` is
|
||||
* how an Express handler is written with a wrapper, and the registration site
|
||||
* named it — the arrow inside has no node of its own, so the binding is the
|
||||
* handler, and the walk lends it the file-scope calls within its lines.
|
||||
*/
|
||||
const HANDLER_KINDS: ReadonlySet<Node['kind']> = new Set(['function', 'method', 'class', 'component', 'constant', 'variable']);
|
||||
const JS_FAMILY: ReadonlySet<string> = new Set(['javascript', 'typescript', 'tsx', 'jsx']);
|
||||
|
||||
/** A React component, by the convention that names one: a PascalCase function in a JS-family file. */
|
||||
@@ -59,7 +66,7 @@ export function routeRoots(cg: CodeGraph, routes: readonly Node[]): Map<string,
|
||||
list.push(e);
|
||||
byRoute.set(e.source, list);
|
||||
}
|
||||
const rank = (n: Node): number => (n.kind === 'function' || n.kind === 'method' ? 0 : n.kind === 'component' ? 1 : 2);
|
||||
const rank = (n: Node): number => (n.kind === 'function' || n.kind === 'method' ? 0 : n.kind === 'component' ? 1 : n.kind === 'class' ? 2 : 3);
|
||||
for (const route of routes) {
|
||||
const list = byRoute.get(route.id);
|
||||
if (!list || list.length === 0) continue;
|
||||
|
||||
+123
-21
@@ -103,8 +103,9 @@ export interface WireStep {
|
||||
depth: number;
|
||||
/**
|
||||
* Why the walk did not go on from this step, when it did not: a cap it hit
|
||||
* (`depth`, `fan-out`, `folded`, `steps`), or `screen` — another screen is
|
||||
* a chapter of its own, drawn but not entered unless `through` asks.
|
||||
* (`depth`, `fan-out`, `folded`, `steps`), or `screen` — another screen, or
|
||||
* an endpoint reached across a tier, is a chapter of its own, drawn but not
|
||||
* entered unless `through` asks.
|
||||
*/
|
||||
cut: 'depth' | 'fan-out' | 'folded' | 'steps' | 'screen' | 'component' | null;
|
||||
/** The event name a native event step arrived on (`onZipComplete`) — the first, when several land here. */
|
||||
@@ -227,8 +228,19 @@ const WALK_KINDS: Edge['kind'][] = ['calls', 'instantiates', 'navigates', 'refer
|
||||
const JS_FAMILY: ReadonlySet<Language> = new Set<Language>(['javascript', 'typescript', 'tsx', 'jsx']);
|
||||
const NATIVE_FAMILY: ReadonlySet<Language> = new Set<Language>(['swift', 'objc', 'java', 'kotlin']);
|
||||
|
||||
/** JS → native is a bridge call; native → JS is an event. Anything else is one family. */
|
||||
export function crossing(from: Language, to: Language): 'bridge' | 'event' | null {
|
||||
/**
|
||||
* JS → native is a bridge call; native → JS is an event. Anything else is one
|
||||
* family — unless the edge itself says which way it crosses: a synthesized
|
||||
* channel (`resolution/tier-synthesizer.ts`) marks a client's request onto its
|
||||
* own route `client→server`, a socket message back `server→client`, and a
|
||||
* queue job or a bus event as a `channel` whose landing is an arrival; a
|
||||
* server action called from a client file is marked `client→server` at
|
||||
* request time, by its directive.
|
||||
*/
|
||||
export function crossing(from: Language, to: Language, meta: Record<string, unknown> = {}): 'bridge' | 'event' | null {
|
||||
if (meta.tier === 'client→server') return 'bridge';
|
||||
if (meta.tier === 'server→client') return 'event';
|
||||
if (meta.channel === 'queue' || meta.channel === 'event' || meta.channel === 'socket') return 'event';
|
||||
if (JS_FAMILY.has(from) && NATIVE_FAMILY.has(to)) return 'bridge';
|
||||
if (NATIVE_FAMILY.has(from) && JS_FAMILY.has(to)) return 'event';
|
||||
return null;
|
||||
@@ -470,6 +482,7 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
|
||||
const fanIn = new Map<string, number>();
|
||||
const chromeParents = new Map<string, number>();
|
||||
const fileScopeRefs = new Map<string, Edge[]>();
|
||||
const fileScopeUnresolved = new Map<string, UnresolvedReference[]>();
|
||||
|
||||
const stepFor = (node: Node, kind: WireStepKind, depth: number, extra: Partial<WireStep> = {}): StepRecord | null => {
|
||||
const existing = steps.get(node.id);
|
||||
@@ -495,7 +508,9 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
|
||||
const routeRoot = isRoute ? (roots.get(node.id) ?? null) : null;
|
||||
const record: StepRecord = {
|
||||
id: node.id,
|
||||
kind: isRoute ? 'screen' : kind,
|
||||
// A route is a screen or an endpoint — except one reached across a
|
||||
// tier (`fetch('/api/users')` onto its own route), which is the crossing.
|
||||
kind: isRoute && kind !== 'bridge' ? 'screen' : kind,
|
||||
anchor: false,
|
||||
node: toNodeRef(node),
|
||||
label: node.name,
|
||||
@@ -708,7 +723,9 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
|
||||
// Another screen is a chapter of its own: the Screens view draws the way
|
||||
// between screens, and a picture that walked on through Home would be the
|
||||
// whole app. Drawn as a boundary, entered on request.
|
||||
if (step.kind === 'screen' && !step.anchor && !through) {
|
||||
// An endpoint reached across a tier is the same kind of boundary: the
|
||||
// request's own picture starts at its handler, entered on request.
|
||||
if ((step.kind === 'screen' || (step.kind === 'bridge' && step.node?.kind === 'route')) && !step.anchor && !through) {
|
||||
step.cut = 'screen';
|
||||
continue;
|
||||
}
|
||||
@@ -743,9 +760,14 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
|
||||
// from the FILE scope, at the wrapper's line. Lend the wrapper those
|
||||
// references, so the screen that renders `<Memoized/>` walks on into
|
||||
// what the component does.
|
||||
// The same for a value a registration is written inside — `const
|
||||
// worker = new Worker('q', async (job) => { … })`: the arrow's calls
|
||||
// belong to the file scope and the constant spans them; a queue job
|
||||
// lands on the constant, and the walk goes on into what the handler does.
|
||||
for (const fold of frontier) {
|
||||
if (fold.node.kind !== 'component' || (bySource.get(fold.node.id)?.length ?? 0) > 0) continue;
|
||||
for (const e of fileScopeFnRefsWithin(cg, fold.node, fileScopeRefs)) {
|
||||
const value = fold.node.kind === 'constant' || fold.node.kind === 'variable';
|
||||
if ((fold.node.kind !== 'component' && !value) || (bySource.get(fold.node.id)?.length ?? 0) > 0) continue;
|
||||
for (const e of fileScopeEdgesWithin(cg, fold.node, fileScopeRefs, value)) {
|
||||
const list = bySource.get(fold.node.id) ?? [];
|
||||
list.push({ ...e, source: fold.node.id });
|
||||
bySource.set(fold.node.id, list);
|
||||
@@ -759,17 +781,38 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
|
||||
if (unknownFanIn.length > 0) for (const [id, n] of cg.getFanIn(unknownFanIn)) fanIn.set(id, n);
|
||||
|
||||
for (const fold of frontier) {
|
||||
// Effects made by this node, folded or not.
|
||||
// A call a synthesized channel already follows — the `fetch` that
|
||||
// reaches its own route, the `queue.add` its consumer picks up — is
|
||||
// the crossing, not also a call outside the index.
|
||||
const channelLines = new Set<number>();
|
||||
/** Per line, the last segment of each call a channel follows there (`add` of `emailQueue.add`). */
|
||||
const channelCalls = new Map<number, Set<string>>();
|
||||
for (const e of bySource.get(fold.node.id) ?? []) {
|
||||
const m = e.metadata as Record<string, unknown> | undefined;
|
||||
if (typeof m?.channel !== 'string' || typeof e.line !== 'number') continue;
|
||||
channelLines.add(e.line);
|
||||
if (typeof m.callee === 'string') {
|
||||
const set = channelCalls.get(e.line) ?? new Set<string>();
|
||||
set.add(m.callee.split(/[.:]/).pop() ?? m.callee);
|
||||
channelCalls.set(e.line, set);
|
||||
}
|
||||
}
|
||||
// Effects made by this node, folded or not. A value a handler is
|
||||
// written inside (`const authUser = asyncHandler(async (req, res) =>
|
||||
// …)`) made none itself — the arrow's calls belong to the file scope —
|
||||
// so it is lent the file's, within its lines, as its call edges are.
|
||||
if (effectScans < MAX_EFFECT_SCANS) {
|
||||
effectScans++;
|
||||
let refs: UnresolvedReference[] = [];
|
||||
try {
|
||||
refs = cg.getUnresolvedReferencesFrom(fold.node.id);
|
||||
if (refs.length === 0 && (fold.node.kind === 'constant' || fold.node.kind === 'variable')) refs = fileScopeRefsWithin(cg, fold.node, fileScopeUnresolved);
|
||||
} catch {
|
||||
refs = [];
|
||||
}
|
||||
for (const ref of [...refs].sort((a, b) => a.line - b.line || a.column - b.column)) {
|
||||
if (ref.referenceKind !== 'calls' && ref.referenceKind !== 'instantiates') continue;
|
||||
if (channelLines.has(ref.line)) continue;
|
||||
await effectLink(step, fold, { referenceName: ref.referenceName, referenceKind: ref.referenceKind, line: ref.line, column: ref.column }, null);
|
||||
}
|
||||
}
|
||||
@@ -824,6 +867,14 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
|
||||
// same case with the constant as the target.
|
||||
let target = targets.get(e.target)!;
|
||||
let retargeted = false;
|
||||
// `api.get('/users')` resolves to the `api` constant — and
|
||||
// `this.audioQueue.add('transcode')` to some `add` by name — AND,
|
||||
// on the same line, a channel follows the call: the channel is the story.
|
||||
if (typeof meta.channel !== 'string' && e.kind === 'calls' && channelLines.has(e.line ?? -1)) {
|
||||
const written = typeof meta.refName === 'string' ? meta.refName : target.name;
|
||||
const last = written.split(/[.:]/).pop() ?? written;
|
||||
if (target.kind === 'constant' || target.kind === 'variable' || channelCalls.get(e.line!)?.has(last)) continue;
|
||||
}
|
||||
if (e.kind === 'calls' && typeof meta.synthesizedBy !== 'string' && e.provenance !== 'heuristic') {
|
||||
const refName = typeof meta.refName === 'string' ? meta.refName : target.name;
|
||||
const bare = !refName.includes('.');
|
||||
@@ -873,11 +924,28 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
|
||||
const isCall = e.kind === 'calls' || e.kind === 'instantiates' || (e.kind === 'references' && meta.fnRef === true);
|
||||
const trigger = isCall ? await triggerAt(fold.node, { line: e.line, column: e.column }) : null;
|
||||
|
||||
// A server action, by its directive: a function in a `'use server'`
|
||||
// file (or opening with the directive) called from a file that is
|
||||
// not — the call crosses to the server, whatever the import says.
|
||||
if (
|
||||
e.provenance !== 'heuristic' &&
|
||||
(e.kind === 'calls' || (e.kind === 'references' && meta.fnRef === true)) &&
|
||||
(target.kind === 'function' || target.kind === 'method') &&
|
||||
JS_FAMILY.has(target.language) &&
|
||||
JS_FAMILY.has(fold.node.language)
|
||||
) {
|
||||
const callee = await calls.directive(target);
|
||||
if ((callee.file === 'server' || callee.own) && (await calls.directive(fold.node)).file !== 'server') {
|
||||
meta.tier = 'client→server';
|
||||
meta.channel = 'server-action';
|
||||
}
|
||||
}
|
||||
|
||||
// What kind of step, if any, this edge arrives at.
|
||||
let kind: WireStepKind | null = null;
|
||||
let linkKind: WireStepLinkKind = 'calls';
|
||||
const extra: Partial<WireStep> = {};
|
||||
if (target.kind === 'route') {
|
||||
if (target.kind === 'route' && meta.tier !== 'client→server') {
|
||||
kind = 'screen';
|
||||
linkKind = 'navigates';
|
||||
} else {
|
||||
@@ -886,8 +954,9 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
|
||||
// a synthesized channel's. A plain name-matched call across the
|
||||
// families (`arr.flat()` landing on a Swift `flat`) is noise, and
|
||||
// is neither drawn nor walked.
|
||||
const cross = crossing(fold.node.language, target.language);
|
||||
const evidenced = e.provenance === 'heuristic' || meta.bridge === 'react-native' || meta.resolvedBy === 'framework';
|
||||
const cross = crossing(fold.node.language, target.language, meta);
|
||||
const evidenced =
|
||||
e.provenance === 'heuristic' || meta.bridge === 'react-native' || meta.resolvedBy === 'framework' || meta.channel === 'server-action';
|
||||
if (cross !== null && !evidenced) continue;
|
||||
if (cross === 'event') {
|
||||
kind = 'event';
|
||||
@@ -937,8 +1006,15 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
|
||||
const at = { line: a.e.line, column: a.e.column };
|
||||
const when = await whenAt(fold.node, at);
|
||||
// A call-shaped hop says what it passes; a navigation already says
|
||||
// its href, a handler binding and an event channel pass nothing.
|
||||
const site = a.linkKind === 'bridge' || a.linkKind === 'store' || a.linkKind === 'calls' ? await withArgs(a.site, fold.node, at) : a.site;
|
||||
// its href, a handler binding and a native event channel pass
|
||||
// nothing. A hop over a synthesized channel is a call in the source
|
||||
// — `fetch('/api/users', {…})`, `emailQueue.add('welcome', {…})` —
|
||||
// and its site reads as written.
|
||||
let site = a.site;
|
||||
if (typeof a.meta.channel === 'string' && a.meta.channel !== 'server-action') {
|
||||
const written = await callAt(fold.node, at);
|
||||
site = written && written.callee ? { ...a.site, text: written.callee, args: written.args } : await withArgs(a.site, fold.node, at);
|
||||
} else if (a.linkKind === 'bridge' || a.linkKind === 'store' || a.linkKind === 'calls') site = await withArgs(a.site, fold.node, at);
|
||||
link(step, to, a.linkKind, fold.chain, [...fold.whens, when], site, a.e, a.trigger);
|
||||
if (to.root !== null && !explored.has(to.id)) {
|
||||
explored.add(to.id);
|
||||
@@ -1116,20 +1192,40 @@ function basename(p: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Function-as-value references made at a file's top level within a node's
|
||||
* lines — what `const Memoized = memo(CaptureComponent)` leaves behind: the
|
||||
* reference belongs to the file scope, the wrapper node spans the line.
|
||||
* Function-as-value references — and, for a value, calls — made at a file's
|
||||
* top level within a node's lines: what `const Memoized = memo(CaptureComponent)`
|
||||
* leaves behind (the reference belongs to the file scope, the wrapper node
|
||||
* spans the line), and what `const worker = new Worker('q', async (job) =>
|
||||
* { … })` leaves behind (the handler's calls belong to the file scope, the
|
||||
* constant spans them).
|
||||
*/
|
||||
function fileScopeFnRefsWithin(cg: CodeGraph, node: Node, memo: Map<string, Edge[]>): Edge[] {
|
||||
function fileScopeEdgesWithin(cg: CodeGraph, node: Node, memo: Map<string, Edge[]>, calls: boolean): Edge[] {
|
||||
let refs = memo.get(node.filePath);
|
||||
if (refs === undefined) {
|
||||
const file = cg.getNodesInFile(node.filePath).find((n) => n.kind === 'file');
|
||||
refs = file
|
||||
? cg.getOutgoingEdgesFrom([file.id], ['references']).filter((e) => (e.metadata as Record<string, unknown> | undefined)?.fnRef === true)
|
||||
? cg
|
||||
.getOutgoingEdgesFrom([file.id], ['references', 'calls'])
|
||||
.filter((e) => e.kind === 'calls' || (e.metadata as Record<string, unknown> | undefined)?.fnRef === true)
|
||||
: [];
|
||||
memo.set(node.filePath, refs);
|
||||
}
|
||||
return refs.filter((e) => typeof e.line === 'number' && e.line >= node.startLine && e.line <= node.endLine);
|
||||
return refs.filter((e) => (calls || e.kind === 'references') && typeof e.line === 'number' && e.line >= node.startLine && e.line <= node.endLine);
|
||||
}
|
||||
|
||||
/** The file scope's unresolved calls within a value's lines — what a wrapped handler's arrow body leaves on the file node. */
|
||||
function fileScopeRefsWithin(cg: CodeGraph, node: Node, memo: Map<string, UnresolvedReference[]>): UnresolvedReference[] {
|
||||
let refs = memo.get(node.filePath);
|
||||
if (refs === undefined) {
|
||||
const file = cg.getNodesInFile(node.filePath).find((n) => n.kind === 'file');
|
||||
try {
|
||||
refs = file ? cg.getUnresolvedReferencesFrom(file.id) : [];
|
||||
} catch {
|
||||
refs = [];
|
||||
}
|
||||
memo.set(node.filePath, refs);
|
||||
}
|
||||
return refs.filter((r) => r.line >= node.startLine && r.line <= node.endLine);
|
||||
}
|
||||
|
||||
/** `push /capture`, `renders <Button>`, `via rn-event-channel`, `calls`. */
|
||||
@@ -1143,6 +1239,7 @@ function siteText(edge: Edge, meta: Record<string, unknown>, target: Node): stri
|
||||
if (edge.kind === 'contains') return `defines ${target.name}`;
|
||||
if (edge.kind === 'instantiates') return `new ${target.name}`;
|
||||
if (meta.bridge === 'react-native') return `bridge ${typeof meta.module === 'string' ? meta.module + '.' : ''}${target.name}`;
|
||||
if (meta.channel === 'http') return `${typeof meta.method === 'string' ? meta.method : 'GET'} ${typeof meta.href === 'string' ? meta.href : target.name}`;
|
||||
if (typeof meta.synthesizedBy === 'string') return `via ${meta.synthesizedBy}`;
|
||||
return `calls ${target.name}`;
|
||||
}
|
||||
@@ -1152,8 +1249,13 @@ function hopLabel(meta: Record<string, unknown>, synthesized: boolean): string {
|
||||
const parts: string[] = [];
|
||||
if (typeof meta.synthesizedBy === 'string') parts.push(`via ${meta.synthesizedBy}`);
|
||||
else if (synthesized) parts.push('inferred');
|
||||
if (meta.channel === 'server-action') parts.push('server action');
|
||||
if (meta.channel === 'http' && typeof meta.method === 'string') parts.push(`${meta.method} ${typeof meta.href === 'string' ? meta.href : ''}`.trim());
|
||||
if (meta.tier === 'client→server') parts.push('to the server');
|
||||
else if (meta.tier === 'server→client') parts.push('from the server');
|
||||
if (meta.resolvedBy === 'receiver-type') parts.push('by the receiver’s declared type');
|
||||
if (typeof meta.event === 'string') parts.push(`event ${meta.event}`);
|
||||
if (typeof meta.event === 'string') parts.push(`${meta.channel === 'queue' ? 'job' : meta.channel === 'socket' ? 'message' : 'event'} ${meta.event}`);
|
||||
if (typeof meta.queue === 'string') parts.push(`queue ${meta.queue}`);
|
||||
if (meta.bridge === 'react-native') parts.push(`React Native bridge${typeof meta.module === 'string' ? ` · ${meta.module}` : ''}`);
|
||||
if (typeof meta.registeredAt === 'string') parts.push(`registered at ${meta.registeredAt}`);
|
||||
return parts.join(' · ');
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
* Symbol view into a parse of the repository.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import type CodeGraph from '../../index';
|
||||
import type { Language } from '../../types';
|
||||
import {
|
||||
@@ -107,8 +108,20 @@ export interface SiteReader {
|
||||
decorators(definition: { filePath: string; language: Language; startLine: number }): Promise<DefinitionDecorators | null>;
|
||||
/** The declared types of the members of the class a definition belongs to, by member name; empty when unreadable. */
|
||||
memberTypes(definition: { filePath: string; language: Language; startLine: number }): Promise<Map<string, string>>;
|
||||
/**
|
||||
* The `'use server'` / `'use client'` directive a JS-family file opens with,
|
||||
* and whether the definition itself opens with `'use server'` (a server
|
||||
* action declared inline). Nothing for other languages or unreadable files.
|
||||
*/
|
||||
directive(definition: { filePath: string; language: Language; startLine: number }): Promise<{ file: 'server' | 'client' | null; own: boolean }>;
|
||||
}
|
||||
|
||||
const JS_FAMILY: ReadonlySet<string> = new Set(['javascript', 'typescript', 'tsx', 'jsx']);
|
||||
/** A file read for its directives, at most. */
|
||||
const MAX_DIRECTIVE_FILE = 512 * 1024;
|
||||
const FILE_DIRECTIVE = /^(?:\s|\/\/[^\n]*\n|\/\*[\s\S]*?\*\/)*(['"])use (server|client)\1/;
|
||||
const OWN_DIRECTIVE = /^\s*(['"])use server\1\s*;?\s*$/m;
|
||||
|
||||
/**
|
||||
* Both readings of one call site — WHEN it runs and WITH WHAT — for the
|
||||
* endpoints that walk chains (Screens, Steps). One file resolution and one
|
||||
@@ -117,6 +130,7 @@ export interface SiteReader {
|
||||
*/
|
||||
export function createSiteReader(cg: CodeGraph, projectRoot: string, maxSites = 600): SiteReader {
|
||||
const files = new Map<string, { abs: string; language: Language } | null>();
|
||||
const texts = new Map<string, string | null>();
|
||||
let sites = 0;
|
||||
const resolve = (caller: { filePath: string; language: Language }): { abs: string; language: Language } | null => {
|
||||
const posix = caller.filePath.replace(/\\/g, '/');
|
||||
@@ -183,6 +197,31 @@ export function createSiteReader(cg: CodeGraph, projectRoot: string, maxSites =
|
||||
if (!file) return new Map();
|
||||
return memberTypesForFile(file.abs, file.language, definition.startLine);
|
||||
},
|
||||
async directive(definition) {
|
||||
// Not counted: a text read, cached per file, no tree.
|
||||
const none = { file: null, own: false } as const;
|
||||
if (!JS_FAMILY.has(definition.language)) return none;
|
||||
const file = resolve(definition);
|
||||
if (!file) return none;
|
||||
let text = texts.get(file.abs);
|
||||
if (text === undefined) {
|
||||
try {
|
||||
text = fs.statSync(file.abs).size <= MAX_DIRECTIVE_FILE ? fs.readFileSync(file.abs, 'utf8') : null;
|
||||
} catch {
|
||||
text = null;
|
||||
}
|
||||
texts.set(file.abs, text);
|
||||
}
|
||||
if (text === null) return none;
|
||||
const head = FILE_DIRECTIVE.exec(text);
|
||||
const fileDirective = head ? (head[2] as 'server' | 'client') : null;
|
||||
let own = false;
|
||||
if (definition.startLine > 0) {
|
||||
const lines = text.split('\n');
|
||||
own = OWN_DIRECTIVE.test(lines.slice(definition.startLine - 1, definition.startLine + 3).join('\n'));
|
||||
}
|
||||
return { file: fileDirective, own };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user