plugadvpl 0.3.4__tar.gz → 0.3.6__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {plugadvpl-0.3.4 → plugadvpl-0.3.6}/PKG-INFO +1 -1
- {plugadvpl-0.3.4 → plugadvpl-0.3.6}/plugadvpl/_version.py +2 -2
- {plugadvpl-0.3.4 → plugadvpl-0.3.6}/plugadvpl/lookups/lint_rules.json +9 -7
- {plugadvpl-0.3.4 → plugadvpl-0.3.6}/plugadvpl/parsing/lint.py +126 -1
- {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/unit/test_lint.py +252 -0
- {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/unit/test_lint_catalog_consistency.py +18 -10
- {plugadvpl-0.3.4 → plugadvpl-0.3.6}/.gitignore +0 -0
- {plugadvpl-0.3.4 → plugadvpl-0.3.6}/README.md +0 -0
- {plugadvpl-0.3.4 → plugadvpl-0.3.6}/plugadvpl/__init__.py +0 -0
- {plugadvpl-0.3.4 → plugadvpl-0.3.6}/plugadvpl/__main__.py +0 -0
- {plugadvpl-0.3.4 → plugadvpl-0.3.6}/plugadvpl/cli.py +0 -0
- {plugadvpl-0.3.4 → plugadvpl-0.3.6}/plugadvpl/db.py +0 -0
- {plugadvpl-0.3.4 → plugadvpl-0.3.6}/plugadvpl/ingest.py +0 -0
- {plugadvpl-0.3.4 → plugadvpl-0.3.6}/plugadvpl/ingest_sx.py +0 -0
- {plugadvpl-0.3.4 → plugadvpl-0.3.6}/plugadvpl/lookups/.gitkeep +0 -0
- {plugadvpl-0.3.4 → plugadvpl-0.3.6}/plugadvpl/lookups/funcoes_nativas.json +0 -0
- {plugadvpl-0.3.4 → plugadvpl-0.3.6}/plugadvpl/lookups/funcoes_restritas.json +0 -0
- {plugadvpl-0.3.4 → plugadvpl-0.3.6}/plugadvpl/lookups/modulos_erp.json +0 -0
- {plugadvpl-0.3.4 → plugadvpl-0.3.6}/plugadvpl/lookups/pontos_entrada_padrao.json +0 -0
- {plugadvpl-0.3.4 → plugadvpl-0.3.6}/plugadvpl/lookups/sql_macros.json +0 -0
- {plugadvpl-0.3.4 → plugadvpl-0.3.6}/plugadvpl/migrations/001_initial.sql +0 -0
- {plugadvpl-0.3.4 → plugadvpl-0.3.6}/plugadvpl/migrations/002_universo2_sx.sql +0 -0
- {plugadvpl-0.3.4 → plugadvpl-0.3.6}/plugadvpl/migrations/003_lint_rules_status.sql +0 -0
- {plugadvpl-0.3.4 → plugadvpl-0.3.6}/plugadvpl/output.py +0 -0
- {plugadvpl-0.3.4 → plugadvpl-0.3.6}/plugadvpl/parsing/.gitkeep +0 -0
- {plugadvpl-0.3.4 → plugadvpl-0.3.6}/plugadvpl/parsing/__init__.py +0 -0
- {plugadvpl-0.3.4 → plugadvpl-0.3.6}/plugadvpl/parsing/parser.py +0 -0
- {plugadvpl-0.3.4 → plugadvpl-0.3.6}/plugadvpl/parsing/stripper.py +0 -0
- {plugadvpl-0.3.4 → plugadvpl-0.3.6}/plugadvpl/parsing/sx_csv.py +0 -0
- {plugadvpl-0.3.4 → plugadvpl-0.3.6}/plugadvpl/query.py +0 -0
- {plugadvpl-0.3.4 → plugadvpl-0.3.6}/plugadvpl/scan.py +0 -0
- {plugadvpl-0.3.4 → plugadvpl-0.3.6}/pyproject.toml +0 -0
- {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/__init__.py +0 -0
- {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/bench/.gitkeep +0 -0
- {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/bench/__init__.py +0 -0
- {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/bench/test_ingest_perf.py +0 -0
- {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/bench/test_sx_ingest_perf.py +0 -0
- {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/e2e_local/.gitkeep +0 -0
- {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/e2e_local/__init__.py +0 -0
- {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/e2e_local/test_e2e_local_ingest.py +0 -0
- {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/e2e_local/test_ingest_sx_real.py +0 -0
- {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/fixtures/expected/.gitkeep +0 -0
- {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/fixtures/sx_synthetic/six.csv +0 -0
- {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/fixtures/sx_synthetic/sx1.csv +0 -0
- {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/fixtures/sx_synthetic/sx2.csv +0 -0
- {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/fixtures/sx_synthetic/sx3.csv +0 -0
- {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/fixtures/sx_synthetic/sx5.csv +0 -0
- {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/fixtures/sx_synthetic/sx6.csv +0 -0
- {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/fixtures/sx_synthetic/sx7.csv +0 -0
- {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/fixtures/sx_synthetic/sx9.csv +0 -0
- {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/fixtures/sx_synthetic/sxa.csv +0 -0
- {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/fixtures/sx_synthetic/sxb.csv +0 -0
- {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/fixtures/sx_synthetic/sxg.csv +0 -0
- {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/fixtures/synthetic/.gitkeep +0 -0
- {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/fixtures/synthetic/_generate.py +0 -0
- {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/fixtures/synthetic/classic_browse.prw +0 -0
- {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/fixtures/synthetic/corrupted.bak +0 -0
- {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/fixtures/synthetic/empty.prw +0 -0
- {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/fixtures/synthetic/encoding_cp1252.prw +0 -0
- {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/fixtures/synthetic/encoding_utf8.prw +0 -0
- {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/fixtures/synthetic/exec_auto.prw +0 -0
- {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/fixtures/synthetic/http_outbound.prw +0 -0
- {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/fixtures/synthetic/huge.prw +0 -0
- {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/fixtures/synthetic/job_rpc.prw +0 -0
- {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/fixtures/synthetic/multi_filial.prw +0 -0
- {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/fixtures/synthetic/mvc_complete.prw +0 -0
- {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/fixtures/synthetic/mvc_hooks.prw +0 -0
- {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/fixtures/synthetic/pe_simple.prw +0 -0
- {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/fixtures/synthetic/pubvars.prw +0 -0
- {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/fixtures/synthetic/reclock_pattern.prw +0 -0
- {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/fixtures/synthetic/reclock_unbalanced.prw +0 -0
- {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/fixtures/synthetic/sql_embedded.prw +0 -0
- {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/fixtures/synthetic/tlpp_namespace.tlpp +0 -0
- {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/fixtures/synthetic/ws_rest.tlpp +0 -0
- {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/fixtures/synthetic/ws_soap.prw +0 -0
- {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/integration/.gitkeep +0 -0
- {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/integration/__init__.py +0 -0
- {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/integration/test_cli.py +0 -0
- {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/integration/test_ingest.py +0 -0
- {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/integration/test_ingest_sx.py +0 -0
- {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/unit/.gitkeep +0 -0
- {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/unit/__snapshots__/test_parser_snapshots.ambr +0 -0
- {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/unit/test_db.py +0 -0
- {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/unit/test_output.py +0 -0
- {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/unit/test_parser.py +0 -0
- {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/unit/test_parser_snapshots.py +0 -0
- {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/unit/test_query.py +0 -0
- {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/unit/test_scan.py +0 -0
- {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/unit/test_stripper.py +0 -0
- {plugadvpl-0.3.4 → plugadvpl-0.3.6}/uv.lock +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: plugadvpl
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.6
|
|
4
4
|
Summary: CLI que indexa fontes ADVPL/Protheus em SQLite com FTS5 para análise por LLM (companheiro do plugin Claude Code plugadvpl)
|
|
5
5
|
Project-URL: Homepage, https://github.com/JoniPraia/plugadvpl
|
|
6
6
|
Project-URL: Issues, https://github.com/JoniPraia/plugadvpl/issues
|
|
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
|
|
|
18
18
|
commit_id: str | None
|
|
19
19
|
__commit_id__: str | None
|
|
20
20
|
|
|
21
|
-
__version__ = version = '0.3.
|
|
22
|
-
__version_tuple__ = version_tuple = (0, 3,
|
|
21
|
+
__version__ = version = '0.3.6'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 3, 6)
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -90,10 +90,11 @@
|
|
|
90
90
|
"titulo": "Shadowing de variável reservada (cFilAnt, cEmpAnt, PARAMIXB, etc.)",
|
|
91
91
|
"severidade": "critical",
|
|
92
92
|
"categoria": "best-practice",
|
|
93
|
-
"descricao": "Declarar variável Local/Static/Private com nome de Public reservada do framework (`cFilAnt`, `cEmpAnt`, `cUserName`, `
|
|
94
|
-
"fix_guidance": "Renomeie a variável local pra algo distinto (prefixo cliente). NUNCA crie Local/Private/Public com o mesmo nome de uma reservada framework.",
|
|
93
|
+
"descricao": "Declarar variável Local/Static/Private/Public com nome de Public reservada do framework Protheus faz shadowing — sua função enxerga `\"\"`/`Nil` em vez do valor real. Cobertas (case-insensitive): `cFilAnt`, `cEmpAnt`, `cUserName`, `cModulo`, `cTransac`, `nProgAnt`, `oMainWnd`, `__cInternet`, `nUsado`, `PARAMIXB`, `aRotina`, `lMsErroAuto`, `lMsHelpAuto`. Detecta também declarações multi-var (`Local cVar1, cFilAnt, cVar2`) e TLPP-typed (`Local cFilAnt as character`).",
|
|
94
|
+
"fix_guidance": "Renomeie a variável local pra algo distinto (prefixo cliente, ex: `cMinhaFilial`). NUNCA crie Local/Private/Public/Static com o mesmo nome de uma reservada framework — não há motivo legítimo de override em código custom.",
|
|
95
95
|
"detection_kind": "regex",
|
|
96
|
-
"status": "
|
|
96
|
+
"status": "active",
|
|
97
|
+
"impl_function": "_check_bp008_shadowed_reserved"
|
|
97
98
|
},
|
|
98
99
|
{
|
|
99
100
|
"regra_id": "SEC-001",
|
|
@@ -192,13 +193,14 @@
|
|
|
192
193
|
},
|
|
193
194
|
{
|
|
194
195
|
"regra_id": "PERF-005",
|
|
195
|
-
"titulo": "RecCount()
|
|
196
|
+
"titulo": "RecCount() para checar existência (use !Eof())",
|
|
196
197
|
"severidade": "warning",
|
|
197
198
|
"categoria": "performance",
|
|
198
|
-
"descricao": "`RecCount()` força full scan do alias para contar todos os registros. Para apenas verificar se existe pelo menos 1 registro, `!Eof()` após `DbSeek
|
|
199
|
-
"fix_guidance": "Substitua `If RecCount() > 0` por `If !Eof()` ou `If !<alias>->(Eof())`. Em SQL, use `SELECT COUNT(*) FROM ... LIMIT 1` ou subquery EXISTS
|
|
199
|
+
"descricao": "`RecCount()` força full scan do alias para contar todos os registros. Para apenas verificar se existe pelo menos 1 registro, `!Eof()` após `DbSeek`/`DbGoTop` é O(1). A regra detecta os padrões `RecCount() > 0`, `RecCount() >= 1`, `RecCount() != 0` e `RecCount() <> 0` (legacy), incluindo variante com alias-call `SA1->(RecCount()) > 0`. NÃO sinaliza limites de business reais (`RecCount() > 100`) nem armazenamento (`nTotal := RecCount()`).",
|
|
200
|
+
"fix_guidance": "Substitua `If RecCount() > 0` por `If !Eof()` ou `If !<alias>->(Eof())`. Em SQL embarcado, use `SELECT COUNT(*) FROM ... LIMIT 1` ou subquery `WHERE EXISTS (...)`.",
|
|
200
201
|
"detection_kind": "regex",
|
|
201
|
-
"status": "
|
|
202
|
+
"status": "active",
|
|
203
|
+
"impl_function": "_check_perf005_reccount_for_existence"
|
|
202
204
|
},
|
|
203
205
|
{
|
|
204
206
|
"regra_id": "PERF-006",
|
|
@@ -673,11 +673,134 @@ def _check_mod002_public_declaration(
|
|
|
673
673
|
return findings
|
|
674
674
|
|
|
675
675
|
|
|
676
|
+
# --- BP-008: shadowing de variável reservada -------------------------------
|
|
677
|
+
|
|
678
|
+
# Variáveis Public criadas pelo framework Protheus que NÃO devem ser declaradas
|
|
679
|
+
# como Local/Static/Private/Public em fonte custom. Comparação é case-insensitive
|
|
680
|
+
# (ADVPL trata identificadores assim).
|
|
681
|
+
_BP008_RESERVED_VARS: frozenset[str] = frozenset(
|
|
682
|
+
name.upper()
|
|
683
|
+
for name in (
|
|
684
|
+
# Contexto de empresa/filial/usuario
|
|
685
|
+
"cFilAnt", "cEmpAnt", "cUserName", "cModulo", "cTransac", "nProgAnt",
|
|
686
|
+
# Janela principal e flags de execucao
|
|
687
|
+
"oMainWnd", "__cInternet", "nUsado",
|
|
688
|
+
# PE / MVC / ExecAuto
|
|
689
|
+
"PARAMIXB", "aRotina", "lMsErroAuto", "lMsHelpAuto",
|
|
690
|
+
)
|
|
691
|
+
)
|
|
692
|
+
|
|
693
|
+
# Detecta declaração: linha começa com (Local|Static|Private|Public) seguido de
|
|
694
|
+
# pelo menos um identificador. Captura group 1 = keyword, group 2 = resto da linha.
|
|
695
|
+
_BP008_DECL_RE = re.compile(
|
|
696
|
+
r"^[ \t]*(Local|Static|Private|Public)\b[ \t]+(.+)$",
|
|
697
|
+
re.IGNORECASE | re.MULTILINE,
|
|
698
|
+
)
|
|
699
|
+
|
|
700
|
+
# Em "cVar1, cFilAnt, cVar2 := 'x' as character", separadores são vírgula top-level.
|
|
701
|
+
# Pra cada peça, o identificador é o primeiro token alfanumérico (antes de :=, as, espaço).
|
|
702
|
+
_BP008_VAR_RE = re.compile(r"^[ \t]*([A-Za-z_][A-Za-z0-9_]*)")
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
def _check_bp008_shadowed_reserved(
|
|
706
|
+
arquivo: str, parsed: dict[str, Any], content: str
|
|
707
|
+
) -> list[dict[str, Any]]:
|
|
708
|
+
"""BP-008 (critical): Local/Static/Private/Public com nome de variável reservada framework.
|
|
709
|
+
|
|
710
|
+
Declarar variável local com nome de Public reservada (cFilAnt, cEmpAnt, PARAMIXB, etc.)
|
|
711
|
+
faz shadowing — sua função enxerga "" / Nil em vez do valor real do framework.
|
|
712
|
+
Bug clássico: programador declara `Local cFilAnt := ""` no início e depois usa cFilAnt
|
|
713
|
+
achando que tem o valor da filial atual.
|
|
714
|
+
"""
|
|
715
|
+
findings: list[dict[str, Any]] = []
|
|
716
|
+
stripped = strip_advpl(content, strip_strings=True)
|
|
717
|
+
funcoes = parsed.get("funcoes", []) or []
|
|
718
|
+
|
|
719
|
+
for m_decl in _BP008_DECL_RE.finditer(stripped):
|
|
720
|
+
keyword = m_decl.group(1)
|
|
721
|
+
rest = m_decl.group(2)
|
|
722
|
+
# Split por vírgula top-level (declarações ADVPL não têm vírgulas dentro de
|
|
723
|
+
# argumentos de função aqui, pois é só lado esquerdo de declaração).
|
|
724
|
+
for piece in rest.split(","):
|
|
725
|
+
m_var = _BP008_VAR_RE.match(piece)
|
|
726
|
+
if not m_var:
|
|
727
|
+
continue
|
|
728
|
+
var_name = m_var.group(1)
|
|
729
|
+
if var_name.upper() not in _BP008_RESERVED_VARS:
|
|
730
|
+
continue
|
|
731
|
+
# Match — calcula linha + funcao + snippet
|
|
732
|
+
offset = m_decl.start()
|
|
733
|
+
linha = _line_at(stripped, offset)
|
|
734
|
+
funcao = _funcao_at_line(funcoes, linha)
|
|
735
|
+
findings.append(
|
|
736
|
+
{
|
|
737
|
+
"arquivo": arquivo,
|
|
738
|
+
"funcao": funcao,
|
|
739
|
+
"linha": linha,
|
|
740
|
+
"regra_id": "BP-008",
|
|
741
|
+
"severidade": "critical",
|
|
742
|
+
"snippet": _snippet_at_line(content, linha),
|
|
743
|
+
"sugestao_fix": (
|
|
744
|
+
f"`{keyword} {var_name}` faz shadowing da variável reservada "
|
|
745
|
+
f"`{var_name}` do framework Protheus. Renomeie para algo distinto "
|
|
746
|
+
f"(ex: prefixo cliente). Toda função vê valor vazio em vez do real."
|
|
747
|
+
),
|
|
748
|
+
}
|
|
749
|
+
)
|
|
750
|
+
return findings
|
|
751
|
+
|
|
752
|
+
|
|
753
|
+
# --- PERF-005: RecCount() para checar existência ---------------------------
|
|
754
|
+
|
|
755
|
+
# Detecta `RecCount() > 0`, `RecCount() >= 1`, `RecCount() != 0`, `RecCount() <> 0`
|
|
756
|
+
# (com ou sem alias-call `SA1->(RecCount())`). NÃO detecta:
|
|
757
|
+
# - `RecCount() > 100` (limite específico, intencional)
|
|
758
|
+
# - `nTotal := RecCount()` (armazena, não checa)
|
|
759
|
+
# Padrão de comparação: > 0, >= 1, != 0, <> 0 (case-insensitive operadores).
|
|
760
|
+
_PERF005_RE = re.compile(
|
|
761
|
+
r"\bRecCount\s*\(\s*\)\s*\)?\s*" # RecCount() possivelmente fechando alias->()
|
|
762
|
+
r"(?:>\s*0(?!\d)|>=\s*1(?!\d)|!=\s*0(?!\d)|<>\s*0(?!\d))",
|
|
763
|
+
re.IGNORECASE,
|
|
764
|
+
)
|
|
765
|
+
|
|
766
|
+
|
|
767
|
+
def _check_perf005_reccount_for_existence(
|
|
768
|
+
arquivo: str, parsed: dict[str, Any], content: str
|
|
769
|
+
) -> list[dict[str, Any]]:
|
|
770
|
+
"""PERF-005 (warning): RecCount() > 0 / >= 1 / != 0 / <> 0 para checar existência.
|
|
771
|
+
|
|
772
|
+
RecCount() força full scan do alias. Para apenas verificar se existe pelo
|
|
773
|
+
menos 1 registro, ``!Eof()`` após DbSeek/DbGoTop é O(1).
|
|
774
|
+
"""
|
|
775
|
+
findings: list[dict[str, Any]] = []
|
|
776
|
+
stripped = strip_advpl(content, strip_strings=True)
|
|
777
|
+
funcoes = parsed.get("funcoes", []) or []
|
|
778
|
+
|
|
779
|
+
for m in _PERF005_RE.finditer(stripped):
|
|
780
|
+
linha = _line_at(stripped, m.start())
|
|
781
|
+
funcao = _funcao_at_line(funcoes, linha)
|
|
782
|
+
findings.append(
|
|
783
|
+
{
|
|
784
|
+
"arquivo": arquivo,
|
|
785
|
+
"funcao": funcao,
|
|
786
|
+
"linha": linha,
|
|
787
|
+
"regra_id": "PERF-005",
|
|
788
|
+
"severidade": "warning",
|
|
789
|
+
"snippet": _snippet_at_line(content, linha),
|
|
790
|
+
"sugestao_fix": (
|
|
791
|
+
"RecCount() força full scan da tabela. Para checar existência, "
|
|
792
|
+
"use !Eof() após DbSeek/DbGoTop (O(1)) ou subquery EXISTS em SQL."
|
|
793
|
+
),
|
|
794
|
+
}
|
|
795
|
+
)
|
|
796
|
+
return findings
|
|
797
|
+
|
|
798
|
+
|
|
676
799
|
# --- Orchestrator -------------------------------------------------------------
|
|
677
800
|
|
|
678
801
|
|
|
679
802
|
def lint_source(parsed: dict[str, Any], content: str) -> list[dict[str, Any]]:
|
|
680
|
-
"""Aplica as
|
|
803
|
+
"""Aplica as 15 regras single-file. Retorna list[Finding] ordenada por linha.
|
|
681
804
|
|
|
682
805
|
Args:
|
|
683
806
|
parsed: dict produzido por parse_source() (com funcoes, sql_embedado, etc.).
|
|
@@ -695,11 +818,13 @@ def lint_source(parsed: dict[str, Any], content: str) -> list[dict[str, Any]]:
|
|
|
695
818
|
findings.extend(_check_bp004_pergunte_no_check(arquivo, parsed, content))
|
|
696
819
|
findings.extend(_check_bp005_too_many_params(arquivo, parsed, content))
|
|
697
820
|
findings.extend(_check_bp006_mixed_reclock_rawapi(arquivo, parsed, content))
|
|
821
|
+
findings.extend(_check_bp008_shadowed_reserved(arquivo, parsed, content))
|
|
698
822
|
findings.extend(_check_sec001_rpcsetenv_in_restful(arquivo, parsed, content))
|
|
699
823
|
findings.extend(_check_sec002_user_function_no_prefix(arquivo, parsed, content))
|
|
700
824
|
findings.extend(_check_perf001_select_star(arquivo, parsed, content))
|
|
701
825
|
findings.extend(_check_perf002_no_notdel(arquivo, parsed, content))
|
|
702
826
|
findings.extend(_check_perf003_no_xfilial(arquivo, parsed, content))
|
|
827
|
+
findings.extend(_check_perf005_reccount_for_existence(arquivo, parsed, content))
|
|
703
828
|
findings.extend(_check_mod001_conout_instead_fwlogmsg(arquivo, parsed, content))
|
|
704
829
|
findings.extend(_check_mod002_public_declaration(arquivo, parsed, content))
|
|
705
830
|
|
|
@@ -447,3 +447,255 @@ class TestLintSourceIntegration:
|
|
|
447
447
|
)
|
|
448
448
|
findings = lint_source(_parsed_for(src), src)
|
|
449
449
|
assert findings == []
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
# --- BP-008: shadowing de variável reservada framework ----------------------
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
class TestBP008ShadowedReserved:
|
|
456
|
+
"""BP-008 (critical): Local/Static/Private/Public com nome de reservada TOTVS."""
|
|
457
|
+
|
|
458
|
+
def test_positive_local_cFilAnt(self) -> None:
|
|
459
|
+
src = (
|
|
460
|
+
"User Function XYZBad()\n"
|
|
461
|
+
" Local cFilAnt := '01'\n" # shadow!
|
|
462
|
+
" ConOut(cFilAnt)\n"
|
|
463
|
+
"Return\n"
|
|
464
|
+
)
|
|
465
|
+
findings = lint_source(_parsed_for(src), src)
|
|
466
|
+
bp008 = [f for f in findings if f["regra_id"] == "BP-008"]
|
|
467
|
+
assert len(bp008) == 1
|
|
468
|
+
assert bp008[0]["severidade"] == "critical"
|
|
469
|
+
assert "cFilAnt" in bp008[0]["snippet"] or "cFilAnt" in bp008[0]["sugestao_fix"]
|
|
470
|
+
|
|
471
|
+
def test_positive_static_cEmpAnt_case_insensitive(self) -> None:
|
|
472
|
+
src = (
|
|
473
|
+
"User Function XYZBad()\n"
|
|
474
|
+
" static CEMPANT := ''\n" # ADVPL is case-insensitive
|
|
475
|
+
"Return\n"
|
|
476
|
+
)
|
|
477
|
+
findings = lint_source(_parsed_for(src), src)
|
|
478
|
+
assert "BP-008" in _ids(findings)
|
|
479
|
+
|
|
480
|
+
def test_positive_private_lMsErroAuto(self) -> None:
|
|
481
|
+
src = (
|
|
482
|
+
"User Function XYZBad()\n"
|
|
483
|
+
" Private lMsErroAuto := .F.\n" # reserved exec auto flag
|
|
484
|
+
"Return\n"
|
|
485
|
+
)
|
|
486
|
+
findings = lint_source(_parsed_for(src), src)
|
|
487
|
+
assert "BP-008" in _ids(findings)
|
|
488
|
+
|
|
489
|
+
def test_positive_public_PARAMIXB(self) -> None:
|
|
490
|
+
src = (
|
|
491
|
+
"User Function XYZBad()\n"
|
|
492
|
+
" Public PARAMIXB := {}\n" # PE arg array, never override
|
|
493
|
+
"Return\n"
|
|
494
|
+
)
|
|
495
|
+
findings = lint_source(_parsed_for(src), src)
|
|
496
|
+
assert "BP-008" in _ids(findings)
|
|
497
|
+
|
|
498
|
+
def test_positive_multi_var_decl(self) -> None:
|
|
499
|
+
"""Local cVar1, cFilAnt, cVar2 := ... — BP-008 pega o cFilAnt no meio."""
|
|
500
|
+
src = (
|
|
501
|
+
"User Function XYZBad()\n"
|
|
502
|
+
" Local cVar1, cFilAnt, cVar2 := 'x'\n"
|
|
503
|
+
"Return\n"
|
|
504
|
+
)
|
|
505
|
+
findings = lint_source(_parsed_for(src), src)
|
|
506
|
+
assert "BP-008" in _ids(findings)
|
|
507
|
+
|
|
508
|
+
def test_positive_tlpp_typed_decl(self) -> None:
|
|
509
|
+
"""Local cFilAnt as character — TLPP type annotation, ainda shadow."""
|
|
510
|
+
src = (
|
|
511
|
+
"User Function XYZBad()\n"
|
|
512
|
+
" Local cFilAnt as character\n"
|
|
513
|
+
"Return\n"
|
|
514
|
+
)
|
|
515
|
+
findings = lint_source(_parsed_for(src), src)
|
|
516
|
+
assert "BP-008" in _ids(findings)
|
|
517
|
+
|
|
518
|
+
def test_negative_similar_name(self) -> None:
|
|
519
|
+
"""cFilAntiga não é cFilAnt — não deve match."""
|
|
520
|
+
src = (
|
|
521
|
+
"User Function XYZGood()\n"
|
|
522
|
+
" Local cFilAntiga := '01'\n"
|
|
523
|
+
" Local cFilAntx := '02'\n"
|
|
524
|
+
" Local nProgAntx := 0\n"
|
|
525
|
+
"Return\n"
|
|
526
|
+
)
|
|
527
|
+
findings = lint_source(_parsed_for(src), src)
|
|
528
|
+
assert "BP-008" not in _ids(findings)
|
|
529
|
+
|
|
530
|
+
def test_negative_reserved_in_string(self) -> None:
|
|
531
|
+
"""'cFilAnt' dentro de string literal não deve match."""
|
|
532
|
+
src = (
|
|
533
|
+
"User Function XYZGood()\n"
|
|
534
|
+
" Local cMsg := 'cFilAnt is reserved'\n"
|
|
535
|
+
" ConOut(cMsg)\n"
|
|
536
|
+
"Return\n"
|
|
537
|
+
)
|
|
538
|
+
findings = lint_source(_parsed_for(src), src)
|
|
539
|
+
assert "BP-008" not in _ids(findings)
|
|
540
|
+
|
|
541
|
+
def test_negative_reserved_in_comment(self) -> None:
|
|
542
|
+
"""// Local cFilAnt não deve match."""
|
|
543
|
+
src = (
|
|
544
|
+
"User Function XYZGood()\n"
|
|
545
|
+
" // Local cFilAnt — comentario explicativo\n"
|
|
546
|
+
" Local cMinhaVar := '01'\n"
|
|
547
|
+
"Return\n"
|
|
548
|
+
)
|
|
549
|
+
findings = lint_source(_parsed_for(src), src)
|
|
550
|
+
assert "BP-008" not in _ids(findings)
|
|
551
|
+
|
|
552
|
+
def test_negative_use_without_declare(self) -> None:
|
|
553
|
+
"""Usar cFilAnt sem declarar como Local NÃO é shadow — é uso correto."""
|
|
554
|
+
src = (
|
|
555
|
+
"User Function XYZGood()\n"
|
|
556
|
+
" ConOut('Filial atual: ' + cFilAnt)\n"
|
|
557
|
+
" DbSelectArea('SA1')\n"
|
|
558
|
+
" DbSeek(xFilial('SA1') + cFilAnt)\n"
|
|
559
|
+
"Return\n"
|
|
560
|
+
)
|
|
561
|
+
findings = lint_source(_parsed_for(src), src)
|
|
562
|
+
assert "BP-008" not in _ids(findings)
|
|
563
|
+
|
|
564
|
+
def test_positive_reports_correct_line(self) -> None:
|
|
565
|
+
"""Snippet/linha apontam pra linha correta."""
|
|
566
|
+
src = (
|
|
567
|
+
"User Function XYZBad()\n" # 1
|
|
568
|
+
" Local cVarOk := '01'\n" # 2
|
|
569
|
+
" Local cEmpAnt := '99'\n" # 3 — shadow aqui
|
|
570
|
+
"Return\n" # 4
|
|
571
|
+
)
|
|
572
|
+
findings = lint_source(_parsed_for(src), src)
|
|
573
|
+
bp008 = [f for f in findings if f["regra_id"] == "BP-008"]
|
|
574
|
+
assert len(bp008) == 1
|
|
575
|
+
assert bp008[0]["linha"] == 3
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
# --- PERF-005: RecCount() para checar existência ---------------------------
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
class TestPERF005ReccountForExistence:
|
|
582
|
+
"""PERF-005 (warning): RecCount() > 0 / >= 1 / != 0 / <> 0 — use !Eof()."""
|
|
583
|
+
|
|
584
|
+
def test_positive_reccount_gt_zero(self) -> None:
|
|
585
|
+
src = (
|
|
586
|
+
"User Function XYZBad()\n"
|
|
587
|
+
" DbSelectArea('SA1')\n"
|
|
588
|
+
" DbGoTop()\n"
|
|
589
|
+
" If RecCount() > 0\n" # full scan
|
|
590
|
+
" ConOut('tem')\n"
|
|
591
|
+
" EndIf\n"
|
|
592
|
+
"Return\n"
|
|
593
|
+
)
|
|
594
|
+
findings = lint_source(_parsed_for(src), src)
|
|
595
|
+
perf = [f for f in findings if f["regra_id"] == "PERF-005"]
|
|
596
|
+
assert len(perf) == 1
|
|
597
|
+
assert perf[0]["severidade"] == "warning"
|
|
598
|
+
|
|
599
|
+
def test_positive_reccount_gte_one(self) -> None:
|
|
600
|
+
src = (
|
|
601
|
+
"User Function XYZBad()\n"
|
|
602
|
+
" If RecCount() >= 1\n"
|
|
603
|
+
" EndIf\n"
|
|
604
|
+
"Return\n"
|
|
605
|
+
)
|
|
606
|
+
findings = lint_source(_parsed_for(src), src)
|
|
607
|
+
assert "PERF-005" in _ids(findings)
|
|
608
|
+
|
|
609
|
+
def test_positive_reccount_neq_zero(self) -> None:
|
|
610
|
+
src = (
|
|
611
|
+
"User Function XYZBad()\n"
|
|
612
|
+
" If RecCount() != 0\n"
|
|
613
|
+
" EndIf\n"
|
|
614
|
+
"Return\n"
|
|
615
|
+
)
|
|
616
|
+
findings = lint_source(_parsed_for(src), src)
|
|
617
|
+
assert "PERF-005" in _ids(findings)
|
|
618
|
+
|
|
619
|
+
def test_positive_reccount_diff_legacy(self) -> None:
|
|
620
|
+
"""ADVPL legacy != é <> — também detecta."""
|
|
621
|
+
src = (
|
|
622
|
+
"User Function XYZBad()\n"
|
|
623
|
+
" If RecCount() <> 0\n"
|
|
624
|
+
" EndIf\n"
|
|
625
|
+
"Return\n"
|
|
626
|
+
)
|
|
627
|
+
findings = lint_source(_parsed_for(src), src)
|
|
628
|
+
assert "PERF-005" in _ids(findings)
|
|
629
|
+
|
|
630
|
+
def test_positive_alias_call(self) -> None:
|
|
631
|
+
"""SA1->(RecCount()) > 0 também é o anti-pattern."""
|
|
632
|
+
src = (
|
|
633
|
+
"User Function XYZBad()\n"
|
|
634
|
+
" If SA1->(RecCount()) > 0\n"
|
|
635
|
+
" EndIf\n"
|
|
636
|
+
"Return\n"
|
|
637
|
+
)
|
|
638
|
+
findings = lint_source(_parsed_for(src), src)
|
|
639
|
+
assert "PERF-005" in _ids(findings)
|
|
640
|
+
|
|
641
|
+
def test_negative_reccount_specific_threshold(self) -> None:
|
|
642
|
+
"""RecCount() > 100 (limite de business, intencional) NÃO é PERF-005."""
|
|
643
|
+
src = (
|
|
644
|
+
"User Function XYZGood()\n"
|
|
645
|
+
" If RecCount() > 100\n"
|
|
646
|
+
" ConOut('muitos')\n"
|
|
647
|
+
" EndIf\n"
|
|
648
|
+
"Return\n"
|
|
649
|
+
)
|
|
650
|
+
findings = lint_source(_parsed_for(src), src)
|
|
651
|
+
assert "PERF-005" not in _ids(findings)
|
|
652
|
+
|
|
653
|
+
def test_negative_reccount_assignment(self) -> None:
|
|
654
|
+
"""nTotal := RecCount() — só armazena, não checa existência."""
|
|
655
|
+
src = (
|
|
656
|
+
"User Function XYZGood()\n"
|
|
657
|
+
" Local nTotal := RecCount()\n"
|
|
658
|
+
" ConOut(cValToChar(nTotal))\n"
|
|
659
|
+
"Return\n"
|
|
660
|
+
)
|
|
661
|
+
findings = lint_source(_parsed_for(src), src)
|
|
662
|
+
assert "PERF-005" not in _ids(findings)
|
|
663
|
+
|
|
664
|
+
def test_negative_reccount_in_string(self) -> None:
|
|
665
|
+
"""'RecCount() > 0' dentro de string não match."""
|
|
666
|
+
src = (
|
|
667
|
+
"User Function XYZGood()\n"
|
|
668
|
+
" Local cMsg := 'Use RecCount() > 0 com cuidado'\n"
|
|
669
|
+
" ConOut(cMsg)\n"
|
|
670
|
+
"Return\n"
|
|
671
|
+
)
|
|
672
|
+
findings = lint_source(_parsed_for(src), src)
|
|
673
|
+
assert "PERF-005" not in _ids(findings)
|
|
674
|
+
|
|
675
|
+
def test_negative_reccount_in_comment(self) -> None:
|
|
676
|
+
"""// RecCount() > 0 — comentário não match."""
|
|
677
|
+
src = (
|
|
678
|
+
"User Function XYZGood()\n"
|
|
679
|
+
" // RecCount() > 0 era usado em codigo legado\n"
|
|
680
|
+
" If !Eof()\n"
|
|
681
|
+
" ConOut('tem')\n"
|
|
682
|
+
" EndIf\n"
|
|
683
|
+
"Return\n"
|
|
684
|
+
)
|
|
685
|
+
findings = lint_source(_parsed_for(src), src)
|
|
686
|
+
assert "PERF-005" not in _ids(findings)
|
|
687
|
+
|
|
688
|
+
def test_positive_reports_correct_line(self) -> None:
|
|
689
|
+
src = (
|
|
690
|
+
"User Function XYZBad()\n" # 1
|
|
691
|
+
" DbSelectArea('SA1')\n" # 2
|
|
692
|
+
" DbGoTop()\n" # 3
|
|
693
|
+
" If RecCount() > 0\n" # 4 — match
|
|
694
|
+
" EndIf\n" # 5
|
|
695
|
+
"Return\n" # 6
|
|
696
|
+
)
|
|
697
|
+
findings = lint_source(_parsed_for(src), src)
|
|
698
|
+
perf = [f for f in findings if f["regra_id"] == "PERF-005"]
|
|
699
|
+
assert len(perf) == 1
|
|
700
|
+
assert perf[0]["linha"] == 4
|
|
701
|
+
assert "Eof" in perf[0]["sugestao_fix"]
|
|
@@ -63,21 +63,29 @@ def impl() -> dict[str, dict[str, str]]:
|
|
|
63
63
|
return out
|
|
64
64
|
|
|
65
65
|
|
|
66
|
-
def
|
|
67
|
-
"""Sanity check:
|
|
68
|
-
assert len(catalog)
|
|
69
|
-
f"
|
|
70
|
-
"
|
|
66
|
+
def test_catalog_has_minimum_total(catalog: dict) -> None:
|
|
67
|
+
"""Sanity check: catálogo deve ter pelo menos 35 regras (mais é OK, menos não)."""
|
|
68
|
+
assert len(catalog) >= 35, (
|
|
69
|
+
f"catálogo encolheu: encontrou {len(catalog)} regras, mínimo esperado 35. "
|
|
70
|
+
"Não remova regras planned sem ticket de discussão."
|
|
71
71
|
)
|
|
72
72
|
|
|
73
73
|
|
|
74
74
|
def test_active_count_matches_impl(catalog: dict, impl: dict) -> None:
|
|
75
|
-
"""
|
|
75
|
+
"""Toda regra active no catálogo deve ter `_check_*` correspondente em lint.py.
|
|
76
|
+
|
|
77
|
+
Test dinâmico (não hardcoded): quantos active no JSON deve == quantos
|
|
78
|
+
`_check_*` em lint.py. Não precisa atualizar quando promove planned→active.
|
|
79
|
+
"""
|
|
76
80
|
n_active = sum(1 for r in catalog.values() if r.get("status") == "active")
|
|
77
|
-
assert n_active ==
|
|
78
|
-
|
|
79
|
-
f"
|
|
80
|
-
|
|
81
|
+
assert n_active == len(impl), (
|
|
82
|
+
f"catalog active={n_active}, impl _check_*={len(impl)} — "
|
|
83
|
+
f"toda regra active precisa ter _check_* correspondente, e vice-versa"
|
|
84
|
+
)
|
|
85
|
+
# Sanity floor: nunca deve cair abaixo do baseline conhecido (v0.3.4 = 24).
|
|
86
|
+
assert len(impl) >= 24, (
|
|
87
|
+
f"esperava pelo menos 24 funções _check_*, encontrou {len(impl)}. "
|
|
88
|
+
"Regrediu? Verifique se algum detector foi removido sem aviso."
|
|
81
89
|
)
|
|
82
90
|
|
|
83
91
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|