{T}

开发自定义分析插件

在第 9 篇我们学习了如何通过插件方案来解决工具在代码结构上的问题,同时为分析工具内置了 defaultPlugintypePluginmethodPluginbrowserPlugin 4 个基础分析插件。

如果分析工具只能支持 属性方法类型 等用途检测的分析指标,那它是毫无生命力的,因为不同业务场景肯定有不同的分析目的,自定义插件就是分析工具提供给开发者拓展分析指标的方案,这节课我们会以代码实操的方式来讲解如何开发 / 调试 / 贡献自定义分析插件。

这一小节讲解的内容,代码,示例都在 code-demo 中,建议大家 clone 到本地对照学习。

分析插件开发流程

我们先来回顾一下开发 methodPlugin 的过程,大致可以归纳为 5 个阶段:

  1. 根据分析指标观察 AST 节点特征:

Method API 调用的主要特征是基准节点位于 CallExpression 类型节点下,TypeScript AST Viewer

typescript
import { app } from 'framework';
app.localStorage.set('store', 'iceman');
  1. 抽象判定逻辑:

判断 baseNode 基准节点的父级节点是否为 CallExpression 类型。

typescript
if(node.parent && tsCompiler.isCallExpression(node.parent)){
    // 判定通过
}
  1. 开发调试插件代码:

开发:根据分析插件规范实现一个函数,内部声明挂载的副作用 Map,实现检测函数 checkFun,并根据插件是否互斥来返回 Boolean 类型的执行结果。

调试:实现插件后,以 API 模式运行,入参需要注册该插件,主程序会在 _dealAST 中调用已注册插件对象的检测方法(<mark>ps:methodPlugin 为内置插件,所以不需要在配置中显示声明,自定义插件需要</mark>)。

  1. 诊断日志定位缺陷报错(如有)

分析程序执行结束后,根据 diagnosis.json 文件中 methodPlugin 执行的错误栈信息定位并修复缺陷。

  1. 验证结果并完善逻辑:

打印分析结果,发现结果不符合预期,原因是没有排除 API 作为函数参数的调用场景:

typescript
import { app } from 'framework';

app.localStorage.set('store', 'iceman');    // 正确调用
getUserInfo(app.info);                      // 需要排除这种调用

完善 checkFun 排除干扰后,再次验证分析结果是否符合预期,符合则结束流程。

typescript
// 存在于函数调用表达式中
if(node.parent && tsCompiler.isCallExpression(node.parent)){
    // 命中函数名method检测,而非参数名
    if(node.parent.expression.pos == node.pos && node.parent.expression.end == node.end){
        // 判定通过
    }
}

了解完分析插件的开发流程后,接下来我们亲自动手来实现一个分析插件。

Class API 分析插件

虽然大部分导出的 API 用途是属性方法类型,但也存在依赖提供方对外提供的场景,接下来我们开发一个识别 Class API 调用的自定义分析插件。

先来观察一下 Class API 被调用时的 AST 结构及特征,以下面这段代码为例:

typescript
import { Car } from 'framework';

function deal() {
    const name = 'iceman'
    const car = new Car(name);
    return car;
}

deal();

把代码放入TypeScript AST Viewer

我们发现 Class API 调用最主要的特征就是基准节点位于 NewExpression 类型节点下,或者说在 NewExpression 表达式内,通过判断基准节点的父级节点是否为 NewExpression 类型是第一步筛选。但需要注意的是,因为它的父级节点也是一个表达式,与 method API 类似,也需要排除 Car 节点出现在 arguments 中即作为参数被调用的干扰场景。

梳理清楚判定逻辑后,我们来实现一下 classPlugin

