feat(expo-router): add Expo Router support for Screens and navigations and introduce Steps API
Introduce Expo Router integration with a new Screens view and API to surface screens and transitions, plus a new Steps API and UI to depict typed steps from anchors or symbols. Extend codegraph’s extraction and resolution to handle namespace objects (export default NAME, two-statement forms, and default bindings) and React hook bindings for handlers, improving accuracy of flows across JS ↔ native boundaries. Add Swift/React Native bridge receiver evidence (RCT_EXTERN_MODULE, RCT_EXTERN_METHOD) and related resolution logic, with tests covering namespace-object resolution, useCallback-driven handlers, and inline RN event listeners. Update UI to include a Steps tab and associated components (StepsView, StepNode, ScreenEdge) and wire navigation to expose steps-based exploration via /api/steps and UI routes. Documentation and changelog reflect the new Expo Router integration and steps surface capabilities.
This commit is contained in:
@@ -175,13 +175,26 @@ function cFamilySpec(extra?: { special?: string[]; addressOfOnly?: boolean }): F
|
||||
// resolve precisely. Bare identifiers stay function-kind-only (a bare id can
|
||||
// never be a method value in JS).
|
||||
const TS_JS_SPEC: FnRefSpec = {
|
||||
idTypes: new Set(['identifier']),
|
||||
// `shorthand_property_identifier`: `{ handleSubmit }` — the object a hook
|
||||
// returns its handlers in, and a namespace object's members.
|
||||
idTypes: new Set(['identifier', 'shorthand_property_identifier']),
|
||||
dispatch: new Map<string, CaptureRule>([
|
||||
['arguments', { mode: 'args' }],
|
||||
['assignment_expression', { mode: 'rhs', field: 'right' }],
|
||||
['variable_declarator', { mode: 'varinit', field: 'value' }],
|
||||
['pair', { mode: 'value', field: 'value' }],
|
||||
['array', { mode: 'list' }],
|
||||
// A JSX attribute value or child: `onPress={handleSubmit}`, `renderItem={renderRow}`,
|
||||
// `<Route component={Home}/>`. The expression's one named child is the value; a
|
||||
// spread or a call normalizes to nothing. This is THE handler-binding idiom of
|
||||
// React, and without it a tap's handler had no edge from the component that
|
||||
// renders it — the Screens and Steps views could not see what a tap does.
|
||||
['jsx_expression', { mode: 'list' }],
|
||||
// An object literal's shorthand members — `return { handleApprove,
|
||||
// handleRetake }` from a hook, `const Api = { upload, createFolder }`.
|
||||
// Every named child is offered; only a shorthand identifier normalizes
|
||||
// (a `pair` is its own container above, a spread or a method is nothing).
|
||||
['object', { mode: 'list' }],
|
||||
]),
|
||||
special: new Set(['member_expression']),
|
||||
};
|
||||
|
||||
@@ -388,6 +388,13 @@ const LITERAL_RECEIVER_TYPES = new Set([
|
||||
'dictionary', 'dict_literal', 'object', 'tuple', 'set',
|
||||
]);
|
||||
|
||||
/**
|
||||
* React hooks that bind a NAME to a handler function (`const onPress =
|
||||
* useCallback(() => {…}, [])`). The arrow inside is extracted as a function
|
||||
* node named by the declarator — see `reactHookBoundName`.
|
||||
*/
|
||||
const REACT_HANDLER_HOOKS = /^(?:React\.)?use(?:Callback|EffectEvent|Event)$/;
|
||||
|
||||
export class TreeSitterExtractor {
|
||||
private filePath: string;
|
||||
private language: Language;
|
||||
@@ -2205,6 +2212,24 @@ export class TreeSitterExtractor {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A top-level binding exported by a LATER statement rather than at its
|
||||
* declaration: `export default NAME`, `export { NAME }`, `export { NAME as
|
||||
* default }`. The declaration's own `isExported` (an `export_statement`
|
||||
* ancestor) cannot see these, so a store written as `const useStore =
|
||||
* create(…)` + `export default useStore` read as unexported and its actions
|
||||
* were never extracted. One anchored regex over the file source; JS-family
|
||||
* callers only.
|
||||
*/
|
||||
private isExportedLater(name: string): boolean {
|
||||
if (!/^[A-Za-z_$][\w$]*$/.test(name)) return false;
|
||||
const re = new RegExp(
|
||||
`^[ \\t]*export\\s+(?:default\\s+${name}\\s*;?[ \\t]*$|\\{[^}]*\\b${name}\\b[^}]*\\})`,
|
||||
'm'
|
||||
);
|
||||
return re.test(this.source);
|
||||
}
|
||||
|
||||
/** Property-key text with surrounding quotes stripped (`'foo'` → `foo`). */
|
||||
private objectKeyName(key: SyntaxNode): string {
|
||||
return getNodeText(key, this.source).replace(/^['"`]|['"`]$/g, '');
|
||||
@@ -2653,7 +2678,10 @@ export class TreeSitterExtractor {
|
||||
// never nodes — so `node`/`callers` on `fetchUser` return "not
|
||||
// found" and the agent Reads the store to reconstruct the flow.
|
||||
// Scoped to EXPORTED consts to exclude inline-object noise
|
||||
// (`ctx.set({...})`) the object-method skip deliberately avoids.
|
||||
// (`ctx.set({...})`) the object-method skip deliberately avoids —
|
||||
// where "exported" includes the two-statement form `const useStore
|
||||
// = create(…)` … `export default useStore` (see isExportedLater),
|
||||
// the shape most React Native stores are written in.
|
||||
const objectOfFns =
|
||||
valueNode && (valueNode.type === 'object' || valueNode.type === 'object_expression')
|
||||
? valueNode
|
||||
@@ -2666,7 +2694,8 @@ export class TreeSitterExtractor {
|
||||
// whose functions are body-local consts — it must fall through to a
|
||||
// normal body walk (extracting those consts), not be skipped here.
|
||||
const hasInlineFns = !!objectOfFns && this.objectHasInlineFunctions(objectOfFns);
|
||||
const extractObjectMethods = isExported && !!objectOfFns && hasInlineFns;
|
||||
const extractObjectMethods =
|
||||
(isExported || this.isExportedLater(name)) && !!objectOfFns && hasInlineFns;
|
||||
|
||||
// RTK Query: `createApi`/`injectEndpoints` define endpoints as
|
||||
// object-literal properties whose values are `build.query/mutation(...)`
|
||||
@@ -5193,6 +5222,36 @@ export class TreeSitterExtractor {
|
||||
targets.add(target);
|
||||
}
|
||||
|
||||
/**
|
||||
* The declarator name a React handler hook binds an anonymous function to —
|
||||
* `const NAME = useCallback(<node>, [...])` — or null for any other shape.
|
||||
* JS-family only; the node must be the hook call's FIRST argument, and the
|
||||
* call's value must be bound directly by a `variable_declarator`.
|
||||
*/
|
||||
private reactHookBoundName(node: SyntaxNode): string | null {
|
||||
if (
|
||||
this.language !== 'typescript' &&
|
||||
this.language !== 'javascript' &&
|
||||
this.language !== 'tsx' &&
|
||||
this.language !== 'jsx'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (node.type !== 'arrow_function' && node.type !== 'function_expression') return null;
|
||||
const args = node.parent;
|
||||
if (!args || args.type !== 'arguments') return null;
|
||||
const first = args.namedChild(0);
|
||||
if (!first || first.startIndex !== node.startIndex || first.endIndex !== node.endIndex) return null;
|
||||
const call = args.parent;
|
||||
if (!call || call.type !== 'call_expression') return null;
|
||||
const callee = getChildByField(call, 'function');
|
||||
if (!callee || !REACT_HANDLER_HOOKS.test(getNodeText(callee, this.source))) return null;
|
||||
const declarator = call.parent;
|
||||
if (!declarator || declarator.type !== 'variable_declarator') return null;
|
||||
const nameNode = getChildByField(declarator, 'name');
|
||||
return nameNode?.type === 'identifier' ? getNodeText(nameNode, this.source) : null;
|
||||
}
|
||||
|
||||
private visitFunctionBody(body: SyntaxNode, _functionId: string): void {
|
||||
if (!this.extractor) return;
|
||||
|
||||
@@ -5315,6 +5374,21 @@ export class TreeSitterExtractor {
|
||||
this.extractFunction(node);
|
||||
return;
|
||||
}
|
||||
// `const handleSubmit = useCallback(() => {…}, [deps])` — React's
|
||||
// memoised handler. The function is anonymous only syntactically: the
|
||||
// arrow is the first argument of a call whose result the declarator
|
||||
// binds, and that binding is the name every `onPress={handleSubmit}`
|
||||
// and `addListener('x', handleSubmit)` uses. Without a node of its own
|
||||
// the handler's calls attribute to the component and the JSX prop or
|
||||
// event registration has nothing to resolve to — the trigger of a flow
|
||||
// is invisible. Bounded to the hooks React documents for handlers
|
||||
// (`useCallback`, `useEffectEvent`, the experimental `useEvent`):
|
||||
// `useMemo` / `useEffect` callbacks are computations, not handlers.
|
||||
const hookBound = this.reactHookBoundName(node);
|
||||
if (hookBound) {
|
||||
this.extractFunction(node, hookBound);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Extract structural nodes found inside function bodies.
|
||||
|
||||
Reference in New Issue
Block a user