Merge pull request #937 from colbymchenry/codegraph-ai
Engine batch: dispatch-synthesizer family, React component/route coverage, installer UX + front-load hook
This commit is contained in:
@@ -36,6 +36,28 @@ import {
|
||||
// Re-export for backward compatibility
|
||||
export { generateNodeId } from './tree-sitter-helpers';
|
||||
|
||||
/**
|
||||
* RTK Query generated-hook naming convention: `use` + PascalCase endpoint (with
|
||||
* an optional `Lazy` variant prefix) + `Query`/`Mutation`. Matches the hook
|
||||
* bindings to extract from an `export const {...} = api` destructuring. Kept in
|
||||
* sync with the same convention in `callback-synthesizer.ts` (the synth side).
|
||||
*/
|
||||
const RTK_HOOK_NAME_RE = /^use[A-Z][A-Za-z0-9]*(?:Query|Mutation)$/;
|
||||
|
||||
/** React HOC callees whose result is itself a component — a PascalCase const
|
||||
* initialized with one of these is a component, not a constant (#841). */
|
||||
const REACT_COMPONENT_HOCS = new Set(['forwardRef', 'memo', 'React.forwardRef', 'React.memo']);
|
||||
|
||||
/** Vue store collections whose object-literal members are the symbols an agent
|
||||
* looks for. Extracted as function nodes so `actions`/`mutations`/`getters` are
|
||||
* findable + readable (the foundation under any later dispatch-bridge synth). */
|
||||
const VUE_STORE_COLLECTION_NAMES = new Set(['actions', 'mutations', 'getters']);
|
||||
/** Store-definition callees whose config object carries those collections. */
|
||||
const VUE_STORE_FACTORY_CALLEES = new Set(['defineStore', 'createStore']);
|
||||
/** Distinct signals that a file is a Vuex/Pinia store (≥2 ⇒ treat a bare
|
||||
* `const actions = {…}` as a store collection — see looksLikeVueStoreFile). */
|
||||
const VUE_STORE_FILE_SIGNAL = /\bdefineStore\b|\bcreateStore\b|\bVuex\b|\bmutations\b|\bactions\b|\bgetters\b|\bnamespaced\b/g;
|
||||
|
||||
/**
|
||||
* Extract the name from a node based on language
|
||||
*/
|
||||
@@ -317,6 +339,8 @@ export class TreeSitterExtractor {
|
||||
// (see flushFnRefCandidates).
|
||||
private fnRefSpec: FnRefSpec | undefined;
|
||||
private fnRefCandidates: Array<FnRefCandidate & { fromNodeId: string }> = [];
|
||||
// Memoized "is this a Vue store file" verdict (per-extractor = per-file).
|
||||
private vueStoreFile: boolean | null = null;
|
||||
|
||||
constructor(filePath: string, source: string, language?: Language) {
|
||||
this.filePath = filePath;
|
||||
@@ -1046,6 +1070,24 @@ export class TreeSitterExtractor {
|
||||
const parentId = this.nodeStack[this.nodeStack.length - 1];
|
||||
if (parentId) this.emitReExportRefs(node, parentId);
|
||||
}
|
||||
// Vuex MODULE default export — `export default { namespaced, actions: {…},
|
||||
// mutations: {…} }` (the canonical Vuex module shape). Object-literal methods
|
||||
// aren't otherwise extracted, so scan the config's actions/mutations/getters
|
||||
// collections and extract their methods as nodes. Store-file gated (the
|
||||
// ≥2-signal heuristic) so a plain default-exported object is untouched; skip
|
||||
// the subtree afterward (the collection methods are now handled).
|
||||
else if (
|
||||
nodeType === 'export_statement' &&
|
||||
(this.language === 'typescript' || this.language === 'tsx' ||
|
||||
this.language === 'javascript' || this.language === 'jsx') &&
|
||||
this.looksLikeVueStoreFile()
|
||||
) {
|
||||
const exported = getChildByField(node, 'value');
|
||||
if (exported && (exported.type === 'object' || exported.type === 'object_expression')) {
|
||||
this.extractStoreCollectionMethods(exported);
|
||||
skipChildren = true;
|
||||
}
|
||||
}
|
||||
// Check for function calls
|
||||
else if (this.extractor.callTypes.includes(nodeType)) {
|
||||
this.extractCall(node);
|
||||
@@ -1383,6 +1425,71 @@ export class TreeSitterExtractor {
|
||||
this.nodeStack.pop();
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect a React component declared via an HOC wrapper whose result is itself a
|
||||
* component: `forwardRef(...)`, `memo(...)`, `React.forwardRef/memo(...)`, and
|
||||
* styled-components / emotion `styled.tag\`…\`` / `styled(Base)\`…\``. These
|
||||
* initializers are a call / tagged-template (not a bare arrow), so the const is
|
||||
* otherwise classified `constant` — and a constant is skipped by both the
|
||||
* JSX-render edge synthesizer and component resolution, so `<Button/>` usages
|
||||
* get no edge and callers/impact silently return empty (#841).
|
||||
*
|
||||
* Returns `{ inner }` — the inline render function to extract as the component
|
||||
* body, or `null` when the wrapper has no inline function (`memo(Imported)`,
|
||||
* `styled.button\`…\``) and only a bodyless component node is minted — or
|
||||
* `undefined` when this initializer is not a recognized component wrapper.
|
||||
*/
|
||||
private reactComponentHoc(valueNode: SyntaxNode): { inner: SyntaxNode | null } | undefined {
|
||||
if (valueNode.type !== 'call_expression') return undefined;
|
||||
const callee = getChildByField(valueNode, 'function');
|
||||
if (!callee) return undefined;
|
||||
const calleeText = getNodeText(callee, this.source);
|
||||
// styled-components / emotion: `styled.button\`…\`` / `styled(Base)\`…\``.
|
||||
// tree-sitter models these tagged templates as a call_expression whose callee
|
||||
// is the `styled.x` / `styled(Base)` tag (\b avoids matching `styledFoo`).
|
||||
// No inline render fn — the argument is the CSS template.
|
||||
if (/^styled\b/.test(calleeText)) return { inner: null };
|
||||
// React HOCs: `forwardRef`/`memo`/`React.forwardRef`/`React.memo`.
|
||||
if (!REACT_COMPONENT_HOCS.has(calleeText)) return undefined;
|
||||
// The first arrow / function-expression argument is the render fn (if inline;
|
||||
// `memo(Imported)` passes a bare identifier and has none).
|
||||
const args = getChildByField(valueNode, 'arguments');
|
||||
let inner: SyntaxNode | null = null;
|
||||
if (args) {
|
||||
for (let i = 0; i < args.namedChildCount; i++) {
|
||||
const a = args.namedChild(i);
|
||||
if (a && (a.type === 'arrow_function' || a.type === 'function_expression')) {
|
||||
inner = a;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { inner };
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a `component` node for an HOC-wrapped React component declaration (see
|
||||
* reactComponentHoc). Named by the declarator (`Button`) and located at it so
|
||||
* the node range spans the body. When the wrapper has an inline render
|
||||
* function, its body is walked so the component's callees (hooks, helpers) are
|
||||
* captured under the component node — matching how a plain
|
||||
* `const Foo = () => …` arrow component already behaves.
|
||||
*/
|
||||
private extractReactComponentNode(
|
||||
name: string,
|
||||
declarator: SyntaxNode,
|
||||
innerFn: SyntaxNode | null,
|
||||
extra: { docstring?: string; signature?: string; isExported?: boolean }
|
||||
): void {
|
||||
const compNode = this.createNode('component', name, declarator, extra);
|
||||
if (!compNode || !innerFn || !this.extractor) return;
|
||||
this.nodeStack.push(compNode.id);
|
||||
const body = this.extractor.resolveBody?.(innerFn, this.extractor.bodyField)
|
||||
?? getChildByField(innerFn, this.extractor.bodyField);
|
||||
if (body) this.visitFunctionBody(body, compNode.id);
|
||||
this.nodeStack.pop();
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a class
|
||||
*/
|
||||
@@ -1945,6 +2052,285 @@ export class TreeSitterExtractor {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* RTK Query: from a `createApi({ ..., endpoints: build => ({...}) })` or a
|
||||
* `baseApi.injectEndpoints({ endpoints: build => ({...}) })` call initializer,
|
||||
* return the object literal of endpoint definitions (the object the `endpoints`
|
||||
* arrow returns). Returns null for any other call — the common case — so this
|
||||
* stays cheap and silent. Keyed on the RTK entry-point names (`createApi` /
|
||||
* `injectEndpoints`) like the framework extractors key on their library APIs.
|
||||
*/
|
||||
private findRtkEndpointsObject(callNode: SyntaxNode): SyntaxNode | null {
|
||||
const callee = getChildByField(callNode, 'function');
|
||||
if (!callee) return null;
|
||||
const calleeName =
|
||||
callee.type === 'identifier'
|
||||
? getNodeText(callee, this.source)
|
||||
: callee.type === 'member_expression'
|
||||
? getNodeText(getChildByField(callee, 'property') ?? callee, this.source)
|
||||
: '';
|
||||
if (calleeName !== 'createApi' && calleeName !== 'injectEndpoints') return null;
|
||||
const args = getChildByField(callNode, 'arguments');
|
||||
if (!args) return null;
|
||||
for (let i = 0; i < args.namedChildCount; i++) {
|
||||
const arg = args.namedChild(i);
|
||||
if (arg?.type !== 'object' && arg?.type !== 'object_expression') continue;
|
||||
for (let j = 0; j < arg.namedChildCount; j++) {
|
||||
const member = arg.namedChild(j);
|
||||
// Two equally-common spellings: `endpoints: build => ({...})` (pair with an
|
||||
// arrow value) and `endpoints(build) { return {...} }` (method shorthand).
|
||||
if (member?.type === 'pair') {
|
||||
const key = getChildByField(member, 'key');
|
||||
if (!key || getNodeText(key, this.source) !== 'endpoints') continue;
|
||||
const value = getChildByField(member, 'value');
|
||||
if (value && (value.type === 'arrow_function' || value.type === 'function_expression')) {
|
||||
return this.functionReturnedObject(value);
|
||||
}
|
||||
} else if (member?.type === 'method_definition') {
|
||||
const key = getChildByField(member, 'name');
|
||||
if (!key || getNodeText(key, this.source) !== 'endpoints') continue;
|
||||
return this.functionReturnedObject(member);
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract each RTK Query endpoint (`getX: build.query({...})` / `build.mutation`)
|
||||
* as a function node named by the endpoint key, spanning its primary handler
|
||||
* (the `queryFn`/`query` arrow) so the fetch logic's calls attribute to the
|
||||
* endpoint. Without this an endpoint exists only as an object-literal property —
|
||||
* never a node — so the generated `useXQuery` hook can't be bridged to it.
|
||||
*/
|
||||
private extractRtkEndpoints(obj: SyntaxNode): void {
|
||||
for (let i = 0; i < obj.namedChildCount; i++) {
|
||||
const member = obj.namedChild(i);
|
||||
if (member?.type !== 'pair') continue;
|
||||
const key = getChildByField(member, 'key');
|
||||
const value = getChildByField(member, 'value');
|
||||
if (!key || value?.type !== 'call_expression') continue;
|
||||
// The value must be a builder dispatch `<builder>.query|mutation(...)`.
|
||||
const callee = getChildByField(value, 'function');
|
||||
if (callee?.type !== 'member_expression') continue;
|
||||
const method = getNodeText(getChildByField(callee, 'property') ?? callee, this.source);
|
||||
if (method !== 'query' && method !== 'mutation' && method !== 'infiniteQuery') continue;
|
||||
const handler = this.rtkEndpointHandler(value);
|
||||
if (handler) {
|
||||
this.extractFunction(handler, this.objectKeyName(key));
|
||||
} else {
|
||||
// Factory / config-only handler (`queryFn: makeQueryFn(url)`): no function
|
||||
// literal to name. Mint a bare endpoint node spanning the builder call so
|
||||
// the generated hook still bridges to it, and walk the call so its handler
|
||||
// factory (and any inline transform) is captured as an outgoing edge.
|
||||
const epNode = this.createNode('function', this.objectKeyName(key), value, {
|
||||
signature: getNodeText(value, this.source).slice(0, 80),
|
||||
});
|
||||
if (epNode) {
|
||||
this.nodeStack.push(epNode.id);
|
||||
this.visitFunctionBody(value, epNode.id);
|
||||
this.nodeStack.pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The primary handler arrow of a `build.query({ queryFn|query: (…) => … })`
|
||||
* endpoint — prefers `queryFn`, then `query`, else the first function-valued
|
||||
* property. Returns null when the endpoint is config-only (no handler arrow).
|
||||
*/
|
||||
private rtkEndpointHandler(callNode: SyntaxNode): SyntaxNode | null {
|
||||
const args = getChildByField(callNode, 'arguments');
|
||||
if (!args) return null;
|
||||
for (let i = 0; i < args.namedChildCount; i++) {
|
||||
const arg = args.namedChild(i);
|
||||
if (arg?.type !== 'object' && arg?.type !== 'object_expression') continue;
|
||||
let queryFn: SyntaxNode | null = null;
|
||||
let query: SyntaxNode | null = null;
|
||||
let firstFn: SyntaxNode | null = null;
|
||||
for (let j = 0; j < arg.namedChildCount; j++) {
|
||||
const member = arg.namedChild(j);
|
||||
// The handler may be `queryFn: () => …` / `query: () => …` (pair) or the
|
||||
// method-shorthand `query(arg) { … }` / `queryFn(arg) { … }`.
|
||||
let fn: SyntaxNode | null = null;
|
||||
let kn = '';
|
||||
if (member?.type === 'pair') {
|
||||
const v = getChildByField(member, 'value');
|
||||
if (v?.type === 'arrow_function' || v?.type === 'function_expression') {
|
||||
fn = v;
|
||||
const k = getChildByField(member, 'key');
|
||||
kn = k ? getNodeText(k, this.source) : '';
|
||||
}
|
||||
} else if (member?.type === 'method_definition') {
|
||||
fn = member;
|
||||
const k = getChildByField(member, 'name');
|
||||
kn = k ? getNodeText(k, this.source) : '';
|
||||
}
|
||||
if (!fn) continue;
|
||||
if (kn === 'queryFn') queryFn = fn;
|
||||
else if (kn === 'query') query = fn;
|
||||
if (!firstFn) firstFn = fn;
|
||||
}
|
||||
if (queryFn) return queryFn;
|
||||
if (query) return query;
|
||||
if (firstFn) return firstFn;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* RTK Query generated-hook bindings. `export const { useGetXQuery,
|
||||
* useUpdateYMutation } = someApi` destructures the hooks RTK generates per
|
||||
* endpoint off a createApi result. They are real exported symbols that
|
||||
* components import, but destructured bindings aren't otherwise extracted —
|
||||
* mint a function node per binding matching the RTK hook convention so the hook
|
||||
* resolves and the synthesizer can bridge it to its endpoint. Gated tight by the
|
||||
* caller (object-pattern off a bare identifier) + the name convention here, so
|
||||
* ordinary destructures stay unextracted.
|
||||
*/
|
||||
private extractRtkHookBindings(pattern: SyntaxNode, isExported: boolean): void {
|
||||
for (let i = 0; i < pattern.namedChildCount; i++) {
|
||||
const binding = pattern.namedChild(i);
|
||||
if (binding?.type !== 'shorthand_property_identifier_pattern') continue;
|
||||
const name = getNodeText(binding, this.source);
|
||||
if (!RTK_HOOK_NAME_RE.test(name)) continue;
|
||||
this.createNode('function', name, binding, {
|
||||
isExported,
|
||||
signature: '= RTK Query generated hook',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Cheap per-file heuristic: the file carries ≥2 distinct Vue-store signals
|
||||
* (defineStore/createStore/Vuex, or the actions/mutations/getters/namespaced
|
||||
* vocabulary). Gates the non-exported `const actions = {…}` Vuex-module form so
|
||||
* a stray `const actions` in unrelated code is never mistaken for a store. */
|
||||
private looksLikeVueStoreFile(): boolean {
|
||||
if (this.vueStoreFile !== null) return this.vueStoreFile;
|
||||
const seen = new Set<string>();
|
||||
VUE_STORE_FILE_SIGNAL.lastIndex = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = VUE_STORE_FILE_SIGNAL.exec(this.source))) {
|
||||
seen.add(m[0]);
|
||||
if (seen.size >= 2) break;
|
||||
}
|
||||
this.vueStoreFile = seen.size >= 2;
|
||||
return this.vueStoreFile;
|
||||
}
|
||||
|
||||
/** True if an object literal has ≥1 inline function member (`key: () => …` /
|
||||
* `method(){}`) — distinguishes an inline action map (zustand/SvelteKit form
|
||||
* actions) from a Pinia SETUP store's all-shorthand `return { foo, bar }`
|
||||
* (whose functions are body-local consts, walked normally instead). */
|
||||
private objectHasInlineFunctions(obj: SyntaxNode): boolean {
|
||||
for (let i = 0; i < obj.namedChildCount; i++) {
|
||||
const member = obj.namedChild(i);
|
||||
if (member?.type === 'method_definition') return true;
|
||||
if (member?.type === 'pair') {
|
||||
const v = getChildByField(member, 'value');
|
||||
if (v?.type === 'arrow_function' || v?.type === 'function_expression') return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Vue store action/mutation/getter collections defined INLINE in a store call:
|
||||
* `defineStore({ actions: {…}, getters: {…} })` (Pinia options form),
|
||||
* `defineStore('id', { actions: {…} })`, `createStore({ mutations: {…} })`,
|
||||
* `new Vuex.Store({ actions: {…} })`. Returns the object literals under those
|
||||
* keys so their methods become nodes. Gated on the store-factory callee. */
|
||||
private findVueStoreCollectionObjects(callNode: SyntaxNode): SyntaxNode[] {
|
||||
const callee = getChildByField(callNode, 'function') ?? getChildByField(callNode, 'constructor');
|
||||
if (!callee) return [];
|
||||
const calleeName =
|
||||
callee.type === 'identifier'
|
||||
? getNodeText(callee, this.source)
|
||||
: callee.type === 'member_expression'
|
||||
? getNodeText(getChildByField(callee, 'property') ?? callee, this.source)
|
||||
: '';
|
||||
if (!VUE_STORE_FACTORY_CALLEES.has(calleeName) && calleeName !== 'Store') return [];
|
||||
const args = getChildByField(callNode, 'arguments');
|
||||
if (!args) return [];
|
||||
const objects: SyntaxNode[] = [];
|
||||
for (let i = 0; i < args.namedChildCount; i++) {
|
||||
const arg = args.namedChild(i);
|
||||
if (arg?.type !== 'object' && arg?.type !== 'object_expression') continue;
|
||||
for (let j = 0; j < arg.namedChildCount; j++) {
|
||||
const member = arg.namedChild(j);
|
||||
if (member?.type !== 'pair') continue;
|
||||
const key = getChildByField(member, 'key');
|
||||
if (!key || !VUE_STORE_COLLECTION_NAMES.has(getNodeText(key, this.source))) continue;
|
||||
const value = getChildByField(member, 'value');
|
||||
if (value && (value.type === 'object' || value.type === 'object_expression')) {
|
||||
objects.push(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return objects;
|
||||
}
|
||||
|
||||
/** Extract the methods of a store-config object's `actions`/`mutations`/`getters`
|
||||
* properties. Used for the canonical Vuex MODULE shape `export default {
|
||||
* namespaced, actions: {…}, mutations: {…} }` — object-literal methods aren't
|
||||
* otherwise extracted, so the actions/mutations would never be nodes. */
|
||||
private extractStoreCollectionMethods(configObj: SyntaxNode): void {
|
||||
for (let j = 0; j < configObj.namedChildCount; j++) {
|
||||
const member = configObj.namedChild(j);
|
||||
if (member?.type !== 'pair') continue;
|
||||
const key = getChildByField(member, 'key');
|
||||
if (!key || !VUE_STORE_COLLECTION_NAMES.has(getNodeText(key, this.source))) continue;
|
||||
const value = getChildByField(member, 'value');
|
||||
if (value && (value.type === 'object' || value.type === 'object_expression')) {
|
||||
this.extractObjectLiteralFunctions(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The SETUP function of a Pinia setup store (`defineStore('id', () => {…})`)
|
||||
* — an arrow/function arg with a block body. Returns null for the options form
|
||||
* (`defineStore({…})`) and for any non-defineStore call. The setup body's local
|
||||
* function consts are the store's actions; the generic body walk doesn't reach
|
||||
* them (nested functions are separate scopes), so they're extracted explicitly. */
|
||||
private findPiniaSetupFn(callNode: SyntaxNode): SyntaxNode | null {
|
||||
const callee = getChildByField(callNode, 'function');
|
||||
if (!callee || callee.type !== 'identifier' || getNodeText(callee, this.source) !== 'defineStore') return null;
|
||||
const args = getChildByField(callNode, 'arguments');
|
||||
if (!args) return null;
|
||||
for (let i = 0; i < args.namedChildCount; i++) {
|
||||
const arg = args.namedChild(i);
|
||||
if (arg?.type !== 'arrow_function' && arg?.type !== 'function_expression') continue;
|
||||
const body = getChildByField(arg, 'body');
|
||||
if (body?.type === 'statement_block') return arg; // block body ⇒ setup form
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Extract a Pinia setup store's actions: the body-local `const foo = () => …`
|
||||
* / `function foo(){}` declarations, named by the binding. (State refs and other
|
||||
* consts are left to the normal value-extraction; only the functions matter as
|
||||
* the store's callable surface.) */
|
||||
private extractPiniaSetupBody(setupFn: SyntaxNode): void {
|
||||
const body = getChildByField(setupFn, 'body');
|
||||
if (!body || body.type !== 'statement_block') return;
|
||||
for (let i = 0; i < body.namedChildCount; i++) {
|
||||
const stmt = body.namedChild(i);
|
||||
if (!stmt) continue;
|
||||
if (stmt.type === 'function_declaration') {
|
||||
this.extractFunction(stmt);
|
||||
} else if (this.extractor!.variableTypes.includes(stmt.type)) {
|
||||
for (let j = 0; j < stmt.namedChildCount; j++) {
|
||||
const decl = stmt.namedChild(j);
|
||||
if (decl?.type !== 'variable_declarator') continue;
|
||||
const v = getChildByField(decl, 'value');
|
||||
if (v?.type === 'arrow_function' || v?.type === 'function_expression') {
|
||||
this.extractFunction(v); // name resolved from the parent declarator
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a variable declaration (const, let, var, etc.)
|
||||
*
|
||||
@@ -1977,8 +2363,15 @@ export class TreeSitterExtractor {
|
||||
|
||||
if (nameNode) {
|
||||
// Skip destructured patterns (e.g., `let { x, y } = $props()` in Svelte)
|
||||
// These produce ugly multi-line names like "{ class: className }"
|
||||
// These produce ugly multi-line names like "{ class: className }".
|
||||
// EXCEPT `export const { useGetXQuery } = someApi` — the RTK Query
|
||||
// generated hooks: real exported symbols destructured off a createApi
|
||||
// result. Mint a node per binding matching the hook convention (gated
|
||||
// on a bare-identifier RHS so ordinary destructures stay skipped).
|
||||
if (nameNode.type === 'object_pattern' || nameNode.type === 'array_pattern') {
|
||||
if (nameNode.type === 'object_pattern' && valueNode?.type === 'identifier') {
|
||||
this.extractRtkHookBindings(nameNode, isExported);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const name = getNodeText(nameNode, this.source);
|
||||
@@ -1992,6 +2385,26 @@ export class TreeSitterExtractor {
|
||||
const initValue = valueNode ? getNodeText(valueNode, this.source).slice(0, 100) : undefined;
|
||||
const initSignature = initValue ? `= ${initValue}${initValue.length >= 100 ? '...' : ''}` : undefined;
|
||||
|
||||
// React HOC-wrapped components (`forwardRef`/`memo`/`styled`) — see
|
||||
// reactComponentHoc. The initializer is a call / tagged-template (not
|
||||
// a bare arrow), so without this the const is a plain `constant`,
|
||||
// which the JSX-render synthesizer and component resolution both skip
|
||||
// → `<Button/>` usages get no edge and callers/impact return empty
|
||||
// (the whole shadcn/ui design-system pattern, #841). PascalCase-gated
|
||||
// to the component naming convention so a memoization util
|
||||
// (`const cache = memo(fn)`) stays a constant.
|
||||
if (valueNode && /^[A-Z]/.test(name)) {
|
||||
const hoc = this.reactComponentHoc(valueNode);
|
||||
if (hoc) {
|
||||
this.extractReactComponentNode(name, child, hoc.inner, {
|
||||
docstring,
|
||||
signature: initSignature,
|
||||
isExported,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const varNode = this.createNode(kind, name, child, {
|
||||
docstring,
|
||||
signature: initSignature,
|
||||
@@ -2025,23 +2438,72 @@ export class TreeSitterExtractor {
|
||||
: valueNode?.type === 'call_expression'
|
||||
? this.findInitializerReturnedObject(valueNode)
|
||||
: null;
|
||||
const extractObjectMethods = isExported && !!objectOfFns;
|
||||
// Only treat as an inline object-of-functions when the object actually
|
||||
// HAS inline functions. A Pinia SETUP store `defineStore('id', () => {
|
||||
// const foo = …; return { foo } })` returns an ALL-SHORTHAND object
|
||||
// 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;
|
||||
|
||||
// RTK Query: `createApi`/`injectEndpoints` define endpoints as
|
||||
// object-literal properties whose values are `build.query/mutation(...)`
|
||||
// calls — nested under an `endpoints` arrow, so neither the
|
||||
// object-of-functions path above nor the normal walk extracts them.
|
||||
// Extract each endpoint as a function node (named by its key), and skip
|
||||
// walking the createApi call body (its handler arrows are extracted
|
||||
// individually below, exactly like the store-factory case).
|
||||
const rtkEndpoints =
|
||||
valueNode?.type === 'call_expression' ? this.findRtkEndpointsObject(valueNode) : null;
|
||||
|
||||
// Pinia SETUP store: `defineStore('id', () => { const foo = …; return {…} })`.
|
||||
// Its actions are body-local consts the generic walk can't reach.
|
||||
const piniaSetup =
|
||||
valueNode?.type === 'call_expression' ? this.findPiniaSetupFn(valueNode) : null;
|
||||
|
||||
// Vue store collections — make `actions`/`mutations`/`getters` findable
|
||||
// function nodes (the foundation under any later dispatch-bridge synth).
|
||||
// Two positions: INLINE in a store call (`defineStore({ actions: {…} })`
|
||||
// / `createStore` / `new Vuex.Store`), and the non-exported Vuex-MODULE
|
||||
// form (`const actions = {…}` at a store file's top level, wired via a
|
||||
// `export default { actions }`). The Pinia SETUP form is handled by the
|
||||
// body walk above (its actions are local consts).
|
||||
const storeCollections: SyntaxNode[] = [];
|
||||
if (valueNode?.type === 'call_expression' || valueNode?.type === 'new_expression') {
|
||||
storeCollections.push(...this.findVueStoreCollectionObjects(valueNode));
|
||||
}
|
||||
if (objectOfFns && !extractObjectMethods &&
|
||||
VUE_STORE_COLLECTION_NAMES.has(name) && this.looksLikeVueStoreFile()) {
|
||||
storeCollections.push(objectOfFns);
|
||||
}
|
||||
|
||||
// Visit the initializer body for calls — EXCEPT object literals (their
|
||||
// function-valued properties are extracted below) and the store-factory
|
||||
// call whose returned object we extract method-by-method below (walking
|
||||
// the whole call would re-visit those method arrows and mis-attribute
|
||||
// their inner calls to the file/module scope).
|
||||
// / createApi / store-collection call whose nested objects we extract
|
||||
// method-by-method below (walking the whole call would re-visit those
|
||||
// method arrows and mis-attribute their inner calls to the file scope).
|
||||
if (valueNode &&
|
||||
valueNode.type !== 'object' &&
|
||||
valueNode.type !== 'object_expression' &&
|
||||
!(extractObjectMethods && valueNode.type === 'call_expression')) {
|
||||
!(extractObjectMethods && valueNode.type === 'call_expression') &&
|
||||
!rtkEndpoints &&
|
||||
!piniaSetup &&
|
||||
storeCollections.length === 0) {
|
||||
this.visitFunctionBody(valueNode, '');
|
||||
}
|
||||
|
||||
if (extractObjectMethods && objectOfFns) {
|
||||
this.extractObjectLiteralFunctions(objectOfFns);
|
||||
}
|
||||
if (rtkEndpoints) {
|
||||
this.extractRtkEndpoints(rtkEndpoints);
|
||||
}
|
||||
if (piniaSetup) {
|
||||
this.extractPiniaSetupBody(piniaSetup);
|
||||
}
|
||||
for (const coll of storeCollections) {
|
||||
this.extractObjectLiteralFunctions(coll);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user