Недавно в статический анализатор PVS-Studio была добавлена поддержка JavaScript/TypeScript, и он на старте умеет искать ошибки, связанные с потоком управления. Причём тут граф потока управления, как мы его добавили и чем это полезно — читайте в этой статье.

Я уже затрагивал эту тему, в частности, в моей серии про taint-анализ в Java, но кратко напомню. Исходный код, попадая в статический анализатор, преобразуется в абстрактное синтаксическое дерево (AST). Оно почти полностью отображает структуру кода, как он написан в редакторе. Для задач анализа потока управления, таких как поиск недостижимого кода, оно подходит плохо. На AST можно написать проверку, которая его ищет, но:
Для решения этих задач и существует граф потока управления (Control Flow Graph, CFG), который отвечает за отображение всех возможных путей в программе.

Состоит он из трёх составляющих:
break/continue и т.п.
В статье про разработку JavaScript/TypeScript анализатора мы уже упоминали, что инструмент включает в себя не только синтаксическое дерево для конкретного языка, но и обобщённое для мультиязыкового анализа. Мы назвали его CAT (Common Abstract Tree).
И хоть пока к JavaScript/TypeScript другие языки не присоединились, уже сейчас мы заложили возможность для расширения CFG: основной движок работает агностично от конкретного языка. Для этого мы описываем набор разных семантик и собираем их как конструктор под нужный нам язык. Таким образом, мы экономим себе время на поддержке других языков в будущем.

Примера таких специальных семантик для JavaScript/TypeScript два:
try блоки, в которых исключения не типизированные, и больше одного catch быть не может.break. Также можно перейти по метке через continue, если она стоит на цикле.Проверка CFG на корректность — отдельное приключение. Первое, что приходит в голову: сериализовать граф в Graphviz либо иным способом, проверить глазами и зафиксировать эталон. Спойлер: это грабли, и мы на них наступили. Вот почему это не работает:
В итоге мы сменили подход и написали мини-интерпретатор нашего CAT на базе CFG. Идея простая: если наш интерпретатор, обойдя граф, даст тот же результат, что и интерпретатор JavaScript, то граф построен верно. Проверяется это простым Assertion API такого вида:
@Test
void branching() {
EvaluationAssert.evaluate("branching")
.withParam("param", true)
.expect("a", 2);
EvaluationAssert.evaluate("branching")
.withParam("param", false)
.expect("a", 0);
}
Этот тест запускает интерпретатор и сверяет результаты выполнения с эталоном, который можно получить, предварительно выполнив TypeScript код:
function branching(param: boolean) {
let a = 1;
if (param) {
a++;
} else {
a--;
}
}
Всю спецификацию JavaScript реализовывать не пришлось — хватило крохотного подмножества. А срезание углов даже сыграло на руку: если не очищать переменные при выходе из области видимости, то можно видеть состояние кода в разных точках.

Ещё один приятный бонус: минималистичный интерпретатор — полноценная проверка концепции для обработки графа и может служить как референсом для использования API, так и ориентиром для дальнейших работ по анализу потока данных.
По итогу имеем работающий строитель графов для JavaScript/TypeScript, строящийся за один проход по AST, что довольно шустро — для React все графы строится за четверть секунды.
Самые рядовые опечатки, которые можно было бы искать и через AST, конечно, идут первыми:
if ( curveLengths[ i ] >= d ) {
diff = curveLengths[ i ] - d;
curve = this.curves[ i ];
var u = 1 - diff / curve.getLength();
return curve.getPointAt( u );
break;
}
Предупреждение PVS-Studio: V7039 Unreachable code detected. Control flow never reaches this statement. three.js 29417.
Граф, который строит анализатор:

merge — это вспомогательный узел для построения графа, который упрощает работу с ним, поскольку обрабатывать узлы с множественными предшественниками неудобно Анализ с merge узлами (пока) не взаимодействует.
На примере выше merge имеет один вход, а не два, как на картинке ранее, так как в true есть return, который обязывает создать ребро сразу к выходу.
На нём отлично видно, что в break нет ни одного пути, поэтому он помечается как недостижимый.
Маловероятно, что эта ошибка на что-то влияет, и, скорее всего, недостижимый break просто является лишним. Но что занятно: этот файл — библиотека Three.js, скопированная внутрь Juice Shop, и такой же ошибки внутри актуального репозитория Three.js мы не нашли.
Кстати, классический баг с Automatic Semicolon Insertion вида:
function foo() {
return // asi happens here
this.bar
}
Будет ловиться точно таким же образом, ведь вставленная ; создаст терминатор в блоке с return и вынесет this.bar в следующий блок:

