Repository: artusjs/core (ts)

Issue:
RFC: manifest.json 文件内容
```json

{
 // 用户项目结构
  "app": {
    "items": [
      {
        "path": "/Users/ssk/Code/core/test/fixtures/app-koa-with-ts/src/bootstrap",
        "extname": ".ts",
        "filename": "bootstrap.ts"
      },
      {
        "path": "/Users/ssk/Code/core/test/fixtures/app-koa-with-ts/src/controllers/hello",
        "extname": ".ts",
        "filename": "hello.ts"
      },
      {
        "path": "/Users/ssk/Code/core/test/fixtures/app-koa-with-ts/src/httpTrigger",
        "extname": ".ts",
        "filename": "httpTrigger.ts"
      },
      {
        "path": "/Users/ssk/Code/core/test/fixtures/app-koa-with-ts/src/koaApp",
        "extname": ".ts",
        "filename": "koaApp.ts"
      },
      {
        "path": "/Users/ssk/Code/core/test/fixtures/app-koa-with-ts/src/services/hello",
        "extname": ".ts",
        "filename": "hello.ts"
      }
    ],
    "packageJson": {
      "path": "/Users/ssk/Code/core/test/fixtures/app-koa-with-ts/package.json",
      "extname": ".json",
      "filename": "package.json"
    },
    "config": [
      {
        "path": "/Users/ssk/Code/core/test/fixtures/app-koa-with-ts/src/config/config.default",
        "extname": ".ts",
        "filename": "config.default.ts"
      }
    ],
    "pluginConfig": [
      {
        "path": "/Users/ssk/Code/core/test/fixtures/app-koa-with-ts/src/config/plugin.default",
        "extname": ".ts",
        "filename": "plugin.default.ts"
      }
    ],
    "exception": [
      {
        "path": "/Users/ssk/Code/core/test/fixtures/app-koa-with-ts/src/artus-exception.yaml",
        "extname": ".yaml",
        "filename": "artus-exception.yaml"
      }
    ],
    "extension": [
      {
        "path": "/Users/ssk/Code/core/test/fixtures/app-koa-with-ts/src/app",
        "extname": ".ts",
        "filename": "app.ts"
      }
    ]
  },
 // 插件列表
  "plugins": {
    "redis": {
      "pluginMeta": {
        "path": "/Users/ssk/Code/core/test/fixtures/app-koa-with-ts/src/redis-plugin/meta",
        "extname": ".ts",
        "filename": "meta.ts"
      },
      "extension": [
        {
          "path": "/Users/ssk/Code/core/test/fixtures/app-koa-with-ts/src/redis-plugin/app",
          "extname": ".ts",
          "filename": "app.ts"
        }
      ]
    }
  }
}

```

Pull request description:
feat: add scanner
close #30 

Patch:
diff --git a/package.json b/package.json
index 89e8d67..97f4aed 100644
--- a/package.json
+++ b/package.json
@@ -22,6 +22,7 @@
     "@types/jest": "^27.4.1",
     "@types/js-yaml": "^4.0.5",
     "@types/koa": "^2.13.4",
+    "@types/minimatch": "^3.0.5",
     "axios": "^0.26.1",
     "egg-ci": "^1.19.0",
     "jest": "^27.5.1",
@@ -34,7 +35,8 @@
     "@artus/injection": "^0.1.1",
     "@artus/pipeline": "^0.1.6",
     "deepmerge": "^4.2.2",
