From 01d0bcb0d1776f3feabfdd2ee415ae94172d91d2 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Thu, 29 May 2025 21:55:34 -0700 Subject: [PATCH 1/2] props --- .../parse/__tests__/ContractAnalyzer.test.ts | 146 +++++++++++++++++- packages/parse/src/ContractAnalyzer.ts | 81 ++++++++-- 2 files changed, 206 insertions(+), 21 deletions(-) diff --git a/packages/parse/__tests__/ContractAnalyzer.test.ts b/packages/parse/__tests__/ContractAnalyzer.test.ts index 6e05e92..4876884 100644 --- a/packages/parse/__tests__/ContractAnalyzer.test.ts +++ b/packages/parse/__tests__/ContractAnalyzer.test.ts @@ -24,7 +24,10 @@ describe('ContractAnalyzer', () => { `; const result = analyzer.analyzeFromCode(code); - expect(result.queries).toEqual(['getState', 'getValue']); + expect(result.queries).toEqual([ + { name: 'getState', params: [], returnType: 'void' }, + { name: 'getValue', params: [], returnType: 'void' }, + ]); expect(result.mutations).toEqual([]); }); @@ -45,7 +48,10 @@ describe('ContractAnalyzer', () => { const result = analyzer.analyzeFromCode(code); expect(result.queries).toEqual([]); - expect(result.mutations).toEqual(['setState', 'updateValue']); + expect(result.mutations).toEqual([ + { name: 'setState', params: [{ name: 'newState', type: 'any' }], returnType: 'void' }, + { name: 'updateValue', params: [{ name: 'value', type: 'any' }], returnType: 'void' }, + ]); }); it('should identify both query and mutation methods', () => { @@ -69,8 +75,13 @@ describe('ContractAnalyzer', () => { `; const result = analyzer.analyzeFromCode(code); - expect(result.queries).toEqual(['getState']); - expect(result.mutations).toEqual(['setState', 'updateAndGet']); + expect(result.queries).toEqual([ + { name: 'getState', params: [], returnType: 'void' }, + ]); + expect(result.mutations).toEqual([ + { name: 'setState', params: [{ name: 'newState', type: 'any' }], returnType: 'void' }, + { name: 'updateAndGet', params: [], returnType: 'void' }, + ]); }); it('should ignore static methods and constructors', () => { @@ -93,7 +104,9 @@ describe('ContractAnalyzer', () => { `; const result = analyzer.analyzeFromCode(code); - expect(result.queries).toEqual(['getState']); + expect(result.queries).toEqual([ + { name: 'getState', params: [], returnType: 'void' }, + ]); expect(result.mutations).toEqual([]); }); @@ -120,8 +133,13 @@ describe('ContractAnalyzer', () => { `; const result = analyzer.analyzeFromCode(code); - expect(result.queries).toEqual(['getState']); - expect(result.mutations).toEqual(['initialize', 'exp']); + expect(result.queries).toEqual([ + { name: 'getState', params: [], returnType: 'void' }, + ]); + expect(result.mutations).toEqual([ + { name: 'initialize', params: [], returnType: 'void' }, + { name: 'exp', params: [], returnType: 'void' }, + ]); }); it('should throw error when no default export is found', () => { @@ -147,4 +165,118 @@ describe('ContractAnalyzer', () => { expect(() => analyzer.analyzeFromCode(code)).toThrow('No default exported class found in the code'); }); + + it('should default parameter type to any when no type annotation', () => { + const code = ` + export default class Contract { + state: any; + + foo(param) { + return this.state; + } + } + `; + + const result = analyzer.analyzeFromCode(code); + expect(result.queries).toEqual([ + { name: 'foo', params: [{ name: 'param', type: 'any' }], returnType: 'void' }, + ]); + expect(result.mutations).toEqual([]); + }); + + it('should handle nested object types inline', () => { + const code = ` + export default class Contract { + state: any; + + handle(data: { nested: { x: string } }) { + return data.nested.x; + } + } + `; + + const result = analyzer.analyzeFromCode(code); + expect(result.queries).toEqual([ + { name: 'handle', params: [{ name: 'data', type: '{ nested: { x: string } }' }], returnType: 'void' }, + ]); + expect(result.mutations).toEqual([]); + }); + + it('should handle parameter type defined by interface with nested props', () => { + const code = ` + interface Data { + nested: { + x: string; + }; + } + + export default class Contract { + state: any; + + process(data: Data) { + return data.nested.x; + } + } + `; + + const result = analyzer.analyzeFromCode(code); + expect(result.queries).toEqual([ + { name: 'process', params: [{ name: 'data', type: 'Data' }], returnType: 'void' }, + ]); + expect(result.mutations).toEqual([]); + }); + + it('should handle async methods returning Promises', () => { + const code = ` + export default class Contract { + state: any; + + async fetchValue(id: number): Promise { + return this.state.values[id]; + } + } + `; + + const result = analyzer.analyzeFromCode(code); + expect(result.queries).toEqual([ + { name: 'fetchValue', params: [{ name: 'id', type: 'number' }], returnType: 'Promise' }, + ]); + expect(result.mutations).toEqual([]); + }); + + it('should handle inline nested object in Promise return types', () => { + const code = ` + export default class Contract { + state: any; + + async loadData(): Promise<{ user: { name: string; age: number } }> { + return { user: this.state.user }; + } + } + `; + + const result = analyzer.analyzeFromCode(code); + expect(result.queries).toEqual([ + { name: 'loadData', params: [], returnType: 'Promise<{ user: { name: string age: number } }>' }, + ]); + expect(result.mutations).toEqual([]); + }); + + it('should handle generic methods', () => { + const code = ` + export default class Contract { + state: any; + + mapItems(items: T[]): T[] { + return items.map(i => i); + } + } + `; + + const result = analyzer.analyzeFromCode(code); + expect(result.queries).toEqual([ + { name: 'mapItems', params: [{ name: 'items', type: 'T[]' }], returnType: 'T[]' }, + ]); + expect(result.mutations).toEqual([]); + }); }); \ No newline at end of file diff --git a/packages/parse/src/ContractAnalyzer.ts b/packages/parse/src/ContractAnalyzer.ts index ccbfdcc..d7919a9 100644 --- a/packages/parse/src/ContractAnalyzer.ts +++ b/packages/parse/src/ContractAnalyzer.ts @@ -2,10 +2,17 @@ import * as parser from '@babel/parser'; import traverse, { NodePath } from '@babel/traverse'; import * as t from '@babel/types'; +import generate from '@babel/generator'; export interface AnalysisResult { - queries: string[]; - mutations: string[]; + queries: MethodInfo[]; + mutations: MethodInfo[]; +} + +export interface MethodInfo { + name: string; + params: { name: string; type: string }[]; + returnType: string; } export class ContractAnalyzer { @@ -41,8 +48,8 @@ export class ContractAnalyzer { throw new Error('No code has been parsed. Call analyzeFromCode first.'); } - const queries: string[] = []; - const mutations: string[] = []; + const queries: MethodInfo[] = []; + const mutations: MethodInfo[] = []; let foundDefaultExport = false; const self = this; @@ -97,8 +104,8 @@ export class ContractAnalyzer { private analyzeClassMethods( parentPath: NodePath, classNode: t.ClassDeclaration | t.ClassExpression, - queries: string[], - mutations: string[] + queries: MethodInfo[], + mutations: MethodInfo[] ): void { parentPath.traverse({ ClassMethod(methodPath: NodePath) { @@ -107,13 +114,50 @@ export class ContractAnalyzer { return; } - const methodName = methodPath.node.key.type === 'Identifier' ? methodPath.node.key.name : ''; + const methodName = methodPath.node.key.type === 'Identifier' + ? methodPath.node.key.name + : ''; if (!methodName) return; + // Collect parameters + const params = methodPath.node.params.map(param => { + if (t.isIdentifier(param)) { + const name = param.name; + let type = 'any'; + if (param.typeAnnotation) { + // generate and compact the type annotation + const rawType = generate(param.typeAnnotation.typeAnnotation).code; + type = rawType.replace(/\n/g, ' ') + .replace(/;/g, '') + .replace(/\s+/g, ' ') + .trim(); + } + return { name, type }; + } else { + const code = generate(param).code; + return { name: code, type: 'unknown' }; + } + }); + + // Collect return type + let returnType = 'void'; + if ( + methodPath.node.returnType && + methodPath.node.returnType.typeAnnotation + ) { + // generate and compact the return type annotation + const rawRet = generate(methodPath.node.returnType.typeAnnotation).code; + returnType = rawRet.replace(/\n/g, ' ') + .replace(/;/g, '') + .replace(/\s+/g, ' ') + .trim(); + } + let readsState = false; let writesState = false; + let hasReturn = false; - // Check for state access + // Check for state access and return statements methodPath.traverse({ MemberExpression(memberPath: NodePath) { const object = memberPath.node.object; @@ -129,7 +173,7 @@ export class ContractAnalyzer { const parent = memberPath.parentPath; if ( t.isAssignmentExpression(parent.node) || - (t.isMemberExpression(parent.node) && + (t.isMemberExpression(parent.node) && t.isAssignmentExpression(parent.parentPath.node)) ) { writesState = true; @@ -138,14 +182,23 @@ export class ContractAnalyzer { } } }, + ReturnStatement(returnPath: NodePath) { + if (returnPath.node.argument) { + hasReturn = true; + } + }, }); - // If a method writes to state, it's a mutation - // If it only reads from state, it's a query + const methodInfo: MethodInfo = { + name: methodName, + params, + returnType, + }; + // If a method writes to state, it's a mutation; else if it reads state or returns something, it's a query if (writesState) { - mutations.push(methodName); - } else if (readsState) { - queries.push(methodName); + mutations.push(methodInfo); + } else if (readsState || hasReturn) { + queries.push(methodInfo); } }, }); From 4e027d013a76751878dd68cc3b992cf3ed682549 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Thu, 29 May 2025 23:33:57 -0700 Subject: [PATCH 2/2] props --- .../parse/__tests__/ContractAnalyzer.test.ts | 320 +++++++++++++++ packages/parse/src/ContractAnalyzer.ts | 364 ++++++++++++++++++ 2 files changed, 684 insertions(+) diff --git a/packages/parse/__tests__/ContractAnalyzer.test.ts b/packages/parse/__tests__/ContractAnalyzer.test.ts index 4876884..e1c05d6 100644 --- a/packages/parse/__tests__/ContractAnalyzer.test.ts +++ b/packages/parse/__tests__/ContractAnalyzer.test.ts @@ -279,4 +279,324 @@ describe('ContractAnalyzer', () => { ]); expect(result.mutations).toEqual([]); }); + + describe('analyzeWithSchema', () => { + it('should handle array types', () => { + const code = ` + export default class Contract { + foo(items: string[]): string[] { + return items; + } + } + `; + + const result = analyzer.analyzeWithSchema(code); + expect(result.queries).toEqual([ + { + name: 'foo', + params: [ { name: 'items', schema: { type: 'array', items: { type: 'string' } } } ], + returnSchema: { type: 'array', items: { type: 'string' } }, + }, + ]); + expect(result.mutations).toEqual([]); + }); + + it('should handle Set types', () => { + const code = ` + export default class Contract { + getSet(s: Set): Set { + return s; + } + } + `; + + const result = analyzer.analyzeWithSchema(code); + expect(result.queries).toEqual([ + { + name: 'getSet', + params: [ { name: 's', schema: { type: 'array', items: { type: 'number' } } } ], + returnSchema: { type: 'array', items: { type: 'number' } }, + }, + ]); + expect(result.mutations).toEqual([]); + }); + + it('should handle Map types', () => { + const code = ` + export default class Contract { + getMap(m: Map): Map { + return m; + } + } + `; + + const result = analyzer.analyzeWithSchema(code); + expect(result.queries).toEqual([ + { + name: 'getMap', + params: [ { name: 'm', schema: { type: 'object', additionalProperties: { type: 'number' } } } ], + returnSchema: { type: 'object', additionalProperties: { type: 'number' } }, + }, + ]); + expect(result.mutations).toEqual([]); + }); + + it('should handle inline object types with arrays', () => { + const code = ` + export default class Contract { + transform(data: { a: number; b: string[] }): { a: number; b: string[] } { + return data; + } + } + `; + + const result = analyzer.analyzeWithSchema(code); + expect(result.queries).toEqual([ + { + name: 'transform', + params: [ { name: 'data', schema: { + type: 'object', + properties: { + a: { type: 'number' }, + b: { type: 'array', items: { type: 'string' } }, + }, + required: ['a', 'b'], + } } ], + returnSchema: { + type: 'object', + properties: { + a: { type: 'number' }, + b: { type: 'array', items: { type: 'string' } }, + }, + required: ['a', 'b'], + }, + }, + ]); + expect(result.mutations).toEqual([]); + }); + + it('should handle class property arrow functions', () => { + const code = ` + export default class Contract { + state: any; + foo = (x: number) => { + return this.state.value; + } + } + `; + + const result = analyzer.analyzeWithSchema(code); + expect(result.queries).toEqual([ + { + name: 'foo', + params: [ { name: 'x', schema: { type: 'number' } } ], + returnSchema: {}, + }, + ]); + expect(result.mutations).toEqual([]); + }); + + it('should handle optional chaining on state', () => { + const code = ` + export default class Contract { + state: any; + getVal() { + return this.state?.foo; + } + } + `; + + const result = analyzer.analyzeWithSchema(code); + expect(result.queries).toEqual([ + { + name: 'getVal', + params: [], + returnSchema: {}, + }, + ]); + expect(result.mutations).toEqual([]); + }); + + it('should handle destructured parameters', () => { + const code = ` + export default class Contract { + diff({ a, b }: { a: number; b: number }) { + return a - b; + } + } + `; + + const result = analyzer.analyzeWithSchema(code); + expect(result.queries).toEqual([ + { + name: 'diff', + params: [ + { + name: '{ a, b }: { a: number; b: number }', + schema: { + type: 'object', + properties: { a: { type: 'number' }, b: { type: 'number' } }, + required: ['a', 'b'], + }, + }, + ], + returnSchema: {}, + }, + ]); + expect(result.mutations).toEqual([]); + }); + + it('should handle default and rest parameters', () => { + const code = ` + export default class Contract { + sum(x = 1, ...rest: number[]): number { + return rest.reduce((u, v) => u + v, x); + } + } + `; + + const result = analyzer.analyzeWithSchema(code); + expect(result.queries).toEqual([ + { + name: 'sum', + params: [ + { name: 'x', schema: {} }, + { name: 'rest', schema: { type: 'array', items: { type: 'number' } } }, + ], + returnSchema: { type: 'number' }, + }, + ]); + expect(result.mutations).toEqual([]); + }); + + it('should handle tuple types', () => { + const code = ` + export default class Contract { + pair(p: [number, string]): [number, string] { + return p; + } + } + `; + + const result = analyzer.analyzeWithSchema(code); + expect(result.queries).toEqual([ + { + name: 'pair', + params: [ { name: 'p', schema: { type: 'array', items: { anyOf: [ { type: 'number' }, { type: 'string' } ] } } } ], + returnSchema: { type: 'array', items: { anyOf: [ { type: 'number' }, { type: 'string' } ] } }, + }, + ]); + expect(result.mutations).toEqual([]); + }); + + it('should handle index signature object types', () => { + const code = ` + export default class Contract { + lookup(m: { [key: string]: number }) { + return m['foo']; + } + } + `; + + const result = analyzer.analyzeWithSchema(code); + expect(result.queries).toEqual([ + { + name: 'lookup', + params: [ { name: 'm', schema: { type: 'object', additionalProperties: { type: 'number' } } } ], + returnSchema: {}, + }, + ]); + expect(result.mutations).toEqual([]); + }); + + it('should handle intersections and unions', () => { + const code = ` + type A = { x: string }; + type B = { y: number }; + export default class Contract { + either(x: string | number): string | number { + return x; + } + both(x: A & B): A & B { + return x; + } + } + `; + + const result = analyzer.analyzeWithSchema(code); + expect(result.queries).toEqual([ + { + name: 'either', + params: [ { name: 'x', schema: { anyOf: [ { type: 'string' }, { type: 'number' } ] } } ], + returnSchema: { anyOf: [ { type: 'string' }, { type: 'number' } ] }, + }, + { + name: 'both', + params: [ { name: 'x', schema: {} } ], + returnSchema: {}, + }, + ]); + expect(result.mutations).toEqual([]); + }); + + it('should handle literal types', () => { + const code = ` + export default class Contract { + mode(m: 'on' | 'off'): 'on' | 'off' { + return m; + } + } + `; + + const result = analyzer.analyzeWithSchema(code); + expect(result.queries).toEqual([ + { + name: 'mode', + params: [ { name: 'm', schema: { anyOf: [ { type: 'string' } ] } } ], + returnSchema: { anyOf: [ { type: 'string' } ] }, + }, + ]); + expect(result.mutations).toEqual([]); + }); + + it('should handle generic Array types', () => { + const code = ` + export default class Contract { + wrapList(a: Array): Array { + return a; + } + } + `; + + const result = analyzer.analyzeWithSchema(code); + expect(result.queries).toEqual([ + { + name: 'wrapList', + params: [ { name: 'a', schema: { type: 'array', items: { type: 'string' } } } ], + returnSchema: { type: 'array', items: { type: 'string' } }, + }, + ]); + expect(result.mutations).toEqual([]); + }); + + it('should handle function type parameters', () => { + const code = ` + export default class Contract { + call(cb: (x: number) => boolean): boolean { + return cb(1); + } + } + `; + + const result = analyzer.analyzeWithSchema(code); + expect(result.queries).toEqual([ + { + name: 'call', + params: [ { name: 'cb', schema: {} } ], + returnSchema: { type: 'boolean' }, + }, + ]); + expect(result.mutations).toEqual([]); + }); + }); }); \ No newline at end of file diff --git a/packages/parse/src/ContractAnalyzer.ts b/packages/parse/src/ContractAnalyzer.ts index d7919a9..6817863 100644 --- a/packages/parse/src/ContractAnalyzer.ts +++ b/packages/parse/src/ContractAnalyzer.ts @@ -15,6 +15,28 @@ export interface MethodInfo { returnType: string; } +// JSON-schema types +export interface JSONSchema { + type?: string; + properties?: Record; + required?: string[]; + items?: JSONSchema; + additionalProperties?: JSONSchema; + anyOf?: JSONSchema[]; + $ref?: string; +} + +export interface SchemaMethodInfo { + name: string; + params: { name: string; schema: JSONSchema }[]; + returnSchema: JSONSchema; +} + +export interface SchemaAnalysisResult { + queries: SchemaMethodInfo[]; + mutations: SchemaMethodInfo[]; +} + export class ContractAnalyzer { private ast: parser.ParseResult | null = null; @@ -203,4 +225,346 @@ export class ContractAnalyzer { }, }); } + + /** + * Parses and analyzes to produce JSON-schema result + */ + public analyzeWithSchema(code: string): SchemaAnalysisResult { + this.parseCode(code); + this.gatherInterfaces(); + return this.analyzeSchema(); + } + + // Map of interface declarations for schema inlining + private interfaceMap: Record = {}; + + // Collect interface declarations from AST + private gatherInterfaces(): void { + this.interfaceMap = {}; + traverse(this.ast!, { + TSInterfaceDeclaration: path => { + this.interfaceMap[path.node.id.name] = path.node; + }, + }); + } + + // Recursively convert TypeScript types to JSON-schema + private typeToSchema(node: t.TSType): JSONSchema { + // primitive keywords + if (t.isTSStringKeyword(node)) return { type: 'string' }; + if (t.isTSNumberKeyword(node)) return { type: 'number' }; + if (t.isTSBooleanKeyword(node)) return { type: 'boolean' }; + // literal types (e.g. 'on', 1, true) + if (t.isTSLiteralType(node)) { + const lit = node.literal; + if (t.isStringLiteral(lit)) return { type: 'string' }; + if (t.isNumericLiteral(lit)) return { type: 'number' }; + if (t.isBooleanLiteral(lit)) return { type: 'boolean' }; + return {}; + } + if (t.isTSAnyKeyword(node)) return {}; + if (t.isTSVoidKeyword(node)) return { type: 'null' }; + // array types + if (t.isTSArrayType(node)) { + return { type: 'array', items: this.typeToSchema(node.elementType) }; + } + // tuple types + if (t.isTSTupleType(node)) { + return { + type: 'array', + items: { anyOf: node.elementTypes.map(el => this.typeToSchema(el)) }, + }; + } + // object literal types + if (t.isTSTypeLiteral(node)) { + const props: Record = {}; + const required: string[] = []; + let indexSchema: JSONSchema | undefined; + for (const member of node.members) { + // normal property + if ( + t.isTSPropertySignature(member) && + t.isIdentifier(member.key) && + member.typeAnnotation + ) { + const key = member.key.name; + props[key] = this.typeToSchema(member.typeAnnotation.typeAnnotation); + if (!member.optional) required.push(key); + } + // index signature [key: string]: Type + else if (t.isTSIndexSignature(member) && member.typeAnnotation) { + indexSchema = this.typeToSchema(member.typeAnnotation.typeAnnotation); + } + } + // only index signature, no named properties + if (Object.keys(props).length === 0 && indexSchema) { + return { type: 'object', additionalProperties: indexSchema }; + } + // object with named properties (and maybe index signature) + const schema: JSONSchema = { type: 'object' }; + if (Object.keys(props).length) schema.properties = props; + if (required.length) schema.required = required; + if (indexSchema) schema.additionalProperties = indexSchema; + return schema; + } + // references (Promise, Set, Map, interfaces, Array) + if (t.isTSTypeReference(node) && t.isIdentifier(node.typeName)) { + const name = node.typeName.name; + // unwrap Promise + if (name === 'Promise' && node.typeParameters?.params.length === 1) { + return this.typeToSchema(node.typeParameters.params[0]); + } + // unwrap Array + if (name === 'Array' && node.typeParameters?.params.length === 1) { + return { type: 'array', items: this.typeToSchema(node.typeParameters.params[0]) }; + } + // handle Set as array + if (name === 'Set' && node.typeParameters?.params.length === 1) { + return { type: 'array', items: this.typeToSchema(node.typeParameters.params[0]) }; + } + // handle Map as object + if (name === 'Map' && node.typeParameters?.params.length === 2) { + return { type: 'object', additionalProperties: this.typeToSchema(node.typeParameters.params[1]) }; + } + // inline interface + const iface = this.interfaceMap[name]; + if (iface) { + return this.typeToSchema(iface.body as t.TSTypeLiteral); + } + return { $ref: name }; + } + // union types + if (t.isTSUnionType(node)) { + // map to schemas and dedupe identical entries + const schemas = node.types.map(tn => this.typeToSchema(tn)); + const unique = Array.from( + new Map(schemas.map(s => [JSON.stringify(s), s])).values() + ); + return { anyOf: unique }; + } + // fallback empty schema + return {}; + } + + /** + * Internal schema-based analysis + */ + private analyzeSchema(): SchemaAnalysisResult { + if (!this.ast) throw new Error('No code has been parsed. Call analyzeWithSchema first.'); + const queries: SchemaMethodInfo[] = []; + const mutations: SchemaMethodInfo[] = []; + let foundDefaultExport = false; + const self = this; + traverse(this.ast, { + ExportDefaultDeclaration(path) { + const decl = path.node.declaration; + if (t.isClassDeclaration(decl)) { + foundDefaultExport = true; + self.analyzeClassMethodsSchema(path, decl, queries, mutations); + } + }, + ExportNamedDeclaration(path) { + for (const specifier of path.node.specifiers) { + if ( + t.isExportSpecifier(specifier) && + t.isIdentifier(specifier.exported) && + specifier.exported.name === 'default' + ) { + const binding = path.scope.getBinding(specifier.local.name); + if (binding) { + const decl = binding.path.node; + if ( + t.isVariableDeclarator(decl) && + t.isClassExpression(decl.init) + ) { + foundDefaultExport = true; + self.analyzeClassMethodsSchema(binding.path, decl.init, queries, mutations); + } + } + } + } + }, + }); + if (!foundDefaultExport) throw new Error('No default exported class found in the code'); + return { queries, mutations }; + } + + // Schema-based method extraction + private analyzeClassMethodsSchema( + parentPath: NodePath, + classNode: t.ClassDeclaration | t.ClassExpression, + queries: SchemaMethodInfo[], + mutations: SchemaMethodInfo[] + ): void { + const self = this; + parentPath.traverse({ + ClassMethod(methodPath: NodePath) { + if (methodPath.node.static || methodPath.node.kind === 'constructor') return; + const methodName = t.isIdentifier(methodPath.node.key) + ? methodPath.node.key.name + : ''; + if (!methodName) return; + // parameter schemas + const params = methodPath.node.params.map(param => { + // identifier parameter + if (t.isIdentifier(param)) { + const name = param.name; + const schema = param.typeAnnotation + ? self.typeToSchema(param.typeAnnotation.typeAnnotation) + : {}; + return { name, schema }; + } + // destructured object parameter + if (t.isObjectPattern(param)) { + // include type annotation in name, compact inline + let patternRaw = generate(param).code; + patternRaw = patternRaw.replace(/\n/g, ' ').replace(/\s+/g, ' ').trim(); + let name = patternRaw; + if (param.typeAnnotation) { + let typeRaw = generate(param.typeAnnotation.typeAnnotation).code; + // remove trailing semicolon before closing brace, preserve space before closing brace + typeRaw = typeRaw.replace(/;\s*}$/, ' }'); + const typeCompact = typeRaw.replace(/\n/g, ' ').replace(/\s+/g, ' ').trim(); + name = `${patternRaw}: ${typeCompact}`; + } + const schema = param.typeAnnotation + ? self.typeToSchema(param.typeAnnotation.typeAnnotation) + : {}; + return { name, schema }; + } + // default value parameter + if (t.isAssignmentPattern(param) && t.isIdentifier(param.left)) { + const name = param.left.name; + const schema = param.left.typeAnnotation + ? self.typeToSchema(param.left.typeAnnotation.typeAnnotation) + : {}; + return { name, schema }; + } + // rest parameter + if (t.isRestElement(param) && t.isIdentifier(param.argument)) { + const name = param.argument.name; + // typeAnnotation may be on RestElement or on its argument identifier + let ann: t.TSType | undefined; + if (param.typeAnnotation) ann = param.typeAnnotation.typeAnnotation; + else if (param.argument.typeAnnotation) ann = param.argument.typeAnnotation.typeAnnotation; + const schema = ann ? self.typeToSchema(ann) : {}; + return { name, schema }; + } + // fallback + return { name: generate(param).code, schema: {} }; + }); + // return schema + const returnSchema = methodPath.node.returnType + ? self.typeToSchema(methodPath.node.returnType.typeAnnotation) + : {}; + // detect state access and returns + let reads = false, writes = false, hasRet = false; + methodPath.traverse({ + MemberExpression(memberPath: NodePath) { + const obj = memberPath.node.object; + const prop = memberPath.node.property; + if ( + t.isThisExpression(obj) && + t.isIdentifier(prop) && + prop.name === 'state' + ) { + const p = memberPath.parentPath; + if ( + t.isAssignmentExpression(p.node) || + (t.isMemberExpression(p.node) && + t.isAssignmentExpression(p.parentPath.node)) + ) writes = true; + else reads = true; + } + }, + ReturnStatement(retPath: NodePath) { + if (retPath.node.argument) hasRet = true; + }, + }); + const info: SchemaMethodInfo = { name: methodName, params, returnSchema }; + if (writes) mutations.push(info); + else if (reads || hasRet) queries.push(info); + }, + // arrow-function class properties + ClassProperty(propPath: NodePath) { + if (propPath.node.static) return; + if (!t.isIdentifier(propPath.node.key)) return; + const methodName = propPath.node.key.name; + const value = propPath.node.value; + if (!t.isArrowFunctionExpression(value)) return; + // parameter schemas + const params = value.params.map(param => { + if (t.isIdentifier(param)) { + const name = param.name; + const schema = param.typeAnnotation + ? self.typeToSchema(param.typeAnnotation.typeAnnotation) + : {}; + return { name, schema }; + } + if (t.isObjectPattern(param)) { + // include type annotation in name, compact inline + let patternRaw = generate(param).code; + patternRaw = patternRaw.replace(/\n/g, ' ').replace(/\s+/g, ' ').trim(); + let name = patternRaw; + if (param.typeAnnotation) { + let typeRaw = generate(param.typeAnnotation.typeAnnotation).code; + // remove trailing semicolon before closing brace, preserve space before closing brace + typeRaw = typeRaw.replace(/;\s*}$/, ' }'); + const typeCompact = typeRaw.replace(/\n/g, ' ').replace(/\s+/g, ' ').trim(); + name = `${patternRaw}: ${typeCompact}`; + } + const schema = param.typeAnnotation + ? self.typeToSchema(param.typeAnnotation.typeAnnotation) + : {}; + return { name, schema }; + } + if (t.isAssignmentPattern(param) && t.isIdentifier(param.left)) { + const name = param.left.name; + const schema = param.left.typeAnnotation + ? self.typeToSchema(param.left.typeAnnotation.typeAnnotation) + : {}; + return { name, schema }; + } + if (t.isRestElement(param) && t.isIdentifier(param.argument)) { + const name = param.argument.name; + let ann: t.TSType | undefined; + if (param.typeAnnotation) ann = param.typeAnnotation.typeAnnotation; + else if (param.argument.typeAnnotation) ann = param.argument.typeAnnotation.typeAnnotation; + const schema = ann ? self.typeToSchema(ann) : {}; + return { name, schema }; + } + return { name: generate(param).code, schema: {} }; + }); + // arrow functions have no return annotation + const returnSchema: JSONSchema = {}; + // detect state access and returns + let reads = false, writes = false, hasRet = false; + propPath.traverse({ + MemberExpression(memberPath: NodePath) { + const obj = memberPath.node.object; + const prop = memberPath.node.property; + if ( + t.isThisExpression(obj) && + t.isIdentifier(prop) && + prop.name === 'state' + ) { + const p = memberPath.parentPath; + if ( + t.isAssignmentExpression(p.node) || + (t.isMemberExpression(p.node) && + t.isAssignmentExpression(p.parentPath.node)) + ) writes = true; + else reads = true; + } + }, + ReturnStatement(retPath: NodePath) { + if (retPath.node.argument) hasRet = true; + }, + }); + const info: SchemaMethodInfo = { name: methodName, params, returnSchema }; + if (writes) mutations.push(info); + else if (reads || hasRet) queries.push(info); + }, + }); + } } \ No newline at end of file