typescript
exports.classPlugin = function (analysisContext) {
    const mapName = 'classMap';
    // 在分析实例上下文挂载副作用
    analysisContext[mapName] = {};

    function isClassCheck (context, tsCompiler, node, depth, apiName, matchImportItem, filePath, projectName, httpRepo, line) {
        try{
            if(node.parent && tsCompiler.isNewExpression(node.parent)){ // 存在于New调用表达式中
                // 命中关键词检测
                if(node.parent.expression.pos == node.pos && node.parent.expression.end == node.end){
                    if (!context[mapName][apiName]) {
                        context[mapName][apiName] = {};
                        context[mapName][apiName].callNum = 1;
                        context[mapName][apiName].callOrigin = matchImportItem.origin;
                        context[mapName][apiName].callFiles = {};
                        context[mapName][apiName].callFiles[filePath] = {};
                        context[mapName][apiName].callFiles[filePath].projectName = projectName;
                        context[mapName][apiName].callFiles[filePath].httpRepo = httpRepo;
                        context[mapName][apiName].callFiles[filePath].lines = [];
                        context[mapName][apiName].callFiles[filePath].lines.push(line);
                    } else {
                        context[mapName][apiName].callNum++;
                        if (!Object.keys(context[mapName][apiName].callFiles).includes(filePath)) {
                            context[mapName][apiName].callFiles[filePath] = {};
                            context[mapName][apiName].callFiles[filePath].projectName = projectName;
                            context[mapName][apiName].callFiles[filePath].httpRepo = httpRepo;
                            context[mapName][apiName].callFiles[filePath].lines = [];
                            context[mapName][apiName].callFiles[filePath].lines.push(line);
                        }else{
                            context[mapName][apiName].callFiles[filePath].lines.push(line);
                        }
                    }
                    return true;             // true: 命中规则, 终止执行后序插件
                }
            }
            return false;                   // false: 未命中检测逻辑, 继续执行后序插件
        }catch(e){
            // console.log(e);
            const info = {
                projectName: projectName,
                matchImportItem: matchImportItem,
                apiName: apiName,
                httpRepo: httpRepo + filePath.split('&')[1] + '#L' + line,
                file: filePath.split('&')[1],
                line: line,
                stack: e.stack
            };
            context.addDiagnosisInfo(info);
            return false;                   // false: 插件执行报错, 继续执行后序插件
        }
    }

    // 返回分析Node节点的函数
    return {
        mapName : mapName,
        checkFun: isClassCheck,
        afterHook: null
    };
}

写完代码后,我们以 API 模式来运行调试插件,code-demo 项目中的 apiMode.js 演示了如何配置插件,建议大家 clone code-demo 项目到本地,安装依赖后执行 npm run analysis:api 即可体验如何调试插件代码。

apiMode.js 代码如下:

typescript
const analysis = require('code-analysis-ts');                         // 代码依赖分析工具
const { classPlugin } = require('./classPlugin');                     // class API 分析插件
// const { classPlugin } = require('code-analysis-plugins');          // class API 分析插件
const { execSync } = require('child_process');                        // 子进程操作
const DefaultBranch = 'main';                                         // 默认分支常量
function getGitBranch() {                                             // 获取当前分支
    try{
        const branchName = execSync('git symbolic-ref --short -q HEAD', {
            encoding: 'utf8'
        }).trim();
        // console.log(branchName);
        return branchName;
    }catch(e){
        return DefaultBranch;
    }
}

