plugadvpl 0.4.2__tar.gz → 0.4.4__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.4}/PKG-INFO +1 -1
  2. {plugadvpl-0.4.2 → plugadvpl-0.4.4}/plugadvpl/_version.py +2 -2
  3. {plugadvpl-0.4.2 → plugadvpl-0.4.4}/plugadvpl/cli.py +125 -16
  4. {plugadvpl-0.4.2 → plugadvpl-0.4.4}/plugadvpl/db.py +1 -1
  5. {plugadvpl-0.4.2 → plugadvpl-0.4.4}/plugadvpl/lookups/execauto_routines.json +50 -0
  6. plugadvpl-0.4.4/plugadvpl/migrations/008_universo3_funcao_indexes.sql +11 -0
  7. {plugadvpl-0.4.2 → plugadvpl-0.4.4}/plugadvpl/parsing/protheus_doc.py +33 -14
  8. {plugadvpl-0.4.2 → plugadvpl-0.4.4}/plugadvpl/parsing/triggers.py +116 -14
  9. {plugadvpl-0.4.2 → plugadvpl-0.4.4}/plugadvpl/query.py +46 -7
  10. {plugadvpl-0.4.2 → plugadvpl-0.4.4}/tests/integration/test_cli.py +152 -0
  11. {plugadvpl-0.4.2 → plugadvpl-0.4.4}/tests/unit/test_execauto.py +18 -0
  12. {plugadvpl-0.4.2 → plugadvpl-0.4.4}/tests/unit/test_protheus_doc.py +121 -0
  13. {plugadvpl-0.4.2 → plugadvpl-0.4.4}/tests/unit/test_triggers.py +66 -0
  14. {plugadvpl-0.4.2 → plugadvpl-0.4.4}/.gitignore +0 -0
  15. {plugadvpl-0.4.2 → plugadvpl-0.4.4}/README.md +0 -0
  16. {plugadvpl-0.4.2 → plugadvpl-0.4.4}/plugadvpl/__init__.py +0 -0
  17. {plugadvpl-0.4.2 → plugadvpl-0.4.4}/plugadvpl/__main__.py +0 -0
  18. {plugadvpl-0.4.2 → plugadvpl-0.4.4}/plugadvpl/ingest.py +0 -0
  19. {plugadvpl-0.4.2 → plugadvpl-0.4.4}/plugadvpl/ingest_sx.py +0 -0
  20. {plugadvpl-0.4.2 → plugadvpl-0.4.4}/plugadvpl/lookups/.gitkeep +0 -0
  21. {plugadvpl-0.4.2 → plugadvpl-0.4.4}/plugadvpl/lookups/funcoes_nativas.json +0 -0
  22. {plugadvpl-0.4.2 → plugadvpl-0.4.4}/plugadvpl/lookups/funcoes_restritas.json +0 -0
  23. {plugadvpl-0.4.2 → plugadvpl-0.4.4}/plugadvpl/lookups/lint_rules.json +0 -0
  24. {plugadvpl-0.4.2 → plugadvpl-0.4.4}/plugadvpl/lookups/modulos_erp.json +0 -0
  25. {plugadvpl-0.4.2 → plugadvpl-0.4.4}/plugadvpl/lookups/pontos_entrada_padrao.json +0 -0
  26. {plugadvpl-0.4.2 → plugadvpl-0.4.4}/plugadvpl/lookups/sql_macros.json +0 -0
  27. {plugadvpl-0.4.2 → plugadvpl-0.4.4}/plugadvpl/migrations/001_initial.sql +0 -0
  28. {plugadvpl-0.4.2 → plugadvpl-0.4.4}/plugadvpl/migrations/002_universo2_sx.sql +0 -0
  29. {plugadvpl-0.4.2 → plugadvpl-0.4.4}/plugadvpl/migrations/003_lint_rules_status.sql +0 -0
  30. {plugadvpl-0.4.2 → plugadvpl-0.4.4}/plugadvpl/migrations/004_consultas_pk_with_tipo.sql +0 -0
  31. {plugadvpl-0.4.2 → plugadvpl-0.4.4}/plugadvpl/migrations/005_universo3_execution_triggers.sql +0 -0
  32. {plugadvpl-0.4.2 → plugadvpl-0.4.4}/plugadvpl/migrations/006_universo3_execauto_calls.sql +0 -0
  33. {plugadvpl-0.4.2 → plugadvpl-0.4.4}/plugadvpl/migrations/007_universo3_protheus_docs.sql +0 -0
  34. {plugadvpl-0.4.2 → plugadvpl-0.4.4}/plugadvpl/output.py +0 -0
  35. {plugadvpl-0.4.2 → plugadvpl-0.4.4}/plugadvpl/parsing/.gitkeep +0 -0
  36. {plugadvpl-0.4.2 → plugadvpl-0.4.4}/plugadvpl/parsing/__init__.py +0 -0
  37. {plugadvpl-0.4.2 → plugadvpl-0.4.4}/plugadvpl/parsing/execauto.py +0 -0
  38. {plugadvpl-0.4.2 → plugadvpl-0.4.4}/plugadvpl/parsing/lint.py +0 -0
  39. {plugadvpl-0.4.2 → plugadvpl-0.4.4}/plugadvpl/parsing/parser.py +0 -0
  40. {plugadvpl-0.4.2 → plugadvpl-0.4.4}/plugadvpl/parsing/stripper.py +0 -0
  41. {plugadvpl-0.4.2 → plugadvpl-0.4.4}/plugadvpl/parsing/sx_csv.py +0 -0
  42. {plugadvpl-0.4.2 → plugadvpl-0.4.4}/plugadvpl/scan.py +0 -0
  43. {plugadvpl-0.4.2 → plugadvpl-0.4.4}/pyproject.toml +0 -0
  44. {plugadvpl-0.4.2 → plugadvpl-0.4.4}/tests/__init__.py +0 -0
  45. {plugadvpl-0.4.2 → plugadvpl-0.4.4}/tests/bench/.gitkeep +0 -0
  46. {plugadvpl-0.4.2 → plugadvpl-0.4.4}/tests/bench/__init__.py +0 -0
  47. {plugadvpl-0.4.2 → plugadvpl-0.4.4}/tests/bench/test_ingest_perf.py +0 -0
  48. {plugadvpl-0.4.2 → plugadvpl-0.4.4}/tests/bench/test_sx_ingest_perf.py +0 -0
  49. {plugadvpl-0.4.2 → plugadvpl-0.4.4}/tests/e2e_local/.gitkeep +0 -0
  50. {plugadvpl-0.4.2 → plugadvpl-0.4.4}/tests/e2e_local/__init__.py +0 -0
  51. {plugadvpl-0.4.2 → plugadvpl-0.4.4}/tests/e2e_local/test_e2e_local_ingest.py +0 -0
  52. {plugadvpl-0.4.2 → plugadvpl-0.4.4}/tests/e2e_local/test_ingest_sx_real.py +0 -0
  53. {plugadvpl-0.4.2 → plugadvpl-0.4.4}/tests/fixtures/expected/.gitkeep +0 -0
  54. {plugadvpl-0.4.2 → plugadvpl-0.4.4}/tests/fixtures/sx_synthetic/six.csv +0 -0
  55. {plugadvpl-0.4.2 → plugadvpl-0.4.4}/tests/fixtures/sx_synthetic/sx1.csv +0 -0
  56. {plugadvpl-0.4.2 → plugadvpl-0.4.4}/tests/fixtures/sx_synthetic/sx2.csv +0 -0
  57. {plugadvpl-0.4.2 → plugadvpl-0.4.4}/tests/fixtures/sx_synthetic/sx3.csv +0 -0
  58. {plugadvpl-0.4.2 → plugadvpl-0.4.4}/tests/fixtures/sx_synthetic/sx5.csv +0 -0
  59. {plugadvpl-0.4.2 → plugadvpl-0.4.4}/tests/fixtures/sx_synthetic/sx6.csv +0 -0
  60. {plugadvpl-0.4.2 → plugadvpl-0.4.4}/tests/fixtures/sx_synthetic/sx7.csv +0 -0
  61. {plugadvpl-0.4.2 → plugadvpl-0.4.4}/tests/fixtures/sx_synthetic/sx9.csv +0 -0
  62. {plugadvpl-0.4.2 → plugadvpl-0.4.4}/tests/fixtures/sx_synthetic/sxa.csv +0 -0
  63. {plugadvpl-0.4.2 → plugadvpl-0.4.4}/tests/fixtures/sx_synthetic/sxb.csv +0 -0
  64. {plugadvpl-0.4.2 → plugadvpl-0.4.4}/tests/fixtures/sx_synthetic/sxb_with_collisions.csv +0 -0
  65. {plugadvpl-0.4.2 → plugadvpl-0.4.4}/tests/fixtures/sx_synthetic/sxg.csv +0 -0
  66. {plugadvpl-0.4.2 → plugadvpl-0.4.4}/tests/fixtures/synthetic/.gitkeep +0 -0
  67. {plugadvpl-0.4.2 → plugadvpl-0.4.4}/tests/fixtures/synthetic/_generate.py +0 -0
  68. {plugadvpl-0.4.2 → plugadvpl-0.4.4}/tests/fixtures/synthetic/classic_browse.prw +0 -0
  69. {plugadvpl-0.4.2 → plugadvpl-0.4.4}/tests/fixtures/synthetic/corrupted.bak +0 -0
  70. {plugadvpl-0.4.2 → plugadvpl-0.4.4}/tests/fixtures/synthetic/empty.prw +0 -0
  71. {plugadvpl-0.4.2 → plugadvpl-0.4.4}/tests/fixtures/synthetic/encoding_cp1252.prw +0 -0
  72. {plugadvpl-0.4.2 → plugadvpl-0.4.4}/tests/fixtures/synthetic/encoding_utf8.prw +0 -0
  73. {plugadvpl-0.4.2 → plugadvpl-0.4.4}/tests/fixtures/synthetic/exec_auto.prw +0 -0
  74. {plugadvpl-0.4.2 → plugadvpl-0.4.4}/tests/fixtures/synthetic/http_outbound.prw +0 -0
  75. {plugadvpl-0.4.2 → plugadvpl-0.4.4}/tests/fixtures/synthetic/huge.prw +0 -0
  76. {plugadvpl-0.4.2 → plugadvpl-0.4.4}/tests/fixtures/synthetic/job_rpc.prw +0 -0
  77. {plugadvpl-0.4.2 → plugadvpl-0.4.4}/tests/fixtures/synthetic/multi_filial.prw +0 -0
  78. {plugadvpl-0.4.2 → plugadvpl-0.4.4}/tests/fixtures/synthetic/mvc_complete.prw +0 -0
  79. {plugadvpl-0.4.2 → plugadvpl-0.4.4}/tests/fixtures/synthetic/mvc_hooks.prw +0 -0
  80. {plugadvpl-0.4.2 → plugadvpl-0.4.4}/tests/fixtures/synthetic/pe_paramixb.prw +0 -0
  81. {plugadvpl-0.4.2 → plugadvpl-0.4.4}/tests/fixtures/synthetic/pe_simple.prw +0 -0
  82. {plugadvpl-0.4.2 → plugadvpl-0.4.4}/tests/fixtures/synthetic/pubvars.prw +0 -0
  83. {plugadvpl-0.4.2 → plugadvpl-0.4.4}/tests/fixtures/synthetic/reclock_alias_dup_trigger.prw +0 -0
  84. {plugadvpl-0.4.2 → plugadvpl-0.4.4}/tests/fixtures/synthetic/reclock_pattern.prw +0 -0
  85. {plugadvpl-0.4.2 → plugadvpl-0.4.4}/tests/fixtures/synthetic/reclock_unbalanced.prw +0 -0
  86. {plugadvpl-0.4.2 → plugadvpl-0.4.4}/tests/fixtures/synthetic/sql_embedded.prw +0 -0
  87. {plugadvpl-0.4.2 → plugadvpl-0.4.4}/tests/fixtures/synthetic/tlpp_namespace.tlpp +0 -0
  88. {plugadvpl-0.4.2 → plugadvpl-0.4.4}/tests/fixtures/synthetic/ws_rest.tlpp +0 -0
  89. {plugadvpl-0.4.2 → plugadvpl-0.4.4}/tests/fixtures/synthetic/ws_restful_classic.prw +0 -0
  90. {plugadvpl-0.4.2 → plugadvpl-0.4.4}/tests/fixtures/synthetic/ws_soap.prw +0 -0
  91. {plugadvpl-0.4.2 → plugadvpl-0.4.4}/tests/integration/.gitkeep +0 -0
  92. {plugadvpl-0.4.2 → plugadvpl-0.4.4}/tests/integration/__init__.py +0 -0
  93. {plugadvpl-0.4.2 → plugadvpl-0.4.4}/tests/integration/test_ingest.py +0 -0
  94. {plugadvpl-0.4.2 → plugadvpl-0.4.4}/tests/integration/test_ingest_sx.py +0 -0
  95. {plugadvpl-0.4.2 → plugadvpl-0.4.4}/tests/unit/.gitkeep +0 -0
  96. {plugadvpl-0.4.2 → plugadvpl-0.4.4}/tests/unit/__snapshots__/test_parser_snapshots.ambr +0 -0
  97. {plugadvpl-0.4.2 → plugadvpl-0.4.4}/tests/unit/test_db.py +0 -0
  98. {plugadvpl-0.4.2 → plugadvpl-0.4.4}/tests/unit/test_lint.py +0 -0
  99. {plugadvpl-0.4.2 → plugadvpl-0.4.4}/tests/unit/test_lint_catalog_consistency.py +0 -0
  100. {plugadvpl-0.4.2 → plugadvpl-0.4.4}/tests/unit/test_output.py +0 -0
  101. {plugadvpl-0.4.2 → plugadvpl-0.4.4}/tests/unit/test_parser.py +0 -0
  102. {plugadvpl-0.4.2 → plugadvpl-0.4.4}/tests/unit/test_parser_snapshots.py +0 -0
  103. {plugadvpl-0.4.2 → plugadvpl-0.4.4}/tests/unit/test_query.py +0 -0
  104. {plugadvpl-0.4.2 → plugadvpl-0.4.4}/tests/unit/test_scan.py +0 -0
  105. {plugadvpl-0.4.2 → plugadvpl-0.4.4}/tests/unit/test_stripper.py +0 -0
  106. {plugadvpl-0.4.2 → plugadvpl-0.4.4}/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.4
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.4'
22
+ __version_tuple__ = version_tuple = (0, 4, 4)
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,
@@ -114,6 +115,29 @@ class TableMode(StrEnum):
114
115
  reclock = "reclock"
