AST 代码扫描实战:如何保障代码质量
前言
项目上线前 code review,通过预演提前发现问题
线上实时监控对账,出现问题时执行预案,及时止血
什么是AST
在计算机科学中,抽象语法树(Abstract Syntax Tree,AST)或简称语法树(Syntax tree),是源代码语法结构的一种抽象表示。它以树状的形式表现编程语言的语法结构,树上的每个节点都表示源代码中的一种结构。
const parser = require('@babel/parser');
const traverse = require('@babel/traverse').default;
// 编写自定义规则插件
const visitor = {};
// 源代码
const code = `var str = "hello world";`;
// code -> ast
const ast = parser.parse(code);
// 用自定义规则遍历ast(即代码扫描)
traverse(ast, visitor);
解决问题
前端金额赋默认值
前端金额计算错误
前端写死固定金额/积分
...
▐ 寻找"金额"
const WHITE_LIST = ['price']; // TODO: 可扩展
const PRICE_REG = new RegExp(WHITE_LIST.map(s => s + '$').join('|'), 'i');
const isPrice = str => PRICE_REG.test(str);
const visitor = {
Identifier(path) {
const {id} = path.node;
if(isPrice(id.name)) {
// 金额变量 匹配成功!
}
}
};
▐ 小试牛刀
// case 1: 直接赋默认值
const price = 10;
// case 2: ES6解构语法赋默认值
const {price = 10} = data;
// case 3: "||"运算符赋默认值
const price = data.price || 10;
// ...
const t = require('@babel/types');
const visitor = {
VariableDeclarator(path) {
const {id, init} = path.node;
if(
t.isIdentifer(id) &&
isPrice(id.name) &&
t.isNumericLiteral(init) &&
init.value > 0
) {
// 直接赋默认值 匹配成功!
}
}
};
const t = require('@babel/types');
const visitor = {
AssignmentPattern(path) {
const {left, right} = path.node;
if (
t.isIdentifer(left) &&
isPrice(left.name) &&
t.isNumericLiteral(right)
&& right.value > 0
) {
// ES6解构语法赋默认值 匹配成功!
}
}
};
const t = require('@babel/types');
const visitor = {
VariableDeclarator(path) {
const {id, init} = path.node;
if(t.isIdentifer(id) && isPrice(id.name)) {
path.traverse({
LogicalExpression(subPath) {
const {operator, right} = subPath.node;
if(
operator === '||' &&
t.isNumericLiteral(right) &&
right.value > 0
) {
// "||"运算符赋默认值 匹配成功!
}
}
});
}
}
};
▐ 变量追踪
const t = require('@babel/types');
const Helper = {
isPriceCalc(priceNode, numNode, operator) {
return (
t.isisIdentifier(priceNode) &&
isPrice(priceNode.name) &&
(t.isNumericLiteral(numNode) || t.isIdentifier(numNode)) &&
['+', '-', '*', '/'].indexOf(operator) > -1
);
}
};
const checkPriceCalcVisitor = {
BinaryExpression(path) {
const {left, right, operator} = path.node;
if(
Helper.isPriceCalc(left, right, operator) ||
Helper.isPriceCalc(right, left, operator)
) {
// 金额计算 匹配成功!
}
}
}
const fen2yuan = (num) => {
return num / 100;
};
const ret = fen2yuan(data.price);
一个scope
{
path: path,
block: path.node,
parentBlock: path.parent,
parent: parentScope,
bindings: [...]
}
其中的一个binding
{
identifier: node,
scope: scope,
path: path,
kind: 'var',
referenced: true,
references: 3,
referencePaths: [path, path, path],
constant: false,
constantViolations: [path]
}
const t = require('@babel/types');
const Helper = {
// ...
findScope(path, matchFunc) {
let scope = path.scope;
while(scope && !matchFunc(scope)) {
scope = scope.parent;
}
return scope;
}
};
const checkPriceCalcVisitor = {
// ...
CallExpression(path) {
const {arguments, callee: {name}} = path.node;
// 匹配金额变量作为实参的函数调用
const priceIdx = arguments.findIndex(arg => isPrice(arg));
if(priceIdx === -1) return;
// 寻找该函数的声明节点
const foundFunc = Helper.findScope(path, scope => {
const binding = scope.bindings[name];
return binding && t.isFunctionDeclaration(binding.path.node);
});
if(!foundFunc) return;
// 匹配实参和形参之间的位置关系
const funcPath = foundFunc.bindings[name].path;
const {params} = funcPath.node;
const param = params[priceIdx];
if(!t.isIdentifier(param)) return;
// 检测函数内是否有对形参的引用
const renamedParam = param.name;
const {referencePaths: refPaths = []} = funcPath.scope.bindings[renamedParam] || {};
if(refPaths.length === 0) return;
// 检测形参的引用部分是否涉及金额计算
for(const refPath of refPaths) {
// TODO: checkPriceCalcVisitor支持指定变量名的检测
refPath.getStatementParent().traverse(checkPriceCalcVisitor);
}
}
}
检测效果
let {
// ...
rPrice = 1
} = res.data || {};
如上所示,当服务端返回的数据异常时,一旦 res.data 为空,那么 rPrice 就会获得默认值 1。经过代码分析后发现 rPrice 代表的就是红包面额,因此理论上就可能会造成资损。
class CardItem extends Component {
static defaultProps = {
itemPrice: '99',
itemName: '...',
itemPic: '...',
// ...
}
// ...
}
如上所示,该代码应该是在开发初期 mock 了展示所需的数据,但是在后续迭代时又没有删除 mock 数据。一旦服务端下发的数据缺少 itemPrice 字段,所有的价格都将显示 99,这也是颗危险的定时炸弹。
const [price, setPrice] = useState(50);
如上所示,这个 hooks 的使用例子默认就会给 price 赋值 50,如果这是一个红包或券 的面额,意味着用 户可能就领到了这 50 元,从而也就造成了资损。
// price1为活动价,price2为原始价
let discount = Math.ceil(100 * (price1 / 1) / (price2 / 1)) / 10;
Toast.show('恭喜您获得双11红包');
总结与展望
✿ 拓展阅读
作者|菉竹
编辑|橙子君
出品|阿里巴巴新零售淘系技术