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.
Files changed (90) hide show
  1. {plugadvpl-0.3.4 → plugadvpl-0.3.6}/PKG-INFO +1 -1
  2. {plugadvpl-0.3.4 → plugadvpl-0.3.6}/plugadvpl/_version.py +2 -2
  3. {plugadvpl-0.3.4 → plugadvpl-0.3.6}/plugadvpl/lookups/lint_rules.json +9 -7
  4. {plugadvpl-0.3.4 → plugadvpl-0.3.6}/plugadvpl/parsing/lint.py +126 -1
  5. {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/unit/test_lint.py +252 -0
  6. {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/unit/test_lint_catalog_consistency.py +18 -10
  7. {plugadvpl-0.3.4 → plugadvpl-0.3.6}/.gitignore +0 -0
  8. {plugadvpl-0.3.4 → plugadvpl-0.3.6}/README.md +0 -0
  9. {plugadvpl-0.3.4 → plugadvpl-0.3.6}/plugadvpl/__init__.py +0 -0
  10. {plugadvpl-0.3.4 → plugadvpl-0.3.6}/plugadvpl/__main__.py +0 -0
  11. {plugadvpl-0.3.4 → plugadvpl-0.3.6}/plugadvpl/cli.py +0 -0
  12. {plugadvpl-0.3.4 → plugadvpl-0.3.6}/plugadvpl/db.py +0 -0
  13. {plugadvpl-0.3.4 → plugadvpl-0.3.6}/plugadvpl/ingest.py +0 -0
  14. {plugadvpl-0.3.4 → plugadvpl-0.3.6}/plugadvpl/ingest_sx.py +0 -0
  15. {plugadvpl-0.3.4 → plugadvpl-0.3.6}/plugadvpl/lookups/.gitkeep +0 -0
  16. {plugadvpl-0.3.4 → plugadvpl-0.3.6}/plugadvpl/lookups/funcoes_nativas.json +0 -0
  17. {plugadvpl-0.3.4 → plugadvpl-0.3.6}/plugadvpl/lookups/funcoes_restritas.json +0 -0
  18. {plugadvpl-0.3.4 → plugadvpl-0.3.6}/plugadvpl/lookups/modulos_erp.json +0 -0
  19. {plugadvpl-0.3.4 → plugadvpl-0.3.6}/plugadvpl/lookups/pontos_entrada_padrao.json +0 -0
  20. {plugadvpl-0.3.4 → plugadvpl-0.3.6}/plugadvpl/lookups/sql_macros.json +0 -0
  21. {plugadvpl-0.3.4 → plugadvpl-0.3.6}/plugadvpl/migrations/001_initial.sql +0 -0
  22. {plugadvpl-0.3.4 → plugadvpl-0.3.6}/plugadvpl/migrations/002_universo2_sx.sql +0 -0
  23. {plugadvpl-0.3.4 → plugadvpl-0.3.6}/plugadvpl/migrations/003_lint_rules_status.sql +0 -0
  24. {plugadvpl-0.3.4 → plugadvpl-0.3.6}/plugadvpl/output.py +0 -0
  25. {plugadvpl-0.3.4 → plugadvpl-0.3.6}/plugadvpl/parsing/.gitkeep +0 -0
  26. {plugadvpl-0.3.4 → plugadvpl-0.3.6}/plugadvpl/parsing/__init__.py +0 -0
  27. {plugadvpl-0.3.4 → plugadvpl-0.3.6}/plugadvpl/parsing/parser.py +0 -0
  28. {plugadvpl-0.3.4 → plugadvpl-0.3.6}/plugadvpl/parsing/stripper.py +0 -0
  29. {plugadvpl-0.3.4 → plugadvpl-0.3.6}/plugadvpl/parsing/sx_csv.py +0 -0
  30. {plugadvpl-0.3.4 → plugadvpl-0.3.6}/plugadvpl/query.py +0 -0
  31. {plugadvpl-0.3.4 → plugadvpl-0.3.6}/plugadvpl/scan.py +0 -0
  32. {plugadvpl-0.3.4 → plugadvpl-0.3.6}/pyproject.toml +0 -0
  33. {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/__init__.py +0 -0
  34. {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/bench/.gitkeep +0 -0
  35. {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/bench/__init__.py +0 -0
  36. {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/bench/test_ingest_perf.py +0 -0
  37. {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/bench/test_sx_ingest_perf.py +0 -0
  38. {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/e2e_local/.gitkeep +0 -0
  39. {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/e2e_local/__init__.py +0 -0
  40. {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/e2e_local/test_e2e_local_ingest.py +0 -0
  41. {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/e2e_local/test_ingest_sx_real.py +0 -0
  42. {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/fixtures/expected/.gitkeep +0 -0
  43. {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/fixtures/sx_synthetic/six.csv +0 -0
  44. {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/fixtures/sx_synthetic/sx1.csv +0 -0
  45. {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/fixtures/sx_synthetic/sx2.csv +0 -0
  46. {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/fixtures/sx_synthetic/sx3.csv +0 -0
  47. {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/fixtures/sx_synthetic/sx5.csv +0 -0
  48. {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/fixtures/sx_synthetic/sx6.csv +0 -0
  49. {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/fixtures/sx_synthetic/sx7.csv +0 -0
  50. {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/fixtures/sx_synthetic/sx9.csv +0 -0
  51. {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/fixtures/sx_synthetic/sxa.csv +0 -0
  52. {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/fixtures/sx_synthetic/sxb.csv +0 -0
  53. {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/fixtures/sx_synthetic/sxg.csv +0 -0
  54. {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/fixtures/synthetic/.gitkeep +0 -0
  55. {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/fixtures/synthetic/_generate.py +0 -0
  56. {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/fixtures/synthetic/classic_browse.prw +0 -0
  57. {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/fixtures/synthetic/corrupted.bak +0 -0
  58. {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/fixtures/synthetic/empty.prw +0 -0
  59. {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/fixtures/synthetic/encoding_cp1252.prw +0 -0
  60. {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/fixtures/synthetic/encoding_utf8.prw +0 -0
  61. {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/fixtures/synthetic/exec_auto.prw +0 -0
  62. {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/fixtures/synthetic/http_outbound.prw +0 -0
  63. {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/fixtures/synthetic/huge.prw +0 -0
  64. {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/fixtures/synthetic/job_rpc.prw +0 -0
  65. {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/fixtures/synthetic/multi_filial.prw +0 -0
  66. {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/fixtures/synthetic/mvc_complete.prw +0 -0
  67. {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/fixtures/synthetic/mvc_hooks.prw +0 -0
  68. {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/fixtures/synthetic/pe_simple.prw +0 -0
  69. {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/fixtures/synthetic/pubvars.prw +0 -0
  70. {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/fixtures/synthetic/reclock_pattern.prw +0 -0
  71. {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/fixtures/synthetic/reclock_unbalanced.prw +0 -0
  72. {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/fixtures/synthetic/sql_embedded.prw +0 -0
  73. {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/fixtures/synthetic/tlpp_namespace.tlpp +0 -0
  74. {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/fixtures/synthetic/ws_rest.tlpp +0 -0
  75. {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/fixtures/synthetic/ws_soap.prw +0 -0
  76. {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/integration/.gitkeep +0 -0
  77. {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/integration/__init__.py +0 -0
  78. {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/integration/test_cli.py +0 -0
  79. {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/integration/test_ingest.py +0 -0
  80. {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/integration/test_ingest_sx.py +0 -0
  81. {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/unit/.gitkeep +0 -0
  82. {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/unit/__snapshots__/test_parser_snapshots.ambr +0 -0
  83. {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/unit/test_db.py +0 -0
  84. {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/unit/test_output.py +0 -0
  85. {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/unit/test_parser.py +0 -0
  86. {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/unit/test_parser_snapshots.py +0 -0
  87. {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/unit/test_query.py +0 -0
  88. {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/unit/test_scan.py +0 -0
  89. {plugadvpl-0.3.4 → plugadvpl-0.3.6}/tests/unit/test_stripper.py +0 -0
  90. {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.4
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.4'
22
- __version_tuple__ = version_tuple = (0, 3, 4)
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`, `PARAMIXB`, `aRotina`, `lMsErroAuto`, `__cInternet`, `nUsado`, etc.) faz shadowing sua função enxerga `\"\"`/`Nil` em vez do valor real do framework.",
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": "planned"
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() > 0 para checar existência (use !Eof())",
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` ou `DbGoTop` é O(1).",
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": "planned"
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 13 regras single-file. Retorna list[Finding] ordenada por linha.
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 test_catalog_has_expected_total(catalog: dict) -> None:
67
- """Sanity check: número total de regras catalogadas (24 active + 11 planned = 35)."""
68
- assert len(catalog) == 35, (
69
- f"esperava 35 regras catalogadas, encontrou {len(catalog)}. "
70
- "Se adicionou/removeu regra intencional, atualize este teste."
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
- """24 regras active no catálogo devem bater com 24 funções _check_* no impl."""
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 == 24, f"esperava 24 active, encontrou {n_active}"
78
- assert len(impl) == 24, (
79
- f"esperava 24 funções _check_* em lint.py, encontrou {len(impl)}. "
80
- "Se adicionou/removeu detector, atualize este teste e o catálogo."
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