From 528ecc58812e0d507061bf358b9b2996ec8ca102 Mon Sep 17 00:00:00 2001 From: luca Date: Tue, 3 Jun 2025 19:12:55 +0800 Subject: [PATCH 1/4] fix: remove schemaExtractor --- .../schemaExtractor.test.ts.snap | 150 ----------- .../build/__tests__/schemaExtractor.test.ts | 74 ------ packages/build/src/index.ts | 1 - packages/build/src/schemaExtractor.ts | 249 ------------------ 4 files changed, 474 deletions(-) delete mode 100644 packages/build/__tests__/__snapshots__/schemaExtractor.test.ts.snap delete mode 100644 packages/build/__tests__/schemaExtractor.test.ts delete mode 100644 packages/build/src/schemaExtractor.ts diff --git a/packages/build/__tests__/__snapshots__/schemaExtractor.test.ts.snap b/packages/build/__tests__/__snapshots__/schemaExtractor.test.ts.snap deleted file mode 100644 index bc3c165..0000000 --- a/packages/build/__tests__/__snapshots__/schemaExtractor.test.ts.snap +++ /dev/null @@ -1,150 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`schemaExtractorPlugin should extract a basic contract with public and private methods 1`] = ` -{ - "methods": [], - "state": { - "properties": { - "count": { - "type": "number", - }, - "startCoin": { - "properties": { - "amount": { - "type": "string", - }, - "denom": { - "type": "string", - }, - }, - "type": "object", - }, - "tokens": { - "items": { - "properties": { - "amount": { - "type": "string", - }, - "denom": { - "type": "string", - }, - }, - "type": "object", - }, - "type": "array", - }, - }, - "type": "object", - }, -} -`; - -exports[`schemaExtractorPlugin should extract methods and state from classes inheritance-contract 1`] = ` -{ - "methods": [ - { - "name": "increment", - "parameters": [], - "returnType": { - "type": "any", - }, - }, - { - "name": "baseMethod", - "parameters": [], - "returnType": { - "type": "any", - }, - }, - ], - "state": { - "properties": { - "count": { - "type": "number", - }, - }, - "type": "object", - }, -} -`; - -exports[`schemaExtractorPlugin should extract methods from classes public methods 1`] = ` -{ - "methods": [ - { - "name": "increment", - "parameters": [], - "returnType": { - "type": "any", - }, - }, - { - "name": "addToken", - "parameters": [ - { - "name": "denom", - "type": { - "type": "string", - }, - }, - { - "name": "amount", - "type": { - "type": "string", - }, - }, - ], - "returnType": { - "type": "any", - }, - }, - { - "name": "removeToken", - "parameters": [ - { - "name": "index", - "type": { - "type": "number", - }, - }, - ], - "returnType": { - "type": "any", - }, - }, - ], - "state": { - "properties": { - "count": { - "type": "number", - }, - "startCoin": { - "properties": { - "amount": { - "type": "string", - }, - "denom": { - "type": "string", - }, - }, - "type": "object", - }, - "tokens": { - "items": { - "properties": { - "amount": { - "type": "string", - }, - "denom": { - "type": "string", - }, - }, - "type": "object", - }, - "type": "array", - }, - }, - "type": "object", - }, -} -`; diff --git a/packages/build/__tests__/schemaExtractor.test.ts b/packages/build/__tests__/schemaExtractor.test.ts deleted file mode 100644 index 9fb446e..0000000 --- a/packages/build/__tests__/schemaExtractor.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import fs from 'fs/promises'; -import { join, resolve } from 'path'; - -import { HyperwebBuild, HyperwebBuildOptions, schemaExtractorPlugin } from '../src'; - -const outputDir = resolve(join(__dirname, '/../../../__output__/schema-data')); - -const runTest = async (fixtureName: string) => { - const fixtureDir = resolve(join(__dirname, `/../../../__fixtures__/schema-data/${fixtureName}`)); - const schemaOutputPath = join(outputDir, `${fixtureName}.schema.json`); - - const buildOptions: Partial = { - entryPoints: [join(fixtureDir, 'index.ts')], - outfile: join(outputDir, `${fixtureName}.bundle.js`), - customPlugins: [ - schemaExtractorPlugin({ - outputPath: schemaOutputPath, - baseDir: fixtureDir, - include: [/\.ts$/], - exclude: [/node_modules/, /\.test\.ts$/], - }), - ], - }; - - await HyperwebBuild.build(buildOptions); - const schemaContent = await fs.readFile(schemaOutputPath, 'utf-8'); - return JSON.parse(schemaContent); -}; - -describe('schemaExtractorPlugin', () => { - it('should extract a basic contract with public and private methods', async () => { - const schemaData = await runTest('state-export'); - - expect(schemaData).toHaveProperty('state'); - expect(schemaData.state).toHaveProperty('type', 'object'); - expect(schemaData.state).toHaveProperty('properties'); - - expect(schemaData).toHaveProperty('methods'); - expect(schemaData.methods).toEqual([]); - - expect(schemaData).toMatchSnapshot(); - }); - - it('should extract methods from classes public methods', async () => { - const schemaData = await runTest('public-methods'); - - expect(schemaData).toHaveProperty('state'); - expect(schemaData.state).toHaveProperty('type', 'object'); - expect(schemaData.state).toHaveProperty('properties'); - - const methodNames = schemaData.methods.map((method: any) => method.name); - expect(methodNames).toContain('addToken'); - expect(methodNames).toContain('increment'); - expect(methodNames).toContain('removeToken'); - expect(methodNames).not.toContain('reset'); - - expect(schemaData).toMatchSnapshot(); - }); - - it('should extract methods and state from classes inheritance-contract', async () => { - const schemaData = await runTest('inheritance-contract'); - - expect(schemaData).toHaveProperty('state'); - expect(schemaData.state).toHaveProperty('type', 'object'); - expect(schemaData.state).toHaveProperty('properties'); - - const methodNames = schemaData.methods.map((method: any) => method.name); - expect(methodNames).toContain('baseMethod'); - expect(methodNames).toContain('increment'); - expect(methodNames).not.toContain('reset'); - - expect(schemaData).toMatchSnapshot(); - }); -}); diff --git a/packages/build/src/index.ts b/packages/build/src/index.ts index 5484333..3aa7101 100644 --- a/packages/build/src/index.ts +++ b/packages/build/src/index.ts @@ -1,3 +1,2 @@ export type { HyperwebBuildOptions } from './build'; export { HyperwebBuild } from './build'; -export { schemaExtractorPlugin } from './schemaExtractor'; diff --git a/packages/build/src/schemaExtractor.ts b/packages/build/src/schemaExtractor.ts deleted file mode 100644 index 044201f..0000000 --- a/packages/build/src/schemaExtractor.ts +++ /dev/null @@ -1,249 +0,0 @@ -import { Plugin } from 'esbuild'; -import { promises as fs } from 'fs'; -import * as path from 'path'; -import * as ts from 'typescript'; - -import { HyperwebBuildOptions } from './build'; - -interface SchemaExtractorOptions { - outputPath?: string; - baseDir?: string; - include?: RegExp[]; - exclude?: RegExp[]; -} - -export const schemaExtractorPlugin = ( - pluginOptions: SchemaExtractorOptions = {} -): Plugin => ({ - name: 'schema-extractor', - - setup(build) { - const hyperwebBuildOptions: HyperwebBuildOptions = build.initialOptions; - - build.onEnd(async () => { - const baseDir = pluginOptions.baseDir || process.cwd(); - const sourceFiles = hyperwebBuildOptions.entryPoints as string[]; - - const program = ts.createProgram(sourceFiles, { - target: ts.ScriptTarget.ESNext, - module: ts.ModuleKind.CommonJS, - strict: true, - }); - - const checker = program.getTypeChecker(); - const schemaData: Record = { state: {}, methods: [] }; - - // Extract state and methods from the contract's default export - program.getSourceFiles().forEach((sourceFile) => { - if (sourceFile.isDeclarationFile) return; - - extractDefaultExport(sourceFile, checker, schemaData); - extractStateInterface(sourceFile, checker, schemaData); - }); - - const outputPath = - pluginOptions.outputPath || path.join(baseDir, 'dist/schema.json'); - console.log('Writing schema data to:', outputPath); - - try { - await fs.writeFile( - outputPath, - JSON.stringify(schemaData, null, 2), - 'utf8' - ); - console.log(`Schema successfully written to ${outputPath}`); - } catch (error) { - console.error('Error writing schema data:', error); - } - }); - }, -}); - -// Helper function to check if a declaration has public visibility -function isPublicDeclaration(node: ts.Node): boolean { - // Check if the node has modifiers and is of a type that may include them - if ('modifiers' in node && Array.isArray(node.modifiers)) { - return !node.modifiers.some( - (mod: ts.Modifier) => - mod.kind === ts.SyntaxKind.PrivateKeyword || - mod.kind === ts.SyntaxKind.ProtectedKeyword - ); - } - // If no modifiers exist, assume the node is public - return true; -} - -function extractDefaultExport( - sourceFile: ts.SourceFile, - checker: ts.TypeChecker, - schemaData: Record -) { - const defaultExport = sourceFile.statements.find((stmt) => - ts.isExportAssignment(stmt) && !stmt.isExportEquals - ) as ts.ExportAssignment | undefined; - - if (defaultExport && defaultExport.expression) { - console.log(`Found default export in file: ${sourceFile.fileName}`); - - const contractSymbol = checker.getSymbolAtLocation(defaultExport.expression); - - if (contractSymbol) { - console.log(`Extracting methods from contract symbol: ${contractSymbol.getName()}`); - extractPublicMethods(contractSymbol, checker, schemaData); - } else { - console.warn(`No symbol found for default export in ${sourceFile.fileName}`); - } - } -} - -// Extract only public methods from the contract and add them to schemaData -function extractPublicMethods( - symbol: ts.Symbol, - checker: ts.TypeChecker, - schemaData: Record -) { - const type = checker.getDeclaredTypeOfSymbol(symbol); - const properties = checker.getPropertiesOfType(type); - - properties.forEach((prop) => { - const propType = checker.getTypeOfSymbolAtLocation( - prop, - prop.valueDeclaration || prop.declarations[0] - ); - - // Check if the property is a method and explicitly public - if (propType.getCallSignatures().length && prop.valueDeclaration) { - // Check if the declaration is public - const isPublic = isPublicDeclaration(prop.valueDeclaration); - - if (isPublic) { - const methodSchema = { - name: prop.getName(), - parameters: [] as { name: string; type: any }[], - returnType: serializeType( - propType.getCallSignatures()[0].getReturnType(), - checker - ), - }; - - // Get parameter types for the method - propType.getCallSignatures()[0].parameters.forEach((param) => { - const paramType = checker.getTypeOfSymbolAtLocation( - param, - param.valueDeclaration || param.declarations[0] - ); - methodSchema.parameters.push({ - name: param.getName(), - type: serializeType(paramType, checker), - }); - }); - - schemaData.methods.push(methodSchema); - console.log(`Extracted public method: ${methodSchema.name}`); - } - } - }); -} - -function extractStateInterface( - sourceFile: ts.SourceFile, - checker: ts.TypeChecker, - schemaData: Record -) { - const stateInterface = sourceFile.statements.find( - (stmt): stmt is ts.InterfaceDeclaration => - ts.isInterfaceDeclaration(stmt) && stmt.name.text === 'State' - ); - - if (stateInterface) { - console.log("Extracting schema for 'State' interface"); - schemaData.state = serializeType( - checker.getTypeAtLocation(stateInterface), - checker - ); - } -} - -function serializeType( - type: ts.Type, - checker: ts.TypeChecker, - typeStack: ts.Type[] = [] -): any { - const typeString = checker.typeToString(type); - - if (typeStack.includes(type)) { - return { $ref: typeString }; - } - - const newTypeStack = [...typeStack, type]; - - if (type.isStringLiteral()) { - return { type: 'string', enum: [type.value] }; - } - if (type.isNumberLiteral()) { - return { type: 'number', enum: [type.value] }; - } - if (typeString === 'string') { - return { type: 'string' }; - } - if (typeString === 'number') { - return { type: 'number' }; - } - if (typeString === 'boolean') { - return { type: 'boolean' }; - } - if (typeString === 'null') { - return { type: 'null' }; - } - if (typeString === 'undefined') { - return { type: 'undefined' }; - } - if (typeString === 'any') { - return { type: 'any' }; - } - - if (checker.isArrayType(type)) { - const typeReference = type as ts.TypeReference; - const typeArguments = checker.getTypeArguments(typeReference); - const elementType = typeArguments[0] || checker.getAnyType(); - return { - type: 'array', - items: serializeType(elementType, checker, newTypeStack), - }; - } - - if (type.isUnion()) { - return { - anyOf: type.types.map((subType) => - serializeType(subType, checker, newTypeStack) - ), - }; - } - - if (type.isIntersection()) { - return { - allOf: type.types.map((subType) => - serializeType(subType, checker, newTypeStack) - ), - }; - } - - const properties = checker.getPropertiesOfType(type); - if (properties.length) { - const result: Record = {}; - properties.forEach((prop) => { - const propType = checker.getTypeOfSymbolAtLocation( - prop, - prop.valueDeclaration || prop.declarations[0] - ); - result[prop.getName()] = serializeType( - propType, - checker, - newTypeStack - ); - }); - return { type: 'object', properties: result }; - } - - return { type: 'any' }; -} From f81ebf9493c59bcd1240ff92eb873e6629bf37dc Mon Sep 17 00:00:00 2001 From: luca Date: Tue, 3 Jun 2025 21:29:41 +0800 Subject: [PATCH 2/4] feat: add tests for collect utils --- packages/parse/__tests__/collect.test.ts | 396 +++++++++++++++++++++++ packages/parse/src/collect.ts | 170 ++++++++++ 2 files changed, 566 insertions(+) create mode 100644 packages/parse/__tests__/collect.test.ts create mode 100644 packages/parse/src/collect.ts diff --git a/packages/parse/__tests__/collect.test.ts b/packages/parse/__tests__/collect.test.ts new file mode 100644 index 0000000..b05f84c --- /dev/null +++ b/packages/parse/__tests__/collect.test.ts @@ -0,0 +1,396 @@ +import { collectSourceFiles, FileMap, CollectOptions } from '../src/collect'; + +describe('collectSourceFiles', () => { + describe('basic functionality', () => { + it('should collect a single file with no imports', () => { + const files = [{ path: 'main.ts', content: 'const x = 1;' }]; + + const result = collectSourceFiles('main.ts', files); + + expect(result).toEqual({ + 'main.ts': 'const x = 1;', + }); + }); + + it('should return empty object when entry file not found', () => { + const files = [{ path: 'other.ts', content: 'const x = 1;' }]; + + const result = collectSourceFiles('main.ts', files); + + expect(result).toEqual({}); + }); + }); + + describe('import following', () => { + it('should follow import statements', () => { + const files = [ + { path: 'main.ts', content: 'import { foo } from "./helper";' }, + { path: 'helper.ts', content: 'export const foo = 1;' }, + ]; + + const result = collectSourceFiles('main.ts', files); + + expect(result).toEqual({ + 'main.ts': 'import { foo } from "./helper";', + 'helper.ts': 'export const foo = 1;', + }); + }); + + it('should follow nested imports', () => { + const files = [ + { path: 'main.ts', content: 'import { foo } from "./utils/helper";' }, + { + path: 'utils/helper.ts', + content: 'import { bar } from "./shared"; export const foo = bar;', + }, + { path: 'utils/shared.ts', content: 'export const bar = 1;' }, + ]; + + const result = collectSourceFiles('main.ts', files); + + expect(result).toEqual({ + 'main.ts': 'import { foo } from "./utils/helper";', + 'utils/helper.ts': 'import { bar } from "./shared"; export const foo = bar;', + 'utils/shared.ts': 'export const bar = 1;', + }); + }); + + it('should handle relative imports with ../', () => { + const files = [ + { path: 'src/main.ts', content: 'import { foo } from "../utils/helper";' }, + { path: 'utils/helper.ts', content: 'export const foo = 1;' }, + ]; + + const result = collectSourceFiles('src/main.ts', files); + + expect(result).toEqual({ + 'src/main.ts': 'import { foo } from "../utils/helper";', + 'utils/helper.ts': 'export const foo = 1;', + }); + }); + }); + + describe('import patterns', () => { + it('should handle different import syntaxes', () => { + const files = [ + { + path: 'main.ts', + content: ` + import { foo } from './helper1'; + import * as helper2 from "./helper2"; + import helper3, { bar } from './helper3'; + import('./helper4'); + const helper5 = require('./helper5'); + export { baz } from './helper6'; + `, + }, + { path: 'helper1.ts', content: 'export const foo = 1;' }, + { path: 'helper2.ts', content: 'export const x = 1;' }, + { path: 'helper3.ts', content: 'export const bar = 1; export default 1;' }, + { path: 'helper4.ts', content: 'export const y = 1;' }, + { path: 'helper5.ts', content: 'module.exports = { z: 1 };' }, + { path: 'helper6.ts', content: 'export const baz = 1;' }, + ]; + + const result = collectSourceFiles('main.ts', files); + const keys = Object.keys(result); + + expect(keys).toHaveLength(7); + expect(keys.includes('main.ts')).toBe(true); + expect(keys.includes('helper1.ts')).toBe(true); + expect(keys.includes('helper2.ts')).toBe(true); + expect(keys.includes('helper3.ts')).toBe(true); + expect(keys.includes('helper4.ts')).toBe(true); + expect(keys.includes('helper5.ts')).toBe(true); + expect(keys.includes('helper6.ts')).toBe(true); + }); + + it('should ignore external package imports', () => { + const files = [ + { + path: 'main.ts', + content: ` + import React from 'react'; + import { lodash } from 'lodash'; + import { foo } from './helper'; + `, + }, + { path: 'helper.ts', content: 'export const foo = 1;' }, + ]; + + const result = collectSourceFiles('main.ts', files); + const keys = Object.keys(result); + + expect(keys).toHaveLength(2); + expect(keys.includes('main.ts')).toBe(true); + expect(keys.includes('helper.ts')).toBe(true); + expect(result['helper.ts']).toBe('export const foo = 1;'); + }); + }); + + describe('extension resolution', () => { + it('should resolve imports without extensions using default extensions', () => { + const files = [ + { path: 'main.ts', content: 'import { foo } from "./helper";' }, + { path: 'helper.ts', content: 'export const foo = 1;' }, + ]; + + const result = collectSourceFiles('main.ts', files); + + expect(result).toEqual({ + 'main.ts': 'import { foo } from "./helper";', + 'helper.ts': 'export const foo = 1;', + }); + }); + + it('should try multiple extensions in order', () => { + const files = [ + { path: 'main.ts', content: 'import { foo } from "./helper";' }, + { path: 'helper.jsx', content: 'export const foo = 1;' }, + ]; + + const result = collectSourceFiles('main.ts', files); + + expect(result).toEqual({ + 'main.ts': 'import { foo } from "./helper";', + 'helper.jsx': 'export const foo = 1;', + }); + }); + + it('should use custom extensions when provided', () => { + const files = [ + { path: 'main.ts', content: 'import { foo } from "./helper";' }, + { path: 'helper.custom', content: 'export const foo = 1;' }, + ]; + + const options: CollectOptions = { + extensions: ['.custom', '.ts'], + }; + + const result = collectSourceFiles('main.ts', files, options); + + expect(result).toEqual({ + 'main.ts': 'import { foo } from "./helper";', + 'helper.custom': 'export const foo = 1;', + }); + }); + + it('should prefer exact path match over extension resolution', () => { + const files = [ + { path: 'main.ts', content: 'import { foo } from "./helper";' }, + { path: 'helper', content: 'export const foo = "exact";' }, + { path: 'helper.ts', content: 'export const foo = "with-ext";' }, + ]; + + const result = collectSourceFiles('main.ts', files); + + expect(result['helper']).toBe('export const foo = "exact";'); + }); + }); + + describe('circular dependencies', () => { + it('should handle circular dependencies without infinite loop', () => { + const files = [ + { path: 'a.ts', content: 'import { b } from "./b"; export const a = 1;' }, + { path: 'b.ts', content: 'import { a } from "./a"; export const b = 2;' }, + ]; + + const result = collectSourceFiles('a.ts', files); + + expect(result).toEqual({ + 'a.ts': 'import { b } from "./b"; export const a = 1;', + 'b.ts': 'import { a } from "./a"; export const b = 2;', + }); + }); + + it('should handle self-imports', () => { + const files = [ + { path: 'main.ts', content: 'import { foo } from "./main"; export const foo = 1;' }, + ]; + + const result = collectSourceFiles('main.ts', files); + + expect(result).toEqual({ + 'main.ts': 'import { foo } from "./main"; export const foo = 1;', + }); + }); + }); + + describe('options', () => { + it('should exclude content when includeContent is false', () => { + const files = [ + { path: 'main.ts', content: 'import { foo } from "./helper";' }, + { path: 'helper.ts', content: 'export const foo = 1;' }, + ]; + + const options: CollectOptions = { + includeContent: false, + }; + + const result = collectSourceFiles('main.ts', files, options); + + expect(result).toEqual({ + 'main.ts': '', + 'helper.ts': '', + }); + }); + + it('should use default options when not provided', () => { + const files = [{ path: 'main.ts', content: 'const x = 1;' }]; + + const result1 = collectSourceFiles('main.ts', files); + const result2 = collectSourceFiles('main.ts', files, {}); + + expect(result1).toEqual(result2); + }); + }); + + describe('edge cases', () => { + it('should handle empty file content', () => { + const files = [{ path: 'main.ts', content: '' }]; + + const result = collectSourceFiles('main.ts', files); + + expect(result).toEqual({ + 'main.ts': '', + }); + }); + + it('should handle files with only comments', () => { + const files = [ + { path: 'main.ts', content: '// This is a comment\n/* Block comment */' }, + ]; + + const result = collectSourceFiles('main.ts', files); + + expect(result).toEqual({ + 'main.ts': '// This is a comment\n/* Block comment */', + }); + }); + + it('should handle malformed import statements', () => { + const files = [ + { + path: 'main.ts', + content: ` + import from './missing-specifier'; + import { from './missing-closing-brace'; + import { foo } from; + import { foo } from "./helper"; + `, + }, + { path: 'helper.ts', content: 'export const foo = 1;' }, + ]; + + const result = collectSourceFiles('main.ts', files); + const keys = Object.keys(result); + + expect(keys).toHaveLength(2); + expect(keys.includes('main.ts')).toBe(true); + expect(keys.includes('helper.ts')).toBe(true); + }); + + it('should handle imports in strings and comments', () => { + const files = [ + { + path: 'main.ts', + content: ` + // import { fake } from "./fake"; + /* import { fake } from "./fake"; */ + const str = 'import { fake } from "./fake";'; + import { foo } from "./helper"; + `, + }, + { path: 'helper.ts', content: 'export const foo = 1;' }, + ]; + + const result = collectSourceFiles('main.ts', files); + const keys = Object.keys(result); + + // Should only collect the real import, not the ones in comments/strings + expect(keys).toHaveLength(2); + expect(keys.includes('main.ts')).toBe(true); + expect(keys.includes('helper.ts')).toBe(true); + expect(result['helper.ts']).toBe('export const foo = 1;'); + }); + + it('should handle absolute path imports that start with /', () => { + const files = [ + { path: 'main.ts', content: 'import { foo } from "/absolute/helper";' }, + { path: 'absolute/helper.ts', content: 'export const foo = 1;' }, + ]; + + const result = collectSourceFiles('main.ts', files); + + expect(result).toEqual({ + 'main.ts': 'import { foo } from "/absolute/helper";', + 'absolute/helper.ts': 'export const foo = 1;', + }); + }); + + it('should handle missing intermediate directories', () => { + const files = [{ path: 'main.ts', content: 'import { foo } from "./missing/helper";' }]; + + const result = collectSourceFiles('main.ts', files); + + expect(result).toEqual({ + 'main.ts': 'import { foo } from "./missing/helper";', + }); + }); + }); + + describe('complex scenarios', () => { + it('should handle deep directory structures', () => { + const files = [ + { + path: 'src/components/Button/index.ts', + content: 'import { styles } from "./styles"; export { Button } from "./Button";', + }, + { + path: 'src/components/Button/Button.tsx', + content: 'import { Props } from "../types"; export const Button = () => {};', + }, + { path: 'src/components/Button/styles.ts', content: 'export const styles = {};' }, + { path: 'src/components/types.ts', content: 'export interface Props {}' }, + ]; + + const result = collectSourceFiles('src/components/Button/index.ts', files); + const keys = Object.keys(result); + + expect(keys).toHaveLength(4); + expect(keys.includes('src/components/Button/index.ts')).toBe(true); + expect(keys.includes('src/components/Button/Button.tsx')).toBe(true); + expect(keys.includes('src/components/Button/styles.ts')).toBe(true); + expect(keys.includes('src/components/types.ts')).toBe(true); + }); + + it('should handle mixed import styles in single file', () => { + const files = [ + { + path: 'main.ts', + content: ` + import React from 'react'; + import { Component } from './components'; + const utils = require('./utils'); + import('./dynamic'); + export { helper } from './helper'; + `, + }, + { path: 'components.ts', content: 'export const Component = {};' }, + { path: 'utils.js', content: 'module.exports = {};' }, + { path: 'dynamic.ts', content: 'export default {};' }, + { path: 'helper.ts', content: 'export const helper = {};' }, + ]; + + const result = collectSourceFiles('main.ts', files); + const keys = Object.keys(result); + + expect(keys).toHaveLength(5); + expect(keys.includes('main.ts')).toBe(true); + expect(keys.includes('components.ts')).toBe(true); + expect(keys.includes('utils.js')).toBe(true); + expect(keys.includes('dynamic.ts')).toBe(true); + expect(keys.includes('helper.ts')).toBe(true); + }); + }); +}); diff --git a/packages/parse/src/collect.ts b/packages/parse/src/collect.ts new file mode 100644 index 0000000..0c83a38 --- /dev/null +++ b/packages/parse/src/collect.ts @@ -0,0 +1,170 @@ +/** + * File map type + */ +export type FileMap = Record; + +/** + * Configuration options for source file collection + */ +export interface CollectOptions { + /** + * File extensions to try when resolving imports + * @default ['.ts', '.tsx', '.js', '.jsx'] + */ + extensions?: string[]; + + /** + * Whether to include content in the result + * @default true + */ + includeContent?: boolean; +} + +/** + * Extract import paths from TypeScript/JavaScript content + */ +function extractImportPaths(content: string): string[] { + const imports: string[] = []; + + // Regex patterns for different import styles + const importPatterns = [ + /import\s+.*?from\s+['"](.*?)['"]/g, // import ... from '...' + /import\s*\(\s*['"](.*?)['"]\s*\)/g, // import('...') + /require\s*\(\s*['"](.*?)['"]\s*\)/g, // require('...') + /export\s+.*?from\s+['"](.*?)['"]/g, // export ... from '...' + ]; + + for (const pattern of importPatterns) { + let match; + while ((match = pattern.exec(content)) !== null) { + const importPath = match[1]; + if (importPath && isLocalImport(importPath)) { + imports.push(importPath); + } + } + } + + return imports; +} + +/** + * Check if an import path is local (not from node_modules) + */ +function isLocalImport(importPath: string): boolean { + return ( + importPath.startsWith('./') || importPath.startsWith('../') || importPath.startsWith('/') + ); +} + +/** + * Resolve import path relative to current directory + */ +function resolveImportPath(importPath: string, currentDir: string): string { + // Handle absolute paths + if (importPath.startsWith('/')) { + return importPath.substring(1); // Remove leading slash + } + + // Handle relative paths + if (importPath.startsWith('./')) { + const cleanPath = importPath.substring(2); + return currentDir ? `${currentDir}/${cleanPath}` : cleanPath; + } + + if (importPath.startsWith('../')) { + const pathParts = currentDir.split('/').filter((part) => part !== ''); + const importParts = importPath.split('/'); + + // Process ../ parts + let i = 0; + while (i < importParts.length && importParts[i] === '..') { + pathParts.pop(); // Go up one directory + i++; + } + + // Add remaining import parts + const remainingParts = importParts.slice(i); + return [...pathParts, ...remainingParts].join('/'); + } + + // Handle direct paths (shouldn't happen for local imports but just in case) + return currentDir ? `${currentDir}/${importPath}` : importPath; +} + +/** + * Get parent directory path + */ +function getParentPath(path: string): string { + return path.split('/').slice(0, -1).join('/'); +} + +/** + * Collect source files and their dependencies + * + * @param entryFile - The entry point file path + * @param files - Array of file objects with path and content + * @param options - Collection options + * @returns Map of file paths to their content + */ +export function collectSourceFiles( + entryFile: string, + files: Array<{ path: string; content: string }>, + options: CollectOptions = {} +): FileMap { + const { extensions = ['.ts', '.tsx', '.js', '.jsx'], includeContent = true } = options; + + // Create file map for quick lookup + const fileMap = Object.fromEntries(files.map((file) => [file.path, file.content])); + + const result: FileMap = {}; + const visited = new Set(); + + function readFile(filePath: string): string | null { + return fileMap[filePath] ?? null; + } + + function tryReadFile(filePath: string): { content: string; actualPath: string } | null { + // Try exact path first + let content = readFile(filePath); + if (content !== null) { + return { content, actualPath: filePath }; + } + + // Try with extensions + for (const ext of extensions) { + const tryPath = filePath + ext; + const tryContent = readFile(tryPath); + if (tryContent !== null) { + return { content: tryContent, actualPath: tryPath }; + } + } + + return null; + } + + function traverse(filePath: string): void { + if (visited.has(filePath)) return; + visited.add(filePath); + + const fileResult = tryReadFile(filePath); + if (!fileResult) return; + + // Store content (or empty string if not including content) + result[fileResult.actualPath] = includeContent ? fileResult.content : ''; + + // Process imports + const imports = extractImportPaths(fileResult.content); + for (const importPath of imports) { + if (isLocalImport(importPath)) { + const resolvedPath = resolveImportPath( + importPath, + getParentPath(fileResult.actualPath) + ); + traverse(resolvedPath); + } + } + } + + traverse(entryFile); + return result; +} From 93011e0088e74e8be697126c63d59a04fb49c250 Mon Sep 17 00:00:00 2001 From: luca Date: Tue, 3 Jun 2025 22:12:06 +0800 Subject: [PATCH 3/4] feat: use AST approach --- packages/parse/__tests__/collect.test.ts | 71 ++-- packages/parse/src/collect.ts | 500 ++++++++++++++++++----- packages/parse/src/index.ts | 1 + 3 files changed, 432 insertions(+), 140 deletions(-) diff --git a/packages/parse/__tests__/collect.test.ts b/packages/parse/__tests__/collect.test.ts index b05f84c..893a66f 100644 --- a/packages/parse/__tests__/collect.test.ts +++ b/packages/parse/__tests__/collect.test.ts @@ -1,11 +1,21 @@ -import { collectSourceFiles, FileMap, CollectOptions } from '../src/collect'; +import { SourceCollector, FileMap, CollectOptions } from '../src/collect'; + +describe('SourceCollector', () => { + let collector: SourceCollector; + let collectorWithoutContent: SourceCollector; + let collectorWithCustomExtensions: SourceCollector; + + beforeAll(() => { + collector = new SourceCollector(); + collectorWithoutContent = new SourceCollector({ includeContent: false }); + collectorWithCustomExtensions = new SourceCollector({ extensions: ['.custom', '.ts'] }); + }); -describe('collectSourceFiles', () => { describe('basic functionality', () => { it('should collect a single file with no imports', () => { const files = [{ path: 'main.ts', content: 'const x = 1;' }]; - const result = collectSourceFiles('main.ts', files); + const result = collector.collect('main.ts', files); expect(result).toEqual({ 'main.ts': 'const x = 1;', @@ -15,7 +25,7 @@ describe('collectSourceFiles', () => { it('should return empty object when entry file not found', () => { const files = [{ path: 'other.ts', content: 'const x = 1;' }]; - const result = collectSourceFiles('main.ts', files); + const result = collector.collect('main.ts', files); expect(result).toEqual({}); }); @@ -28,7 +38,7 @@ describe('collectSourceFiles', () => { { path: 'helper.ts', content: 'export const foo = 1;' }, ]; - const result = collectSourceFiles('main.ts', files); + const result = collector.collect('main.ts', files); expect(result).toEqual({ 'main.ts': 'import { foo } from "./helper";', @@ -46,7 +56,7 @@ describe('collectSourceFiles', () => { { path: 'utils/shared.ts', content: 'export const bar = 1;' }, ]; - const result = collectSourceFiles('main.ts', files); + const result = collector.collect('main.ts', files); expect(result).toEqual({ 'main.ts': 'import { foo } from "./utils/helper";', @@ -61,7 +71,7 @@ describe('collectSourceFiles', () => { { path: 'utils/helper.ts', content: 'export const foo = 1;' }, ]; - const result = collectSourceFiles('src/main.ts', files); + const result = collector.collect('src/main.ts', files); expect(result).toEqual({ 'src/main.ts': 'import { foo } from "../utils/helper";', @@ -92,7 +102,7 @@ describe('collectSourceFiles', () => { { path: 'helper6.ts', content: 'export const baz = 1;' }, ]; - const result = collectSourceFiles('main.ts', files); + const result = collector.collect('main.ts', files); const keys = Object.keys(result); expect(keys).toHaveLength(7); @@ -118,7 +128,7 @@ describe('collectSourceFiles', () => { { path: 'helper.ts', content: 'export const foo = 1;' }, ]; - const result = collectSourceFiles('main.ts', files); + const result = collector.collect('main.ts', files); const keys = Object.keys(result); expect(keys).toHaveLength(2); @@ -135,7 +145,7 @@ describe('collectSourceFiles', () => { { path: 'helper.ts', content: 'export const foo = 1;' }, ]; - const result = collectSourceFiles('main.ts', files); + const result = collector.collect('main.ts', files); expect(result).toEqual({ 'main.ts': 'import { foo } from "./helper";', @@ -149,7 +159,7 @@ describe('collectSourceFiles', () => { { path: 'helper.jsx', content: 'export const foo = 1;' }, ]; - const result = collectSourceFiles('main.ts', files); + const result = collector.collect('main.ts', files); expect(result).toEqual({ 'main.ts': 'import { foo } from "./helper";', @@ -163,11 +173,7 @@ describe('collectSourceFiles', () => { { path: 'helper.custom', content: 'export const foo = 1;' }, ]; - const options: CollectOptions = { - extensions: ['.custom', '.ts'], - }; - - const result = collectSourceFiles('main.ts', files, options); + const result = collectorWithCustomExtensions.collect('main.ts', files); expect(result).toEqual({ 'main.ts': 'import { foo } from "./helper";', @@ -182,7 +188,7 @@ describe('collectSourceFiles', () => { { path: 'helper.ts', content: 'export const foo = "with-ext";' }, ]; - const result = collectSourceFiles('main.ts', files); + const result = collector.collect('main.ts', files); expect(result['helper']).toBe('export const foo = "exact";'); }); @@ -195,7 +201,7 @@ describe('collectSourceFiles', () => { { path: 'b.ts', content: 'import { a } from "./a"; export const b = 2;' }, ]; - const result = collectSourceFiles('a.ts', files); + const result = collector.collect('a.ts', files); expect(result).toEqual({ 'a.ts': 'import { b } from "./b"; export const a = 1;', @@ -208,7 +214,7 @@ describe('collectSourceFiles', () => { { path: 'main.ts', content: 'import { foo } from "./main"; export const foo = 1;' }, ]; - const result = collectSourceFiles('main.ts', files); + const result = collector.collect('main.ts', files); expect(result).toEqual({ 'main.ts': 'import { foo } from "./main"; export const foo = 1;', @@ -223,11 +229,7 @@ describe('collectSourceFiles', () => { { path: 'helper.ts', content: 'export const foo = 1;' }, ]; - const options: CollectOptions = { - includeContent: false, - }; - - const result = collectSourceFiles('main.ts', files, options); + const result = collectorWithoutContent.collect('main.ts', files); expect(result).toEqual({ 'main.ts': '', @@ -238,8 +240,9 @@ describe('collectSourceFiles', () => { it('should use default options when not provided', () => { const files = [{ path: 'main.ts', content: 'const x = 1;' }]; - const result1 = collectSourceFiles('main.ts', files); - const result2 = collectSourceFiles('main.ts', files, {}); + const result1 = collector.collect('main.ts', files); + const collector2 = new SourceCollector({}); + const result2 = collector2.collect('main.ts', files); expect(result1).toEqual(result2); }); @@ -249,7 +252,7 @@ describe('collectSourceFiles', () => { it('should handle empty file content', () => { const files = [{ path: 'main.ts', content: '' }]; - const result = collectSourceFiles('main.ts', files); + const result = collector.collect('main.ts', files); expect(result).toEqual({ 'main.ts': '', @@ -261,7 +264,7 @@ describe('collectSourceFiles', () => { { path: 'main.ts', content: '// This is a comment\n/* Block comment */' }, ]; - const result = collectSourceFiles('main.ts', files); + const result = collector.collect('main.ts', files); expect(result).toEqual({ 'main.ts': '// This is a comment\n/* Block comment */', @@ -282,7 +285,7 @@ describe('collectSourceFiles', () => { { path: 'helper.ts', content: 'export const foo = 1;' }, ]; - const result = collectSourceFiles('main.ts', files); + const result = collector.collect('main.ts', files); const keys = Object.keys(result); expect(keys).toHaveLength(2); @@ -304,7 +307,7 @@ describe('collectSourceFiles', () => { { path: 'helper.ts', content: 'export const foo = 1;' }, ]; - const result = collectSourceFiles('main.ts', files); + const result = collector.collect('main.ts', files); const keys = Object.keys(result); // Should only collect the real import, not the ones in comments/strings @@ -320,7 +323,7 @@ describe('collectSourceFiles', () => { { path: 'absolute/helper.ts', content: 'export const foo = 1;' }, ]; - const result = collectSourceFiles('main.ts', files); + const result = collector.collect('main.ts', files); expect(result).toEqual({ 'main.ts': 'import { foo } from "/absolute/helper";', @@ -331,7 +334,7 @@ describe('collectSourceFiles', () => { it('should handle missing intermediate directories', () => { const files = [{ path: 'main.ts', content: 'import { foo } from "./missing/helper";' }]; - const result = collectSourceFiles('main.ts', files); + const result = collector.collect('main.ts', files); expect(result).toEqual({ 'main.ts': 'import { foo } from "./missing/helper";', @@ -354,7 +357,7 @@ describe('collectSourceFiles', () => { { path: 'src/components/types.ts', content: 'export interface Props {}' }, ]; - const result = collectSourceFiles('src/components/Button/index.ts', files); + const result = collector.collect('src/components/Button/index.ts', files); const keys = Object.keys(result); expect(keys).toHaveLength(4); @@ -382,7 +385,7 @@ describe('collectSourceFiles', () => { { path: 'helper.ts', content: 'export const helper = {};' }, ]; - const result = collectSourceFiles('main.ts', files); + const result = collector.collect('main.ts', files); const keys = Object.keys(result); expect(keys).toHaveLength(5); diff --git a/packages/parse/src/collect.ts b/packages/parse/src/collect.ts index 0c83a38..2b5e206 100644 --- a/packages/parse/src/collect.ts +++ b/packages/parse/src/collect.ts @@ -1,3 +1,7 @@ +import { parse, ParserOptions } from '@babel/parser'; +import traverse, { NodePath } from '@babel/traverse'; +import * as t from '@babel/types'; + /** * File map type */ @@ -21,150 +25,434 @@ export interface CollectOptions { } /** - * Extract import paths from TypeScript/JavaScript content + * Represents a parsed file with its AST and metadata */ -function extractImportPaths(content: string): string[] { - const imports: string[] = []; - - // Regex patterns for different import styles - const importPatterns = [ - /import\s+.*?from\s+['"](.*?)['"]/g, // import ... from '...' - /import\s*\(\s*['"](.*?)['"]\s*\)/g, // import('...') - /require\s*\(\s*['"](.*?)['"]\s*\)/g, // require('...') - /export\s+.*?from\s+['"](.*?)['"]/g, // export ... from '...' - ]; - - for (const pattern of importPatterns) { - let match; - while ((match = pattern.exec(content)) !== null) { - const importPath = match[1]; - if (importPath && isLocalImport(importPath)) { - imports.push(importPath); - } - } - } +interface ParsedFile { + path: string; + content: string; + ast: t.File | null; + imports: ImportInfo[]; + exports: ExportInfo[]; +} - return imports; +/** + * Import information extracted from AST + */ +interface ImportInfo { + source: string; + type: 'import' | 'require' | 'dynamic-import'; + specifiers: string[]; + resolvedPath?: string; } /** - * Check if an import path is local (not from node_modules) + * Export information extracted from AST */ -function isLocalImport(importPath: string): boolean { - return ( - importPath.startsWith('./') || importPath.startsWith('../') || importPath.startsWith('/') - ); +interface ExportInfo { + source?: string; + specifiers: string[]; + type: 'named' | 'default' | 'namespace'; } /** - * Resolve import path relative to current directory + * Dependency graph node */ -function resolveImportPath(importPath: string, currentDir: string): string { - // Handle absolute paths - if (importPath.startsWith('/')) { - return importPath.substring(1); // Remove leading slash +interface DependencyNode { + path: string; + dependencies: Set; + dependents: Set; +} + +/** + * AST-based source file collector + */ +export class SourceCollector { + private files: Map = new Map(); + private dependencyGraph: Map = new Map(); + private extensions: string[]; + private includeContent: boolean; + + constructor(options: CollectOptions = {}) { + this.extensions = options.extensions ?? ['.ts', '.tsx', '.js', '.jsx']; + this.includeContent = options.includeContent ?? true; } - // Handle relative paths - if (importPath.startsWith('./')) { - const cleanPath = importPath.substring(2); - return currentDir ? `${currentDir}/${cleanPath}` : cleanPath; + /** + * Get parser options based on file extension + */ + private getParserOptions(filePath: string): ParserOptions { + const ext = this.getFileExtension(filePath); + const isTypeScript = ext === '.ts' || ext === '.tsx'; + const isJSX = ext === '.tsx' || ext === '.jsx'; + + return { + sourceType: 'module', + allowImportExportEverywhere: true, + allowReturnOutsideFunction: true, + errorRecovery: true, + plugins: [ + ...(isTypeScript ? ['typescript' as const] : []), + ...(isJSX ? ['jsx' as const] : []), + 'decorators-legacy', + 'classProperties', + 'objectRestSpread', + 'asyncGenerators', + 'functionBind', + 'exportDefaultFrom', + 'exportNamespaceFrom', + 'dynamicImport', + 'nullishCoalescingOperator', + 'optionalChaining', + 'optionalCatchBinding', + 'throwExpressions', + 'topLevelAwait', + ], + }; } - if (importPath.startsWith('../')) { - const pathParts = currentDir.split('/').filter((part) => part !== ''); - const importParts = importPath.split('/'); + /** + * Parse a single file into AST and extract import/export information + */ + private parseFile(path: string, content: string): ParsedFile { + const parsedFile: ParsedFile = { + path, + content, + ast: null, + imports: [], + exports: [], + }; - // Process ../ parts - let i = 0; - while (i < importParts.length && importParts[i] === '..') { - pathParts.pop(); // Go up one directory - i++; + try { + const parserOptions = this.getParserOptions(path); + parsedFile.ast = parse(content, parserOptions); + + if (parsedFile.ast) { + this.extractImportsAndExports(parsedFile); + } + } catch (error) { + // If AST parsing fails, try to extract basic imports with regex fallback + parsedFile.imports = this.extractImportsWithRegex(content); } - // Add remaining import parts - const remainingParts = importParts.slice(i); - return [...pathParts, ...remainingParts].join('/'); + return parsedFile; } - // Handle direct paths (shouldn't happen for local imports but just in case) - return currentDir ? `${currentDir}/${importPath}` : importPath; -} + /** + * Extract imports and exports from AST + */ + private extractImportsAndExports(parsedFile: ParsedFile): void { + if (!parsedFile.ast) return; -/** - * Get parent directory path - */ -function getParentPath(path: string): string { - return path.split('/').slice(0, -1).join('/'); -} + traverse(parsedFile.ast, { + // Handle: import ... from '...' + ImportDeclaration(path: NodePath) { + const source = path.node.source.value; + const specifiers = path.node.specifiers.map((spec) => { + if (t.isImportDefaultSpecifier(spec)) return 'default'; + if (t.isImportNamespaceSpecifier(spec)) return '*'; + return spec.imported.type === 'Identifier' + ? spec.imported.name + : spec.imported.value; + }); -/** - * Collect source files and their dependencies - * - * @param entryFile - The entry point file path - * @param files - Array of file objects with path and content - * @param options - Collection options - * @returns Map of file paths to their content - */ -export function collectSourceFiles( - entryFile: string, - files: Array<{ path: string; content: string }>, - options: CollectOptions = {} -): FileMap { - const { extensions = ['.ts', '.tsx', '.js', '.jsx'], includeContent = true } = options; + parsedFile.imports.push({ + source, + type: 'import', + specifiers, + }); + }, + + // Handle: export ... from '...' + ExportNamedDeclaration(path: NodePath) { + const source = path.node.source?.value; + const specifiers = + path.node.specifiers?.map((spec) => { + if (t.isExportDefaultSpecifier(spec)) return 'default'; + if (t.isExportNamespaceSpecifier(spec)) return '*'; + return spec.exported.type === 'Identifier' + ? spec.exported.name + : spec.exported.value; + }) ?? []; + + if (source) { + // Re-export from another module + parsedFile.imports.push({ + source, + type: 'import', + specifiers: specifiers.length > 0 ? specifiers : ['*'], + }); + } + + parsedFile.exports.push({ + source, + specifiers, + type: 'named', + }); + }, + + // Handle: export * from '...' + ExportAllDeclaration(path: NodePath) { + const source = path.node.source.value; + + parsedFile.imports.push({ + source, + type: 'import', + specifiers: ['*'], + }); + + parsedFile.exports.push({ + source, + specifiers: ['*'], + type: 'namespace', + }); + }, + + // Handle: export default ... + ExportDefaultDeclaration(path: NodePath) { + parsedFile.exports.push({ + specifiers: ['default'], + type: 'default', + }); + }, + + // Handle: import('...') and require('...') + CallExpression(path: NodePath) { + const callee = path.node.callee; + + // Dynamic import() + if (t.isImport(callee)) { + const arg = path.node.arguments[0]; + if (t.isStringLiteral(arg)) { + parsedFile.imports.push({ + source: arg.value, + type: 'dynamic-import', + specifiers: ['*'], + }); + } + } + + // require('...') + if (t.isIdentifier(callee) && callee.name === 'require') { + const arg = path.node.arguments[0]; + if (t.isStringLiteral(arg)) { + parsedFile.imports.push({ + source: arg.value, + type: 'require', + specifiers: ['*'], + }); + } + } + }, + }); + } + + /** + * Fallback regex-based import extraction + */ + private extractImportsWithRegex(content: string): ImportInfo[] { + const imports: ImportInfo[] = []; + + const importPatterns = [ + { pattern: /import\s+.*?from\s+['"](.*?)['"]/g, type: 'import' as const }, + { pattern: /import\s*\(\s*['"](.*?)['"]\s*\)/g, type: 'dynamic-import' as const }, + { pattern: /require\s*\(\s*['"](.*?)['"]\s*\)/g, type: 'require' as const }, + { pattern: /export\s+.*?from\s+['"](.*?)['"]/g, type: 'import' as const }, + ]; + + for (const { pattern, type } of importPatterns) { + let match; + while ((match = pattern.exec(content)) !== null) { + const source = match[1]; + if (source && this.isLocalImport(source)) { + imports.push({ + source, + type, + specifiers: ['*'], + }); + } + } + } + + return imports; + } + + /** + * Check if an import path is local + */ + private isLocalImport(importPath: string): boolean { + return ( + importPath.startsWith('./') || importPath.startsWith('../') || importPath.startsWith('/') + ); + } - // Create file map for quick lookup - const fileMap = Object.fromEntries(files.map((file) => [file.path, file.content])); + /** + * Get file extension + */ + private getFileExtension(filePath: string): string { + const lastDot = filePath.lastIndexOf('.'); + return lastDot !== -1 ? filePath.substring(lastDot) : ''; + } + + /** + * Resolve import path relative to current file + */ + private resolveImportPath(importPath: string, currentFilePath: string): string { + const currentDir = this.getParentPath(currentFilePath); + + // Handle absolute paths + if (importPath.startsWith('/')) { + return importPath.substring(1); + } + + // Handle relative paths + if (importPath.startsWith('./')) { + const cleanPath = importPath.substring(2); + return currentDir ? `${currentDir}/${cleanPath}` : cleanPath; + } + + if (importPath.startsWith('../')) { + const pathParts = currentDir.split('/').filter((part) => part !== ''); + const importParts = importPath.split('/'); + + let i = 0; + while (i < importParts.length && importParts[i] === '..') { + pathParts.pop(); + i++; + } - const result: FileMap = {}; - const visited = new Set(); + const remainingParts = importParts.slice(i); + return [...pathParts, ...remainingParts].join('/'); + } - function readFile(filePath: string): string | null { - return fileMap[filePath] ?? null; + return currentDir ? `${currentDir}/${importPath}` : importPath; } - function tryReadFile(filePath: string): { content: string; actualPath: string } | null { + /** + * Get parent directory path + */ + private getParentPath(path: string): string { + return path.split('/').slice(0, -1).join('/'); + } + + /** + * Try to find a file with different extensions + */ + private findFileWithExtensions(basePath: string): string | null { // Try exact path first - let content = readFile(filePath); - if (content !== null) { - return { content, actualPath: filePath }; + if (this.files.has(basePath)) { + return basePath; } // Try with extensions - for (const ext of extensions) { - const tryPath = filePath + ext; - const tryContent = readFile(tryPath); - if (tryContent !== null) { - return { content: tryContent, actualPath: tryPath }; + for (const ext of this.extensions) { + const tryPath = basePath + ext; + if (this.files.has(tryPath)) { + return tryPath; } } return null; } - function traverse(filePath: string): void { - if (visited.has(filePath)) return; - visited.add(filePath); - - const fileResult = tryReadFile(filePath); - if (!fileResult) return; - - // Store content (or empty string if not including content) - result[fileResult.actualPath] = includeContent ? fileResult.content : ''; - - // Process imports - const imports = extractImportPaths(fileResult.content); - for (const importPath of imports) { - if (isLocalImport(importPath)) { - const resolvedPath = resolveImportPath( - importPath, - getParentPath(fileResult.actualPath) - ); - traverse(resolvedPath); + /** + * Build dependency graph from parsed files + */ + private buildDependencyGraph(): void { + // Initialize nodes + for (const [path, parsedFile] of this.files) { + this.dependencyGraph.set(path, { + path, + dependencies: new Set(), + dependents: new Set(), + }); + } + + // Resolve imports and build edges + for (const [path, parsedFile] of this.files) { + const node = this.dependencyGraph.get(path)!; + + for (const importInfo of parsedFile.imports) { + if (this.isLocalImport(importInfo.source)) { + const resolvedPath = this.resolveImportPath(importInfo.source, path); + const actualPath = this.findFileWithExtensions(resolvedPath); + + if (actualPath) { + importInfo.resolvedPath = actualPath; + node.dependencies.add(actualPath); + + const dependentNode = this.dependencyGraph.get(actualPath); + if (dependentNode) { + dependentNode.dependents.add(path); + } + } + } } } } - traverse(entryFile); - return result; + /** + * Collect files starting from entry point using dependency graph + */ + private collectFromEntry(entryPath: string): Set { + const collected = new Set(); + const queue = [entryPath]; + + while (queue.length > 0) { + const currentPath = queue.shift()!; + + if (collected.has(currentPath)) continue; + if (!this.files.has(currentPath)) continue; + + collected.add(currentPath); + + const node = this.dependencyGraph.get(currentPath); + if (node) { + for (const dependency of node.dependencies) { + if (!collected.has(dependency)) { + queue.push(dependency); + } + } + } + } + + return collected; + } + + /** + * Main collection method + */ + collect(entryFile: string, files: Array<{ path: string; content: string }>): FileMap { + // Clear previous state + this.files.clear(); + this.dependencyGraph.clear(); + + // Parse all files into ASTs + for (const file of files) { + const parsedFile = this.parseFile(file.path, file.content); + this.files.set(file.path, parsedFile); + } + + // Build dependency graph + this.buildDependencyGraph(); + + // Find the actual entry file (try with extensions if needed) + const actualEntryPath = this.findFileWithExtensions(entryFile); + if (!actualEntryPath) { + return {}; + } + + // Collect all dependencies from entry point + const collectedPaths = this.collectFromEntry(actualEntryPath); + + // Build result map + const result: FileMap = {}; + for (const path of collectedPaths) { + const parsedFile = this.files.get(path); + if (parsedFile) { + result[path] = this.includeContent ? parsedFile.content : ''; + } + } + + return result; + } } diff --git a/packages/parse/src/index.ts b/packages/parse/src/index.ts index 46de708..7105d55 100644 --- a/packages/parse/src/index.ts +++ b/packages/parse/src/index.ts @@ -1,2 +1,3 @@ export * from './ContractAnalyzer'; export * from './types'; +export * from './collect'; From 0f7c348b5627a243a3720e64097b6a05e0572510 Mon Sep 17 00:00:00 2001 From: luca Date: Tue, 3 Jun 2025 22:31:11 +0800 Subject: [PATCH 4/4] fix: resolve excessive relative import --- packages/parse/__tests__/collect.test.ts | 35 ++++++++++++++++++++++++ packages/parse/src/collect.ts | 9 +++++- 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/packages/parse/__tests__/collect.test.ts b/packages/parse/__tests__/collect.test.ts index 893a66f..ec22e41 100644 --- a/packages/parse/__tests__/collect.test.ts +++ b/packages/parse/__tests__/collect.test.ts @@ -259,6 +259,41 @@ describe('SourceCollector', () => { }); }); + it('should not resolve excessive ../ paths that go beyond root', () => { + const files = [ + { + path: 'packages/parse/src/main.ts', + content: 'import { dec } from "../../../../docs/test.ts";', + }, + { path: 'docs/test.ts', content: 'export const dec = 1;' }, + ]; + + const result = collector.collect('packages/parse/src/main.ts', files); + + // Should only include the entry file, not the incorrectly resolved target + expect(result).toEqual({ + 'packages/parse/src/main.ts': 'import { dec } from "../../../../docs/test.ts";', + }); + expect(result['docs/test.ts']).toBeUndefined(); + }); + + it('should handle multiple excessive ../ at the beginning', () => { + const files = [ + { + path: 'src/main.ts', + content: 'import { foo } from "../../../../../../../../utils/helper.ts";', + }, + { path: 'utils/helper.ts', content: 'export const foo = 1;' }, + ]; + + const result = collector.collect('src/main.ts', files); + + expect(result).toEqual({ + 'src/main.ts': 'import { foo } from "../../../../../../../../utils/helper.ts";', + }); + expect(result['utils/helper.ts']).toBeUndefined(); + }); + it('should handle files with only comments', () => { const files = [ { path: 'main.ts', content: '// This is a comment\n/* Block comment */' }, diff --git a/packages/parse/src/collect.ts b/packages/parse/src/collect.ts index 2b5e206..1ba429a 100644 --- a/packages/parse/src/collect.ts +++ b/packages/parse/src/collect.ts @@ -315,11 +315,18 @@ export class SourceCollector { const importParts = importPath.split('/'); let i = 0; - while (i < importParts.length && importParts[i] === '..') { + while (i < importParts.length && importParts[i] === '..' && pathParts.length > 0) { pathParts.pop(); i++; } + // If there are still '..' parts but no more parent directories, + // the path is invalid (goes beyond root) + if (i < importParts.length && importParts[i] === '..') { + // Return an invalid path that won't resolve + return `__INVALID_PATH__/${importPath}`; + } + const remainingParts = importParts.slice(i); return [...pathParts, ...remainingParts].join('/'); }