-    "js-yaml": "^4.1.0"
+    "js-yaml": "^4.1.0",
+    "minimatch": "^5.0.1"
   },
   "ci": {
     "type": "github",
diff --git a/src/constraints.ts b/src/constraints.ts
index 26eb0e2..e07c466 100644
--- a/src/constraints.ts
+++ b/src/constraints.ts
@@ -20,3 +20,22 @@ export const HOOK_CONSTRUCTOR_PARAMS = 'appHook:constructorParams';
 export const HOOK_CONSTRUCTOR_PARAMS_APP = 'appHook:constructorParams:app';
 export const HOOK_CONSTRUCTOR_PARAMS_CONTAINER = 'appHook:constructorParams:container';
 export const HOOK_NAME_META_PREFIX = 'hookName:';
+
+export const DEFAULT_EXCLUDES = [
+  'test',
+  'node_modules',
+  '.*',
+  'tsconfig*.json',
+  '*.d.ts',
+  'jest.config.*',
+  'package.json',
+  'meta.*',
+  'LICENSE'
+];
+
+export const PLUGIN_CONFIG_PATTERN = 'plugin.*';
+export const CONFIG_PATTERN = 'config.*';
+export const EXTENSION_PATTERN = 'app.*';
+export const PLUGIN_META = 'meta';
+export const PACKAGE_JSON = 'package.json';
+export const EXCEPTION_FILE = 'artus-exception.yaml';
diff --git a/src/index.ts b/src/index.ts
index 28af0dc..5e02929 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -13,6 +13,7 @@ export * from './lifecycle';
 export * from './exception';
 export * from './plugin';
 export * from './application';
+export * from './scanner';
 
 import Trigger from './trigger';
 export { Trigger };
diff --git a/src/loader/types.ts b/src/loader/types.ts
index d711339..e8f536a 100644
--- a/src/loader/types.ts
+++ b/src/loader/types.ts
@@ -1,6 +1,6 @@
 import { Container } from '@artus/injection';
 
-type LoaderType = 'module'|'file'|string;
+type LoaderType = 'module' | 'file' | string;
 
 interface Manifest {
   rootDir: string;
diff --git a/src/scanner/index.ts b/src/scanner/index.ts
new file mode 100644
index 0000000..9ba6b7c
--- /dev/null
+++ b/src/scanner/index.ts
@@ -0,0 +1,4 @@
+
+export * from './scan';
+
+export * from './types';
\ No newline at end of file
diff --git a/src/scanner/scan.ts b/src/scanner/scan.ts
new file mode 100644
index 0000000..6e859ac
--- /dev/null
+++ b/src/scanner/scan.ts
@@ -0,0 +1,220 @@
+import *  as fs from 'fs/promises';
+import { existsSync } from 'fs';
+import * as path from 'path';
+import { getDefaultExtensions, isMatch } from '../utils';
+import compatibleRequire from '../utils/compatible-require';
+import {
+    CONFIG_PATTERN,
+    DEFAULT_EXCLUDES,
+    PLUGIN_CONFIG_PATTERN,
+    PLUGIN_META,
+    PACKAGE_JSON,
+    EXCEPTION_FILE,
+    EXTENSION_PATTERN
+} from '../constraints';
+import {
+    ScannerOptions,
+    ScannerItem,
+    ScannerUnit,
+    ScannerManifest
+} from './types';
+
+export class Scanner {
+    private options: ScannerOptions;
+    private moduleExtensions: string[];
+    constructor(options: Partial<ScannerOptions> = {}) {
+        this.moduleExtensions = getDefaultExtensions();
+        this.options = {
+            appName: 'app',
+            needWriteFile: true,
+            ...options,
+            excluded: DEFAULT_EXCLUDES.concat(options.excluded ?? []),
+            extensions: this.moduleExtensions.concat(options.extensions ?? [], ['.yaml']),
+        };
+    }
+
+    public async scan(root: string) {
+        const manifest: ScannerManifest = {};
+        await this.scanUnit(this.options.appName!, root, manifest);
+        const { [this.options.appName]: app, ...others } = manifest;
+        const result = { app, plugins: {} };
+        Object.keys(others).forEach(name => {
+            result.plugins[name] = others[name];
+        });
+        if (this.options.needWriteFile) {
+            this.writeFile('manifest.json', JSON.stringify(result, null, 2));
+        }
+        return result;
+    }
+
+    private async scanUnit(appName: string, root: string, manifest: ScannerManifest) {
+        const result = await this.walk(root);
+        const packageJson = this.getPackageJson(root);
+        const pluginMeta = this.getPluginMeta(root);
+        manifest[appName] = {
+            packageJson: packageJson ? { path: packageJson, extname: '.json', filename: PACKAGE_JSON } : undefined,
+            pluginMeta: pluginMeta ? { ...pluginMeta, filename: `${PLUGIN_META}${pluginMeta.extname}` } : undefined,
+            items: this.filterArray(result.items),
+            config: this.filterArray(result.config),
+            pluginConfig: this.filterArray(result.pluginConfig),
+            exception: this.filterArray(result.exception),
+            extension: this.filterArray(result.extension),
+        };
+
+        for (const pluginItem of result.pluginConfig) {
+            const pluginConfig = compatibleRequire(pluginItem.path);
+            if (!Array.isArray(pluginConfig)) {
+                continue;
+            }
+            for (const plugin of pluginConfig) {
+                if (!manifest[plugin.name]) {
+                    let pluginPath = plugin.path;
+                    if (plugin.package) {
+                        pluginPath = require.resolve(plugin.package);
+                    }
+                    await this.scanUnit(plugin.name, pluginPath, manifest);
+                }
+            }
+        }
+    }
+
+    private async walk(root: string) {
+        const result: ScannerUnit = {
+            items: [],
+            config: [],
+            pluginConfig: [],
+            exception: [],
+            extension: [],
+        };
+        if (!existsSync(root)) {
+            return result;
+        }
+
+        const stat = await fs.stat(root);
+        if (!stat.isDirectory()) {
+            return result;
+        }
+
+        const items = await fs.readdir(root);
+        for (const item of items) {
+            const realPath = path.resolve(root, item);
+            const extname = path.extname(realPath);
+            if (this.isExclude(item, extname)) {
+                continue;
+            }
+            const itemStat = await fs.stat(realPath);
+            if (itemStat.isDirectory()) {
+                // ignore plugin dir
+                // TODO:  怎么判断是否是插件文件夹
+                if (this.exist(realPath, PLUGIN_META)) {
+                    continue;
+                }
+                const subDir = await this.walk(realPath);
+                result.items = result.items.concat(subDir.items);
+                result.config = result.config.concat(subDir.config);
+                result.pluginConfig = result.pluginConfig.concat(subDir.pluginConfig);
+                result.exception = result.exception.concat(subDir.exception);
+                result.extension = result.extension.concat(subDir.extension);
+                continue;
+            }
+
+            if (itemStat.isFile()) {
+                const filename = path.basename(realPath);
+                const filenameWithoutExt = path.basename(realPath, extname);
+                const item: ScannerItem = {
+                    path: this.moduleExtensions.includes(extname) ? path.resolve(root, filenameWithoutExt) : realPath,
+                    extname,
+                    filename,
+                };
+
+                // TODO: 后面考虑重构通过参数传递匹配规则
+                switch (true) {
+                    case this.isConfig(filename):
+                        result.config.push(item)
+                        break;
+                    case this.isPluginConfig(filename):
+                        result.pluginConfig.push(item);
+                        break;
+                    case this.isException(filename):
+                        result.exception.push(item);
+                        break;
+                    case this.isExtension(filename):
+                        result.extension.push(item);
+                        break;
+                    default: result.items.push(item);
+                }
+            }
+        }
+        return result;
+    }
+
+    /**
+     * ignore excluded match and extensions not match
+     * @param {string} filename 
+     * @param {string} extname 
+     * @returns {Boolean}
+     */
+    private isExclude(filename: string, extname: string): boolean {
+        let result = false;
+        const { excluded } = this.options;
+        if (!result && excluded) {
+            result = isMatch(filename, excluded);
+        }
+
+        if (!result && extname) {
+            result = !this.options.extensions.includes(extname);
+        }
+        return result;
+    }
+
+    // TODO: 怎么判断是否是配置文件
+    private isConfig(filename: string): boolean {
+        return isMatch(filename, CONFIG_PATTERN)
+    }
+
+    // TODO: 怎么判断是否是插件配置文件
+    private isPluginConfig(filename: string): boolean {
+        return isMatch(filename, PLUGIN_CONFIG_PATTERN);
+    }
+
+    // TODO:
+    private isException(filename: string): boolean {
+        return isMatch(filename, EXCEPTION_FILE);
+    }
+
+    // TODO:
+    private isExtension(filename: string,): boolean {
+        return isMatch(filename, EXTENSION_PATTERN);
+    }
+
+    private getPackageJson(dir: string) {
+        if (existsSync(path.resolve(dir, PACKAGE_JSON))) {
+            return path.resolve(dir, PACKAGE_JSON);
+        }
+        return undefined;
+    }
+
+    private getPluginMeta(dir: string) {
+        const extname = this.options.extensions.find(ext => {
+            return existsSync(path.resolve(dir, `${PLUGIN_META}${ext}`));
+        });
+        return extname ? { path: path.resolve(dir, PLUGIN_META), extname } : undefined;
+    }
+
+    private filterArray<T = any>(arr: T[]) {
+        if (!Array.isArray(arr) || !arr.length) {
+            return undefined;
+        }
+        return arr;
+    }
+
+    private exist(dir: string, filename: string): boolean {
+        return this.options.extensions.some(ext => {
+            return existsSync(path.resolve(dir, `${filename}${ext}`));
+        });
+    }
+
+    private async writeFile(filename: string = 'manifest.json', data: string) {
+        await fs.writeFile(filename, data);
+    }
+}
\ No newline at end of file
diff --git a/src/scanner/types.ts b/src/scanner/types.ts
new file mode 100644
index 0000000..f83c6f3
--- /dev/null
+++ b/src/scanner/types.ts
@@ -0,0 +1,27 @@
+
+
+export interface ScannerOptions {
+    appName: string;
+    extensions: string[];
+    needWriteFile: boolean;
+    excluded?: string[];
+}
+export interface ScannerItem {
+    path: string;
+    extname: string;
+    filename: string;
+}
+export interface ScannerUnit {
+    items: ScannerItem[];
+    packageJson?: ScannerItem;
+    pluginMeta?: ScannerItem;
+    config: ScannerItem[];
+    pluginConfig: ScannerItem[];
+    extension: ScannerItem[];
+    exception: ScannerItem[];
+
+}
+
+export interface ScannerManifest {
+    [name: string]: Partial<ScannerUnit>;
+}
\ No newline at end of file
diff --git a/src/utils/index.ts b/src/utils/index.ts
index 2390333..69ef646 100644
--- a/src/utils/index.ts
+++ b/src/utils/index.ts
@@ -1,8 +1,7 @@
 import path from 'path';
 import fs from 'fs';
 import compatibleRequire from './compatible-require';
-
-
+import minimatch from 'minimatch';
 
 export const loadFile = async (filepath: string) => {
   try {
@@ -16,4 +15,13 @@ export const loadFile = async (filepath: string) => {
   }
 }
 
+export function getDefaultExtensions() {
+  return Object.keys(require.extensions)
+}
 
+export function isMatch(filename: string, patterns: string | string[]) {
+  if (!Array.isArray(patterns)) {
+    patterns = [patterns];
+  }
+  return patterns.some(pattern => minimatch(filename, pattern));
+}


Test patch:
diff --git a/test/fixtures/app-koa-with-ts/src/artus-exception.yaml b/test/fixtures/app-koa-with-ts/src/artus-exception.yaml
new file mode 100644
index 0000000..7059258
--- /dev/null
+++ b/test/fixtures/app-koa-with-ts/src/artus-exception.yaml
@@ -0,0 +1,8 @@
+ARTUS:GLOBAL_TEST:
+  desc: 全局测试错误，仅用于单元测试
+  detailUrl: https://github.com/artusjs/spec
+ARTUS:GLOBAL_TEST_I18N:
+  desc:
+    zh: 全局测试错误，仅用于单元测试
+    en: This is a test exception, only valid in unit-test
+  detailUrl: https://github.com/artusjs/spec
diff --git a/test/fixtures/app-koa-with-ts/src/config/config.default.ts b/test/fixtures/app-koa-with-ts/src/config/config.default.ts
new file mode 100644
index 0000000..9f4e203
--- /dev/null
+++ b/test/fixtures/app-koa-with-ts/src/config/config.default.ts
@@ -0,0 +1,5 @@
+
+
+export default {
+    name: 'artus'
+}
\ No newline at end of file
diff --git a/test/fixtures/app-koa-with-ts/src/config/plugin.default.ts b/test/fixtures/app-koa-with-ts/src/config/plugin.default.ts
new file mode 100644
index 0000000..93bf125
--- /dev/null
+++ b/test/fixtures/app-koa-with-ts/src/config/plugin.default.ts
@@ -0,0 +1,5 @@
+
+import path from 'path';
+export default [
+    { name: 'redis', path: path.resolve(__dirname, '../redis-plugin') },
+]
\ No newline at end of file
diff --git a/test/fixtures/app-koa-with-ts/src/redis-plugin/app.ts b/test/fixtures/app-koa-with-ts/src/redis-plugin/app.ts
new file mode 100644
index 0000000..6215eb8
--- /dev/null
+++ b/test/fixtures/app-koa-with-ts/src/redis-plugin/app.ts
@@ -0,0 +1,6 @@
+
+
+export default class Hook {
+    async willReady() {
+    }
+}
\ No newline at end of file
diff --git a/test/fixtures/app-koa-with-ts/src/redis-plugin/meta.ts b/test/fixtures/app-koa-with-ts/src/redis-plugin/meta.ts
new file mode 100644
index 0000000..96cc084
--- /dev/null
+++ b/test/fixtures/app-koa-with-ts/src/redis-plugin/meta.ts
@@ -0,0 +1,5 @@
+
+export default {
+    name: "redis",
+    dependencies: []
+}
\ No newline at end of file
diff --git a/test/scanner.test.ts b/test/scanner.test.ts
new file mode 100644
index 0000000..6ef85b1
--- /dev/null
+++ b/test/scanner.test.ts
@@ -0,0 +1,24 @@
+import { Scanner } from '../src/scanner';
+import path from 'path';
+
+
+describe('test/scanner.test.ts', () => {
+    it('should scan application', async () => {
+        const scanner = new Scanner({ needWriteFile: false, extensions: ['.ts', '.js', '.json'] });
+        const manifest = await scanner.scan(path.resolve(__dirname, './fixtures/app-koa-with-ts'));
+        expect(manifest).toBeDefined();
+        const appManifest = manifest.app;
+        expect(appManifest.items).toBeDefined();
+        expect(appManifest.packageJson).toBeDefined();
+        expect(appManifest.config!.length).toBe(1);
+        expect(appManifest.pluginConfig!.length).toBe(1);
+        expect(appManifest.pluginConfig!.length).toBe(1);
+        expect(appManifest.exception!.length).toBe(1);
+        expect(appManifest.extension!.length).toBe(1);
+
+        expect(manifest.plugins).toBeDefined();
+        const pluginManifest = manifest.plugins['redis'];
+        expect(pluginManifest.pluginMeta).toBeDefined();
+        expect(pluginManifest.extension!.length).toBe(1);
+    })
+});
\ No newline at end of file