开发自定义分析插件
在第 9 篇我们学习了如何通过插件方案来解决工具在代码结构上的问题,同时为分析工具内置了 defaultPlugin、typePlugin、methodPlugin、browserPlugin 4 个基础分析插件。
如果分析工具只能支持 属性、方法、类型 等用途检测的分析指标,那它是毫无生命力的,因为不同业务场景肯定有不同的分析目的,自定义插件就是分析工具提供给开发者拓展分析指标的方案,这节课我们会以代码实操的方式来讲解如何开发 / 调试 / 贡献自定义分析插件。
这一小节讲解的内容,代码,示例都在 code-demo 中,建议大家 clone 到本地对照学习。
分析插件开发流程
我们先来回顾一下开发 methodPlugin 的过程,大致可以归纳为 5 个阶段:
- 根据分析指标观察 AST 节点特征:
Method API 调用的主要特征是基准节点位于 CallExpression 类型节点下,TypeScript AST Viewer
import { app } from 'framework';
app.localStorage.set('store', 'iceman');- 抽象判定逻辑:
判断 baseNode 基准节点的父级节点是否为 CallExpression 类型。
if(node.parent && tsCompiler.isCallExpression(node.parent)){
// 判定通过
}- 开发调试插件代码:
开发:根据分析插件规范实现一个函数,内部声明挂载的副作用 Map,实现检测函数 checkFun,并根据插件是否互斥来返回 Boolean 类型的执行结果。
调试:实现插件后,以 API 模式运行,入参需要注册该插件,主程序会在 _dealAST 中调用已注册插件对象的检测方法(<mark>ps:methodPlugin 为内置插件,所以不需要在配置中显示声明,自定义插件需要</mark>)。
- 诊断日志定位缺陷报错(如有) :
分析程序执行结束后,根据 diagnosis.json 文件中 methodPlugin 执行的错误栈信息定位并修复缺陷。
- 验证结果并完善逻辑:
打印分析结果,发现结果不符合预期,原因是没有排除 API 作为函数参数的调用场景:
import { app } from 'framework';
app.localStorage.set('store', 'iceman'); // 正确调用
getUserInfo(app.info); // 需要排除这种调用完善 checkFun 排除干扰后,再次验证分析结果是否符合预期,符合则结束流程。
// 存在于函数调用表达式中
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 结构及特征,以下面这段代码为例:
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:
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 代码如下:
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 被继承的场景:
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,所以我们可以通过判断基准节点父级节点、爷爷节点的类型来判断调用逻辑,完善一下我们的插件代码:
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
小结
这一小节我们学习了如何开发自定义分析插件,需要大家掌握以下知识点:
- 分析插件的开发流程可以归纳为
5个阶段:观察 AST 节点特征 , 抽象判定逻辑 , 开发调试插件代码,通过诊断日志定位缺陷(如有),验证结果并完善逻辑。 Class API除了通过 new 表达式实例化这一种调用方式外,还有被继承的调用场景,开发 classPlugin 的时候这两种情况都需要考虑。- code-analysis-plugins 是针对 code-analysis-ts 分析工具的共享插件库,开发者可以通过它贡献自定义插件。