async function scan() {
    try{
        const { report, diagnosisInfos } = await analysis({
            scanSource: [{                                                          // 必须,待扫描源码的配置信息
                name: 'Code-Demo',                                                                // 必填,项目名称
                path: ['src'],                                                                    // 必填,需要扫描的文件路径(基准路径为配置文件所在路径)
                packageFile: 'package.json',                                                      // 可选,package.json 文件路径配置,用于收集依赖的版本信息
                format: null,                                                                     // 可选, 文件路径格式化函数,默认为null,一般不需要配置
                httpRepo: `https://github.com/liangxin199045/code-demo/blob/${getGitBranch()}/`   // 可选,项目gitlab/github url的访问前缀,用于点击行信息跳转,不填则不跳转
            }],
            analysisTarget: 'framework',                                            // 必须,要分析的目标依赖名
            analysisPlugins: [classPlugin],                                         // 可选,自定义分析插件,默认为空数组,一般不需要配置
            blackList: ['app.localStorage.set', 'location.href'],                   // 可选,需要标记的黑名单api,默认为空数组
            browserApis: ['window','document','history','location'],                // 可选,要分析的BrowserApi,默认为空数组
            reportDir: 'docs',                                                      // 可选,生成代码分析报告的目录,默认为'report',不支持多级目录配置
            reportTitle: 'Code-Demo代码分析报告',                                    // 可选,代码分析报告标题,默认为'代码依赖分析报告'
            isScanVue: true,                                                        // 可选,是否要扫描分析vue中的ts代码,默认为false
            scorePlugin: 'default'                                                  // 可选,评分插件: Function|'default'|null, default表示运行默认插件,默认为null表示不评分
        });
        console.log(report.classMap);  // 这里为了验证结果,只打印classMap数据
    }catch(e){
        console.log(e);
    }
};

scan();

程序执行结束后,我们通过打印出来的 classMap 来验证一下分析插件的准确性(截图中的打印结果是 code-demo 项目中执行 npm run analysis:api 后分析的结果):

乍一看结果好像是符合预期的,我们成功找到了 Class API 及相关调用信息,但我们再仔细思考一下,Class API 除了被 new 表达式实例化这一种调用方式以外,还有什么调用场景呢?

对,被继承 我们还需要考虑 Class API 被继承的场景:

typescript
import { Car } from 'framework';

class BaoMa extends Car {
    constructor() {
        super();
        this.brand = 'baoma'
    }

    run() {
        super.run();
    }
}

同理,将上述示例代码放入TypeScript AST Viewer,观察一下 Class API 被继承场景 AST 的结构及特征:

Class API 被继承最主要的特征是基准节点位于 HeritageClause 类型节点中孙子节点的位置,并且基准节点的父级节点类型为 ExpressionWithTypeArguments,所以我们可以通过判断基准节点父级节点、爷爷节点的类型来判断调用逻辑,完善一下我们的插件代码:

