import { readdirSync, readFileSync } from "node:fs"; import { join } from "node:path"; import ts from "typescript"; const LIMITE = 10; const PASTA = __dirname; type Medida = { nome: string; linha: number; complexidade: number }; const OPERADORES = new Set([ ts.SyntaxKind.AmpersandAmpersandToken, ts.SyntaxKind.BarBarToken, ts.SyntaxKind.QuestionQuestionToken, ]); function ehDecisao(no: ts.Node): boolean { if (ts.isBinaryExpression(no)) return OPERADORES.has(no.operatorToken.kind); return ( ts.isIfStatement(no) || ts.isConditionalExpression(no) || ts.isForStatement(no) || ts.isForInStatement(no) || ts.isForOfStatement(no) || ts.isWhileStatement(no) || ts.isDoStatement(no) || ts.isCaseClause(no) || ts.isCatchClause(no) ); } function ehFuncao(no: ts.Node): no is ts.FunctionLikeDeclaration { return ( ts.isFunctionDeclaration(no) || ts.isFunctionExpression(no) || ts.isArrowFunction(no) || ts.isMethodDeclaration(no) || ts.isConstructorDeclaration(no) || ts.isGetAccessorDeclaration(no) || ts.isSetAccessorDeclaration(no) ); } function nomeDaFuncao(no: ts.FunctionLikeDeclaration): string { if (ts.isConstructorDeclaration(no)) return "constructor"; if (no.name) return no.name.getText(); const pai = no.parent; if (ts.isVariableDeclaration(pai) || ts.isPropertyAssignment(pai)) { return pai.name.getText(); } return "(anônima)"; } /** Complexidade ciclomática de cada função do código-fonte (cada uma medida à parte). */ export function medirComplexidade(codigo: string): Medida[] { const fonte = ts.createSourceFile( "medida.ts", codigo, ts.ScriptTarget.Latest, true, ); const medidas: Medida[] = []; function medir(funcao: ts.FunctionLikeDeclaration): void { let complexidade = 1; const visitar = (no: ts.Node): void => { if (ehFuncao(no)) { medir(no); return; } if (ehDecisao(no)) complexidade++; ts.forEachChild(no, visitar); }; ts.forEachChild(funcao, visitar); const { line } = fonte.getLineAndCharacterOfPosition(funcao.getStart()); medidas.push({ nome: nomeDaFuncao(funcao), linha: line + 1, complexidade, }); } const percorrer = (no: ts.Node): void => { if (ehFuncao(no)) medir(no); else ts.forEachChild(no, percorrer); }; percorrer(fonte); return medidas.sort((a, b) => a.linha - b.linha); } function comDecisoes(n: number): string { const ifs = Array.from({ length: n }, (_, i) => `if (x > ${i}) y++;`); return `function f(x: number) { let y = 0; ${ifs.join(" ")} return y; }`; } describe("medirComplexidade", () => { it("função sem decisão vale 1", () => { expect( medirComplexidade("function f() { return 1; }")[0].complexidade, ).toBe(1); }); it("10 decisões valem 11 e são reprovadas", () => { const [medida] = medirComplexidade(comDecisoes(10)); expect(medida.complexidade).toBe(11); expect(medida.complexidade > LIMITE).toBe(true); }); it("9 decisões valem 10 e passam", () => { const [medida] = medirComplexidade(comDecisoes(9)); expect(medida.complexidade).toBe(10); expect(medida.complexidade > LIMITE).toBe(false); }); it("decisões de função aninhada não contam na de fora", () => { const medidas = medirComplexidade( "function fora(a: number) { const dentro = () => { if (a) return 1; return a ? 2 : 3; }; return dentro; }", ); expect(medidas.find((m) => m.nome === "fora")?.complexidade).toBe(1); expect(medidas.find((m) => m.nome === "dentro")?.complexidade).toBe(3); }); it("default não conta, case conta", () => { const [medida] = medirComplexidade( "function f(x: number) { switch (x) { case 1: return 1; case 2: return 2; default: return 0; } }", ); expect(medida.complexidade).toBe(3); }); it("&&, || e ?? contam", () => { const [medida] = medirComplexidade( "function f(a: any, b: any, c: any) { return (a && b) || (c ?? a); }", ); expect(medida.complexidade).toBe(4); }); it("laços, catch, do e ternário contam; métodos são medidos", () => { const medidas = medirComplexidade( "class A { m(l: number[]) { for (const i of l) {} for (const k in l) {} for (;;) {} while (1) {} do {} while (0); try {} catch {} return l ? 1 : 2; } }", ); expect(medidas[0]).toMatchObject({ nome: "m", complexidade: 8 }); }); }); describe("complexidade ciclomática de lib/jogo", () => { it(`nenhuma função passa de ${LIMITE}`, () => { const reprovadas: string[] = []; const arquivos = readdirSync(PASTA).filter( (f) => f.endsWith(".ts") && !f.endsWith(".test.ts"), ); for (const arquivo of arquivos) { const codigo = readFileSync(join(PASTA, arquivo), "utf8"); for (const m of medirComplexidade(codigo)) { if (m.complexidade > LIMITE) { reprovadas.push(`${arquivo}:${m.linha} ${m.nome} ${m.complexidade}`); } } } expect(reprovadas).toEqual([]); }); });