plugadvpl 0.4.2__tar.gz → 0.4.3__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 (106) hide show
  1. {plugadvpl-0.4.2 → plugadvpl-0.4.3}/PKG-INFO +1 -1
  2. {plugadvpl-0.4.2 → plugadvpl-0.4.3}/plugadvpl/_version.py +2 -2
  3. {plugadvpl-0.4.2 → plugadvpl-0.4.3}/plugadvpl/cli.py +21 -2
  4. {plugadvpl-0.4.2 → plugadvpl-0.4.3}/plugadvpl/db.py +1 -1
  5. {plugadvpl-0.4.2 → plugadvpl-0.4.3}/plugadvpl/lookups/execauto_routines.json +50 -0
  6. plugadvpl-0.4.3/plugadvpl/migrations/008_universo3_funcao_indexes.sql +11 -0
  7. {plugadvpl-0.4.2 → plugadvpl-0.4.3}/plugadvpl/parsing/protheus_doc.py +26 -12
  8. {plugadvpl-0.4.2 → plugadvpl-0.4.3}/plugadvpl/parsing/triggers.py +116 -14
  9. {plugadvpl-0.4.2 → plugadvpl-0.4.3}/plugadvpl/query.py +29 -4
  10. {plugadvpl-0.4.2 → plugadvpl-0.4.3}/tests/integration/test_cli.py +33 -0
  11. {plugadvpl-0.4.2 → plugadvpl-0.4.3}/tests/unit/test_execauto.py +18 -0
  12. {plugadvpl-0.4.2 → plugadvpl-0.4.3}/tests/unit/test_protheus_doc.py +76 -0
  13. {plugadvpl-0.4.2 → plugadvpl-0.4.3}/tests/unit/test_triggers.py +66 -0
  14. {plugadvpl-0.4.2 → plugadvpl-0.4.3}/.gitignore +0 -0
  15. {plugadvpl-0.4.2 → plugadvpl-0.4.3}/README.md +0 -0
  16. {plugadvpl-0.4.2 → plugadvpl-0.4.3}/plugadvpl/__init__.py +0 -0
  17. {plugadvpl-0.4.2 → plugadvpl-0.4.3}/plugadvpl/__main__.py +0 -0
  18. {plugadvpl-0.4.2 → plugadvpl-0.4.3}/plugadvpl/ingest.py +0 -0
  19. {plugadvpl-0.4.2 → plugadvpl-0.4.3}/plugadvpl/ingest_sx.py +0 -0
  20. {plugadvpl-0.4.2 → plugadvpl-0.4.3}/plugadvpl/lookups/.gitkeep +0 -0
  21. {plugadvpl-0.4.2 → plugadvpl-0.4.3}/plugadvpl/lookups/funcoes_nativas.json +0 -0
  22. {plugadvpl-0.4.2 → plugadvpl-0.4.3}/plugadvpl/lookups/funcoes_restritas.json +0 -0
  23. {plugadvpl-0.4.2 → plugadvpl-0.4.3}/plugadvpl/lookups/lint_rules.json +0 -0
  24. {plugadvpl-0.4.2 → plugadvpl-0.4.3}/plugadvpl/lookups/modulos_erp.json +0 -0
  25. {plugadvpl-0.4.2 → plugadvpl-0.4.3}/plugadvpl/lookups/pontos_entrada_padrao.json +0 -0
  26. {plugadvpl-0.4.2 → plugadvpl-0.4.3}/plugadvpl/lookups/sql_macros.json +0 -0
  27. {plugadvpl-0.4.2 → plugadvpl-0.4.3}/plugadvpl/migrations/001_initial.sql +0 -0
  28. {plugadvpl-0.4.2 → plugadvpl-0.4.3}/plugadvpl/migrations/002_universo2_sx.sql +0 -0
  29. {plugadvpl-0.4.2 → plugadvpl-0.4.3}/plugadvpl/migrations/003_lint_rules_status.sql +0 -0
  30. {plugadvpl-0.4.2 → plugadvpl-0.4.3}/plugadvpl/migrations/004_consultas_pk_with_tipo.sql +0 -0
  31. {plugadvpl-0.4.2 → plugadvpl-0.4.3}/plugadvpl/migrations/005_universo3_execution_triggers.sql +0 -0
  32. {plugadvpl-0.4.2 → plugadvpl-0.4.3}/plugadvpl/migrations/006_universo3_execauto_calls.sql +0 -0
  33. {plugadvpl-0.4.2 → plugadvpl-0.4.3}/plugadvpl/migrations/007_universo3_protheus_docs.sql +0 -0
  34. {plugadvpl-0.4.2 → plugadvpl-0.4.3}/plugadvpl/output.py +0 -0
  35. {plugadvpl-0.4.2 → plugadvpl-0.4.3}/plugadvpl/parsing/.gitkeep +0 -0
  36. {plugadvpl-0.4.2 → plugadvpl-0.4.3}/plugadvpl/parsing/__init__.py +0 -0
  37. {plugadvpl-0.4.2 → plugadvpl-0.4.3}/plugadvpl/parsing/execauto.py +0 -0
  38. {plugadvpl-0.4.2 → plugadvpl-0.4.3}/plugadvpl/parsing/lint.py +0 -0
  39. {plugadvpl-0.4.2 → plugadvpl-0.4.3}/plugadvpl/parsing/parser.py +0 -0
  40. {plugadvpl-0.4.2 → plugadvpl-0.4.3}/plugadvpl/parsing/stripper.py +0 -0
  41. {plugadvpl-0.4.2 → plugadvpl-0.4.3}/plugadvpl/parsing/sx_csv.py +0 -0
  42. {plugadvpl-0.4.2 → plugadvpl-0.4.3}/plugadvpl/scan.py +0 -0
  43. {plugadvpl-0.4.2 → plugadvpl-0.4.3}/pyproject.toml +0 -0
  44. {plugadvpl-0.4.2 → plugadvpl-0.4.3}/tests/__init__.py +0 -0
  45. {plugadvpl-0.4.2 → plugadvpl-0.4.3}/tests/bench/.gitkeep +0 -0
  46. {plugadvpl-0.4.2 → plugadvpl-0.4.3}/tests/bench/__init__.py +0 -0
  47. {plugadvpl-0.4.2 → plugadvpl-0.4.3}/tests/bench/test_ingest_perf.py +0 -0
  48. {plugadvpl-0.4.2 → plugadvpl-0.4.3}/tests/bench/test_sx_ingest_perf.py +0 -0
  49. {plugadvpl-0.4.2 → plugadvpl-0.4.3}/tests/e2e_local/.gitkeep +0 -0
  50. {plugadvpl-0.4.2 → plugadvpl-0.4.3}/tests/e2e_local/__init__.py +0 -0
  51. {plugadvpl-0.4.2 → plugadvpl-0.4.3}/tests/e2e_local/test_e2e_local_ingest.py +0 -0
  52. {plugadvpl-0.4.2 → plugadvpl-0.4.3}/tests/e2e_local/test_ingest_sx_real.py +0 -0
  53. {plugadvpl-0.4.2 → plugadvpl-0.4.3}/tests/fixtures/expected/.gitkeep +0 -0
  54. {plugadvpl-0.4.2 → plugadvpl-0.4.3}/tests/fixtures/sx_synthetic/six.csv +0 -0
  55. {plugadvpl-0.4.2 → plugadvpl-0.4.3}/tests/fixtures/sx_synthetic/sx1.csv +0 -0
  56. {plugadvpl-0.4.2 → plugadvpl-0.4.3}/tests/fixtures/sx_synthetic/sx2.csv +0 -0
  57. {plugadvpl-0.4.2 → plugadvpl-0.4.3}/tests/fixtures/sx_synthetic/sx3.csv +0 -0
  58. {plugadvpl-0.4.2 → plugadvpl-0.4.3}/tests/fixtures/sx_synthetic/sx5.csv +0 -0
  59. {plugadvpl-0.4.2 → plugadvpl-0.4.3}/tests/fixtures/sx_synthetic/sx6.csv +0 -0
  60. {plugadvpl-0.4.2 → plugadvpl-0.4.3}/tests/fixtures/sx_synthetic/sx7.csv +0 -0
  61. {plugadvpl-0.4.2 → plugadvpl-0.4.3}/tests/fixtures/sx_synthetic/sx9.csv +0 -0
  62. {plugadvpl-0.4.2 → plugadvpl-0.4.3}/tests/fixtures/sx_synthetic/sxa.csv +0 -0
  63. {plugadvpl-0.4.2 → plugadvpl-0.4.3}/tests/fixtures/sx_synthetic/sxb.csv +0 -0
  64. {plugadvpl-0.4.2 → plugadvpl-0.4.3}/tests/fixtures/sx_synthetic/sxb_with_collisions.csv +0 -0
  65. {plugadvpl-0.4.2 → plugadvpl-0.4.3}/tests/fixtures/sx_synthetic/sxg.csv +0 -0
  66. {plugadvpl-0.4.2 → plugadvpl-0.4.3}/tests/fixtures/synthetic/.gitkeep +0 -0
  67. {plugadvpl-0.4.2 → plugadvpl-0.4.3}/tests/fixtures/synthetic/_generate.py +0 -0
  68. {plugadvpl-0.4.2 → plugadvpl-0.4.3}/tests/fixtures/synthetic/classic_browse.prw +0 -0
  69. {plugadvpl-0.4.2 → plugadvpl-0.4.3}/tests/fixtures/synthetic/corrupted.bak +0 -0
  70. {plugadvpl-0.4.2 → plugadvpl-0.4.3}/tests/fixtures/synthetic/empty.prw +0 -0
  71. {plugadvpl-0.4.2 → plugadvpl-0.4.3}/tests/fixtures/synthetic/encoding_cp1252.prw +0 -0
  72. {plugadvpl-0.4.2 → plugadvpl-0.4.3}/tests/fixtures/synthetic/encoding_utf8.prw +0 -0
  73. {plugadvpl-0.4.2 → plugadvpl-0.4.3}/tests/fixtures/synthetic/exec_auto.prw +0 -0
  74. {plugadvpl-0.4.2 → plugadvpl-0.4.3}/tests/fixtures/synthetic/http_outbound.prw +0 -0
  75. {plugadvpl-0.4.2 → plugadvpl-0.4.3}/tests/fixtures/synthetic/huge.prw +0 -0
  76. {plugadvpl-0.4.2 → plugadvpl-0.4.3}/tests/fixtures/synthetic/job_rpc.prw +0 -0
  77. {plugadvpl-0.4.2 → plugadvpl-0.4.3}/tests/fixtures/synthetic/multi_filial.prw +0 -0
  78. {plugadvpl-0.4.2 → plugadvpl-0.4.3}/tests/fixtures/synthetic/mvc_complete.prw +0 -0
  79. {plugadvpl-0.4.2 → plugadvpl-0.4.3}/tests/fixtures/synthetic/mvc_hooks.prw +0 -0
  80. {plugadvpl-0.4.2 → plugadvpl-0.4.3}/tests/fixtures/synthetic/pe_paramixb.prw +0 -0
  81. {plugadvpl-0.4.2 → plugadvpl-0.4.3}/tests/fixtures/synthetic/pe_simple.prw +0 -0
  82. {plugadvpl-0.4.2 → plugadvpl-0.4.3}/tests/fixtures/synthetic/pubvars.prw +0 -0
  83. {plugadvpl-0.4.2 → plugadvpl-0.4.3}/tests/fixtures/synthetic/reclock_alias_dup_trigger.prw +0 -0
  84. {plugadvpl-0.4.2 → plugadvpl-0.4.3}/tests/fixtures/synthetic/reclock_pattern.prw +0 -0
  85. {plugadvpl-0.4.2 → plugadvpl-0.4.3}/tests/fixtures/synthetic/reclock_unbalanced.prw +0 -0
  86. {plugadvpl-0.4.2 → plugadvpl-0.4.3}/tests/fixtures/synthetic/sql_embedded.prw +0 -0
  87. {plugadvpl-0.4.2 → plugadvpl-0.4.3}/tests/fixtures/synthetic/tlpp_namespace.tlpp +0 -0
  88. {plugadvpl-0.4.2 → plugadvpl-0.4.3}/tests/fixtures/synthetic/ws_rest.tlpp +0 -0
  89. {plugadvpl-0.4.2 → plugadvpl-0.4.3}/tests/fixtures/synthetic/ws_restful_classic.prw +0 -0
  90. {plugadvpl-0.4.2 → plugadvpl-0.4.3}/tests/fixtures/synthetic/ws_soap.prw +0 -0
  91. {plugadvpl-0.4.2 → plugadvpl-0.4.3}/tests/integration/.gitkeep +0 -0
  92. {plugadvpl-0.4.2 → plugadvpl-0.4.3}/tests/integration/__init__.py +0 -0
  93. {plugadvpl-0.4.2 → plugadvpl-0.4.3}/tests/integration/test_ingest.py +0 -0
  94. {plugadvpl-0.4.2 → plugadvpl-0.4.3}/tests/integration/test_ingest_sx.py +0 -0
  95. {plugadvpl-0.4.2 → plugadvpl-0.4.3}/tests/unit/.gitkeep +0 -0
  96. {plugadvpl-0.4.2 → plugadvpl-0.4.3}/tests/unit/__snapshots__/test_parser_snapshots.ambr +0 -0
  97. {plugadvpl-0.4.2 → plugadvpl-0.4.3}/tests/unit/test_db.py +0 -0
  98. {plugadvpl-0.4.2 → plugadvpl-0.4.3}/tests/unit/test_lint.py +0 -0
  99. {plugadvpl-0.4.2 → plugadvpl-0.4.3}/tests/unit/test_lint_catalog_consistency.py +0 -0
  100. {plugadvpl-0.4.2 → plugadvpl-0.4.3}/tests/unit/test_output.py +0 -0
  101. {plugadvpl-0.4.2 → plugadvpl-0.4.3}/tests/unit/test_parser.py +0 -0
  102. {plugadvpl-0.4.2 → plugadvpl-0.4.3}/tests/unit/test_parser_snapshots.py +0 -0
  103. {plugadvpl-0.4.2 → plugadvpl-0.4.3}/tests/unit/test_query.py +0 -0
  104. {plugadvpl-0.4.2 → plugadvpl-0.4.3}/tests/unit/test_scan.py +0 -0
  105. {plugadvpl-0.4.2 → plugadvpl-0.4.3}/tests/unit/test_stripper.py +0 -0
  106. {plugadvpl-0.4.2 → plugadvpl-0.4.3}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: plugadvpl