115
116
 
116
117
 
118
+ # v0.4.4 (UX #4): Enums pros filtros enumeráveis dos comandos Universo 3.
119
+ # Typer rejeita valores fora do enum antes de chegar na query (com mensagem
120
+ # clara listando as opções válidas) — substitui o comportamento antigo de
121
+ # silenciosamente retornar vazio em `--op invalida` / `--kind tipoinexistente`.
122
+
123
+
124
+ class WorkflowKind(StrEnum):
125
+ """Kinds do comando ``workflow`` (Universo 3 Feature A)."""
126
+
127
+ workflow = "workflow"
128
+ schedule = "schedule"
129
+ job_standalone = "job_standalone"
130
+ mail_send = "mail_send"
131
+
132
+
133
+ class ExecAutoOp(StrEnum):
134
+ """Operações do filtro ``--op`` em ``execauto`` (Universo 3 Feature B)."""
135
+
136
+ inc = "inc"
137
+ alt = "alt"
138
+ exc = "exc"
139
+
140
+
117
141
  # ---------------------------------------------------------------------------
118
142
  # Callback global — popula ctx.obj com flags compartilhadas.
119
143
  # ---------------------------------------------------------------------------
@@ -243,6 +267,41 @@ def _with_ro_db(
243
267
  conn.close()
244
268
 
245
269
 
270
+ def _empty_result_hints(
271
+ filters_applied: bool,
272
+ *,
273
+ table_label: str,
274
+ extra_when_filtered: list[str] | None = None,
275
+ ) -> list[str]:
276
+ """Sugestões para resultado vazio (v0.4.4 UX #3).
277
+
278
+ Diferencia 2 cenários:
279
+
280
+ - ``filters_applied=True``: filtro semanticamente vazio (ex.: --arquivo
281
+ inexistente) → sugere verificar o filtro, NÃO sugere reingest caro.
282
+ - ``filters_applied=False``: tabela realmente vazia → sugere reingest.
283
+
284
+ Args:
285
+ filters_applied: True se o usuário passou pelo menos 1 filtro.
286
+ table_label: rótulo amigável da tabela (ex.: "triggers", "calls").
287
+ extra_when_filtered: hints adicionais úteis quando filtrado
288
+ (ex.: ``--dynamic`` pra execauto).
289
+ """
290
+ if filters_applied:
291
+ hints = [
292
+ "Filtro retornou vazio. Verifique se os argumentos batem com o índice:",
293
+ " plugadvpl find <termo> # confirma nome",
294
+ " plugadvpl status # ver contadores",
295
+ ]
296
+ if extra_when_filtered:
297
+ hints.extend(extra_when_filtered)
298
+ return hints
299
+ return [
300
+ f"Nenhum {table_label} no índice. Rode:",
301
+ " plugadvpl ingest --no-incremental",
302
+ ]
303
+
304
+
246
305
  # ---------------------------------------------------------------------------
247
306
  # version
248
307
  # ---------------------------------------------------------------------------
@@ -934,7 +993,24 @@ def grep(
934
993
  """Busca textual no conteúdo dos chunks (FTS5 / LIKE / identifier)."""
935
994
 
936
995
  limit = ctx.obj["limit"] or 50
937
- rows = _with_ro_db(ctx, lambda c: grep_fts(c, pattern, mode=mode.value, limit=limit))
996
+ try:
997
+ rows = _with_ro_db(ctx, lambda c: grep_fts(c, pattern, mode=mode.value, limit=limit))
998
+ except sqlite3.OperationalError as exc:
999
+ # v0.4.4 (BUG #1): FTS5 rejeita caracteres como `/`, `(`, `)`. Antes
1000
+ # propagava traceback completo vazando paths internos. Agora mensagem
1001
+ # amigável + sugestão de modo alternativo.
1002
+ if mode == GrepMode.fts and "fts5" in str(exc).lower():
1003
+ typer.echo(
1004
+ f"Padrão FTS5 inválido: {pattern!r}.\n"
1005
+ f"FTS5 não aceita caracteres como '/', '(', ')', '[', ']'. "
1006
+ f"Operadores válidos: '+', '*', '\"frase\"', 'OR', 'AND', 'NEAR'.\n"
1007
+ f"Alternativas:\n"
1008
+ f" plugadvpl grep {pattern!r} -m literal (substring exata via LIKE)\n"
1009
+ f" plugadvpl grep <termo> -m identifier (busca por símbolo)",
1010
+ err=True,
1011
+ )
1012
+ raise typer.Exit(code=2) from exc
1013
+ raise
938
1014
  _render_from_ctx(
939
1015
  ctx,
940
1016
  rows,
@@ -1110,11 +1186,12 @@ def sx_status_cmd(ctx: typer.Context) -> None:
1110
1186
  def workflow(
1111
1187
  ctx: typer.Context,
1112
1188
  kind: Annotated[
1113
- str | None,
1189
+ WorkflowKind | None,
1114
1190
  typer.Option(
1115
1191
  "--kind",
1116
1192
  "-k",
1117
1193
  help="Filtra por tipo: workflow|schedule|job_standalone|mail_send",
1194
+ case_sensitive=False,
1118
1195
  ),
1119
1196
  ] = None,
1120
1197
  target: Annotated[
@@ -1163,9 +1240,12 @@ def workflow(
1163
1240
  + (f" (arquivo={arquivo})" if arquivo else "")
1164
1241
  ),
1165
1242
  next_steps=(
1166
- [f"plugadvpl find {target}" for target in {r["target"] for r in rows[:3] if r["target"]}]
1243
+ [f"plugadvpl find {t}" for t in {r["target"] for r in rows[:3] if r["target"]}]
1167
1244
  if rows
1168
- else ["plugadvpl ingest --no-incremental # se nada detectado"]
1245
+ else _empty_result_hints(
1246
+ bool(kind or target or arquivo),
1247
+ table_label="execution trigger",
1248
+ )
1169
1249
  ),
1170
1250
  )
1171
1251
 
@@ -1191,8 +1271,13 @@ def execauto(
1191
1271
  typer.Option("--arquivo", "-a", help="Filtra por arquivo (basename, case-insensitive)."),
1192
1272
  ] = None,
1193
1273
  op: Annotated[
1194
- str | None,
1195
- typer.Option("--op", "-o", help="Filtra por operação: inc|alt|exc (op_code 3/4/5)."),
1274
+ ExecAutoOp | None,
1275
+ typer.Option(
1276
+ "--op",
1277
+ "-o",
1278
+ help="Filtra por operação: inc|alt|exc (op_code 3/4/5).",
1279
+ case_sensitive=False,
1280
+ ),
1196
1281
  ] = None,
1197
1282
  dynamic: Annotated[
1198
1283
  bool | None,
@@ -1249,10 +1334,13 @@ def execauto(
1249
1334
  for arq in {r["arquivo"] for r in rows[:3]}
1250
1335
  ]
1251
1336
  if rows
1252
- else [
1253
- "plugadvpl ingest --no-incremental # se esperava findings",
1254
- "plugadvpl execauto --dynamic # ver calls não-resolvíveis",
1255
- ]
1337
+ else _empty_result_hints(
1338
+ bool(routine or modulo or arquivo or op or dynamic is not None),
1339
+ table_label="execauto call",
1340
+ extra_when_filtered=[
1341
+ " plugadvpl execauto --dynamic # ver calls não-resolvíveis",
1342
+ ],
1343
+ )
1256
1344
  ),
1257
1345
  )
1258
1346
 
@@ -1305,10 +1393,28 @@ def docs(
1305
1393
  formatado em Markdown. Use ``--orphans`` pra ver funções sem header.
1306
1394
  """
1307
1395
  if show:
1308
- d = _with_ro_db(ctx, lambda c: protheus_doc_show(c, show))
1309
- if d is None:
1396
+ # v0.4.3 (I2): com homônimos, --arquivo desambiguar; sem --arquivo,
1397
+ # avisa em stderr e mostra o primeiro alfabeticamente.
1398
+ homonyms = _with_ro_db(ctx, lambda c: protheus_doc_homonyms(c, show))
1399
+ if not homonyms:
1310
1400
  typer.echo(f"Nenhum Protheus.doc encontrado pra função '{show}'.", err=True)
1311
1401
  raise typer.Exit(code=1)
1402
+ if len(homonyms) > 1 and not arquivo:
1403
+ typer.echo(
1404
+ f"Aviso: '{show}' tem doc em {len(homonyms)} fontes: "
1405
+ f"{', '.join(homonyms)}. Mostrando '{homonyms[0]}'. "
1406
+ f"Use --arquivo <nome> pra escolher.",
1407
+ err=True,
1408
+ )
1409
+ d = _with_ro_db(
1410
+ ctx, lambda c: protheus_doc_show(c, show, arquivo=arquivo)
1411
+ )
1412
+ if d is None:
1413
+ typer.echo(
1414
+ f"Nenhum Protheus.doc encontrado pra '{show}' em '{arquivo}'.",
1415
+ err=True,
1416
+ )
1417
+ raise typer.Exit(code=1)
1312
1418
  typer.echo(render_pdoc_markdown(d))
1313
1419
  return
1314
1420
 
@@ -1368,10 +1474,13 @@ def docs(
1368
1474
  for r in rows[:3] if r.get("funcao")
1369
1475
  ]
1370
1476
  if rows
1371
- else [
1372
- "plugadvpl docs --orphans # funções sem header",
1373
- "plugadvpl ingest --no-incremental # se esperava docs",
1374
- ]
1477
+ else _empty_result_hints(
1478
+ bool(modulo or author or funcao or arquivo or deprecated is not None or tipo),
1479
+ table_label="Protheus.doc",
1480
+ extra_when_filtered=[
1481
+ " plugadvpl docs --orphans # funções sem header (BP-007)",
1482
+ ],
1483
+ )
1375
1484
  ),
1376
1485
  )
1377
1486
 
@@ -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,14 +30,21 @@ _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.
41
+ # v0.4.4 (BUG #2): inclui construtos de Web Service (WSSTRUCT/WSSERVICE/
42
+ # WSRESTFUL/WSMETHOD) que não têm parens. Antes ficavam órfãos e
43
+ # `docs --funcao`/`docs --show` não encontrava.
38
44
  _NEXT_DECL_RE = re.compile(
39
45
  r"^\s*(?:User\s+|Static\s+|Main\s+)?Function\s+(\w+)\s*\("
40
- r"|^\s*Method\s+(\w+)\s*\(",
46
+ r"|^\s*Method\s+(\w+)\s*\("
47
+ r"|^\s*WS(?:STRUCT|SERVICE|RESTFUL|METHOD)\s+(\w+)\b",
41
48
  re.IGNORECASE | re.MULTILINE,
42
49
  )
43
50
 
@@ -95,15 +102,13 @@ def infer_module(arquivo: str, funcao: str | None) -> str | None:
95
102
  # 1. Exact match (rotina exata no catálogo).
96
103
  if funcao_upper in idx:
97
104
  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.
105
+ # 2. Prefix match (4 primeiros chars). v0.4.3 (C5): aceita se TODOS
106
+ # os matches do prefixo apontam pro MESMO módulo. Ambiguidade None
107
+ # (não inventar). Antes retornava SIGAEST silenciosamente para MATA*.
100
108
  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"]
109
+ matched_modules = {e["module"] for k, e in idx.items() if k.startswith(prefix4)}
110
+ if len(matched_modules) == 1:
111
+ return matched_modules.pop()
107
112
  return None
108
113
 
109
114
 
@@ -200,15 +205,29 @@ def _line_at(content: str, offset: int) -> int:
200
205
  return content.count("\n", 0, offset) + 1
201
206
 
202
207
 
208
+ _PDOC_ORPHAN_LINE_CAP = 80 # v0.4.3 (C4): cap de proximidade pra associar bloco→decl
209
+
210
+
203
211
  def _resolve_next_decl(
204
212
  content: str, after_offset: int
205
213
  ) -> tuple[str | None, int | None]:
206
- """Acha próxima decl de função/método após offset. Retorna (nome, linha_1based)."""
214
+ """Acha próxima decl de função/método após offset. Retorna (nome, linha_1based).
215
+
216
+ v0.4.3 (C4): cap de ``_PDOC_ORPHAN_LINE_CAP`` linhas entre o offset (fim do
217
+ bloco) e a decl encontrada. Acima disso retorna (None, None) — bloco é
218
+ tratado como órfão (preserva sinal de cobertura BP-007 e impede que função
219
+ distante ganhe doc errada associada).
220
+ """
207
221
  m = _NEXT_DECL_RE.search(content, after_offset)
208
222
  if not m:
209
223
  return None, None
210
- name = m.group(1) or m.group(2)
211
- return name, _line_at(content, m.start())
224
+ block_end_line = _line_at(content, max(0, after_offset - 1))
225
+ decl_line = _line_at(content, m.start())
226
+ if decl_line - block_end_line > _PDOC_ORPHAN_LINE_CAP:
227
+ return None, None
228
+ # v0.4.4 (BUG #2): grupo 3 cobre WS constructs (WSSTRUCT/WSSERVICE/etc).
229
+ name = m.group(1) or m.group(2) or m.group(3)
230
+ return name, decl_line
212
231
 
213
232
 
214
233
  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
 
@@ -1088,8 +1088,11 @@ def protheus_docs_query(
1088
1088
  where.append("author LIKE ? COLLATE NOCASE")
1089
1089
  params.append(f"%{author}%")
1090
1090
  if funcao:
1091
- where.append("funcao = ? COLLATE NOCASE")
1092
- params.append(funcao)
1091
+ # v0.4.4 (BUG #2): fallback pra funcao_id quando a coluna funcao
1092
+ # ficou NULL (ex.: bloco órfão, ou DB indexado em versão antiga
1093
+ # que não conhecia WSSTRUCT/WSSERVICE/etc).
1094
+ where.append("(funcao = ? COLLATE NOCASE OR funcao_id = ? COLLATE NOCASE)")
1095
+ params.extend([funcao, funcao])
1093
1096
  if arquivo:
1094
1097
  where.append("arquivo = ? COLLATE NOCASE")
1095
1098
  params.append(arquivo)
@@ -1132,20 +1135,56 @@ def protheus_docs_orphans(conn: sqlite3.Connection) -> list[dict[str, Any]]:
1132
1135
 
1133
1136
 
1134
1137
  def protheus_doc_show(
1135
- conn: sqlite3.Connection, funcao: str
1138
+ conn: sqlite3.Connection,
1139
+ funcao: str,
1140
+ *,
1141
+ arquivo: str | None = None,
1136
1142
  ) -> dict[str, Any] | None:
1137
1143
  """Retorna doc completo de uma função (modo `--show`).
1138
1144
 
1139
- Se múltiplos docs (raro função homônima em fontes diferentes),
1140
- retorna o primeiro. Match case-insensitive.
1145
+ v0.4.3 (I2): aceita ``arquivo`` opcional pra desambiguar quando
1146
+ homônimos. Caller pode usar :func:`protheus_doc_homonyms` antes pra
1147
+ detectar e listar opções.
1148
+
1149
+ v0.4.4 (BUG #2): match também via ``funcao_id`` quando coluna ``funcao``
1150
+ está NULL (DBs antigos com WSSTRUCT/WSSERVICE não-resolvidos, ou
1151
+ blocos órfãos cuja decl seguinte ficou > 80 linhas adiante).
1141
1152
  """
1142
- sql = f"SELECT {_PDOC_COLUMNS} FROM protheus_docs WHERE funcao = ? COLLATE NOCASE"
1143
- row = conn.execute(sql, (funcao,)).fetchone()
1153
+ sql = (
1154
+ f"SELECT {_PDOC_COLUMNS} FROM protheus_docs "
1155
+ "WHERE (funcao = ? COLLATE NOCASE OR funcao_id = ? COLLATE NOCASE)"
1156
+ )
1157
+ params: list[Any] = [funcao, funcao]
1158
+ if arquivo:
1159
+ sql += " AND arquivo = ? COLLATE NOCASE"
1160
+ params.append(arquivo)
1161
+ sql += " ORDER BY arquivo, linha_bloco_inicio LIMIT 1"
1162
+ row = conn.execute(sql, params).fetchone()
1144
1163
  if row is None:
1145
1164
  return None
1146
1165
  return _row_to_pdoc(row)
1147
1166
 
1148
1167
 
1168
+ def protheus_doc_homonyms(
1169
+ conn: sqlite3.Connection, funcao: str
1170
+ ) -> list[str]:
1171
+ """v0.4.3 (I2): lista arquivos com Protheus.doc pra ``funcao``.
1172
+
1173
+ Usado por `docs --show` pra avisar quando há ambiguidade. Retorna lista
1174
+ ordenada de basenames.
1175
+
1176
+ v0.4.4 (BUG #2): match também via ``funcao_id`` (cobertura de blocos
1177
+ órfãos e WS constructs em DBs antigos).
1178
+ """
1179
+ rows = conn.execute(
1180
+ "SELECT DISTINCT arquivo FROM protheus_docs "
1181
+ "WHERE funcao = ? COLLATE NOCASE OR funcao_id = ? COLLATE NOCASE "
1182
+ "ORDER BY arquivo",
1183
+ (funcao, funcao),
1184
+ ).fetchall()
1185
+ return [r[0] for r in rows]
1186
+
1187
+
1149
1188
  def render_pdoc_markdown(d: dict[str, Any]) -> str:
1150
1189
  """Renderiza um doc em Markdown estruturado pra modo `--show`."""
1151
1190
  lines: list[str] = []