feat(ui): Steps for servers — route roots, server effects, request/decorator triggers, guards for Python/Java/Kotlin/C#/Go/C
- api/route-roots.ts: the symbol a route runs (references-edge handler, exported page component, or the route itself for an inline handler), shared by steps and screens; the bare Steps tab lists an API's endpoints by router file - api/effects.ts: database / response / queue / email / payments / cache / auth / process / network / storage / device / telemetry, matched on the call as written per language family, with model + read/write and the literal status on a response site - graph/branch-guards.ts: callSitesForFile (the whole member chain), memberTypesInTree, decoratorsForFile, request/decorator triggers with the middleware/guard chain; guard + argument rules for Python, Java, Kotlin, C#, Go and C - steps.ts: classify on the chain before trusting a name match, retarget this.x.y() by declared type, skip test doubles after the effect pre-check, project kind on the wire - viewer: kindWord/kindWords per project kind, endpoint chooser, response boxes labelled by status codes - python.ts: FastAPI detected from a monorepo sub-directory; is-test-file: samples/examples package paths are not tests - tests: ui-steps-api-servers, ui-effects, branch-guards-languages; spec §3.13 Servers paragraph, CHANGELOG, plan doc Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01REFyW9hmNrxhwN5wxRoAkC
This commit is contained in:
co-authored by
Claude Fable 5
parent
5e06204deb
commit
950686def4
+427
-81
@@ -42,7 +42,11 @@ import type { Edge, Language, Node, UnresolvedReference } from '../../types';
|
||||
import { badRequest, intParam, notFound } from './respond';
|
||||
import { createSiteReader } from './when';
|
||||
import type { SiteTrigger } from '../../graph/branch-guards';
|
||||
import { classifyEffect, responseStatus, type Effect } from './effects';
|
||||
import { looksLikeComponent, routeRoots } from './route-roots';
|
||||
import { splitRouteName } from './routes';
|
||||
import { HUB_THRESHOLD, UNCERTAIN_BELOW, toNodeRef, type WireNodeRef } from './wire';
|
||||
import { isTestPath } from '../../search/query-utils';
|
||||
|
||||
// =============================================================================
|
||||
// Wire shapes
|
||||
@@ -73,6 +77,8 @@ export interface WireStepSite {
|
||||
when: string;
|
||||
/** What fires THIS site, when it differs from the link's first. */
|
||||
trigger?: WireStepTrigger;
|
||||
/** For a response site: the status code it sends, when literal (`res.status(404)`, `throw new NotFoundException`). */
|
||||
status?: number;
|
||||
}
|
||||
|
||||
/** What fires a step or a link: the event it is written under, and the function that writes it there. */
|
||||
@@ -107,13 +113,31 @@ export interface WireStep {
|
||||
events?: string[];
|
||||
/** For a handler: what fires it — the first binding the walk met. */
|
||||
trigger?: WireStepTrigger;
|
||||
/** For a screen: its path and the component that renders it. */
|
||||
screen?: { path: string; component: WireNodeRef | null };
|
||||
/**
|
||||
* For a screen or an endpoint: its path and the symbol that serves it — the
|
||||
* component a screen renders, the handler an endpoint runs. `endpoint` when
|
||||
* the route leads with an HTTP verb (`POST /users`); `inline` when the
|
||||
* handler is an anonymous function at the registration site, so the route
|
||||
* itself stands in for it and `component` is null.
|
||||
*/
|
||||
screen?: { path: string; component: WireNodeRef | null; endpoint: boolean; inline: boolean };
|
||||
/**
|
||||
* For an effect: the calls one function makes into one category — `api` is
|
||||
* the first, `apis` all of them — and the function that makes them.
|
||||
* the first, `apis` all of them — and the function that makes them. A
|
||||
* database call also says the model / table it touches when the call
|
||||
* names one, and whether it reads or writes; a response box lists the
|
||||
* status codes its sites send.
|
||||
*/
|
||||
effect?: { api: string; apis: string[]; category: string; by: WireNodeRef; line: number };
|
||||
effect?: {
|
||||
api: string;
|
||||
apis: string[];
|
||||
category: string;
|
||||
by: WireNodeRef;
|
||||
line: number;
|
||||
model?: string;
|
||||
access?: 'read' | 'write';
|
||||
statuses?: number[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface WireStepLink {
|
||||
@@ -138,6 +162,12 @@ export interface WireStepsPayload {
|
||||
anchor: WireNodeRef;
|
||||
/** Other symbols that share the anchor's name, when it was given by name. */
|
||||
ambiguous: WireNodeRef[];
|
||||
/**
|
||||
* What the index is a picture of, decided from its routes: an `app` of
|
||||
* screens, an `api` of endpoints, or a `web` app with both. The viewer's
|
||||
* words (screen / endpoint, store action / data) follow it.
|
||||
*/
|
||||
project: 'app' | 'api' | 'web';
|
||||
steps: WireStep[];
|
||||
links: WireStepLink[];
|
||||
depth: number;
|
||||
@@ -174,6 +204,8 @@ const MAX_FANOUT = 80;
|
||||
const MAX_EFFECT_SCANS = 800;
|
||||
/** Call sites read for conditions and arguments per request. */
|
||||
const MAX_WHEN_SITES = 1600;
|
||||
/** Call sites read for the callee as written (effect classification) per request — lookups on trees the guards parsed anyway. */
|
||||
const MAX_CALL_SITES = 4000;
|
||||
/** Longest effect-box label before its argument list is cut. */
|
||||
const MAX_EFFECT_LABEL = 56;
|
||||
/**
|
||||
@@ -214,33 +246,50 @@ export function isStoreFile(file: string): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls that leave the index and change something outside the process. A
|
||||
* curated table, deliberately: "any call into a package" is every `Date` and
|
||||
* `Math.max`, and a box for each would bury the ones that matter. Matched on
|
||||
* the reference text as written at the call.
|
||||
* What a call is when it leaves the index, by the reference text alone — the
|
||||
* mobile app's table, kept for callers that have no language in hand. The
|
||||
* Steps walk itself classifies on the call AS WRITTEN with the language and
|
||||
* the project kind (`effects.ts`).
|
||||
*/
|
||||
export const EFFECTS: ReadonlyArray<{ category: string; test: RegExp }> = [
|
||||
{
|
||||
category: 'network',
|
||||
test: /^(?:fetch|axios|ky|got|superagent|XMLHttpRequest|WebSocket)$|^(?:axios|api|client|http|https|httpClient|apiClient|instance|request|agent|graphql|apollo|supabase)\.(?:get|post|put|patch|delete|head|request|query|mutate|rpc|invoke)$|^URLSession(?:\.|$)|^(?:Alamofire|AF)\.|\.(?:dataTask|uploadTask|downloadTask)$/,
|
||||
},
|
||||
{
|
||||
category: 'storage',
|
||||
test: /^(?:AsyncStorage|SecureStore|MMKV|localStorage|sessionStorage|indexedDB|UserDefaults|Keychain|KeychainAccess|FileSystem|RNFS|FileManager|fs|fsp)\b/,
|
||||
},
|
||||
{
|
||||
category: 'device',
|
||||
test: /^(?:Linking|Share|Clipboard|Notifications|Camera|ImagePicker|MediaLibrary|Haptics|Alert|Vibration|Location|Geolocation|Permissions|UIApplication|AVCaptureSession|AVAudioSession|CLLocationManager|UNUserNotificationCenter)\b/,
|
||||
},
|
||||
{
|
||||
category: 'telemetry',
|
||||
test: /^(?:DdRum|DdLogs|DdTrace|DdSdkReactNative|CustomerIO|Sentry|Bugsnag|analytics|Analytics|crashlytics|Crashlytics|mixpanel|Mixpanel|amplitude|Amplitude|posthog|PostHog|LDClient|ldClient)\b/,
|
||||
},
|
||||
];
|
||||
|
||||
export function effectCategory(referenceName: string): string | null {
|
||||
for (const e of EFFECTS) if (e.test.test(referenceName)) return e.category;
|
||||
return null;
|
||||
return classifyEffect({ text: referenceName, kind: 'calls' })?.category ?? null;
|
||||
}
|
||||
|
||||
/** A method of a repository / DAO / mapper, by the container's name — the ORM boundary in a project that types it. */
|
||||
const REPOSITORY_CONTAINER = /(?:Repository|Repositories|Repo|Dao|DAO|Mapper|Store|Datastore)$/;
|
||||
|
||||
/** Decorators that gate a handler: guards, interceptors, pipes, roles, auth, validation, transactions, throttles. */
|
||||
const GUARD_DECORATOR =
|
||||
/^(?:UseGuards|UseInterceptors|UsePipes|UseFilters|Roles|Auth|Public|Permissions|Throttle|SkipThrottle|Authorize|AllowAnonymous|PreAuthorize|PostAuthorize|Secured|RolesAllowed|PermitAll|DenyAll|Transactional|Validated|login_required|permission_required|user_passes_test|staff_member_required|require_http_methods|require_POST|require_GET|csrf_exempt|csrf_protect|ratelimit|throttle_classes|permission_classes|authentication_classes|cache_page|ValidateAntiForgeryToken|RequireAuthorization|RequireRole|RequireHttps|EnableCors|CrossOrigin|Cacheable|CacheEvict|CachePut|RateLimiter|CircuitBreaker|Retry|Timeout|Bulkhead|jwt_required|Security|ApiBearerAuth|ApiKeyAuth|BearerAuth|OAuth|Scopes|Roles|HasRole|HasPermission|Idempotent|Lock|Locked|Retryable|Recover)$|Guard|Interceptor|Pipe$|Filter$|Auth|Role|Permission|Throttle|Valid|Transaction|Csrf|Limit/i;
|
||||
/** Decorators that ARE the route, the DI wiring, or documentation — never a guard. */
|
||||
const NOT_A_GUARD =
|
||||
/^(?:Get|Post|Put|Patch|Delete|Head|Options|All|Controller|RestController|Resolver|Query|Mutation|Subscription|Injectable|Module|Api\w*|Http(?:Get|Post|Put|Patch|Delete|Head|Options)|Route|RequestMapping|\w+Mapping|Component|Service|Repository|Bean|Autowired|Override|Inject|Param|Body|Res|Req|Headers|Ip|HostParam|Session|UploadedFiles?|HttpCode|Header|Redirect|Render|Version|SerializeOptions|ResponseBody|ResponseStatus|Produces|Consumes|FromBody|FromRoute|FromQuery|FromForm|FromHeader|FromServices|Path|PathVariable|RequestParam|RequestBody|RequestHeader|ModelAttribute|Valid|Args|Context|Parent|Info|Field|ObjectType|InputType|ArgsType|Entity|Column|PrimaryGeneratedColumn|OneToMany|ManyToOne|Prop|Schema|Type|Expose|Exclude|Transform|IsString|IsNumber|IsOptional|Length|Min|Max|Deprecated|SuppressWarnings|FunctionalInterface|Slf4j|Data|Builder|Getter|Setter|NoArgsConstructor|AllArgsConstructor|RequiredArgsConstructor|Value|ConfigurationProperties|Configuration|EnableScheduling|SpringBootApplication|Profile|Order|Primary|Qualifier|Lazy|Scope|JsonProperty|JsonIgnore|Nullable|NonNull|NotNull|Size|Pattern|Email|Positive|router\.\w+|app\.\w+|api\.\w+|bp\.\w+|blueprint\.\w+|\w+\.(?:route|get|post|put|patch|delete))$/;
|
||||
/** Decorators that fire a function from outside a request: a job, an event, a message, a schedule. */
|
||||
const CONSUMER_DECORATOR =
|
||||
/^(?:Process|Processor|OnEvent|OnQueueEvent|OnWorkerEvent|OnGlobalQueueEvent|Cron|Interval|Timeout|MessagePattern|EventPattern|SubscribeMessage|Scheduled|Schedules?|KafkaListener|RabbitListener|RabbitSubscribe|RabbitRPC|JmsListener|SqsListener|SqsMessageHandler|EventListener|TransactionalEventListener|StreamListener|ServiceActivator|receiver|shared_task|task|periodic_task|app\.task|celery\.task|on|hears|command|event|listen|listener|Consume|Consumer|Subscribe|Subscriber|CapSubscribe|Function|FunctionName|TimerTrigger|QueueTrigger|ServiceBusTrigger|EventGridTrigger|BlobTrigger|CosmosDBTrigger|Job|job|Worker|worker|EventHandler|CommandHandler|QueryHandler|OnMessage|MessageHandler|GrpcMethod|GrpcStreamMethod|WebSocketGateway|dramatiq\.actor|actor|huey\.task|db_task|Signal|signal|hook|Hook|OnModuleInit|OnApplicationBootstrap|PostConstruct|PreDestroy|Bean|Startup|Shutdown)$/;
|
||||
|
||||
/** The name of a decorator, before its arguments. */
|
||||
function decoratorName(text: string): string {
|
||||
return text.replace(/\(.*$/s, '').trim();
|
||||
}
|
||||
|
||||
/** The first string literal in a decorator's arguments — `'email'` of `@Process('email')`. */
|
||||
function decoratorLiteral(text: string): string | null {
|
||||
const m = /\(\s*(['"`])((?:(?!\1).)*)\1/.exec(text);
|
||||
return m ? `'${m[2]}'` : null;
|
||||
}
|
||||
|
||||
function isGuardDecorator(text: string): boolean {
|
||||
const name = decoratorName(text);
|
||||
if (NOT_A_GUARD.test(name)) return false;
|
||||
return GUARD_DECORATOR.test(name);
|
||||
}
|
||||
|
||||
/** FastAPI: `dependencies=[Depends(auth), Depends(rate_limit)]` inside the route decorator. */
|
||||
function dependenciesIn(text: string): string[] {
|
||||
const m = /dependencies\s*=\s*\[([^\]]*)\]/.exec(text);
|
||||
if (!m) return [];
|
||||
return m[1]!.split(/,(?![^()]*\))/).map((x) => x.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -269,25 +318,150 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
|
||||
|
||||
const { anchor, ambiguous } = resolveAnchor(cg, query);
|
||||
|
||||
// Route → the component it renders, and the routes by id.
|
||||
// Route → where its code starts: the handler a resolver named, the page a
|
||||
// screen file exports, or the route itself standing in for an inline
|
||||
// handler (`route-roots.ts`) — and what kind of project this is, for the
|
||||
// words the viewer uses.
|
||||
const routes = cg.getNodesByKind('route');
|
||||
const renders = routes.length === 0 ? [] : cg.getOutgoingEdgesFrom(routes.map((r) => r.id), ['calls', 'instantiates']);
|
||||
const componentOf = new Map<string, Node>();
|
||||
if (renders.length > 0) {
|
||||
const components = cg.getNodesByIds(renders.map((e) => e.target));
|
||||
for (const edge of renders) {
|
||||
const c = components.get(edge.target);
|
||||
if (c && !componentOf.has(edge.source)) componentOf.set(edge.source, c);
|
||||
}
|
||||
}
|
||||
const roots = routeRoots(cg, routes);
|
||||
const project = projectKind(routes, stats.edgesByKind?.navigates ?? 0);
|
||||
|
||||
const reader = createSiteReader(cg, projectRoot, MAX_WHEN_SITES);
|
||||
const calls = createSiteReader(cg, projectRoot, MAX_CALL_SITES);
|
||||
const whenAt = (caller: Node, site: { line?: number; column?: number }) => reader.when(caller, site);
|
||||
const argsAt = (caller: Node, site: { line?: number; column?: number }) => reader.args(caller, site);
|
||||
const withArgs = async (site: WireStepSite, caller: Node, at: { line?: number; column?: number }): Promise<WireStepSite> => {
|
||||
const args = await argsAt(caller, at);
|
||||
return args === null ? site : { ...site, args };
|
||||
};
|
||||
/** The call as written at a site, and what it passes — one read for both. */
|
||||
const callAt = (caller: Node, site: { line?: number; column?: number; callee?: string }) => calls.callSite(caller, site);
|
||||
|
||||
// The declared type of a receiver: `OwnerRepository owners` in a Spring
|
||||
// controller makes `owners.save` the database; `private readonly
|
||||
// usersService: UsersService` in a Nest controller says where
|
||||
// `this.usersService.findByEmail` goes. The index keeps the first in a
|
||||
// field's signature and nothing of the second, so the class body is read
|
||||
// from the tree at request time, once per class.
|
||||
const fileTypes = new Map<string, Map<string, string>>();
|
||||
const classTypes = new Map<string, Map<string, string>>();
|
||||
const receiverTypeFor = async (caller: Node, callee: string): Promise<string | null> => {
|
||||
const first = callee.replace(/^(?:this|self)\./, '').split(/[.:(]/)[0] ?? '';
|
||||
if (!first || /^[A-Z]/.test(first)) return null;
|
||||
const declared = await memberTypesOf(caller);
|
||||
const own = declared.get(first) ?? declared.get(first.replace(/^_/, '')) ?? null;
|
||||
if (own) return own;
|
||||
let types = fileTypes.get(caller.filePath);
|
||||
if (!types) {
|
||||
types = new Map();
|
||||
for (const n of cg.getNodesInFile(caller.filePath)) {
|
||||
if ((n.kind !== 'field' && n.kind !== 'property' && n.kind !== 'variable' && n.kind !== 'parameter') || !n.signature) continue;
|
||||
const sig = n.signature.replace(/\s+/g, ' ').trim();
|
||||
// `OwnerRepository owners`, `private final OwnerRepository owners`, `owners: OwnerRepository`, `val owners: OwnerRepository`.
|
||||
const typed = new RegExp(`(?:^|\\s)([A-Z][\\w<>,?. ]*?)\\s+${n.name}\\b`).exec(sig) ?? new RegExp(`\\b${n.name}\\s*:\\s*([A-Z][\\w<>,?. ]*)`).exec(sig);
|
||||
if (typed && !types.has(n.name)) types.set(n.name, typed[1]!.trim());
|
||||
}
|
||||
fileTypes.set(caller.filePath, types);
|
||||
}
|
||||
return types.get(first) ?? null;
|
||||
};
|
||||
const memberTypesOf = async (node: Node): Promise<Map<string, string>> => {
|
||||
const key = `${node.filePath}:${node.startLine}`;
|
||||
let types = classTypes.get(key);
|
||||
if (!types) {
|
||||
types = await calls.memberTypes(node);
|
||||
classTypes.set(key, types);
|
||||
}
|
||||
return types;
|
||||
};
|
||||
|
||||
// Where a member call really goes, by the receiver's declared type: the
|
||||
// class named by the type, and its method of the call's name. Null when the
|
||||
// type names nothing in the index (an ORM's `Repository<Cat>`) — then the
|
||||
// call leaves the index, and the effect table says as what.
|
||||
const classByName = new Map<string, Node | null>();
|
||||
/** The class / interface / struct a declared type names in the index, or null for a library's. */
|
||||
const classOfType = async (type: string): Promise<Node | null> => {
|
||||
const typeName = type.replace(/<.*$/, '').replace(/^[*&]+/, '').replace(/[?!]$/, '').split(/[.:]/).pop()?.trim() ?? '';
|
||||
if (!typeName || /^(?:string|number|boolean|any|unknown|object|void|String|Integer|Long|Boolean|int|long|bool|var|dynamic|Object|List|Map|Set|Array|Promise|Optional|Task|IEnumerable|Iterable)$/.test(typeName)) return null;
|
||||
let cls = classByName.get(typeName);
|
||||
if (cls === undefined) {
|
||||
const found = cg.getNodesByName(typeName).filter((n) => n.kind === 'class' || n.kind === 'interface' || n.kind === 'struct');
|
||||
cls = found.find((n) => !isTestPath(n.filePath)) ?? found[0] ?? null;
|
||||
classByName.set(typeName, cls);
|
||||
}
|
||||
return cls;
|
||||
};
|
||||
const resolveByReceiver = async (caller: Node, callee: string): Promise<Node | null> => {
|
||||
const segments = callee.replace(/\([^()]*\)/g, '').split(/[.:]+/).filter(Boolean);
|
||||
if (segments.length < 2) return null;
|
||||
const type = await receiverTypeFor(caller, callee);
|
||||
if (!type) return null;
|
||||
const cls = await classOfType(type);
|
||||
if (!cls) return null;
|
||||
const method = segments[segments.length - 1]!;
|
||||
const members = cg.getNodesInFile(cls.filePath).filter((n) => (n.kind === 'method' || n.kind === 'function') && n.name === method && n.startLine >= cls!.startLine && n.endLine <= cls!.endLine);
|
||||
return members[0] ?? null;
|
||||
};
|
||||
|
||||
/** A method the walk cannot enter (an interface's, an ORM's) on a repository-shaped container. */
|
||||
const repositoryMethod = (target: Node): boolean => {
|
||||
if (target.kind !== 'method' && target.kind !== 'function') return false;
|
||||
if (isTestPath(target.filePath)) return false;
|
||||
const container = target.qualifiedName.replace(/[.:]+[^.:]*$/, '').split(/[.:]+/).pop() ?? '';
|
||||
if (!REPOSITORY_CONTAINER.test(container) && !/(?:^|\/)(?:repositories|repository|dao|daos|mappers)\//i.test(posix(target.filePath))) return false;
|
||||
return cg.getOutgoingEdgesFrom([target.id], WALK_KINDS).length === 0;
|
||||
};
|
||||
|
||||
/** What runs before a route's handler: the middleware arguments at the registration, or the guard decorators on it. */
|
||||
const chainFor = async (route: Node, root: Node | null): Promise<string[]> => {
|
||||
const after: string[] = [];
|
||||
if (JS_FAMILY.has(route.language)) {
|
||||
const site = await calls.callSite(route, { line: route.startLine, column: 0 });
|
||||
if (site && /\.(?:get|post|put|patch|delete|all|use|head|options|route)$/i.test(site.callee)) {
|
||||
const args = site.argList.slice(1);
|
||||
if (args.length > 0 && !/^\{/.test(args[args.length - 1]!)) args.pop();
|
||||
for (const a of args) if (a && !/^\{ ?…? ?\}$/.test(a)) after.push(a);
|
||||
}
|
||||
}
|
||||
if (root && root.id !== route.id) {
|
||||
const decs = await calls.decorators(root);
|
||||
if (decs) {
|
||||
for (const d of [...decs.class, ...decs.own]) {
|
||||
if (!JS_FAMILY.has(root.language) && !/^(?:python)$/.test(root.language)) {
|
||||
if (isGuardDecorator(d)) after.push(d);
|
||||
continue;
|
||||
}
|
||||
for (const dep of dependenciesIn(d)) after.push(dep);
|
||||
if (isGuardDecorator(d)) after.push(d);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...new Set(after)];
|
||||
};
|
||||
|
||||
/** The request a route's handler serves, as its trigger. */
|
||||
const requestTrigger = async (route: Node, root: Node | null): Promise<WireStepTrigger | null> => {
|
||||
const { method, path } = splitRouteName(route.name);
|
||||
if (method === null) return null;
|
||||
const after = await chainFor(route, root);
|
||||
return { kind: 'request', name: method, of: path, in: basename(route.filePath), ...(after.length > 0 ? { after } : {}) };
|
||||
};
|
||||
|
||||
/** A job, an event, a message or a schedule that fires a function, from its decorators. */
|
||||
const consumerTrigger = async (node: Node): Promise<WireStepTrigger | null> => {
|
||||
if (node.kind !== 'function' && node.kind !== 'method') return null;
|
||||
const decs = await calls.decorators(node);
|
||||
if (!decs) return null;
|
||||
for (const d of decs.own) {
|
||||
const name = decoratorName(d);
|
||||
const last = name.split('.').pop() ?? name;
|
||||
if (!CONSUMER_DECORATOR.test(name) && !CONSUMER_DECORATOR.test(last)) continue;
|
||||
const guards = [...decs.class, ...decs.own].filter((x) => x !== d && isGuardDecorator(x));
|
||||
return { kind: 'decorator', name, of: decoratorLiteral(d), in: basename(node.filePath), ...(guards.length > 0 ? { after: guards } : {}) };
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const steps = new Map<string, StepRecord>();
|
||||
const links = new Map<string, WireStepLink>();
|
||||
@@ -318,20 +492,36 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
|
||||
return null;
|
||||
}
|
||||
const isRoute = node.kind === 'route';
|
||||
const routeRoot = isRoute ? (roots.get(node.id) ?? null) : null;
|
||||
const record: StepRecord = {
|
||||
id: node.id,
|
||||
kind: isRoute ? 'screen' : kind,
|
||||
anchor: false,
|
||||
node: toNodeRef(node),
|
||||
label: isRoute ? node.name : node.name,
|
||||
sub: isRoute ? (componentOf.get(node.id)?.name ?? posix(node.filePath)) : posix(node.filePath),
|
||||
label: node.name,
|
||||
// A screen says its component, an endpoint its handler; a route the
|
||||
// graph bound to nothing says only where it is registered.
|
||||
sub: isRoute
|
||||
? routeRoot === null
|
||||
? basename(node.filePath)
|
||||
: routeRoot.inline
|
||||
? `inline handler · ${basename(node.filePath)}`
|
||||
: routeRoot.node.name
|
||||
: posix(node.filePath),
|
||||
depth,
|
||||
cut: null,
|
||||
...extra,
|
||||
root: isRoute ? (componentOf.get(node.id) ?? null) : node,
|
||||
root: isRoute ? (routeRoot?.node ?? null) : node,
|
||||
};
|
||||
if (kind === 'event' && extra.event) record.events = [extra.event];
|
||||
if (isRoute) record.screen = { path: node.name, component: componentOf.has(node.id) ? toNodeRef(componentOf.get(node.id)!) : null };
|
||||
if (isRoute) {
|
||||
record.screen = {
|
||||
path: node.name,
|
||||
component: routeRoot !== null && !routeRoot.inline ? toNodeRef(routeRoot.node) : null,
|
||||
endpoint: splitRouteName(node.name).method !== null,
|
||||
inline: routeRoot?.inline ?? false,
|
||||
};
|
||||
}
|
||||
steps.set(node.id, record);
|
||||
return record;
|
||||
};
|
||||
@@ -339,37 +529,102 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
|
||||
// One box per (function, category): `uploadARCapture` makes one network
|
||||
// call, three storage calls and three telemetry calls — three boxes, each
|
||||
// listing its calls, not seven.
|
||||
const effectStep = (by: Node, ref: { referenceName: string; line: number }, category: string, depth: number): StepRecord | null => {
|
||||
const effectSub = (e: NonNullable<WireStep['effect']>, by: Node): string =>
|
||||
[e.category, e.model, e.access, by.name].filter((x): x is string => !!x).join(' · ');
|
||||
const effectStep = (by: Node, ref: { referenceName: string; line: number }, effect: Effect, depth: number): StepRecord | null => {
|
||||
const category = effect.category;
|
||||
const id = `effect:${by.id}:${category}`;
|
||||
const existing = steps.get(id);
|
||||
if (existing) {
|
||||
const apis = existing.effect!.apis;
|
||||
if (!apis.includes(ref.referenceName)) {
|
||||
apis.push(ref.referenceName);
|
||||
existing.label = `${apis[0]} +${apis.length - 1}`;
|
||||
const e = existing.effect!;
|
||||
if (!e.apis.includes(ref.referenceName)) {
|
||||
e.apis.push(ref.referenceName);
|
||||
existing.label = `${e.apis[0]} +${e.apis.length - 1}`;
|
||||
}
|
||||
// Several models behind one box: list them; several accesses: say both.
|
||||
if (effect.model && e.model !== effect.model) {
|
||||
const models = new Set((e.model ?? '').split(', ').filter(Boolean));
|
||||
models.add(effect.model);
|
||||
e.model = [...models].slice(0, 3).join(', ') + (models.size > 3 ? ', …' : '');
|
||||
}
|
||||
if (effect.access && e.access && e.access !== effect.access) e.access = undefined;
|
||||
existing.sub = effectSub(e, by);
|
||||
return existing;
|
||||
}
|
||||
if (steps.size >= limit) {
|
||||
truncated.steps++;
|
||||
return null;
|
||||
}
|
||||
const e: NonNullable<WireStep['effect']> = {
|
||||
api: ref.referenceName,
|
||||
apis: [ref.referenceName],
|
||||
category,
|
||||
by: toNodeRef(by),
|
||||
line: ref.line,
|
||||
...(effect.model ? { model: effect.model } : {}),
|
||||
...(effect.access ? { access: effect.access } : {}),
|
||||
};
|
||||
const record: StepRecord = {
|
||||
id,
|
||||
kind: 'effect',
|
||||
anchor: false,
|
||||
node: null,
|
||||
label: ref.referenceName,
|
||||
sub: `${category} · ${by.name}`,
|
||||
sub: effectSub(e, by),
|
||||
depth,
|
||||
cut: null,
|
||||
effect: { api: ref.referenceName, apis: [ref.referenceName], category, by: toNodeRef(by), line: ref.line },
|
||||
effect: e,
|
||||
root: null,
|
||||
};
|
||||
steps.set(id, record);
|
||||
return record;
|
||||
};
|
||||
|
||||
/** One effect site: the call as written, what it passes, when, what fires it, and — for a response — the status. */
|
||||
const effectLink = async (
|
||||
step: StepRecord,
|
||||
fold: Fold,
|
||||
ref: { referenceName: string; referenceKind: 'calls' | 'instantiates'; line: number; column?: number },
|
||||
trigger: WireStepTrigger | null,
|
||||
fallbackArgs: string | null = null,
|
||||
requireReceiver = false
|
||||
): Promise<boolean> => {
|
||||
const at = { line: ref.line, column: ref.column };
|
||||
const site = await callAt(fold.node, { ...at, callee: ref.referenceName });
|
||||
// The site read must be THIS call: its last segment is the reference's.
|
||||
const last = (n: string) => n.replace(/\([^()]*\)/g, '').split(/[.:]/).pop() ?? n;
|
||||
const usable = !!site && site.callee !== '' && last(site.callee) === last(ref.referenceName);
|
||||
const text = usable ? site.callee : ref.referenceName;
|
||||
if (requireReceiver && !/[.:>]/.test(text)) return false;
|
||||
const args = usable ? site.args : fallbackArgs;
|
||||
// The receiver's declared type counts only when the call leaves the
|
||||
// index through it: a library's `Repository<Cat>`, or the project's own
|
||||
// `OwnerRepository` interface whose `save` comes from Spring Data — never
|
||||
// a project class that declares the method, which is a place to walk into.
|
||||
const declared = await receiverTypeFor(fold.node, text);
|
||||
const receiverType = declared && (await resolveByReceiver(fold.node, text)) === null ? declared : null;
|
||||
const effect = classifyEffect({
|
||||
text,
|
||||
kind: ref.referenceKind,
|
||||
language: fold.node.language,
|
||||
project,
|
||||
receiverType,
|
||||
args,
|
||||
});
|
||||
if (effect === null) return false;
|
||||
const target = effectStep(fold.node, { referenceName: text, line: ref.line }, effect, step.depth + 1);
|
||||
if (target === null) return true;
|
||||
const when = await whenAt(fold.node, at);
|
||||
const wireSite: WireStepSite = { file: posix(fold.node.filePath), line: ref.line, text, when: '' };
|
||||
if (args !== null) wireSite.args = args;
|
||||
if (effect.category === 'response') {
|
||||
const status = responseStatus(text, args, ref.referenceKind);
|
||||
if (status !== null) wireSite.status = status;
|
||||
}
|
||||
link(step, target, 'effect', fold.chain, [...fold.whens, when], wireSite, null, trigger ?? (await triggerAt(fold.node, at)));
|
||||
return true;
|
||||
};
|
||||
|
||||
const link = (
|
||||
from: StepRecord,
|
||||
to: StepRecord,
|
||||
@@ -395,7 +650,13 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
|
||||
if (existing) {
|
||||
if (structural(stamped) && existing.sites.some((s) => !structural(s))) return;
|
||||
if (!structural(stamped) && existing.sites.every(structural)) existing.sites.length = 0;
|
||||
if (!existing.sites.some((s) => s.file === site.file && s.line === site.line)) existing.sites.push(stamped);
|
||||
// One statement, two references (`res.status(201)` and its `.json(…)`):
|
||||
// the outer call is the site, the inner one folds into it.
|
||||
const sameLine = existing.sites.findIndex((s) => s.file === site.file && s.line === site.line);
|
||||
if (sameLine < 0) existing.sites.push(stamped);
|
||||
else if (stamped.text.startsWith(existing.sites[sameLine]!.text) && stamped.text.length > existing.sites[sameLine]!.text.length) {
|
||||
existing.sites[sameLine] = stamped;
|
||||
}
|
||||
if (!existing.trigger && trigger) existing.trigger = trigger;
|
||||
if (when !== existing.when) {
|
||||
if (!when || !existing.when) existing.when = '';
|
||||
@@ -425,9 +686,18 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
|
||||
return t ? { ...t, in: caller.name } : null;
|
||||
};
|
||||
|
||||
// The anchor: a screen keeps its kind and explores from its component.
|
||||
// The anchor: a screen keeps its kind and explores from its component; an
|
||||
// endpoint says the request that fires it and what runs before its handler;
|
||||
// a function says the job, event or schedule written on it.
|
||||
const first = stepFor(anchor, 'anchor', 0)!;
|
||||
first.anchor = true;
|
||||
if (anchor.kind === 'route') {
|
||||
const t = await requestTrigger(anchor, first.root);
|
||||
if (t) first.trigger = t;
|
||||
} else {
|
||||
const t = await consumerTrigger(anchor);
|
||||
if (t) first.trigger = t;
|
||||
}
|
||||
const queue: StepRecord[] = [first];
|
||||
/** Steps whose exploration has been queued — each is explored once, from the first row it appears on. */
|
||||
const explored = new Set<string>([first.id]);
|
||||
@@ -500,14 +770,7 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
|
||||
}
|
||||
for (const ref of [...refs].sort((a, b) => a.line - b.line || a.column - b.column)) {
|
||||
if (ref.referenceKind !== 'calls' && ref.referenceKind !== 'instantiates') continue;
|
||||
const category = effectCategory(ref.referenceName);
|
||||
if (category === null) continue;
|
||||
const target = effectStep(fold.node, ref, category, step.depth + 1);
|
||||
if (target === null) continue;
|
||||
const at = { line: ref.line, column: ref.column };
|
||||
const when = await whenAt(fold.node, at);
|
||||
const site = await withArgs({ file: posix(fold.node.filePath), line: ref.line, text: ref.referenceName, when: '' }, fold.node, at);
|
||||
link(step, target, 'effect', fold.chain, [...fold.whens, when], site, null, await triggerAt(fold.node, at));
|
||||
await effectLink(step, fold, { referenceName: ref.referenceName, referenceKind: ref.referenceKind, line: ref.line, column: ref.column }, null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -516,8 +779,14 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
|
||||
const meta = (e.metadata ?? {}) as Record<string, unknown>;
|
||||
if (e.kind === 'references') return meta.fnRef === true;
|
||||
if (e.kind === 'contains') {
|
||||
// A function's nested handlers; and, when the walk STARTS at a
|
||||
// class (a ViewSet, a class-based view bound to a route), its
|
||||
// methods — never a class met on the way, whose methods are not
|
||||
// what the caller reached.
|
||||
const t = targets.get(e.target);
|
||||
return (fold.node.kind === 'function' || fold.node.kind === 'method') && !!t && (t.kind === 'function' || t.kind === 'method');
|
||||
const fromFunction = fold.node.kind === 'function' || fold.node.kind === 'method';
|
||||
const fromRootClass = fold.node.kind === 'class' && fold.chain.length === 0 && fold.node.id === step.root?.id;
|
||||
return (fromFunction || fromRootClass) && !!t && (t.kind === 'function' || t.kind === 'method');
|
||||
}
|
||||
return true;
|
||||
});
|
||||
@@ -541,10 +810,55 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
|
||||
trigger: WireStepTrigger | null;
|
||||
}
|
||||
const arrivals: Arrival[] = [];
|
||||
const fromTest = isTestPath(fold.node.filePath);
|
||||
for (const e of edges) {
|
||||
const target = targets.get(e.target);
|
||||
if (!target || target.kind === 'file' || target.id === fold.node.id) continue;
|
||||
const found = targets.get(e.target);
|
||||
if (!found || found.kind === 'file') continue;
|
||||
const meta = (e.metadata ?? {}) as Record<string, unknown>;
|
||||
|
||||
// A member call the index kept only the last segment of (`create`
|
||||
// for `prisma.user.create`) resolves by name alone — a guess, and
|
||||
// often the wrong one. The call AS WRITTEN decides first: an effect
|
||||
// is drawn as one and the guessed edge is not walked. A call through
|
||||
// a project-made value (`client.post` on the axios instance) is the
|
||||
// same case with the constant as the target.
|
||||
let target = targets.get(e.target)!;
|
||||
let retargeted = false;
|
||||
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('.');
|
||||
// A member call whose receiver is declared as a type the index
|
||||
// holds no class for (`DataStore<UserPreferences>`) leaves the
|
||||
// index too, whatever name-matched — an `updateData` on a test
|
||||
// double, say.
|
||||
let external = false;
|
||||
if (!bare && target.kind !== 'constant' && target.kind !== 'variable') {
|
||||
const declared = await receiverTypeFor(fold.node, refName);
|
||||
external = !!declared && (await classOfType(declared)) === null;
|
||||
}
|
||||
if (bare || external || target.kind === 'constant' || target.kind === 'variable') {
|
||||
const drawn = await effectLink(step, fold, { referenceName: refName, referenceKind: 'calls', line: e.line ?? fold.node.startLine, column: e.column }, null, null, true);
|
||||
if (drawn) continue;
|
||||
// Not an effect: does the receiver's declared type say where the
|
||||
// call goes? A class in the index wins over the name-only guess.
|
||||
if (bare) {
|
||||
const written = await callAt(fold.node, { line: e.line, column: e.column, callee: refName });
|
||||
if (written && /[.:]/.test(written.callee) && (written.callee.split(/[.:]/).pop() ?? '') === refName) {
|
||||
const real = await resolveByReceiver(fold.node, written.callee);
|
||||
if (real && real.id !== target.id) {
|
||||
target = real;
|
||||
retargeted = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (target.id === fold.node.id) continue;
|
||||
// A production walk never enters a test double: an interface's
|
||||
// dispatch into `TestUserDataRepository`, or a `DataStore` name-matched
|
||||
// to the in-memory one, is the test suite's story. Judged after the
|
||||
// call as written had its chance to be an effect.
|
||||
if (!fromTest && isTestPath(target.filePath)) continue;
|
||||
const site: WireStepSite = {
|
||||
file: posix(fold.node.filePath),
|
||||
line: e.line ?? fold.node.startLine,
|
||||
@@ -607,13 +921,19 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
|
||||
if (trigger) extra.trigger = trigger;
|
||||
}
|
||||
}
|
||||
if (retargeted) meta.resolvedBy = 'receiver-type';
|
||||
arrivals.push({ e, target, meta, site, kind, linkKind, extra, trigger });
|
||||
}
|
||||
|
||||
for (const a of arrivals) {
|
||||
if (a.kind === null) continue;
|
||||
const fresh = !steps.has(a.target.id);
|
||||
const to = stepFor(a.target, a.kind, step.depth + 1, a.extra);
|
||||
if (to === null) continue;
|
||||
if (fresh && !to.trigger) {
|
||||
const t = a.target.kind === 'route' ? await requestTrigger(a.target, to.root) : await consumerTrigger(a.target);
|
||||
if (t) to.trigger = t;
|
||||
}
|
||||
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
|
||||
@@ -635,18 +955,17 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
|
||||
// `client` constant, not to anything outside the index. The call
|
||||
// text is the evidence: the call is the effect, the constant is not
|
||||
// a place to walk into.
|
||||
if (e.kind === 'calls' && (target.kind === 'constant' || target.kind === 'variable')) {
|
||||
const api = typeof meta.refName === 'string' ? meta.refName : null;
|
||||
const category = api === null ? null : effectCategory(api);
|
||||
if (api !== null && category !== null) {
|
||||
const to = effectStep(fold.node, { referenceName: api, line: e.line ?? fold.node.startLine }, category, step.depth + 1);
|
||||
if (to === null) continue;
|
||||
const at = { line: e.line, column: e.column };
|
||||
const when = await whenAt(fold.node, at);
|
||||
const site = await withArgs({ file: posix(fold.node.filePath), line: e.line ?? fold.node.startLine, text: api, when: '' }, fold.node, at);
|
||||
link(step, to, 'effect', fold.chain, [...fold.whens, when], site, null, a.trigger);
|
||||
continue;
|
||||
}
|
||||
// A thrown exception the framework answers with (`throw new
|
||||
// NotFoundException(…)` on a class the project defines) is a
|
||||
// response, not a place to walk into; a repository's method the
|
||||
// walk cannot enter (an interface's, the ORM's) is the database.
|
||||
if (e.kind === 'instantiates' && target.kind === 'class') {
|
||||
if (await effectLink(step, fold, { referenceName: target.name, referenceKind: 'instantiates', line: e.line ?? fold.node.startLine, column: e.column }, a.trigger)) continue;
|
||||
}
|
||||
if (e.kind === 'calls' && repositoryMethod(target)) {
|
||||
const container = target.qualifiedName.replace(/[.:]+[^.:]*$/, '').split(/[.:]+/).pop() ?? '';
|
||||
const api = typeof meta.refName === 'string' && meta.refName.includes('.') ? meta.refName : `${container}.${target.name}`;
|
||||
if (await effectLink(step, fold, { referenceName: api, referenceKind: 'calls', line: e.line ?? fold.node.startLine, column: e.column }, a.trigger)) continue;
|
||||
}
|
||||
|
||||
// Already a step, reached here by a plain call: a link, not a fold.
|
||||
@@ -693,8 +1012,19 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
|
||||
sitesByStep.set(l.to, list);
|
||||
}
|
||||
for (const step of steps.values()) {
|
||||
if (step.kind !== 'effect' || !step.effect || step.effect.apis.length !== 1) continue;
|
||||
if (step.kind !== 'effect' || !step.effect) continue;
|
||||
const sites = sitesByStep.get(step.id) ?? [];
|
||||
// A response box is the endpoint's contract: the status codes it can
|
||||
// send, when they are literal, are its label; the rows say when.
|
||||
if (step.effect.category === 'response') {
|
||||
const statuses = [...new Set(sites.map((s) => s.status).filter((x): x is number => typeof x === 'number'))].sort((a, b) => a - b);
|
||||
if (statuses.length > 0) {
|
||||
step.effect.statuses = statuses;
|
||||
step.label = statuses.join(' · ');
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (step.effect.apis.length !== 1) continue;
|
||||
if (sites.length !== 1 || sites[0]!.args === undefined) continue;
|
||||
const label = `${step.effect.api}(${sites[0]!.args})`;
|
||||
step.label = label.length > MAX_EFFECT_LABEL ? `${label.slice(0, MAX_EFFECT_LABEL - 2)}…)` : label;
|
||||
@@ -704,6 +1034,7 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
|
||||
return {
|
||||
anchor: toNodeRef(anchor),
|
||||
ambiguous,
|
||||
project,
|
||||
steps: ordered.map(({ root: _root, ...step }) => step),
|
||||
links: [...links.values()].sort((a, b) => a.id.localeCompare(b.id)),
|
||||
depth: depthCap,
|
||||
@@ -763,11 +1094,25 @@ function isSharedChrome(cg: CodeGraph, component: Node, memo: Map<string, number
|
||||
return renderParents(cg, component, memo) >= SHARED_CHROME_MIN;
|
||||
}
|
||||
|
||||
/** A React component, by the convention that names one: a PascalCase function in a JS-family file. */
|
||||
function looksLikeComponent(node: Node): boolean {
|
||||
if (node.kind === 'component') return true;
|
||||
if (node.kind !== 'function') return false;
|
||||
return JS_FAMILY.has(node.language) && /^[A-Z]/.test(node.name);
|
||||
/**
|
||||
* What kind of project the picture is of, by what its routes are: endpoints
|
||||
* (`POST /users`) make an API; screens with navigation between them make an
|
||||
* app; both — pages and the endpoints behind them — make a web app.
|
||||
*/
|
||||
export function projectKind(routes: readonly Node[], navigates: number): 'app' | 'api' | 'web' {
|
||||
let endpoints = 0;
|
||||
let pages = 0;
|
||||
for (const r of routes) {
|
||||
if (splitRouteName(r.name).method !== null) endpoints++;
|
||||
else if (r.name.startsWith('/')) pages++;
|
||||
}
|
||||
if (endpoints === 0) return 'app';
|
||||
return navigates > 0 || pages > 0 ? 'web' : 'api';
|
||||
}
|
||||
|
||||
function basename(p: string): string {
|
||||
const s = posix(p);
|
||||
return s.slice(s.lastIndexOf('/') + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -807,6 +1152,7 @@ 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.resolvedBy === 'receiver-type') parts.push('by the receiver’s declared type');
|
||||
if (typeof meta.event === 'string') parts.push(`event ${meta.event}`);
|
||||
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}`);
|
||||
|
||||
Reference in New Issue
Block a user