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:
co-authored by
Claude Fable 5
parent
950686def4
commit
b1f40c57dd
@@ -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