Занятный случай "защитного программирования" произошёл в Phaser:
if (!childA.parentContainer && !childB.parentContainer)
{
return this.displayList.getIndex(childB)
- this.displayList.getIndex(childA);
}
else if (childA.parentContainer === childB.parentContainer) {
// more branches ending with return statements here
} else
{
var listA = childA.getIndexList();
var listB = childB.getIndexList();
var len = Math.min(listA.length, listB.length);
for (var i = 0; i < len; i++)
{
var indexA = listA[i];
var indexB = listB[i];
if (indexA === indexB)
{
continue;
}
else
{
return indexB - indexA;
}
}
return listB.length - listA.length;
}
// Technically this shouldn't happen, but ...
// eslint-disable-next-line no-unreachable
return 0;
Предупреждение PVS-Studio: V7039 Unreachable code detected. Control flow never reaches this statement. InputPlugin.js 2981
Веток else-if было больше, но я их убрал для краткости и чтобы уменьшить граф:
Если вчитаться в комментарий и посмотреть на код, то опасение программиста должно стать понятным: выше сложная логика с 5+ ветками, каждая из которых завершает поток выполнения, поэтому он решил перестраховаться, не доверившись даже ESLint. Мы же вслед за ним по топологии графа увидели, что пути в return 0 просто нет.
"Бесплатно" мы получаем и нахождение циклов, которые выполняются бесконечно или, наоборот, всего один раз. Такой случай попался в уже упомянутом Three.js:
loop:
for (var pos = 0; pos < limit; pos++) {
for (; pos < limit; pos++) { // <=
for (var k = 0; k < needleLength; k++) {
if (haystack[pos + k] !== needle[k]) {
continue loop;
}
}
return pos;
}
}
Предупреждение PVS-Studio: V7039 Unreachable code detected. Control flow never reaches this statement. opentype.module.js 6109.
Граф потока управления:

Диагностика обнаружила, что на графе инкремент переменной pos в цикле недостижим. Посмотрев на код, легко заметить, что все ветки действительно прерывают второй for, и у него будет лишь одна итерация. А на графе это подтверждается — пути в инкремент нет.
Попал этот код в проект из OpenType, но ныне скопированная зависимость уже удалена.
Случаи с вложенными try и прерыванием потока выполнения из finally не только экзотические, но часто и считаются code smell, так что сходу найти срабатывания в Open Source не вышло.
Однако это был один из самых нетривиальных случаев для обработки из-за запутанности семантики try-catch-finally. Нужно учитывать:
try секции catch, finally или обе.throw, но и обрабатывать, куда тебя приведут неявные исключения.finally, когда оно произошло в try или catch.try, если оно произошло в finally.Так что покуда этот случай показать хочется, приведу синтетический пример:
function tryCatchFinallyCase() {
try {
try {
throw new Error("Whoops");
} finally {
console.log("inner")
}
return true // V7039
} finally {
console.log("outer")
}
return false; // V7039
}
Для такой функции анализатор построит следующий граф:

На графе можно чётко увидеть, что сами рёбра несут в себе информацию о том, какую операцию они "запомнили" до выхода из функции. В то же время недостижимые return узлы идентифицируются анализатором.
Мы рассмотрели ошибки, которые анализатор способен находить при помощи нового механизма. И действительно, он уже показал свою эффективность, позволив достаточно тривиально реализовать диагностики V7039 (недостижимый код) и V7040 (бесконечная рекурсия) к релизу PVS-Studio 8.00. Но помимо диагностик, перед нами открываются и новые перспективы:
В общем, если CAT дал нам возможность расширения вширь, то CFG позволяет расширяться вглубь, так что теперь почва для развития нового анализатора стала ещё более плодотворной.
На этом небольшой экскурс в эту технологию анализатора заканчивается. Надеюсь, вам было интересно узнать больше о том, как статический анализатор устроен изнутри, и какие ошибки можно найти при помощи анализа потока управления. Если вдруг у вас был опыт с подобными технологиями, то пишите в комментариях — будет интересно почитать. А впереди всё ещё маячит обещанная статья про устройство нашего CAT, как и другие статьи про качество кода, так что не забывайте следить за нами в:
0