typescript
exports.classPlugin = function (analysisContext) {
    const mapName = 'classMap';
    // 在分析实例上下文挂载副作用
    analysisContext[mapName] = {};

    function isClassCheck (context, tsCompiler, node, depth, apiName, matchImportItem, filePath, projectName, httpRepo, line) {
        try{
            if(node.parent && tsCompiler.isNewExpression(node.parent)){                                    // 存在于New调用表达式中
                if(node.parent.expression.pos == node.pos && node.parent.expression.end == node.end){    // 命中关键词检测
                    if (!context[mapName][apiName]) {
                        context[mapName][apiName] = {};
                        context[mapName][apiName].callNum = 1;
                        context[mapName][apiName].callOrigin = matchImportItem.origin;
                        context[mapName][apiName].callFiles = {};
                        context[mapName][apiName].callFiles[filePath] = {};
                        context[mapName][apiName].callFiles[filePath].projectName = projectName;
                        context[mapName][apiName].callFiles[filePath].httpRepo = httpRepo;
                        context[mapName][apiName].callFiles[filePath].lines = [];
                        context[mapName][apiName].callFiles[filePath].lines.push(line);
                    } else {
                        context[mapName][apiName].callNum++;
                        if (!Object.keys(context[mapName][apiName].callFiles).includes(filePath)) {
                            context[mapName][apiName].callFiles[filePath] = {};
                            context[mapName][apiName].callFiles[filePath].projectName = projectName;
                            context[mapName][apiName].callFiles[filePath].httpRepo = httpRepo;
                            context[mapName][apiName].callFiles[filePath].lines = [];
                            context[mapName][apiName].callFiles[filePath].lines.push(line);
                        }else{
                            context[mapName][apiName].callFiles[filePath].lines.push(line);
                        }
                    }
                    return true;             // true: 命中规则, 终止执行后序插件
                }
            }else if(node.parent && tsCompiler.isExpressionWithTypeArguments(node.parent) && tsCompiler.isHeritageClause(node.parent.parent)){  // 被继承表达式中
                if (!context[mapName][apiName]) {
                    context[mapName][apiName] = {};
                    context[mapName][apiName].callNum = 1;
                    context[mapName][apiName].callOrigin = matchImportItem.origin;
                    context[mapName][apiName].callFiles = {};
                    context[mapName][apiName].callFiles[filePath] = {};
                    context[mapName][apiName].callFiles[filePath].projectName = projectName;
                    context[mapName][apiName].callFiles[filePath].httpRepo = httpRepo;
                    context[mapName][apiName].callFiles[filePath].lines = [];
                    context[mapName][apiName].callFiles[filePath].lines.push(line);
                } else {
                    context[mapName][apiName].callNum++;
                    if (!Object.keys(context[mapName][apiName].callFiles).includes(filePath)) {
                        context[mapName][apiName].callFiles[filePath] = {};
                        context[mapName][apiName].callFiles[filePath].projectName = projectName;
                        context[mapName][apiName].callFiles[filePath].httpRepo = httpRepo;
                        context[mapName][apiName].callFiles[filePath].lines = [];
                        context[mapName][apiName].callFiles[filePath].lines.push(line);
                    }else{
                        context[mapName][apiName].callFiles[filePath].lines.push(line);
                    }
                }
                return true;             // true: 命中规则, 终止执行后序插件
            }
            return false;                   // false: 未命中检测逻辑, 继续执行后序插件
        }catch(e){
            // console.log(e);
            const info = {
                projectName: projectName,
                matchImportItem: matchImportItem,
                apiName: apiName,
                httpRepo: httpRepo + filePath.split('&')[1] + '#L' + line,
                file: filePath.split('&')[1],
                line: line,
                stack: e.stack
            };
            context.addDiagnosisInfo(info);
            return false;                   // false: 插件执行报错, 继续执行后序插件
        }
    }

    // 返回分析Node节点的函数
    return {
        mapName : mapName,
        checkFun: isClassCheck,
        afterHook: null
    };
}

再次执行后,分析结果是符合预期的,我们找到了 Class API 被继承调用的场景(截图中的打印结果是 code-demo 项目中执行 npm run analysis:api 后分析的结果):

至此,Class API 调用检测的分析插件开发完成。

贡献插件代码

随着代码分析工具的普及,很多开发团队都会需要 classPlugin 这样的自定义分析插件,很显然大家没有必要都去实现一个 classPlugin,插件体系的优点就在于可以结合社区的力量去完善各种分析指标,从而提升工具生命力。

code-analysis-plugins 是针对 code-analysis-ts 分析工具的共享插件库,开发者可以通过提交 PR 的形式将自己开发的分析插件提交到该仓库。有了插件库以后,实际开发中我们先去插件库中找相关的分析插件,安装 npm 包后直接引入即可,通过插件库引入分析插件的示例同样可以参考 code-demo

小结

这一小节我们学习了如何开发自定义分析插件,需要大家掌握以下知识点:

  1. 分析插件的开发流程可以归纳为 5 个阶段:观察 AST 节点特征 抽象判定逻辑 开发调试插件代码,通过诊断日志定位缺陷(如有),验证结果并完善逻辑。
  2. Class API 除了通过 new 表达式实例化这一种调用方式外,还有被继承的调用场景,开发 classPlugin 的时候这两种情况都需要考虑。
  3. code-analysis-plugins 是针对 code-analysis-ts 分析工具的共享插件库,开发者可以通过它贡献自定义插件。