3
- Version: 0.4.2
3
+ Version: 0.4.3
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.4.2'
22
- __version_tuple__ = version_tuple = (0, 4, 2)
21
+ __version__ = version = '0.4.3'
22
+ __version_tuple__ = version_tuple = (0, 4, 3)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -60,6 +60,7 @@ from plugadvpl.query import (
60
60
  execauto_calls_query,
61
61
  execution_triggers_query,
62
62
  find_any,
63
+ protheus_doc_homonyms,
63
64
  protheus_doc_show,
64
65
  protheus_docs_orphans,
65
66
  protheus_docs_query,
@@ -1305,10 +1306,28 @@ def docs(
1305
1306
  formatado em Markdown. Use ``--orphans`` pra ver funções sem header.
1306
1307
  """
1307
1308
  if show:
1308
- d = _with_ro_db(ctx, lambda c: protheus_doc_show(c, show))
1309
- if d is None:
1309
+ # v0.4.3 (I2): com homônimos, --arquivo desambiguar; sem --arquivo,
1310
+ # avisa em stderr e mostra o primeiro alfabeticamente.
1311
+ homonyms = _with_ro_db(ctx, lambda c: protheus_doc_homonyms(c, show))
1312
+ if not homonyms:
1310
1313
  typer.echo(f"Nenhum Protheus.doc encontrado pra função '{show}'.", err=True)
1311
1314
  raise typer.Exit(code=1)
1315
+ if len(homonyms) > 1 and not arquivo:
1316
+ typer.echo(
1317
+ f"Aviso: '{show}' tem doc em {len(homonyms)} fontes: "
1318
+ f"{', '.join(homonyms)}. Mostrando '{homonyms[0]}'. "
1319
+ f"Use --arquivo <nome> pra escolher.",
1320
+ err=True,
1321
+ )
1322
+ d = _with_ro_db(
1323
+ ctx, lambda c: protheus_doc_show(c, show, arquivo=arquivo)
1324
+ )
1325
+ if d is None:
1326
+ typer.echo(
1327
+ f"Nenhum Protheus.doc encontrado pra '{show}' em '{arquivo}'.",
1328
+ err=True,
1329
+ )
1330
+ raise typer.Exit(code=1)
1312
1331
  typer.echo(render_pdoc_markdown(d))
1313
1332
  return
1314
1333
 
@@ -11,7 +11,7 @@ from typing import TYPE_CHECKING
11
11
  if TYPE_CHECKING:
12
12
  from pathlib import Path
13
13
 
14
- SCHEMA_VERSION = "7"
14
+ SCHEMA_VERSION = "8"
15
15
 
16
16
 
17
17
  # Mapeamento {filename JSON -> (tabela, colunas em ordem)}.
@@ -16,6 +16,16 @@
16
16
  "source_url": "https://tdn.totvs.com",
17
17
  "verified": true
18
18
  },
19
+ {
20
+ "routine": "MATA020",
21
+ "module": "SIGACOM",
22
+ "type": "cadastro",
23
+ "label": "Cadastro de Fornecedores",
24
+ "tables_primary": ["SA2"],
25
+ "tables_secondary": [],
26
+ "source_url": "https://tdn.totvs.com",
27
+ "verified": true
28
+ },
19
29
  {
20
30
  "routine": "MATA030",
21
31
  "module": "SIGAFIN",
@@ -26,6 +36,16 @@
26
36
  "source_url": "https://tdn.totvs.com",
27
37
  "verified": true
28
38
  },
39
+ {
40
+ "routine": "MATA040",
41
+ "module": "SIGAFIN",
42
+ "type": "cadastro",
43
+ "label": "Cadastro de Bancos",
44
+ "tables_primary": ["SA6"],
45
+ "tables_secondary": [],
46
+ "source_url": "https://tdn.totvs.com",
47
+ "verified": true
48
+ },
29
49
  {
30
50
  "routine": "MATA050",
31
51
  "module": "SIGAFAT",
@@ -66,6 +86,16 @@
66
86
  "source_url": "https://tdn.totvs.com/pages/viewpage.action?pageId=318605213",
67
87
  "verified": true
68
88
  },
89
+ {
90
+ "routine": "MATA112",
91
+ "module": "SIGAFIN",
92
+ "type": "cadastro",
93
+ "label": "Plano de Pagamento",
94
+ "tables_primary": ["SE4"],
95
+ "tables_secondary": [],
96
+ "source_url": "https://tdn.totvs.com",
97
+ "verified": false
98
+ },
69
99
  {
70
100
  "routine": "MATA120",
71
101
  "module": "SIGACOM",
@@ -166,6 +196,26 @@
166
196
  "source_url": "https://tdn.totvs.com/pages/releaseview.action?pageId=6784012",
167
197
  "verified": true
168
198
  },
199
+ {
200
+ "routine": "FATA010",
201
+ "module": "SIGAFAT",
202
+ "type": "cadastro",
203
+ "label": "Cadastro de Bandeira de Cartao",
204
+ "tables_primary": ["AE1"],
205
+ "tables_secondary": [],
206
+ "source_url": "https://tdn.totvs.com",
207
+ "verified": false
208
+ },
209
+ {
210
+ "routine": "FATA050",
211
+ "module": "SIGAFAT",
212
+ "type": "movimento",
213
+ "label": "Liberacao de Pedidos de Venda",
214
+ "tables_primary": ["SC9"],
215
+ "tables_secondary": [],
216
+ "source_url": "https://tdn.totvs.com",
217
+ "verified": false
218
+ },
169
219
  {
170
220
  "routine": "MATA460",
171
221
  "module": "SIGAFAT",
@@ -0,0 +1,11 @@
1
+ -- v0.4.3 (I6) — indices em `funcao` nas 3 tabelas Universo 3.
2
+ -- Antes: queries cross-ref ("quais funcoes no fonte X chamam ExecAuto?")
3
+ -- forcavam scan + filter Python. Agora idx cobre `funcao` nas 3 tables.
4
+
5
+ CREATE INDEX IF NOT EXISTS idx_exec_funcao
6
+ ON execution_triggers(funcao);
7
+
8
+ CREATE INDEX IF NOT EXISTS idx_execauto_funcao
9
+ ON execauto_calls(funcao);
10
+
11
+ -- protheus_docs ja tem idx_pdoc_funcao (criado em migration 007), nao precisa.
@@ -30,8 +30,11 @@ _PDOC_BLOCK_RE = re.compile(
30
30
  r"[ \t]*" # SEM \s — id, se houver, fica na MESMA linha do opening
31
31
  r"(?P<id>[\w:]+)?"
32
32
  r"(?P<body>.*?)"
33
- r"/\*/",
34
- re.IGNORECASE | re.DOTALL,
33
+ # v0.4.3 (C2): fechamento ANCORADO a start-of-line. `/*/` em meio a comentário
34
+ # de @example não fecha bloco (padrão oficial TOTVS — fechamento fica sozinho
35
+ # na própria linha).
36
+ r"^[ \t]*/\*/[ \t]*$",
37
+ re.IGNORECASE | re.DOTALL | re.MULTILINE,
35
38
  )
36
39
 
37
40
  # Próxima decl de função/método após o fechamento.
@@ -95,15 +98,13 @@ def infer_module(arquivo: str, funcao: str | None) -> str | None:
95
98
  # 1. Exact match (rotina exata no catálogo).
96
99
  if funcao_upper in idx:
97
100
  return idx[funcao_upper]["module"]
98
- # 2. Prefix match (4 primeiros chars). Determinístico: ordena por
99
- # nome de rotina e pega o primeiro alfabético entre os matches.
101
+ # 2. Prefix match (4 primeiros chars). v0.4.3 (C5): aceita se TODOS
102
+ # os matches do prefixo apontam pro MESMO módulo. Ambiguidade None
103
+ # (não inventar). Antes retornava SIGAEST silenciosamente para MATA*.
100
104
  prefix4 = funcao_upper[:4]
101
- matches = sorted(
102
- (e for k, e in idx.items() if k.startswith(prefix4)),
103
- key=lambda e: e["routine"],
104
- )
105
- if matches:
106
- return matches[0]["module"]
105
+ matched_modules = {e["module"] for k, e in idx.items() if k.startswith(prefix4)}
106
+ if len(matched_modules) == 1:
107
+ return matched_modules.pop()
107
108
  return None
108
109
 
109
110
 
@@ -200,15 +201,28 @@ def _line_at(content: str, offset: int) -> int:
200
201
  return content.count("\n", 0, offset) + 1
201
202
 
202
203
 
204
+ _PDOC_ORPHAN_LINE_CAP = 80 # v0.4.3 (C4): cap de proximidade pra associar bloco→decl
205
+
206
+
203
207
  def _resolve_next_decl(
204
208
  content: str, after_offset: int
205
209
  ) -> tuple[str | None, int | None]:
206
- """Acha próxima decl de função/método após offset. Retorna (nome, linha_1based)."""
210
+ """Acha próxima decl de função/método após offset. Retorna (nome, linha_1based).
211
+
212
+ v0.4.3 (C4): cap de ``_PDOC_ORPHAN_LINE_CAP`` linhas entre o offset (fim do
213
+ bloco) e a decl encontrada. Acima disso retorna (None, None) — bloco é
214
+ tratado como órfão (preserva sinal de cobertura BP-007 e impede que função
215
+ distante ganhe doc errada associada).
216
+ """
207
217
  m = _NEXT_DECL_RE.search(content, after_offset)
208
218
  if not m:
209
219
  return None, None
220
+ block_end_line = _line_at(content, max(0, after_offset - 1))
221
+ decl_line = _line_at(content, m.start())
222
+ if decl_line - block_end_line > _PDOC_ORPHAN_LINE_CAP:
223
+ return None, None
210
224
  name = m.group(1) or m.group(2)
211
- return name, _line_at(content, m.start())
225
+ return name, decl_line
212
226
 
213
227
 
214
228
  def _parse_body(body: str) -> tuple[str, list[tuple[str, str]]]:
@@ -75,14 +75,9 @@ _JOB_MAIN_RE = re.compile(
75
75
  r"^[ \t]*Main\s+Function\s+(\w+)\s*\(",
76
76
  re.IGNORECASE | re.MULTILINE,
77
77
  )
78
- # RpcSetEnv qualquer formato (literal ou variável).
79
- _JOB_RPCSETENV_RE = re.compile(
80
- r"\bRpcSetEnv\s*\(\s*"
81
- r"(?:['\"]([^'\"]*)['\"]|(\w+))\s*,\s*" # emp
82
- r"(?:['\"]([^'\"]*)['\"]|(\w+))" # fil
83
- r"(?:[^)]*?['\"](\w*)['\"])?", # módulo (5º arg, opcional)
84
- re.IGNORECASE,
85
- )
78
+ # RpcSetEnv localiza o início; args extraídos via _parse_rpcsetenv_args
79
+ # (v0.4.3 C3: regex única era frágil quando os 6 args vinham literais consecutivos).
80
+ _JOB_RPCSETENV_RE = re.compile(r"\bRpcSetEnv\s*\(", re.IGNORECASE)
86
81
  # RpcSetType(3) — sem licença.
87
82
  _JOB_RPCSETTYPE_RE = re.compile(r"\bRpcSetType\s*\(\s*3\s*\)", re.IGNORECASE)
88
83
  # Sleep(N*1000) ou Sleep(N) em ms — extrai segundos.
@@ -102,6 +97,10 @@ _MAIL_UDC_CONNECT_RE = re.compile(r"^\s*CONNECT\s+SMTP\b", re.IGNORECASE | re.MU
102
97
  _MAIL_TMAILMANAGER_RE = re.compile(r"\bTMailManager\s*\(", re.IGNORECASE)
103
98
  _MAIL_TMAILMESSAGE_RE = re.compile(r"\bTMailMessage\s*\(", re.IGNORECASE)
104
99
  _MAIL_SEND_METHOD_RE = re.compile(r":\s*Send\s*\(", re.IGNORECASE)
100
+ # v0.4.3 (I1): TMailManager:SendMail/SmtpConnect — variantes legadas (sem TMailMessage).
101
+ _MAIL_TMM_SEND_METHODS_RE = re.compile(
102
+ r":\s*(?:SendMail|SmtpConnect|Send)\s*\(", re.IGNORECASE,
103
+ )
105
104
  # Anexo: ATTACHMENT (UDC) ou :AttachFile(
106
105
  _MAIL_ATTACH_RE = re.compile(
107
106
  r"\bATTACHMENT\b|:\s*AttachFile\s*\(", re.IGNORECASE,
@@ -127,6 +126,89 @@ def _snippet_at(content: str, linha: int, max_len: int = 200) -> str:
127
126
  return ""
128
127
 
129
128
 
129
+ # --- Helpers ----------------------------------------------------------------
130
+
131
+
132
+ def _split_top_level_commas(s: str) -> list[str]:
133
+ """Split por vírgulas top-level (ignora dentro de (), {}, []).
134
+
135
+ Usado pra extrair args de chamadas com aridade variável onde regex única
136
+ fica frágil (vide RpcSetEnv com 6 literais consecutivos — C3 v0.4.3).
137
+ Caller espera passar conteúdo já stripado (strings → spaces).
138
+ """
139
+ parts: list[str] = []
140
+ depth_paren = depth_brace = depth_bracket = 0
141
+ last = 0
142
+ for i, c in enumerate(s):
143
+ if c == "(":
144
+ depth_paren += 1
145
+ elif c == ")":
146
+ depth_paren -= 1
147
+ elif c == "{":
148
+ depth_brace += 1
149
+ elif c == "}":
150
+ depth_brace -= 1
151
+ elif c == "[":
152
+ depth_bracket += 1
153
+ elif c == "]":
154
+ depth_bracket -= 1
155
+ elif (
156
+ c == ","
157
+ and depth_paren == 0
158
+ and depth_brace == 0
159
+ and depth_bracket == 0
160
+ ):
161
+ parts.append(s[last:i])
162
+ last = i + 1
163
+ parts.append(s[last:])
164
+ return parts
165
+
166
+
167
+ def _find_balanced_paren(s: str, open_idx: int) -> int:
168
+ """Dado idx de `(`, retorna idx do `)` casado. -1 se não casar."""
169
+ depth = 0
170
+ for i in range(open_idx, len(s)):
171
+ c = s[i]
172
+ if c == "(":
173
+ depth += 1
174
+ elif c == ")":
175
+ depth -= 1
176
+ if depth == 0:
177
+ return i
178
+ return -1
179
+
180
+
181
+ def _parse_rpcsetenv_args(content: str, original: str, open_paren_offset: int) -> dict[str, str]:
182
+ """Extrai (empresa, filial, modulo) de uma chamada RpcSetEnv pelos args
183
+ posicionais. v0.4.3 (C3): substitui regex frágil que falhava com 6 args
184
+ literais consecutivos.
185
+
186
+ Args:
187
+ content: source stripado (strings → spaces) onde o match foi encontrado.
188
+ original: source original (não usado aqui — kept simples).
189
+ open_paren_offset: índice do `(` em ``content``.
190
+ """
191
+ close = _find_balanced_paren(content, open_paren_offset)
192
+ if close == -1:
193
+ return {"empresa": "", "filial": "", "modulo": ""}
194
+ args = _split_top_level_commas(content[open_paren_offset + 1 : close])
195
+
196
+ def _arg(idx: int) -> str:
197
+ if idx >= len(args):
198
+ return ""
199
+ token = args[idx].strip()
200
+ # Remove aspas se literal; caso contrário devolve identificador (variável).
201
+ if len(token) >= 2 and token[0] == token[-1] and token[0] in ("'", '"'):
202
+ return token[1:-1]
203
+ return token
204
+
205
+ return {
206
+ "empresa": _arg(0),
207
+ "filial": _arg(1),
208
+ "modulo": _arg(4), # 5º arg (0-indexed)
209
+ }
210
+
211
+
130
212
  # --- Detectores -------------------------------------------------------------
131
213
 
132
214
 
@@ -134,13 +216,21 @@ def _detect_workflow(content: str, stripped: str) -> list[dict[str, Any]]:
134
216
  """Detecta `TWFProcess`, `MsWorkflow`, `WFPrepEnv` + extrai metadata."""
135
217
  out: list[dict[str, Any]] = []
136
218
  # TWFProcess (moderno) — emite 1 trigger por chamada com process_id.
137
- for m in _WF_TWFPROCESS_RE.finditer(stripped):
219
+ # v0.4.3 (C1): coleta TODAS as posições primeiro pra calcular scope_end como
220
+ # próxima instanciação (vs janela fixa de 5000 chars que misturava callbacks
221
+ # entre TWFProcess vizinhos no mesmo fonte).
222
+ twfprocess_matches = list(_WF_TWFPROCESS_RE.finditer(stripped))
223
+ for i, m in enumerate(twfprocess_matches):
138
224
  process_id = m.group(1) or ""
139
225
  description = m.group(2) or ""
140
226
  linha = _line_at(stripped, m.start())
141
- # Buscar callbacks no contexto da função (até 50 linhas pra frente).
142
227
  scope_start = m.start()
143
- scope_end = min(len(stripped), scope_start + 5000)
228
+ if i + 1 < len(twfprocess_matches):
229
+ # Cap pelo próximo TWFProcess (preserva isolamento entre workflows).
230
+ scope_end = twfprocess_matches[i + 1].start()
231
+ else:
232
+ # Último — vai até EOF (mas com cap defensivo de 5000 chars).
233
+ scope_end = min(len(stripped), scope_start + 5000)
144
234
  scope = stripped[scope_start:scope_end]
145
235
  callbacks: dict[str, str] = {}
146
236
  for cm in _WF_CALLBACK_RE.finditer(scope):
@@ -263,9 +353,13 @@ def _detect_job_standalone(content: str, stripped: str) -> list[dict[str, Any]]:
263
353
  continue
264
354
  empresa = filial = modulo = ""
265
355
  if rpc_match:
266
- empresa = rpc_match.group(1) or rpc_match.group(2) or ""
267
- filial = rpc_match.group(3) or rpc_match.group(4) or ""
268
- modulo = rpc_match.group(5) or ""
356
+ # v0.4.3 (C3): args via paren-balanced split (regex única era frágil
357
+ # quando 6 args vinham literais consecutivos sem vírgulas vazias).
358
+ # rpc_match.end() é offset em `body` (slice de `stripped`).
359
+ parsed_args = _parse_rpcsetenv_args(body, body, rpc_match.end() - 1)
360
+ empresa = parsed_args["empresa"]
361
+ filial = parsed_args["filial"]
362
+ modulo = parsed_args["modulo"]
269
363
  # Sleep — extrai intervalo em segundos (assume Sleep(N*1000) = N segundos).
270
364
  sleep_seconds = 0
271
365
  sm = _JOB_SLEEP_RE.search(body)
@@ -335,6 +429,14 @@ def _detect_mail_send(content: str, stripped: str) -> list[dict[str, Any]]:
335
429
  # TMailMessage:Send (preferido — TMailManager sozinho é só conexão).
336
430
  for m in _MAIL_TMAILMESSAGE_RE.finditer(stripped):
337
431
  _emit(m.start(), "TMailManager")
432
+ # v0.4.3 (I1): TMailManager solo (sem TMailMessage) — legacy. Detecta se há
433
+ # TMailManager + chamada de envio (`:SendMail`/`:Send`) no mesmo fonte e
434
+ # ainda nao temos trigger no fonte.
435
+ if not any(t["target"] == "TMailManager" for t in out):
436
+ tmm_match = _MAIL_TMAILMANAGER_RE.search(stripped)
437
+ send_match = _MAIL_TMM_SEND_METHODS_RE.search(stripped)
438
+ if tmm_match and send_match:
439
+ _emit(tmm_match.start(), "TMailManager")
338
440
  return out
339
441
 
340
442
 
@@ -1132,20 +1132,45 @@ def protheus_docs_orphans(conn: sqlite3.Connection) -> list[dict[str, Any]]:
1132
1132
 
1133
1133
 
1134
1134
  def protheus_doc_show(
1135
- conn: sqlite3.Connection, funcao: str
1135
+ conn: sqlite3.Connection,
1136
+ funcao: str,
1137
+ *,
1138
+ arquivo: str | None = None,
1136
1139
  ) -> dict[str, Any] | None:
1137
1140
  """Retorna doc completo de uma função (modo `--show`).
1138
1141
 
1139
- Se múltiplos docs (raro função homônima em fontes diferentes),
1140
- retorna o primeiro. Match case-insensitive.
1142
+ v0.4.3 (I2): aceita ``arquivo`` opcional pra desambiguar quando
1143
+ homônimos. Caller pode usar :func:`protheus_doc_homonyms` antes pra
1144
+ detectar e listar opções.
1141
1145
  """
1142
1146
  sql = f"SELECT {_PDOC_COLUMNS} FROM protheus_docs WHERE funcao = ? COLLATE NOCASE"
1143
- row = conn.execute(sql, (funcao,)).fetchone()
1147
+ params: list[Any] = [funcao]
1148
+ if arquivo:
1149
+ sql += " AND arquivo = ? COLLATE NOCASE"
1150
+ params.append(arquivo)
1151
+ sql += " ORDER BY arquivo, linha_bloco_inicio LIMIT 1"
1152
+ row = conn.execute(sql, params).fetchone()
1144
1153
  if row is None:
1145
1154
  return None
1146
1155
  return _row_to_pdoc(row)
1147
1156
 
1148
1157
 
1158
+ def protheus_doc_homonyms(
1159
+ conn: sqlite3.Connection, funcao: str
1160
+ ) -> list[str]:
1161
+ """v0.4.3 (I2): lista arquivos com Protheus.doc pra ``funcao``.
1162
+
1163
+ Usado por `docs --show` pra avisar quando há ambiguidade. Retorna lista
1164
+ ordenada de basenames.
1165
+ """
1166
+ rows = conn.execute(
1167
+ "SELECT DISTINCT arquivo FROM protheus_docs "
1168
+ "WHERE funcao = ? COLLATE NOCASE ORDER BY arquivo",
1169
+ (funcao,),
1170
+ ).fetchall()
1171
+ return [r[0] for r in rows]
1172
+
1173
+
1149
1174
  def render_pdoc_markdown(d: dict[str, Any]) -> str:
1150
1175
  """Renderiza um doc em Markdown estruturado pra modo `--show`."""
1151
1176
  lines: list[str] = []
@@ -1068,6 +1068,39 @@ class TestDocs:
1068
1068
  funcs = {r["funcao"] for r in rows}
1069
1069
  assert "MT460NEW" in funcs
1070
1070
 
1071
+ def test_docs_show_homonym_warns_and_supports_arquivo(
1072
+ self, tmp_path: Path, runner: CliRunner
1073
+ ) -> None:
1074
+ """v0.4.3 (I2): 2 fontes com mesma funcao -> --show avisa em stderr
1075
+ e --arquivo desambiguar."""
1076
+ src = tmp_path / "src"
1077
+ src.mkdir()
1078
+ (src / "FnA.prw").write_bytes(
1079
+ b'/*/{Protheus.doc} HomFn\nDoc do A.\n@author Anna\n/*/\n'
1080
+ b'User Function HomFn()\nReturn\n'
1081
+ )
1082
+ (src / "FnB.prw").write_bytes(
1083
+ b'/*/{Protheus.doc} HomFn\nDoc do B.\n@author Beto\n/*/\n'
1084
+ b'User Function HomFn()\nReturn\n'
1085
+ )
1086
+ runner.invoke(app, ["--root", str(src), "init"])
1087
+ runner.invoke(app, ["--root", str(src), "ingest"])
1088
+
1089
+ # Sem --arquivo: aviso em stderr + mostra primeiro alfabeticamente
1090
+ result = runner.invoke(
1091
+ app, ["--root", str(src), "docs", "--show", "HomFn"]
1092
+ )
1093
+ assert result.exit_code == 0
1094
+ assert "2 fontes" in result.stderr or "Aviso" in result.stderr
1095
+ assert "Anna" in result.stdout # FnA.prw vem antes alfabeticamente
1096
+
1097
+ # Com --arquivo FnB.prw: mostra o do Beto
1098
+ result2 = runner.invoke(
1099
+ app, ["--root", str(src), "docs", "--show", "HomFn", "--arquivo", "FnB.prw"]
1100
+ )
1101
+ assert result2.exit_code == 0
1102
+ assert "Beto" in result2.stdout
1103
+
1071
1104
  def test_docs_persisted_in_db(self, docs_project: Path) -> None:
1072
1105
  """Sanity: tabela protheus_docs existe e tem 2 rows."""
1073
1106
  db = docs_project / ".plugadvpl" / "index.db"
@@ -256,6 +256,24 @@ class TestCatalog:
256
256
  assert "SC5" in mata410["tables_primary"]
257
257
  assert "SC6" in mata410["tables_primary"]
258
258
 
259
+ def test_catalog_no_duplicate_routines(self) -> None:
260
+ """v0.4.3 (I5): nome de rotina deve ser unico no catalogo.
261
+
262
+ Permite o lookup determinístico (`_routines_index` faz dict[upper_name]).
263
+ Duplicata silenciosa faria a 2a entrada sobrescrever a 1a sem warning.
264
+ """
265
+ cat = load_execauto_catalog()
266
+ names = [r["routine"].upper() for r in cat["routines"]]
267
+ dups = [n for n in set(names) if names.count(n) > 1]
268
+ assert not dups, f"Rotinas duplicadas no catalogo: {dups}"
269
+
270
+ def test_catalog_has_v043_additions(self) -> None:
271
+ """v0.4.3 (I5): novas rotinas comuns adicionadas — 6 entradas extras."""
272
+ cat = load_execauto_catalog()
273
+ names = {r["routine"] for r in cat["routines"]}
274
+ for novo in ("MATA020", "MATA040", "MATA112", "FATA010", "FATA050"):
275
+ assert novo in names, f"Esperado {novo} no catalogo v0.4.3"
276
+
259
277
 
260
278
  # --- arg_count + linha + snippet ------------------------------------------
261
279
 
@@ -213,6 +213,19 @@ class TestModuleInference:
213
213
  """Path SIGAFIN deve vencer prefixo MATA*."""
214
214
  assert infer_module("src/SIGAFIN/MATA410.prw", "MATA410") == "SIGAFIN"
215
215
 
216
+ def test_module_ambiguous_prefix_returns_none(self) -> None:
217
+ """v0.4.3 (C5): prefix `MATA` casa rotinas de SIGAEST/SIGAFAT/SIGACOM.
218
+
219
+ Antes: retornava SIGAEST silenciosamente (sort alfabético favorecia).
220
+ Agora: ambiguidade real → None (não inventar). MATA999 não existe no
221
+ catálogo e o prefixo é ambíguo entre 3 módulos.
222
+ """
223
+ assert infer_module("X.prw", "MATA999") is None
224
+
225
+ def test_module_unambiguous_prefix_still_resolves(self) -> None:
226
+ """Prefixo `FINA` mapeia 100% pra SIGAFIN — sem ambiguidade, resolve."""
227
+ assert infer_module("X.prw", "FINA999") == "SIGAFIN"
228
+
216
229
 
217
230
  # --- Edge cases -----------------------------------------------------------
218
231
 
@@ -236,6 +249,40 @@ class TestEdgeCases:
236
249
  assert len(docs) == 1
237
250
  assert docs[0]["linha_funcao"] is None
238
251
 
252
+ def test_orphan_block_with_distant_function_treated_as_orphan(self) -> None:
253
+ """v0.4.3 (C4): bloco órfão NÃO deve "puxar" função muito longe.
254
+
255
+ Cap: max 80 linhas entre /*/ fechamento e próxima decl. Acima disso
256
+ funcao=None, linha_funcao=None (preserva sinal de "órfão" e impede
257
+ que a função seguinte ganhe doc errada).
258
+ """
259
+ # 100 linhas vazias entre /*/ e a decl
260
+ spacer = "\n".join(["// linha de filler"] * 100)
261
+ src = (
262
+ '/*/{Protheus.doc} Soltinho\nDoc.\n/*/\n'
263
+ f'{spacer}\n'
264
+ 'User Function MuitoDepois()\n'
265
+ 'Return\n'
266
+ )
267
+ docs = extract_protheus_docs(src)
268
+ assert len(docs) == 1
269
+ d = docs[0]
270
+ assert d["funcao"] is None, (
271
+ "Esperado funcao=None pra bloco com decl 100+ linhas adiante"
272
+ )
273
+ assert d["linha_funcao"] is None
274
+
275
+ def test_block_with_function_within_cap_resolves(self) -> None:
276
+ """Sanity: decl dentro do cap (5 linhas) ainda resolve."""
277
+ src = (
278
+ '/*/{Protheus.doc} Fn\nDoc.\n/*/\n'
279
+ '// 1\n// 2\n// 3\n'
280
+ 'User Function Fn()\nReturn\n'
281
+ )
282
+ d = extract_protheus_docs(src)[0]
283
+ assert d["funcao"] == "Fn"
284
+ assert d["linha_funcao"] is not None
285
+
239
286
  def test_summary_stops_at_first_tag(self) -> None:
240
287
  """Summary = linhas até primeira @tag, não inclui tags."""
241
288
  src = (
@@ -278,6 +325,35 @@ class TestEdgeCases:
278
325
  assert d["history"][0]["user"] == "fvernier"
279
326
  assert "Refactor" in d["history"][0]["desc"]
280
327
 
328
+ def test_example_with_inline_close_marker_does_not_close(self) -> None:
329
+ """v0.4.3 (C2): `/*/` literal em meio de comentário no @example NÃO
330
+ deve fechar o bloco prematuramente.
331
+
332
+ Antes (bug): regex non-greedy `(?P<body>.*?)/\\*/` casava o primeiro
333
+ `/*/` que aparecesse — mesmo dentro do exemplo. Agora o fechamento
334
+ exige start-of-line (padrão oficial TOTVS — `/*/` fica sozinho na
335
+ própria linha).
336
+ """
337
+ src = (
338
+ '/*/{Protheus.doc} Fn\n'
339
+ 'Doc.\n'
340
+ '@example\n'
341
+ ' // /*/ exemplo dentro do comentario\n'
342
+ ' Local x := 1\n'
343
+ '/*/\n'
344
+ 'User Function Fn()\n'
345
+ 'Return\n'
346
+ )
347
+ docs = extract_protheus_docs(src)
348
+ assert len(docs) == 1
349
+ d = docs[0]
350
+ assert d["funcao"] == "Fn"
351
+ # O exemplo deve incluir o código completo (incluindo a linha do `/*/` interno).
352
+ assert d["examples"], "esperado pelo menos 1 example"
353
+ ex = d["examples"][0]
354
+ assert "Local x" in ex
355
+ assert "exemplo dentro" in ex
356
+
281
357
  def test_example_with_at_inside_code(self) -> None:
282
358
  """@example com '@' dentro do código não conta como nova tag."""
283
359
  src = (
@@ -65,6 +65,30 @@ class TestWorkflowTrigger:
65
65
  triggers = extract_execution_triggers(src)
66
66
  assert "workflow" not in _kinds(triggers)
67
67
 
68
+ def test_two_twfprocess_distinct_callbacks(self) -> None:
69
+ """v0.4.3 (C1): 2 TWFProcess no mesmo arquivo nao devem misturar callbacks.
70
+
71
+ Antes (bug): scope_end fixo de 5000 chars capturava callback do segundo
72
+ TWFProcess e atribuia ao primeiro. Agora scope eh limitado pelo proximo
73
+ TWFProcess (ou EOF se nao houver).
74
+ """
75
+ src = (
76
+ 'User Function W1()\n'
77
+ ' oWF1 := TWFProcess():New("P1", "D1")\n'
78
+ ' oWF1:bReturn := {|o| Cb1(o)}\n'
79
+ 'Return\n'
80
+ 'User Function W2()\n'
81
+ ' oWF2 := TWFProcess():New("P2", "D2")\n'
82
+ ' oWF2:bReturn := {|o| Cb2(o)}\n'
83
+ 'Return\n'
84
+ )
85
+ triggers = extract_execution_triggers(src)
86
+ wf = [t for t in triggers if t["kind"] == "workflow" and t["metadata"].get("process_id")]
87
+ assert len(wf) == 2
88
+ by_pid = {t["metadata"]["process_id"]: t for t in wf}
89
+ assert by_pid["P1"]["metadata"]["return_callback"].lower() == "cb1"
90
+ assert by_pid["P2"]["metadata"]["return_callback"].lower() == "cb2"
91
+
68
92
 
69
93
  # --- Schedule ---------------------------------------------------------------
70
94
 
@@ -142,6 +166,27 @@ class TestJobStandaloneTrigger:
142
166
  assert meta["stop_flag"] == "/stop_nfe.flg"
143
167
  assert meta["no_license"] is True
144
168
 
169
+ def test_rpcsetenv_six_literal_args_extracts_modulo(self) -> None:
170
+ """v0.4.3 (C3): RpcSetEnv com 6 args literais consecutivos extrai modulo.
171
+
172
+ Antes (bug): regex `(?:[^)]*?['\"](\\w*)['\"])?` consumia args 3 e 4
173
+ sem alcancar o 5o. Agora usa split top-level pra pegar o 5o exato.
174
+ """
175
+ src = (
176
+ 'Main Function J()\n'
177
+ ' RpcSetEnv("01","01","","","FAT","J")\n'
178
+ ' While !File("/stop_j.flg")\n'
179
+ ' Sleep(60000)\n'
180
+ ' EndDo\n'
181
+ 'Return\n'
182
+ )
183
+ triggers = extract_execution_triggers(src)
184
+ jobs = [t for t in triggers if t["kind"] == "job_standalone"]
185
+ assert len(jobs) == 1
186
+ assert jobs[0]["metadata"]["empresa"] == "01"
187
+ assert jobs[0]["metadata"]["filial"] == "01"
188
+ assert jobs[0]["metadata"]["modulo"] == "FAT"
189
+
145
190
  def test_negative_main_function_without_rpcsetenv(self) -> None:
146
191
  """Main Function sem RpcSetEnv (entry point standalone) — NAO eh job."""
147
192
  src = (
@@ -200,6 +245,27 @@ class TestMailSendTrigger:
200
245
  mails = [t for t in triggers if t["kind"] == "mail_send"]
201
246
  assert any(m["metadata"]["variant"] == "UDC" for m in mails)
202
247
 
248
+ def test_positive_tmailmanager_solo_without_tmailmessage(self) -> None:
249
+ """v0.4.3 (I1): TMailManager + :Send sem TMailMessage (legacy padrao).
250
+
251
+ Antes: detector exigia TMailMessage; fontes legados (anteriores ao
252
+ TMailMessage) eram ignorados. Agora TMailManager + :Send no mesmo
253
+ scope tambem vira trigger.
254
+ """
255
+ src = (
256
+ 'User Function ZLegacy()\n'
257
+ ' Local oSrv := TMailManager():New()\n'
258
+ ' oSrv:Init("", SuperGetMv("MV_RELSERV"), SuperGetMv("MV_RELACNT"), "", 0, 587)\n'
259
+ ' oSrv:SmtpConnect()\n'
260
+ ' oSrv:SendMail(cFrom, aTo, cSubject, cBody)\n'
261
+ ' oSrv:Disconnect()\n'
262
+ 'Return\n'
263
+ )
264
+ triggers = extract_execution_triggers(src)
265
+ mails = [t for t in triggers if t["kind"] == "mail_send"]
266
+ assert len(mails) >= 1
267
+ assert mails[0]["metadata"]["variant"] == "TMailManager"
268
+
203
269
  def test_negative_mail_in_comment(self) -> None:
204
270
  """MailAuto em comentario nao dispara."""
205
271
  src = (
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