diff --git a/source/source_lcao/wavefunc_in_pw.cpp b/source/source_lcao/wavefunc_in_pw.cpp index 1ff08b2e5e7..47a0005c385 100644 --- a/source/source_lcao/wavefunc_in_pw.cpp +++ b/source/source_lcao/wavefunc_in_pw.cpp @@ -1,4 +1,5 @@ #include // Peize Lin fix bug about strcmp 2016-08-02 +#include #include "wavefunc_in_pw.h" #include "source_io/module_parameter/parameter.h" #include "source_base/math_integral.h" @@ -7,175 +8,174 @@ #include "source_base/math_ylmreal.h" void Wavefunc_in_pw::make_table_q( - const UnitCell &ucell, - std::vector &fn, - ModuleBase::realArray &table_local) + const UnitCell &ucell, + std::vector &fn, + ModuleBase::realArray &table_local) { - ModuleBase::TITLE("Wavefunc_in_pw","make_table_q"); - - if( fn.size() != static_cast(ucell.ntype) ) - { - ModuleBase::WARNING_QUIT("Wavefunc_in_pw::make_table_q","maybe NUMERICAL_ORBITAL is not read in, please check."); - } - - for(int it=0; it> word; - if (std::strcmp(word , "END") == 0) // Peize Lin fix bug about strcmp 2016-08-02 - { - break; - } - } - - ModuleBase::CHECK_NAME(in, "Mesh"); - in >> meshr; - int meshr_read = meshr; - if(meshr%2==0) - { - ++meshr; - } - GlobalV::ofs_running << " meshr=" << meshr; - - ModuleBase::CHECK_NAME(in, "dr"); - in >> dr; - GlobalV::ofs_running << " dr=" << dr; - - double* radial = new double[meshr]; - double *psi = new double[meshr]; - double* psir = new double[meshr]; - double* rab = new double[meshr]; - - ModuleBase::GlobalFunc::ZEROS(radial, meshr); - ModuleBase::GlobalFunc::ZEROS(psi, meshr); - ModuleBase::GlobalFunc::ZEROS(psir, meshr); - ModuleBase::GlobalFunc::ZEROS(rab, meshr); - for(int ir=0; ir> name1 >> name2 >> name3; - assert( name1 == "Type" ); - in >> tmp_it >> tmp_l >> tmp_n; - if( L == tmp_l && N == tmp_n ) - { - // meshr_read is different from meshr if meshr is even number. - for(int ir=0; ir> psi[ir]; - //psi[ir] = 1.0; //hahaha - psir[ir] = psi[ir] * radial[ir]; - } - find = true; - } - else - { - double no_use = 0.0; - for(int ir=0; ir> no_use; - } - } - } - double* table = new double[PARAM.globalv.nqx]; - Wavefunc_in_pw::integral(ucell,meshr, psir, radial, rab, L, table); - for(int iq=0; iq(ucell.ntype) ) + { + ModuleBase::WARNING_QUIT( + "Wavefunc_in_pw::make_table_q", + "maybe NUMERICAL_ORBITAL is not read in, please check." + ); + } + + for(int it=0; it> word; + if (std::strcmp(word , "END") == 0) // Peize Lin fix bug about strcmp 2016-08-02 + { + break; + } + } + + ModuleBase::CHECK_NAME(in, "Mesh"); + in >> meshr; + int meshr_read = meshr; + if(meshr%2==0) + { + ++meshr; + } + GlobalV::ofs_running << " meshr=" << meshr; + + ModuleBase::CHECK_NAME(in, "dr"); + in >> dr; + GlobalV::ofs_running << " dr=" << dr; + + std::vector radial(meshr); + std::vector psi(meshr); + std::vector psir(meshr); + std::vector rab(meshr); + + ModuleBase::GlobalFunc::ZEROS(radial.data(), meshr); + ModuleBase::GlobalFunc::ZEROS(psi.data(), meshr); + ModuleBase::GlobalFunc::ZEROS(psir.data(), meshr); + ModuleBase::GlobalFunc::ZEROS(rab.data(), meshr); + for(int ir=0; ir> name1 >> name2 >> name3; + assert( name1 == "Type" ); + in >> tmp_it >> tmp_l >> tmp_n; + if( L == tmp_l && N == tmp_n ) + { + // meshr_read is different from meshr if meshr is even number. + for(int ir=0; ir> psi[ir]; + //psi[ir] = 1.0; //hahaha + psir[ir] = psi[ir] * radial[ir]; + } + find = true; + } + else + { + double no_use = 0.0; + for(int ir=0; ir> no_use; + } + } + } + std::vector table(PARAM.globalv.nqx); + Wavefunc_in_pw::integral( + ucell, meshr, psir.data(), radial.data(), rab.data(), L, table.data()); + for (int iq = 0; iq < PARAM.globalv.nqx; ++iq) + { + //double energy_q = pow(iq * PARAM.globalv.dq,2); + table_local(it, ic, iq) = table[iq];//* Wavefunc_in_pw::smearing(energy_q,150,0.666666); + } + ++ic; + }// N + }// L + }// T + + + if(GlobalV::MY_RANK==0) + { + for(int it=0; it inner_part(meshr); + for (int ir = 0; ir < meshr; ++ir) + { + inner_part[ir] = psir[ir] * psir[ir]; + } + + double unit = 0.0; + ModuleBase::Integral::Simpson_Integral(meshr, inner_part.data(), rab, unit); + ModuleBase::GlobalFunc::OUT(GlobalV::ofs_running, "normalize unit", unit); + + std::vector aux(meshr); + std::vector vchi(meshr); + for (int iq = 0; iq < PARAM.globalv.nqx; ++iq) + { + const double q = PARAM.globalv.dq * iq; + ModuleBase::Sphbes::Spherical_Bessel(meshr, r, q, l, aux.data()); + for (int ir = 0; ir < meshr; ++ir) + { + vchi[ir] = psir[ir] * aux[ir] * r[ir]; + } + + double vqint = 0.0; + ModuleBase::Integral::Simpson_Integral(meshr, vchi.data(), rab, vqint); + + table[iq] = vqint * pref; + } + return; } void Wavefunc_in_pw::produce_local_basis_in_pw(const UnitCell& ucell, - const int& ik, + const int& ik, const ModulePW::PW_Basis_K* wfc_basis, const Structure_Factor& sf, ModuleBase::ComplexMatrix& psi, const ModuleBase::realArray& table_local) { - ModuleBase::TITLE("Wavefunc_in_pw","produce_local_basis_in_pw"); - assert(ik>=0); - const int npw = wfc_basis->npwk[ik]; - const int total_lm = ( ucell.lmax + 1) * ( ucell.lmax + 1); - ModuleBase::matrix ylm(total_lm, npw); - std::complex *aux = new std::complex[npw]; - double *chiaux = nullptr; - - ModuleBase::Vector3 *gk = new ModuleBase::Vector3[npw]; - for(int ig=0;iggetgpluskcar(ik, ig); - } - - ModuleBase::YlmReal::Ylm_Real(total_lm, npw, gk, ylm); - - //int index = 0; - double *flq = new double[npw]; - int iwall=0; - for (int it = 0;it < ucell.ntype;it++) - { - for (int ia = 0;ia < ucell.atoms[it].na;ia++) - { + ModuleBase::TITLE("Wavefunc_in_pw","produce_local_basis_in_pw"); + assert(ik>=0); + const int npw = wfc_basis->npwk[ik]; + const int total_lm = ( ucell.lmax + 1) * ( ucell.lmax + 1); + ModuleBase::matrix ylm(total_lm, npw); + std::vector> aux(npw); + std::vector chiaux; + bool chiaux_init = false; + + std::vector> gk(npw); + for (int ig = 0; ig < npw; ++ig) + { + gk[ig] = wfc_basis->getgpluskcar(ik, ig); + } + + ModuleBase::YlmReal::Ylm_Real(total_lm, npw, gk.data(), ylm); + + //int index = 0; + std::vector flq(npw); + int iwall=0; + for (int it = 0;it < ucell.ntype;it++) + { + for (int ia = 0;ia < ucell.atoms[it].na;ia++) + { std::complex* sk = sf.get_sk(ik, it, ia, wfc_basis); int ic = 0; for(int L = 0; L < ucell.atoms[it].nwl+1; L++) - { - std::complex lphase = pow(ModuleBase::NEG_IMAG_UNIT, L); //mohan 2010-04-19 - for(int N=0; N < ucell.atoms[it].l_nchi[L]; N++) - { -// GlobalV::ofs_running << " it=" << it << " ia=" << ia << " L=" << L << " N=" << N << std::endl; - - for(int ig=0; ig lphase = pow(ModuleBase::NEG_IMAG_UNIT, L); //mohan 2010-04-19 + for(int N=0; N < ucell.atoms[it].l_nchi[L]; N++) + { +// GlobalV::ofs_running << " it=" << it << " ia=" << ia << " L=" << L << " N=" << N << std::endl; + + for(int ig=0; ignpwk_max) = lphase * sk[ig] * ylm(lm, ig) * flq[ig]; } iwall += 2; } - }//if - else - {//atomic_wfc_so_mag - double alpha = 0.0, gamma = 0.0; - std::complex fup,fdown; - //int nc; - //This routine creates two functions only in the case j=l+1/2 or exit in the other case - if(fabs(j-L+0.5)<1e-4) { continue; + }//if + else + {//atomic_wfc_so_mag + double alpha = 0.0, gamma = 0.0; + std::complex fup,fdown; + //int nc; + // This routine creates two functions only + // in the case j=l+1/2 or exit otherwise + if(fabs(j-L+0.5)<1e-4) { continue; } - delete[] chiaux; - chiaux = new double [npw]; - //Find the functions j= l- 1/2 - if(L==0) { - for(int ig=0;ig ucell.natomwfc) { @@ -375,92 +385,96 @@ void Wavefunc_in_pw::produce_local_basis_in_pw(const UnitCell& ucell, aux[ig] = sk[ig] * ylm(lm,ig) * chiaux[ig]; } //rotate wfc as needed - //first rotation with angle alpha around (OX) - for(int ig = 0;ignpwk_max) = (cos(0.5 * gamma) - ModuleBase::IMAG_UNIT * sin(0.5 * gamma)) * fdown; // second rotation with angle gamma around(OZ) fup = cos(0.5 * (alpha + ModuleBase::PI)) * aux[ig]; fdown = ModuleBase::IMAG_UNIT * sin(0.5 * (alpha + ModuleBase::PI))*aux[ig]; - psi(iwall+2*L+1,ig) = (cos(0.5*gamma) + ModuleBase::IMAG_UNIT*sin(0.5*gamma))*fup; + psi(iwall + 2 * L + 1, ig) = + (cos(0.5 * gamma) + ModuleBase::IMAG_UNIT * sin(0.5 * gamma)) + * fup; psi(iwall + 2 * L + 1, ig + wfc_basis->npwk_max) = (cos(0.5 * gamma) - ModuleBase::IMAG_UNIT * sin(0.5 * gamma)) * fdown; } iwall++; } - iwall += 2*L +1; - } // end else INPUT.starting_spin_angle || !PARAM.globalv.domag - } // end if ucell.atoms[it].has_so - else - {//atomic_wfc_nc - double alpha = 0.0, gamman = 0.0; - std::complex fup = 0.0, fdown = 0.0; - alpha = ucell.atoms[it].angle1[ia]; - gamman = -ucell.atoms[it].angle2[ia] + 0.5*ModuleBase::PI; - for(int m = 0;m<2*L+1;m++) - { - const int lm = L*L +m; - if (iwall + 2 * L + 1 > ucell.natomwfc) - { - ModuleBase::WARNING_QUIT("this->wf.atomic_wfc()", "error: too many wfcs"); - } + iwall += 2*L +1; + } // end else INPUT.starting_spin_angle || !PARAM.globalv.domag + } // end if ucell.atoms[it].has_so + else + {//atomic_wfc_nc + double alpha = 0.0, gamman = 0.0; + std::complex fup = 0.0, fdown = 0.0; + alpha = ucell.atoms[it].angle1[ia]; + gamman = -ucell.atoms[it].angle2[ia] + 0.5*ModuleBase::PI; + for(int m = 0;m<2*L+1;m++) + { + const int lm = L*L +m; + if (iwall + 2 * L + 1 > ucell.natomwfc) + { + ModuleBase::WARNING_QUIT("this->wf.atomic_wfc()", "error: too many wfcs"); + } for (int ig = 0; ig < npw; ig++) { aux[ig] = sk[ig] * ylm(lm,ig) * flq[ig]; } //rotate function - //first, rotation with angle alpha around(OX) - for(int ig = 0;ignpwk_max) = (cos(0.5 * gamman) - ModuleBase::IMAG_UNIT * sin(0.5 * gamman)) * fdown; // second rotation with angle gamma around(OZ) fup = cos(0.5 * (alpha + ModuleBase::PI)) * aux[ig]; fdown = ModuleBase::IMAG_UNIT * sin(0.5 * (alpha + ModuleBase::PI)) * aux[ig]; - psi(iwall+2*L+1,ig) = (cos(0.5*gamman) + ModuleBase::IMAG_UNIT*sin(0.5*gamman))*fup; + psi(iwall + 2 * L + 1, ig) = + (cos(0.5 * gamman) + ModuleBase::IMAG_UNIT * sin(0.5 * gamman)) + * fup; psi(iwall + 2 * L + 1, ig + wfc_basis->npwk_max) = (cos(0.5 * gamman) - ModuleBase::IMAG_UNIT * sin(0.5 * gamman)) * fdown; } // end ig iwall++; } // end m - iwall += 2*L+1; - } // end else ucell.atoms[it].has_so - } // end for is_N + iwall += 2*L+1; + } // end else ucell.atoms[it].has_so + } // end for is_N } // end if PARAM.inp.noncolin - else - {//LSDA and nomagnet case - for(int m=0; m<2*L+1; m++) - { - const int lm = L*L+m; - for(int ig=0; ig &orbital_files, - ModuleBase::realArray &table_local); + void make_table_q( + const UnitCell &ucell, + std::vector &orbital_files, + ModuleBase::realArray &table_local); - void integral( - const UnitCell& ucell, - const int meshr, // number of mesh points - const double *psir, - const double *r, - const double *rab, - const int &l, - double* table); - - //mohan add 2010-04-20 - double smearing( - const double &energy_x, - const double &ecut, - const double &beta); + void integral( + const UnitCell& ucell, + const int meshr, // number of mesh points + const double *psir, + const double *r, + const double *rab, + const int &l, + double* table); + + //mohan add 2010-04-20 + double smearing( + const double &energy_x, + const double &ecut, + const double &beta); void produce_local_basis_in_pw(const UnitCell& ucell, - const int& ik, + const int& ik, const ModulePW::PW_Basis_K* wfc_basis, const Structure_Factor& sf, ModuleBase::ComplexMatrix& psi, diff --git a/tools/03_code_analysis/code_quality_score.py b/tools/03_code_analysis/code_quality_score.py new file mode 100644 index 00000000000..dfdc217aa66 --- /dev/null +++ b/tools/03_code_analysis/code_quality_score.py @@ -0,0 +1,1259 @@ +#!/usr/bin/env python3 +"""ABACUS code quality scoring tool. + +Scans source files/directories and assigns each file a quality score +starting from 100, deducting points for each rule violation found. + +Rules (per-file score starts at 100): + Core rules: + - filename stem longer than 20 chars: -1 + - filename contains uppercase letters: -1 + - filename extension is .hpp: -50 + - each public member variable in a class/struct: -1 + - member function longer than 50 lines: -1 per additional 50-line block + Zero-cost rules (no C++ parsing needed): + - tab indentation: -1 per line (cap 5) + - `using namespace std;`: -1 per occurrence (cap 5) + - line longer than 120 chars: -1 per line (cap 5) + - Chinese characters in comments/code: -1 per line (cap 5) + - UPPERCASE constant naming (>3 chars all caps): -1 per occurrence (cap 5) + Interface & dependency rules: + - function declaration with default parameter: -2 per occurrence (cap 5) + - GlobalV::/GlobalC::/PARAM.* cross-layer dependency: -3 per occurrence (cap 10) + - #include of .hpp implementation header: -2 per occurrence (cap 5) + - `friend` keyword exposing internals: -1 per occurrence (cap 5) + - unpaired `new` without matching `delete`: -1 per occurrence (cap 5) + - local variable shadowing a member variable: -1 per occurrence (cap 5) + - file longer than 500 lines: -2 per additional 50-line block + - each `new` keyword usage: -1 per occurrence (no cap) + - function with more than 7 parameters: -1 per extra param (cap 30 per file) + - function cyclomatic complexity > 10 (if/for/while/switch/case/&&/||): + -1 per extra point (cap 30 per file) + - post-C++11 feature usage: -80 per file (one-shot); detects high-confidence + C++14/17/20/23 tokens such as `std::make_unique`, `if constexpr`, + `[[nodiscard]]`, `auto [...]` structured bindings, `concept`, + `requires`, `consteval`, `co_await`, `std::optional`, `std::variant`, + `std::any`, `std::span`, `std::expected`, `std::format`, etc. + +Usage: + python3 code_quality_score.py source/source_base + # writes report to ./code_quality_score.txt (text default output file) + python3 code_quality_score.py source/ -o report.txt + python3 code_quality_score.py --format json path/to/file.cpp + python3 code_quality_score.py --format json source/ -o report.json + python3 code_quality_score.py --min-score 80 source/ +""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from dataclasses import dataclass, field +from pathlib import Path +from typing import List, Optional, Sequence, Tuple + + +SOURCE_EXTENSIONS = {".c", ".cc", ".cpp", ".cxx", ".cu", ".h", ".hh", ".hpp", ".hxx", ".cuh"} + +SKIP_DIRS = { + ".git", "build", "__pycache__", "node_modules", ".cache", + "third_party", "thirdparty", ".vscode", ".idea", ".trae-cn", + "Dependencies", + "test", "tests", "test_serial", "test_parallel", "unit_test", "unittest", +} + +CAPS = { + "tab_indentation": 5, + "using_namespace_std": 5, + "line_too_long": 5, + "chinese_comment": 5, + "uppercase_constant": 5, + "default_parameter": 5, + "global_dependency": 10, + "hpp_include": 5, + "friend_keyword": 5, + "unpaired_new_delete": 5, + "member_local_name_conflict": 5, +} + +WEIGHTS = { + "filename_too_long": 1, + "filename_uppercase": 1, + "hpp_implementation": 50, + "public_member_variable": 1, + "member_function_too_long": 1, + "tab_indentation": 1, + "using_namespace_std": 1, + "line_too_long": 1, + "chinese_comment": 1, + "uppercase_constant": 1, + "default_parameter": 2, + "global_dependency": 3, + "hpp_include": 2, + "friend_keyword": 1, + "unpaired_new_delete": 1, + "member_local_name_conflict": 1, + "file_too_long": 2, + "raw_new_keyword": 1, + "too_many_parameters": 1, + "high_cyclomatic_complexity": 1, + "post_cpp11_feature": 80, +} + +CAPS = { + "tab_indentation": 5, + "using_namespace_std": 5, + "line_too_long": 5, + "chinese_comment": 5, + "uppercase_constant": 5, + "default_parameter": 5, + "global_dependency": 10, + "hpp_include": 5, + "friend_keyword": 5, + "unpaired_new_delete": 5, + "member_local_name_conflict": 5, + "too_many_parameters": 30, + "high_cyclomatic_complexity": 30, +} + +FUNCTION_LENGTH_THRESHOLD = 50 +FUNCTION_LENGTH_STEP = 50 +FILE_LENGTH_THRESHOLD = 500 +FILE_LENGTH_STEP = 50 +FUNCTION_PARAM_THRESHOLD = 7 +CYCLO_THRESHOLD = 10 +LINE_LENGTH_LIMIT = 120 +FILENAME_LENGTH_LIMIT = 20 +PASS_THRESHOLD = 60 + +CHINESE_RE = re.compile("[\u4e00-\u9fff]") +USING_NS_STD_RE = re.compile(r"\busing\s+namespace\s+std\b") +UPPERCASE_CONST_RE = re.compile(r"\b[A-Z][A-Z0-9_]{2,}\b") +ACCESS_RE = re.compile(r"^\s*(public|private|protected)\s*:") +CLASS_OPEN_RE = re.compile(r"\b(class|struct)\s+(\w+)\b") + +GLOBAL_DEPENDENCY_RE = re.compile(r"\b(?:GlobalV::|GlobalC::|PARAM(?:\.|->|::))") +HPP_INCLUDE_RE = re.compile(r'^\s*#\s*include\s+[<"][^>"]+\.hpp[>"]') +FRIEND_RE = re.compile(r"\bfriend\b") +NEW_EXPR_RE = re.compile(r"\bnew\s+\w") +DELETE_EXPR_RE = re.compile(r"\bdelete\s*\[\s*\]?\s+\w") + +DEFAULT_PARAM_RE = re.compile( + r"\b\w+\s*\([^();]*\b\w+\s*=(?![=>])[^();]*\)\s*" + r"(?:const\s*|noexcept\s*|override\s*|final\s*)*[;{]" +) + +# Strict declaration regex: requires `type ... name ;` or `type ... name = ...;`. +# Excludes pure assignments like `counter = counter + 1;` which lack a leading type. +DECL_RE = re.compile(r"^\s*(?:\w+[\s\*]+)+(\w+)\s*(?:=[^;]*)?;\s*$") + +BLOCK_KEYWORD_PREFIXES = ( + "public:", "private:", "protected:", + "typedef", "using", "static_assert", + "class", "struct", "friend", + "//", "/*", "*", + "return", "if ", "for ", "while ", + "switch ", "case ", "template", + "break", "continue", "goto", "default:", + "else", "do", "try", "catch", "throw", + "namespace", "enum", "union", +) + +NON_FUNCTION_KEYWORDS = { + "if", "for", "while", "switch", "sizeof", "return", "throw", + "catch", "class", "struct", "namespace", "enum", "union", + "template", "static_assert", "typedef", "using", "new", + "delete", "operator", "do", "else", "goto", "continue", + "break", "try", +} +FUNC_NAME_RE = re.compile(r"\b([A-Za-z_]\w*)\s*\(") +QUALIFIER_RE = re.compile(r"\b(?:const|override|final|noexcept)\b") +CYCLO_KEYWORDS_RE = re.compile(r"\b(?:if|for|while|switch|case)\b|&&|\|\|") + +# Post-C++11 features with low false-positive rates. +# Each entry is (label, compiled_regex) — the label appears in the finding. +POST_CPP11_PATTERNS: List[Tuple[str, "re.Pattern[str]"]] = [ + # C++14 + ( + "std::make_unique", + re.compile(r"\bstd::make_unique\s*<"), + ), + ( + "digit-separator in numeric literal (C++14)", + re.compile(r"(?]=?|[.<>=])" + r"\s*\.\.\.\s*\)" + r"|\(\s*\.\.\.\s*(?:\+\+|\&\&|\|\||[+\-*/%^&|<>]=?|[.<>=])" + r"\s*[A-Za-z_]\w*\s*\)" + r"|\(\s*[A-Za-z_]\w*\s*(?:\+\+|\&\&|\|\||[+\-*/%^&|<>]=?|[.<>=])" + r"\s*\.\.\.\s*(?:\+\+|\&\&|\|\||[+\-*/%^&|<>]=?|[.<>=])" + r"\s*[^,)]+\s*\)" + ), + ), + ( + "std::optional (C++17)", + re.compile(r"\bstd::optional\b"), + ), + ( + "std::variant (C++17)", + re.compile(r"\bstd::variant\b"), + ), + ( + "std::any (C++17)", + re.compile(r"\bstd::any\b"), + ), + ( + "[[nodiscard]] (C++17)", + re.compile(r"\[\[nodiscard\b"), + ), + ( + "[[maybe_unused]] (C++17)", + re.compile(r"\[\[maybe_unused\b"), + ), + # C++20 + ( + "concept (C++20)", + re.compile(r"\bconcept\b"), + ), + ( + "requires (C++20)", + re.compile(r"\brequires\b"), + ), + ( + "consteval (C++20)", + re.compile(r"\bconsteval\b"), + ), + ( + "constinit (C++20)", + re.compile(r"\bconstinit\b"), + ), + ( + "coroutine co_await / co_yield / co_return (C++20)", + re.compile(r"\bco_(?:await|yield|return)\b"), + ), + ( + "std::span (C++20)", + re.compile(r"\bstd::span\b"), + ), + ( + "std::ranges (C++20)", + re.compile(r"\bstd::ranges::"), + ), + ( + "std::format (C++20)", + re.compile(r"\bstd::format\b"), + ), + # C++23 + ( + "std::expected (C++23)", + re.compile(r"\bstd::expected\b"), + ), + ( + "std::print / std::println (C++23)", + re.compile(r"\bstd::print(?:ln)?\s*\("), + ), +] + + +@dataclass +class Finding: + rule: str + line: Optional[int] + reason: str + deduction: int + + +@dataclass +class FileReport: + path: str + score: int + findings: List[Finding] = field(default_factory=list) + + def to_dict(self) -> dict: + return { + "path": self.path, + "score": self.score, + "findings": [ + { + "rule": f.rule, + "line": f.line, + "reason": f.reason, + "deduction": f.deduction, + } + for f in self.findings + ], + } + + +def discover_files(paths: Sequence[str]) -> List[Path]: + """Walk input paths and return source files, skipping generated dirs.""" + result: List[Path] = [] + for p in paths: + path = Path(p) + if path.is_file(): + if path.suffix in SOURCE_EXTENSIONS: + result.append(path) + elif path.is_dir(): + for sub in path.rglob("*"): + if not sub.is_file(): + continue + if any(part in SKIP_DIRS for part in sub.parts): + continue + if sub.suffix in SOURCE_EXTENSIONS: + result.append(sub) + return result + + +def strip_comments(content: str) -> str: + """Return content with comments replaced by spaces, preserving line numbers. + + Handles // line comments and /* */ block comments. String literals are + respected so that '/' inside strings is not mistaken for a comment. + """ + out = [] + i = 0 + n = len(content) + in_string = False + in_char = False + while i < n: + c = content[i] + if in_string: + out.append(c) + if c == "\\" and i + 1 < n: + out.append(content[i + 1]) + i += 2 + continue + if c == '"': + in_string = False + i += 1 + continue + if in_char: + out.append(c) + if c == "\\" and i + 1 < n: + out.append(content[i + 1]) + i += 2 + continue + if c == "'": + in_char = False + i += 1 + continue + if c == '"': + in_string = True + out.append(c) + i += 1 + continue + if c == "'": + in_char = True + out.append(c) + i += 1 + continue + if c == "/" and i + 1 < n: + if content[i + 1] == "/": + # line comment until newline + j = content.find("\n", i) + if j < 0: + j = n + out.append(" " * (j - i)) + i = j + continue + if content[i + 1] == "*": + # block comment until */ + j = content.find("*/", i + 2) + if j < 0: + j = n + else: + j += 2 + block = content[i:j] + # preserve newlines + out.append(re.sub(r"[^\n]", " ", block)) + i = j + continue + out.append(c) + i += 1 + return "".join(out) + + +def strip_strings(content: str) -> str: + """Return content with string and character literals erased to spaces, + preserving line numbers. + + The result still has the same number of characters and lines, but no + letter/word survives inside "..." or '...'. This prevents false matches + on keywords that happen to appear inside string literals. + """ + out = [] + i = 0 + n = len(content) + in_string = False + in_char = False + while i < n: + c = content[i] + if in_string: + if c == "\\" and i + 1 < n: + # keep the escape sequence length but blank it out + out.append(" ") + if content[i + 1] == "\n": + out.append("\n") + else: + out.append(" ") + i += 2 + continue + if c == '"': + in_string = False + out.append('"') # keep the boundary marker (safest for line numbers) + i += 1 + continue + if c == "\n": + out.append("\n") + else: + out.append(" ") + i += 1 + continue + if in_char: + if c == "\\" and i + 1 < n: + out.append(" ") + if content[i + 1] == "\n": + out.append("\n") + else: + out.append(" ") + i += 2 + continue + if c == "'": + in_char = False + out.append("'") + i += 1 + continue + if c == "\n": + out.append("\n") + else: + out.append(" ") + i += 1 + continue + if c == '"': + in_string = True + out.append('"') + i += 1 + continue + if c == "'": + in_char = True + out.append("'") + i += 1 + continue + out.append(c) + i += 1 + return "".join(out) + + +def find_class_blocks(code: str) -> List[Tuple[int, int, str, str]]: + """Find top-level class/struct blocks. Returns list of + (start_line_1indexed, end_line_1indexed, kind, name). + + Walks brace matching starting from each `class X {` / `struct X {` opener. + """ + blocks: List[Tuple[int, int, str, str]] = [] + i = 0 + n = len(code) + while i < n: + m = CLASS_OPEN_RE.search(code, i) + if not m: + break + # find the opening brace after the class/struct header + # allow inheritance clauses: class X : public Y { ... } + brace_pos = code.find("{", m.end()) + if brace_pos < 0: + break + # reject if there's a `;` before the brace (forward declaration) + if ";" in code[m.end():brace_pos]: + i = m.end() + continue + depth = 1 + j = brace_pos + 1 + in_string = False + in_char = False + while j < n and depth > 0: + c = code[j] + if in_string: + if c == "\\": + j += 2 + continue + if c == '"': + in_string = False + j += 1 + continue + if in_char: + if c == "\\": + j += 2 + continue + if c == "'": + in_char = False + j += 1 + continue + if c == '"': + in_string = True + j += 1 + continue + if c == "'": + in_char = True + j += 1 + continue + if c == "{": + depth += 1 + elif c == "}": + depth -= 1 + if depth == 0: + break + j += 1 + if depth == 0: + start_line = code[:m.start()].count("\n") + 1 + end_line = code[:j].count("\n") + 1 + blocks.append((start_line, end_line, m.group(1), m.group(2))) + i = j + 1 + else: + i = m.end() + return blocks + + +def detect_access_at_line(class_lines: List[str], target_idx: int, default_access: str) -> str: + """Determine the active access specifier for line `target_idx` inside class.""" + access = default_access + for k in range(target_idx + 1): + m = ACCESS_RE.match(class_lines[k]) + if m: + access = m.group(1) + return access + + +def is_public_member_var(line: str) -> bool: + """Heuristic: does this stripped code line look like a public member variable + declaration (not a function, not a typedef, not a nested class)?""" + s = line.strip() + if not s.endswith(";"): + return False + if "(" in s or ")" in s: + return False + if "{" in s or "}" in s: + return False + if "=" in s and "(" in s: + return False + bad_prefixes = ( + "public:", "private:", "protected:", + "typedef", "using", "static_assert", + "class", "struct", "friend", + "//", "/*", "*", + "return", "if ", "for ", "while ", "switch ", "case ", + "template", + ) + if s.startswith(bad_prefixes): + return False + # skip macro-like lines (all caps with parens already excluded above) + # require at least one identifier character + if not re.search(r"[A-Za-z_]", s): + return False + return True + + +def _match_var_decl(stripped_line: str) -> Optional[str]: + """If line looks like a variable declaration `type name;` or + `type name = ...;`, return the variable name; else None. + + Excludes pure assignments (e.g. `counter = counter + 1;`) which lack a + leading type token. This is a best-effort heuristic, not a full parser. + """ + s = stripped_line.strip() + if not s or s.startswith(BLOCK_KEYWORD_PREFIXES): + return None + m = DECL_RE.match(s) + return m.group(1) if m else None + + +def count_function_params(params_str: str) -> int: + """Count top-level parameters by counting commas at bracket depth 0. + + Tracks (), [] {} and <> as nesting (so commas inside std::map or + function-pointer params are not counted as param separators). + """ + s = params_str.strip() + if not s or s == "void": + return 0 + depth = 0 + count = 1 + for c in s: + if c in "([{<": + depth += 1 + elif c in ")]}>": + depth = max(0, depth - 1) + elif c == "," and depth == 0: + count += 1 + return count + + +def find_long_function_signatures( + content: str, threshold: int +) -> List[Tuple[int, str, int]]: + """Find function declarations/definitions whose parameter count exceeds threshold. + + Returns list of (line_no, function_name, param_count). Best-effort: + requires either a return-type prefix before the function name (for `;` + and `=` endings) or a `{` ending (for constructors/destructors which + have no return type). + """ + stripped = strip_comments(content) + n = len(stripped) + findings: List[Tuple[int, str, int]] = [] + + i = 0 + while i < n: + m = FUNC_NAME_RE.search(stripped, i) + if not m: + break + name = m.group(1) + if name in NON_FUNCTION_KEYWORDS: + i = m.end() + continue + + # inspect the prefix before `name(` to reject calls/lambdas/macros + line_start = stripped.rfind("\n", 0, m.start()) + 1 + prefix = stripped[line_start:m.start()] + prefix_stripped = prefix.rstrip() + prefix_lstripped = prefix.lstrip() + + # skip preprocessor lines + if prefix_lstripped.startswith("#"): + i = m.end() + continue + # skip lambdas: prefix ends with `]` + if prefix_stripped.endswith("]"): + i = m.end() + continue + # skip method calls: prefix ends with `.` or `->` + if prefix_stripped.endswith(".") or prefix_stripped.endswith("->"): + i = m.end() + continue + # skip assignment-result calls: prefix ends with `=` (but not `==`) + if prefix_stripped.endswith("=") and not prefix_stripped.endswith("=="): + i = m.end() + continue + # skip function-pointer typedefs + if "typedef" in prefix_stripped: + i = m.end() + continue + + # match parens to find params string + paren_open = m.end() - 1 + depth = 1 + j = paren_open + 1 + while j < n and depth > 0: + c = stripped[j] + if c == "(": + depth += 1 + elif c == ")": + depth -= 1 + if depth == 0: + break + j += 1 + if depth != 0: + i = m.end() + continue + params_str = stripped[paren_open + 1:j] + + # after `)`: optional qualifiers (const/override/final/noexcept), then ; { or = + after = stripped[j + 1:].lstrip() + k = 0 + while True: + mq = QUALIFIER_RE.match(after, k) + if not mq: + break + k = mq.end() + while k < len(after) and after[k] in " \t\n": + k += 1 + if k >= len(after) or after[k] not in ";{=": + i = j + 1 + continue + + # for `;` and `=`, require a return-type prefix (else it looks like a + # function call). For `{`, allow empty prefix (constructor/destructor). + if after[k] in ";=" and not prefix_stripped: + i = j + 1 + continue + + param_count = count_function_params(params_str) + if param_count > threshold: + line_no = stripped[:paren_open].count("\n") + 1 + findings.append((line_no, name, param_count)) + + i = j + 1 + + return findings + + +def find_function_bodies(content: str) -> List[Tuple[int, str, int, int]]: + """Find function definitions (with body), not just declarations. + + Returns list of (signature_line_no, function_name, body_start_pos, + body_end_pos) where positions are absolute offsets in stripped content. + Reuses the prefix/reject logic from find_long_function_signatures. + """ + stripped = strip_comments(content) + n = len(stripped) + bodies: List[Tuple[int, str, int, int]] = [] + + i = 0 + while i < n: + m = FUNC_NAME_RE.search(stripped, i) + if not m: + break + name = m.group(1) + if name in NON_FUNCTION_KEYWORDS: + i = m.end() + continue + + line_start = stripped.rfind("\n", 0, m.start()) + 1 + prefix = stripped[line_start:m.start()] + prefix_stripped = prefix.rstrip() + prefix_lstripped = prefix.lstrip() + + if prefix_lstripped.startswith("#"): + i = m.end() + continue + if prefix_stripped.endswith("]"): + i = m.end() + continue + if prefix_stripped.endswith(".") or prefix_stripped.endswith("->"): + i = m.end() + continue + if prefix_stripped.endswith("=") and not prefix_stripped.endswith("=="): + i = m.end() + continue + if "typedef" in prefix_stripped: + i = m.end() + continue + + paren_open = m.end() - 1 + depth = 1 + j = paren_open + 1 + while j < n and depth > 0: + c = stripped[j] + if c == "(": + depth += 1 + elif c == ")": + depth -= 1 + if depth == 0: + break + j += 1 + if depth != 0: + i = m.end() + continue + + # find what comes after `)`: skip whitespace and qualifiers + pos = j + 1 + while pos < n and stripped[pos] in " \t\n": + pos += 1 + while True: + mq = QUALIFIER_RE.match(stripped, pos) + if not mq: + break + pos = mq.end() + while pos < n and stripped[pos] in " \t\n": + pos += 1 + + # we need a `{` body (not `;` declaration, not `= 0` pure virtual) + if pos >= n or stripped[pos] != "{": + i = j + 1 + continue + + # match braces to find body end + body_open = pos + depth = 1 + body_close = body_open + 1 + while body_close < n and depth > 0: + c = stripped[body_close] + if c == "{": + depth += 1 + elif c == "}": + depth -= 1 + if depth == 0: + break + body_close += 1 + if depth != 0: + i = j + 1 + continue + + sig_line_no = stripped[:paren_open].count("\n") + 1 + bodies.append((sig_line_no, name, body_open, body_close)) + i = body_close + 1 + + return bodies + + +def find_high_complexity_functions( + content: str, threshold: int +) -> List[Tuple[int, str, int]]: + """Find functions whose cyclomatic complexity exceeds threshold. + + Cyclomatic complexity counts: if, for, while, switch, case, &&, ||. + Returns list of (line_no, function_name, complexity). + """ + stripped = strip_comments(content) + findings: List[Tuple[int, str, int]] = [] + for sig_line, name, body_open, body_close in find_function_bodies(content): + body = stripped[body_open + 1:body_close] + complexity = len(CYCLO_KEYWORDS_RE.findall(body)) + if complexity > threshold: + findings.append((sig_line, name, complexity)) + return findings + + +def find_post_cpp11_features( + content: str, +) -> List[Tuple[int, str]]: + """Find the first occurrence of each post-C++11 feature. + + Uses the patterns in POST_CPP11_PATTERNS against content with both + comments AND string literals blanked out. String blanking prevents + false matches on keywords embedded in user-facing messages (e.g. + `"X requires Y"` in a WARNING_QUIT call). + + Returns a list of (line_no, feature_label) — one entry per distinct + detected feature so the finding message is informative (the deduction + is one-shot -80 per file regardless of how many features hit). + """ + stripped = strip_strings(strip_comments(content)) + hits: List[Tuple[int, str]] = [] + for label, regex in POST_CPP11_PATTERNS: + m = regex.search(stripped) + if m: + line_no = stripped[:m.start()].count("\n") + 1 + hits.append((line_no, label)) + return hits + + +def analyze_class_blocks(content: str) -> Tuple[List[Finding], List[Finding], List[Finding]]: + """Analyze class/struct blocks for public member variables, long member + functions, and member/local name conflicts. + + Returns (public_member_findings, long_function_findings, name_conflict_findings). + """ + pub_findings: List[Finding] = [] + long_func_findings: List[Finding] = [] + conflict_findings: List[Finding] = [] + + stripped = strip_comments(content) + lines = stripped.split("\n") + + for start_line, end_line, kind, name in find_class_blocks(stripped): + default_access = "private" if kind == "class" else "public" + body_start = start_line - 1 + body_end = end_line - 1 + class_lines = lines[body_start:body_end + 1] + + # pre-compute access per line (for public member variable rule) + access_per_line: List[str] = [] + cur_access = default_access + for k in range(len(class_lines)): + m = ACCESS_RE.match(class_lines[k]) + if m: + cur_access = m.group(1) + access_per_line.append(cur_access) + + # pass 1: collect all member variable names (any access section). + # depth starts at 0; the class header line brings it to 1. Only lines + # that begin AND end at depth 1 are class-body member declarations + # (lines that open a function body bring depth from 1 to 2). + member_var_names: set = set() + depth = 0 + for line in class_lines: + prev_depth = depth + depth += line.count("{") - line.count("}") + if prev_depth == 1 and depth == 1: + var_name = _match_var_decl(line) + if var_name: + member_var_names.add(var_name) + + # pass 2: walk class body for findings + depth = 0 + func_start_abs = -1 + for idx in range(len(class_lines)): + line = class_lines[idx] + abs_line = body_start + idx + 1 # 1-indexed absolute line + prev_depth = depth + + # public member variable: only at depth 1 (class body, not in a function) + if prev_depth == 1 and access_per_line[idx] == "public": + if is_public_member_var(line): + pub_findings.append(Finding( + rule="public_member_variable", + line=abs_line, + reason=f"public member in {kind} {name}: {line.strip()}", + deduction=WEIGHTS["public_member_variable"], + )) + + # member/local name conflict: only at depth >= 2 (function body) + if prev_depth >= 2: + var_name = _match_var_decl(line) + if var_name and var_name in member_var_names: + conflict_findings.append(Finding( + rule="member_local_name_conflict", + line=abs_line, + reason=( + f"local variable '{var_name}' in {kind} {name} " + f"shadows a member variable" + ), + deduction=WEIGHTS["member_local_name_conflict"], + )) + + # apply brace counting for this line + depth = prev_depth + line.count("{") - line.count("}") + + # function body start: transition from depth 1 to >=2 + if prev_depth == 1 and depth >= 2 and func_start_abs < 0: + func_start_abs = abs_line + # function body end: transition from >=2 back to 1 + if prev_depth >= 2 and depth == 1 and func_start_abs > 0: + func_end_abs = abs_line + func_lines = func_end_abs - func_start_abs + 1 + if func_lines > FUNCTION_LENGTH_THRESHOLD: + excess = func_lines - FUNCTION_LENGTH_THRESHOLD + blocks = (excess + FUNCTION_LENGTH_STEP - 1) // FUNCTION_LENGTH_STEP + long_func_findings.append(Finding( + rule="member_function_too_long", + line=func_start_abs, + reason=( + f"member function in {kind} {name} spans {func_lines} lines " + f"(exceeds {FUNCTION_LENGTH_THRESHOLD})" + ), + deduction=blocks * WEIGHTS["member_function_too_long"], + )) + func_start_abs = -1 + + return pub_findings, long_func_findings, conflict_findings + + +def analyze_file(path: Path) -> FileReport: + """Analyze one file and return its FileReport.""" + findings: List[Finding] = [] + name = path.name + stem = path.stem + suffix = path.suffix + + # filename rules + if len(stem) > FILENAME_LENGTH_LIMIT: + findings.append(Finding( + rule="filename_too_long", + line=None, + reason=( + f"filename '{name}' stem has {len(stem)} chars " + f"(limit {FILENAME_LENGTH_LIMIT})" + ), + deduction=WEIGHTS["filename_too_long"], + )) + if any(c.isupper() for c in stem): + findings.append(Finding( + rule="filename_uppercase", + line=None, + reason=f"filename '{name}' contains uppercase letters", + deduction=WEIGHTS["filename_uppercase"], + )) + if suffix == ".hpp": + findings.append(Finding( + rule="hpp_implementation", + line=None, + reason=".hpp implementation header prohibited (use .cpp + .h split)", + deduction=WEIGHTS["hpp_implementation"], + )) + + # read content + try: + content = path.read_text(encoding="utf-8", errors="replace") + except OSError as e: + findings.append(Finding( + rule="read_error", + line=None, + reason=f"failed to read file: {e}", + deduction=0, + )) + return FileReport(path=str(path), score=100, findings=findings) + + lines = content.split("\n") + + # file length rule (no cap: large files legitimately scale deduction) + total_lines = len(lines) + if total_lines > FILE_LENGTH_THRESHOLD: + excess = total_lines - FILE_LENGTH_THRESHOLD + blocks = (excess + FILE_LENGTH_STEP - 1) // FILE_LENGTH_STEP + findings.append(Finding( + rule="file_too_long", + line=None, + reason=( + f"file has {total_lines} lines " + f"(exceeds {FILE_LENGTH_THRESHOLD} by {excess})" + ), + deduction=blocks * WEIGHTS["file_too_long"], + )) + + # line-based rules with caps + tab_count = sum(1 for l in lines if "\t" in l) + long_line_count = sum(1 for l in lines if len(l) > LINE_LENGTH_LIMIT) + using_ns_count = sum( + 1 for l in lines if USING_NS_STD_RE.search(strip_comments(l)) + ) + chinese_count = sum(1 for l in lines if CHINESE_RE.search(l)) + + stripped_content = strip_comments(content) + upper_const_count = 0 + for l in stripped_content.split("\n"): + upper_const_count += len(UPPERCASE_CONST_RE.findall(l)) + + # interface & dependency rules + global_dep_count = len(GLOBAL_DEPENDENCY_RE.findall(stripped_content)) + hpp_include_count = sum(1 for l in lines if HPP_INCLUDE_RE.match(l)) + friend_count = len(FRIEND_RE.findall(stripped_content)) + + # default parameter: scan each stripped line for function-decl signature + default_param_count = 0 + for l in stripped_content.split("\n"): + default_param_count += len(DEFAULT_PARAM_RE.findall(l)) + + # unpaired new/delete (file-level): rough heuristic, cap applied below + new_count = len(NEW_EXPR_RE.findall(stripped_content)) + delete_count = len(DELETE_EXPR_RE.findall(stripped_content)) + unpaired_new = max(0, new_count - delete_count) + + # raw `new` keyword usage: each occurrence costs 1 (no cap) + raw_new_count = new_count + + def append_capped(rule: str, count: int) -> None: + if count == 0: + return + cap = CAPS.get(rule) + actual = min(count, cap) if cap else count + cap_text = f" (capped at {cap})" if cap and count > cap else "" + findings.append(Finding( + rule=rule, + line=None, + reason=f"{count} occurrence(s){cap_text}", + deduction=actual * WEIGHTS[rule], + )) + + append_capped("tab_indentation", tab_count) + append_capped("line_too_long", long_line_count) + append_capped("using_namespace_std", using_ns_count) + append_capped("chinese_comment", chinese_count) + append_capped("uppercase_constant", upper_const_count) + append_capped("default_parameter", default_param_count) + append_capped("global_dependency", global_dep_count) + append_capped("hpp_include", hpp_include_count) + append_capped("friend_keyword", friend_count) + append_capped("unpaired_new_delete", unpaired_new) + append_capped("raw_new_keyword", raw_new_count) + + # too-many-parameters rule (per-function, capped across the file) + long_param_funcs = find_long_function_signatures(content, FUNCTION_PARAM_THRESHOLD) + cap_params = CAPS.get("too_many_parameters") + running_param_deduction = 0 + for line_no, fname, pcount in long_param_funcs: + excess = pcount - FUNCTION_PARAM_THRESHOLD + per_deduction = excess * WEIGHTS["too_many_parameters"] + if cap_params is not None and running_param_deduction + per_deduction > cap_params: + per_deduction = max(0, cap_params - running_param_deduction) + if per_deduction == 0: + break + running_param_deduction += per_deduction + findings.append(Finding( + rule="too_many_parameters", + line=line_no, + reason=( + f"function '{fname}' has {pcount} parameters " + f"(exceeds {FUNCTION_PARAM_THRESHOLD} by {excess})" + ), + deduction=per_deduction, + )) + + # high cyclomatic complexity rule (per-function, capped across the file) + high_cyclo_funcs = find_high_complexity_functions(content, CYCLO_THRESHOLD) + cap_cyclo = CAPS.get("high_cyclomatic_complexity") + running_cyclo_deduction = 0 + for line_no, fname, cyclo in high_cyclo_funcs: + excess = cyclo - CYCLO_THRESHOLD + per_deduction = excess * WEIGHTS["high_cyclomatic_complexity"] + if cap_cyclo is not None and running_cyclo_deduction + per_deduction > cap_cyclo: + per_deduction = max(0, cap_cyclo - running_cyclo_deduction) + if per_deduction == 0: + break + running_cyclo_deduction += per_deduction + findings.append(Finding( + rule="high_cyclomatic_complexity", + line=line_no, + reason=( + f"function '{fname}' has cyclomatic complexity {cyclo} " + f"(exceeds {CYCLO_THRESHOLD} by {excess})" + ), + deduction=per_deduction, + )) + + # post-C++11 feature rule: one-shot -80, lists all distinct features + post_cpp11_hits = find_post_cpp11_features(content) + if post_cpp11_hits: + feature_list = ", ".join( + f"'{label}' (line {ln})" for ln, label in post_cpp11_hits + ) + findings.append(Finding( + rule="post_cpp11_feature", + line=post_cpp11_hits[0][0], + reason=( + f"uses post-C++11 feature(s): {feature_list} " + f"(repo baseline is C++11)" + ), + deduction=WEIGHTS["post_cpp11_feature"], + )) + + # class-based rules + pub_findings, long_func_findings, conflict_findings = analyze_class_blocks(content) + findings.extend(pub_findings) + findings.extend(long_func_findings) + # name conflict findings already respect cap via per-file cap on the rule + if len(conflict_findings) > CAPS.get("member_local_name_conflict", len(conflict_findings)): + cap = CAPS["member_local_name_conflict"] + conflict_findings = conflict_findings[:cap] + findings.extend(conflict_findings) + + # compute score + score = 100 + for f in findings: + score -= f.deduction + if score < 0: + score = 0 + + return FileReport(path=str(path), score=score, findings=findings) + + +def render_text(reports: List[FileReport], min_score: Optional[int]) -> str: + """Render reports as plain text.""" + if not reports: + return "No files to analyze.\n" + + visible = reports if min_score is None else [r for r in reports if r.score <= min_score] + sorted_reports = sorted(visible, key=lambda r: r.score) + + out: List[str] = [] + width = 70 + out.append("=" * width) + out.append("Code Quality Score Report") + out.append("=" * width) + out.append("") + out.append(f"{'Score':>6} {'File'}") + out.append("-" * width) + for r in sorted_reports: + out.append(f"{r.score:>6} {r.path}") + out.append("") + + for r in sorted_reports: + if not r.findings: + continue + out.append("-" * width) + out.append(f"File: {r.path} (score: {r.score})") + out.append("-" * width) + for f in r.findings: + loc = f"line {f.line}" if f.line else "file" + out.append(f" [{f.rule}] {loc}: {f.reason} (-{f.deduction})") + out.append("") + + total = len(reports) + visible_n = len(visible) + avg = sum(r.score for r in reports) / total if total else 0.0 + passing = sum(1 for r in reports if r.score >= PASS_THRESHOLD) + out.append("=" * width) + out.append(f"Files scanned: {total}") + out.append(f"Files shown: {visible_n}") + out.append(f"Average score: {avg:.1f}") + out.append(f"Passing (>= {PASS_THRESHOLD}): {passing}/{total}") + out.append("=" * width) + return "\n".join(out) + "\n" + + +def render_json(reports: List[FileReport], min_score: Optional[int]) -> str: + visible = reports if min_score is None else [r for r in reports if r.score <= min_score] + return json.dumps({ + "summary": { + "total_scanned": len(reports), + "total_shown": len(visible), + "average_score": ( + sum(r.score for r in reports) / len(reports) if reports else 0.0 + ), + "passing": sum(1 for r in reports if r.score >= PASS_THRESHOLD), + "pass_threshold": PASS_THRESHOLD, + }, + "files": [r.to_dict() for r in sorted(visible, key=lambda r: r.score)], + }, indent=2) + + +def parse_args(argv: Optional[Sequence[str]] = None) -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="ABACUS code quality scoring tool", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument("paths", nargs="+", help="file or directory paths to scan") + parser.add_argument( + "--format", choices=["text", "json"], default="text", + help="output format (default: text)", + ) + parser.add_argument( + "--min-score", type=int, default=None, + help="only show files with score <= this value in output", + ) + parser.add_argument( + "--output", "-o", default=None, + help="write report to this file instead of stdout " + "(default: code_quality_score.txt when format is text and " + "no explicit output is given)", + ) + return parser.parse_args(argv) + + +def main(argv: Optional[Sequence[str]] = None) -> int: + args = parse_args(argv) + files = discover_files(args.paths) + if not files: + print(f"No source files found in: {args.paths}", file=sys.stderr) + return 1 + + reports = [analyze_file(f) for f in files] + + if args.format == "json": + rendered = render_json(reports, args.min_score) + else: + rendered = render_text(reports, args.min_score) + + out_path = args.output + if out_path is None and args.format == "text": + out_path = "code_quality_score.txt" + + if out_path: + Path(out_path).write_text(rendered, encoding="utf-8") + print( + f"Report written to {out_path} " + f"({len(reports)} files scanned, " + f"{sum(1 for r in reports if r.score >= PASS_THRESHOLD)} passing)", + file=sys.stderr, + ) + else: + print(rendered) + return 0 + + +if __name__ == "__main__": + sys.exit(main())