diff --git a/README.md b/README.md index dc07a20c8..993371274 100644 --- a/README.md +++ b/README.md @@ -137,6 +137,40 @@ E o efeito prático: `ObterItemComprado()` deixa de apontar para o stub `CCTCPRG a apontar para `VerDadosGerais()` em `7.5/csw75/rotinas/CCTCP/CCTCPRG001.mac:L833` — com precisão de label, não de arquivo. +### Gerar o grafo de um subsistema do padrão + +O irmão do script anterior, para o ERP padrão em vez de um cliente — escopo por prefixo de +módulo dentro de uma árvore de versão: + +```powershell +.venv\Scripts\python.exe scripts\csw_grafo_modulo.py CCT --versao 8.1 # têxtil +.venv\Scripts\python.exe scripts\csw_grafo_modulo.py CCPV --versao 7.5 # pedido de venda +``` + +`CCT` pega os 99 diretórios `rotinas/CCT*` da 8.1 (11.386 rotinas). Sai em +`\-`. Medido no têxtil: 12.511 arquivos com o fechamento → 360.905 +nós, 1.144.309 arestas, 1,5% em stub, 315s. + +Classes não entram por regra de nome: o mapeamento rotina→pacote é irregular (`CCTCO` tem +`classescls/TCo`, `CCTTGT` não tem `TTgt`), então quem decide é o fechamento — a classe +entra quando o código do módulo a referencia. + +### Níveis de fechamento (`--hops`) + +Os dois scripts aceitam `--hops N` (padrão 1). O hop 1 traz o que o *seu* código chama; o +hop 2 traz o que essas rotinas chamam. O que melhora é a proporção, não a contagem +absoluta — cada nível resolve o que o anterior pediu, e os arquivos novos têm a própria +fronteira: + +| cliente GB | hop 1 | hop 2 | +|---|---|---| +| arquivos | 949 | 2.038 | +| arestas | 93.756 | 221.275 | +| em stub | 4.382 (4,7%) | 6.323 (2,9%) | + +O resíduo nunca vai a zero: sobram nomes que não existem em árvore alguma — os placeholders +`*zzz` do framework (`$$ver^CCTCzzz`, 4.767 arestas no têxtil) e referências mortas. + ### Consultar ```powershell diff --git a/scripts/csw_grafo_cliente.py b/scripts/csw_grafo_cliente.py index 89e6d3d47..aa5e7db1d 100644 --- a/scripts/csw_grafo_cliente.py +++ b/scripts/csw_grafo_cliente.py @@ -35,7 +35,6 @@ from __future__ import annotations import argparse -import json import os import subprocess import sys @@ -44,139 +43,49 @@ REPO = Path(__file__).resolve().parent.parent sys.path.insert(0, str(REPO)) +sys.path.insert(0, str(Path(__file__).resolve().parent)) WORKSPACE_PADRAO = Path(os.environ.get("GRAPHIFY_CSW_WORKSPACE", r"C:\workspacecsw\projetos")) OUT_PADRAO = Path(os.environ.get("GRAPHIFY_CSW_OUT", r"C:\workspacecsw\graphify-csw")) -SUFFIXES = (".mac", ".cls", ".inc") -# Componentes por familia de versao (CLAUDE.md: 7.x -> cswutil70, 8.x -> cswutil80). -COMPONENTES = {"7": "COMP-7.0", "8": "COMP-8.0"} - - -# ── coleta e indexacao ─────────────────────────────────────────────────────── - -def coletar(raiz: Path, pular: tuple[str, ...] = ()) -> list[Path]: - """Todos os fontes IRIS sob `raiz`, ignorando diretorios que casem `pular`.""" - achados: list[Path] = [] - for dirpath, _dirnames, filenames in os.walk(raiz): - low = dirpath.lower().replace("\\", "/") - if any(x in low for x in pular): - continue - for fn in filenames: - if os.path.splitext(fn)[1].lower() in SUFFIXES: - achados.append(Path(dirpath) / fn) - return achados - - -def canonico(nome: str) -> str: - """`%CSW1UTI`, `_CSW1UTI` e `csw1uti` viram uma chave só — o `%` vira `_` no disco.""" - return nome.lstrip("%_").casefold() - - -def indexar(raizes: list[Path], pular: tuple[str, ...] = ()) -> tuple[dict, dict]: - """(rotinas/includes por nome canonico, classes pelo nome pontuado do path).""" - nomes: dict[str, list[Path]] = {} - classes: dict[str, list[Path]] = {} - for raiz in raizes: - if not raiz.is_dir(): - continue - for p in coletar(raiz, pular): - stem, ext = os.path.splitext(p.name) - if ext.lower() == ".cls": - # classescls/Pkg_Sub/Nome.cls -> Pkg.Sub.Nome - pontuado = f"{p.parent.name.replace('_', '.')}.{stem}".casefold() - classes.setdefault(pontuado, []).append(p) - else: - nomes.setdefault(canonico(stem), []).append(p) - return nomes, classes - - -def resolver(externos: list[str], nomes: dict, classes: dict) -> tuple[set[Path], list[str]]: - """(arquivos que atendem os nomes externos, nomes que ninguem atende).""" - arquivos: set[Path] = set() - faltam: list[str] = [] - for nome in externos: - hits = nomes.get(canonico(nome)) or classes.get(nome.casefold()) or [] - if hits: - arquivos.update(hits) - else: - faltam.append(nome) - return arquivos, faltam - - -def externos_de(resultado: dict) -> list[str]: - """Nomes que o resolvedor nao achou no escopo — a lista do que falta trazer.""" - return [ - str(n.get("label", "")) for n in resultado.get("nodes", []) - if str(n.get("id", "")).startswith("ref") and n.get("label") - ] - - -# ── versoes ────────────────────────────────────────────────────────────────── - -def versoes_disponiveis(workspace: Path) -> list[str]: - return sorted( - d.name for d in workspace.iterdir() - if d.is_dir() and d.name[:1].isdigit() and "." in d.name - ) -def _releases(pom: Path, sistema: str) -> list[tuple[int, ...]]: - """Releases declarados para um `sistemaId` no pomcs.xml, como tuplas ordenaveis.""" - import xml.etree.ElementTree as ET - try: - raiz = ET.parse(pom).getroot() - except (OSError, ET.ParseError): - return [] - achados: list[tuple[int, ...]] = [] - for dep in raiz.iter("dependencia"): - sid = dep.findtext("sistemaId") or "" - if sid.strip().casefold() != sistema: - continue - for rel in dep.iter("release"): - partes = (rel.text or "").strip().split(".") - if len(partes) >= 2 and all(p.isdigit() for p in partes[:2]): - achados.append(tuple(int(p) for p in partes if p.isdigit())) - return achados - - -def detectar_versao(cliente_dir: Path, workspace: Path) -> tuple[str, str]: - """(versao do ERP, diretorio de componentes) declarados no pomcs.xml do cliente. +from csw_grafo_comum import ( # noqa: E402 + arvores_padrao, + coletar, + contar_stubs, + escrever_grafo, + externos_de, + fechar, + releases_pomcs, + versoes_disponiveis, +) + + +def detectar_versao(cliente_dir: Path, workspace: Path) -> str: + """Versao do ERP declarada no pomcs.xml do cliente. Cada cliente roda a sua versao, e nada na arvore de fontes diz qual — os `.vscode/settings.json` ficam nas pastas de VERSAO. Quem declara e o `pomcs.xml` do - cliente: `csw` lista os releases do ERP (o maior e o vigente) - e `cswutil` o dos componentes, que da a pasta `COMP-x.y`. + cliente: `csw` lista os releases do ERP e o maior e o vigente. - Nao tente adivinhar comparando os fontes: nome de rotina e estavel entre versoes, e - as quatro arvores do workspace resolvem exatamente os mesmos nomes — medido, 291 de - 291 em todas. Sem o pomcs.xml a resposta e perguntar. + Nao tente adivinhar comparando os fontes: nome de rotina e estavel entre versoes, e as + quatro arvores do workspace resolvem exatamente os mesmos nomes — medido, 291 de 291 em + todas. Sem o pomcs.xml a resposta e perguntar. """ pom = cliente_dir / "pomcs.xml" - erp = comp = "" - if pom.is_file(): - csw = _releases(pom, "csw") - util = _releases(pom, "cswutil") - if csw: - maior = max(csw) - erp = f"{maior[0]}.{maior[1]}" - if util: - maior_util = max(util) - comp = f"COMP-{maior_util[0]}.{maior_util[1]}" - if erp: - print(f" pomcs.xml declara: ERP {erp}" - + (f", componentes {comp}" if comp else "") - + f" (releases csw: {', '.join('.'.join(map(str, r)) for r in sorted(csw))})") - if not erp: - # Sem declaracao: perguntar, porque comparar fontes nao discrimina versao. + csw = releases_pomcs(pom, "csw") if pom.is_file() else [] + if csw: + maior = max(csw) + erp = f"{maior[0]}.{maior[1]}" + print(f" pomcs.xml declara ERP {erp} " + f"(releases csw: {', '.join('.'.join(map(str, r)) for r in sorted(csw))})") + else: disponiveis = versoes_disponiveis(workspace) print(f" {pom.name} nao declara a versao do ERP.") - escolha = input(f" Versao do ERP {disponiveis} [7.5]: ").strip() or "7.5" - erp = escolha - if not comp: - comp = COMPONENTES.get(erp[:1], "COMP-7.0") + erp = input(f" Versao do ERP {disponiveis} [7.5]: ").strip() or "7.5" if not (workspace / erp).is_dir(): raise SystemExit(f"erro: arvore da versao {erp} nao existe em {workspace}") - return erp, comp + return erp # ── principal ──────────────────────────────────────────────────────────────── @@ -194,6 +103,8 @@ def main() -> int: help=f"diretorio de saida (padrao: {OUT_PADRAO}\\)") ap.add_argument("--sem-fechamento", action="store_true", help="extrai so a customizacao (rapido; deixa ~31%% das arestas em stub)") + ap.add_argument("--hops", type=int, default=1, metavar="N", + help="niveis de fechamento (padrao 1; 2 fecha as dependencias do padrao)") ap.add_argument("--cluster", action="store_true", help="roda cluster-only ao final (comunidades + GRAPH_REPORT.md)") ap.add_argument("--sequencial", action="store_true", help="desliga o pool de processos") @@ -239,42 +150,29 @@ def main() -> int: else: # FASE 2 — de quem a customizacao depende, e onde isso mora. print("[2/4] resolvendo o fechamento de dependencia...", flush=True) - if args.versao: - versao = args.versao - comp_nome = COMPONENTES.get(versao[:1], "COMP-7.0") - else: - versao, comp_nome = detectar_versao(cliente_dir, workspace) - erp = workspace / versao / f"csw{versao.replace('.', '')}" - comp = workspace / comp_nome - # Produtos dos customizadores (AD*, CX*, TT*) tambem sao chamados pela - # customizacao; entram excluindo `custom/` para nao arrastar outro cliente. - produtos = workspace / "DESENV" - nomes, classes = indexar([erp, comp, produtos], pular=("desenv/custom",)) - fechamento, faltam = resolver(externos, nomes, classes) - print(f" ERP {versao} + {comp.name} + produtos DESENV -> " - f"{len(fechamento)} arquivos ({len(faltam)} nomes sem dono)", flush=True) - - # FASE 3 — a unica passada que faz as arestas cruzarem. Os arquivos do cliente - # vem do cache da fase 1, entao aqui se paga so pelo fechamento. - alvos = sorted(set(arquivos_cliente) | fechamento) - print(f"[3/4] extraindo customizacao + fechamento ({len(alvos)} arquivos)...", flush=True) - t0 = time.time() - resultado = extract(alvos, cache_root=out, root=workspace, parallel=not args.sequencial) - print(f" {len(resultado['nodes'])} nos, {len(resultado['edges'])} arestas em " - f"{time.time() - t0:.0f}s", flush=True) + versao = args.versao or detectar_versao(cliente_dir, workspace) + # ERP + core + componentes da versao, mais os produtos dos customizadores + # (AD*, CX*, TT*), que a customizacao tambem chama. `desenv/custom` fica de fora + # para nao arrastar outro cliente para dentro deste grafo. + arvores = (arvores_padrao(workspace, versao, pom_cliente=cliente_dir / "pomcs.xml") + + [workspace / "DESENV"]) + print(" " + ", ".join(a.name for a in arvores if a.is_dir()), flush=True) + + def extrair(lista): + print(f"[3/4] extraindo {len(lista)} arquivos...", flush=True) + t0 = time.time() + r = extract(lista, cache_root=out, root=workspace, parallel=not args.sequencial) + print(f" {len(r['nodes'])} nos, {len(r['edges'])} arestas em " + f"{time.time() - t0:.0f}s", flush=True) + return r + + resultado, fechamento, faltam = fechar( + arquivos_cliente, parcial, arvores, extrair, hops=args.hops, + pular=("desenv/custom",)) # FASE 4 — grava no formato que o CLI do graphify le. - grafo = out / "graphify-out" / "graph.json" - grafo.write_text( - json.dumps({"nodes": resultado["nodes"], "edges": resultado["edges"], - "hyperedges": [], "input_tokens": 0, "output_tokens": 0}, - ensure_ascii=False), - encoding="utf-8") - # Ancora o corpus real: e o que faz os path:linha do relatorio abrirem no workspace. - (out / "graphify-out" / ".graphify_root").write_text(str(workspace), encoding="utf-8") - - ref_ids = {n["id"] for n in resultado["nodes"] if str(n["id"]).startswith("ref")} - em_stub = sum(1 for e in resultado["edges"] if e.get("target") in ref_ids) + grafo = escrever_grafo(out, resultado, workspace) + em_stub = contar_stubs(resultado) mb = grafo.stat().st_size / 1024 / 1024 print(f"[4/4] gravado: {grafo} ({mb:.0f} MB)") diff --git a/scripts/csw_grafo_comum.py b/scripts/csw_grafo_comum.py new file mode 100644 index 000000000..d8e7671b2 --- /dev/null +++ b/scripts/csw_grafo_comum.py @@ -0,0 +1,219 @@ +"""Peças compartilhadas pelos geradores de grafo CSW (cliente e módulo padrão). + +O que vive aqui é o que os dois modos fazem igual: varrer fontes IRIS, indexar nomes do +jeito que uma referência ObjectScript os escreve, e resolver o fechamento de dependência. + +A ideia do fechamento: uma referência nomeia uma rotina/classe/include, nunca um caminho, +e o resolvedor de `graphify` casa esses nomes DEPOIS que o corpus inteiro foi extraído — +então arquivo fora da MESMA extração vira stub. Como o grafo já aponta o que faltou (os nós +`ref_iris_*`), dá para resolver esses nomes contra as árvores padrão e re-extrair só o +necessário, em vez de arrastar o ERP inteiro. +""" +from __future__ import annotations + +import os +from pathlib import Path + +SUFFIXES = (".mac", ".cls", ".inc") +# Fallback quando o pomcs.xml da versão não declara o release de `cswutil`. +COMPONENTES_PADRAO = {"7": "COMP-7.0", "8": "COMP-8.0"} + + +def coletar(raiz: Path, pular: tuple[str, ...] = ()) -> list[Path]: + """Todos os fontes IRIS sob `raiz`, ignorando diretórios que casem `pular`.""" + achados: list[Path] = [] + for dirpath, _dirnames, filenames in os.walk(raiz): + low = dirpath.lower().replace("\\", "/") + if any(x in low for x in pular): + continue + for fn in filenames: + if os.path.splitext(fn)[1].lower() in SUFFIXES: + achados.append(Path(dirpath) / fn) + return achados + + +def canonico(nome: str) -> str: + """`%CSW1UTI`, `_CSW1UTI` e `csw1uti` viram uma chave só — o `%` vira `_` no disco.""" + return nome.lstrip("%_").casefold() + + +def indexar(raizes: list[Path], pular: tuple[str, ...] = ()) -> tuple[dict, dict]: + """(rotinas/includes por nome canônico, classes pelo nome pontuado do path).""" + nomes: dict[str, list[Path]] = {} + classes: dict[str, list[Path]] = {} + for raiz in raizes: + if not raiz.is_dir(): + continue + for p in coletar(raiz, pular): + stem, ext = os.path.splitext(p.name) + if ext.lower() == ".cls": + # classescls/Pkg_Sub/Nome.cls -> Pkg.Sub.Nome + pontuado = f"{p.parent.name.replace('_', '.')}.{stem}".casefold() + classes.setdefault(pontuado, []).append(p) + else: + nomes.setdefault(canonico(stem), []).append(p) + return nomes, classes + + +def resolver(externos: list[str], nomes: dict, classes: dict) -> tuple[set[Path], list[str]]: + """(arquivos que atendem os nomes externos, nomes que ninguém atende).""" + arquivos: set[Path] = set() + faltam: list[str] = [] + for nome in externos: + hits = nomes.get(canonico(nome)) or classes.get(nome.casefold()) or [] + if hits: + arquivos.update(hits) + else: + faltam.append(nome) + return arquivos, faltam + + +def externos_de(resultado: dict) -> list[str]: + """Nomes que o resolvedor não achou no escopo — a lista do que falta trazer.""" + return [ + str(n.get("label", "")) for n in resultado.get("nodes", []) + if str(n.get("id", "")).startswith("ref") and n.get("label") + ] + + +# ── versões e árvores ──────────────────────────────────────────────────────── + +def versoes_disponiveis(workspace: Path) -> list[str]: + return sorted( + d.name for d in workspace.iterdir() + if d.is_dir() and d.name[:1].isdigit() and "." in d.name + ) + + +def releases_pomcs(pom: Path, sistema: str) -> list[tuple[int, ...]]: + """Releases declarados para um `sistemaId` num pomcs.xml, como tuplas ordenáveis.""" + import xml.etree.ElementTree as ET + try: + raiz = ET.parse(pom).getroot() + except (OSError, ET.ParseError): + return [] + achados: list[tuple[int, ...]] = [] + for dep in raiz.iter("dependencia"): + sid = dep.findtext("sistemaId") or "" + if sid.strip().casefold() != sistema: + continue + for rel in dep.iter("release"): + partes = (rel.text or "").strip().split(".") + if len(partes) >= 2 and all(p.isdigit() for p in partes[:2]): + achados.append(tuple(int(p) for p in partes if p.isdigit())) + return achados + + +def componentes_da_versao(workspace: Path, versao: str, pom_cliente: Path | None = None) -> str: + """Pasta de componentes a usar, e quem manda quando as fontes discordam. + + Duas fontes declaram `cswutil`, e elas NÃO concordam: `7.5/csw75/pomcs.xml` pede + `8.0.21` (o trunk do ERP é construído contra um release novo dos componentes), + enquanto `DESENV/custom/gb/pomcs.xml` — o cliente que roda essa 7.5 — pede `7.0.130`. + Misturar ERP 7.5 com componentes 8.0 seria errado em silêncio: os nomes de rotina + existem nas duas árvores, então o fechamento "fecharia" com o conteúdo da versão + errada e nada apareceria como falha. + + Por isso: a família da versão do ERP decide (7.x -> COMP-7.0, 8.x -> COMP-8.0, que é a + convenção documentada no CLAUDE.md do workspace); o pomcs do CLIENTE, quando existe, + tem precedência porque é o que descreve o que está implantado; e uma divergência sai + na tela em vez de ser resolvida por chute. + """ + familia = COMPONENTES_PADRAO.get(versao[:1], "COMP-7.0") + escolhido = familia + fonte = f"convenção de família ({versao[:1]}.x)" + if pom_cliente is not None and pom_cliente.is_file(): + util = releases_pomcs(pom_cliente, "cswutil") + if util: + maior = max(util) + escolhido = f"COMP-{maior[0]}.{maior[1]}" + fonte = f"pomcs.xml do cliente (cswutil {'.'.join(map(str, maior))})" + pom_erp = workspace / versao / f"csw{versao.replace('.', '')}" / "pomcs.xml" + util_erp = releases_pomcs(pom_erp, "cswutil") + if util_erp: + maior_erp = max(util_erp) + do_erp = f"COMP-{maior_erp[0]}.{maior_erp[1]}" + if do_erp != escolhido: + print(f" ! a árvore {versao} declara cswutil " + f"{'.'.join(map(str, maior_erp))} ({do_erp}), mas vou usar {escolhido} " + f"pela {fonte}; passe --comp para forçar") + return escolhido + + +def arvores_padrao(workspace: Path, versao: str, pom_cliente: Path | None = None, + comp: str | None = None) -> list[Path]: + """Árvores onde uma referência do padrão pode morar, na ordem em que se procura. + + Três, não uma: o ERP (`csw81`), o core (`cswcore81` — árvore irmã pequena, mas é onde + vivem as rotinas `DD*`) e os componentes (`COMP-x.y`). + """ + vv = versao.replace(".", "") + return [ + workspace / versao / f"csw{vv}", + workspace / versao / f"cswcore{vv}", + workspace / (comp or componentes_da_versao(workspace, versao, pom_cliente)), + ] + + +def fechar(base, parcial, arvores, extrair, hops=1, pular=(), log=print): + """Fecha o grafo por dependência, `hops` níveis, re-extraindo a cada nível. + + `base` são os arquivos do escopo (customização ou módulos), `parcial` o resultado de + já tê-los extraído, `extrair(lista) -> resultado` a função de extração e `arvores` onde + procurar o que faltou. + + Por que mais de um nível pode valer: o hop 1 traz o que o SEU código chama; essas + rotinas trazidas chamam outras, que ficam em stub. + + Cada hop resolve as arestas que o nível anterior deixou pendentes, mas os arquivos que + ele traz têm a própria fronteira — então o que melhora é a PROPORÇÃO, não a contagem + absoluta. Medido no cliente GB: hop 1 = 949 arquivos e 4,7% das arestas em stub; hop 2 + = 2.038 arquivos e 2,9%, com o total de arestas passando de 94 mil para 221 mil. Ou + seja: mais contexto real no grafo, com o stub virando uma fatia menor dele. + + O laço para sozinho quando um nível não acha arquivo novo, e o resíduo nunca vai a zero: + sobram nomes que não existem em árvore alguma — os placeholders `*zzz` do framework e + referências mortas no fonte. + + Retorna `(resultado, fechamento, faltam)`. + """ + nomes, classes = indexar(arvores, pular) + base = set(base) + resultado = parcial + fechamento: set = set() + faltam: list[str] = [] + for hop in range(1, hops + 1): + externos = externos_de(resultado) + novos, faltam = resolver(externos, nomes, classes) + novos -= base | fechamento + log(f" hop {hop}: {len(externos)} nomes externos -> {len(novos)} arquivos novos " + f"({len(faltam)} nomes sem dono)") + if not novos: + break + fechamento |= novos + resultado = extrair(sorted(base | fechamento)) + return resultado, fechamento, faltam + + +# ── saída ──────────────────────────────────────────────────────────────────── + +def escrever_grafo(out: Path, resultado: dict, workspace: Path) -> Path: + """Grava graph.json no formato que o CLI do graphify lê, + a âncora do corpus.""" + import json + destino = out / "graphify-out" + destino.mkdir(parents=True, exist_ok=True) + grafo = destino / "graph.json" + grafo.write_text( + json.dumps({"nodes": resultado["nodes"], "edges": resultado["edges"], + "hyperedges": [], "input_tokens": 0, "output_tokens": 0}, + ensure_ascii=False), + encoding="utf-8") + # `.graphify_root` aponta o corpus, não o diretório de saída: é o que faz os + # path:linha do relatório abrirem no workspace (e o grafo poder ser movido). + (destino / ".graphify_root").write_text(str(workspace), encoding="utf-8") + return grafo + + +def contar_stubs(resultado: dict) -> int: + ref = {n["id"] for n in resultado["nodes"] if str(n["id"]).startswith("ref")} + return sum(1 for e in resultado["edges"] if e.get("target") in ref) diff --git a/scripts/csw_grafo_modulo.py b/scripts/csw_grafo_modulo.py new file mode 100644 index 000000000..a72fb3a45 --- /dev/null +++ b/scripts/csw_grafo_modulo.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python3 +"""Gera o grafo de um SUBSISTEMA do ERP Consistem padrao, fechado por dependencia. + +Uso: + python scripts/csw_grafo_modulo.py CCT --versao 8.1 # textil da 8.1 + python scripts/csw_grafo_modulo.py CCPV --versao 7.5 # pedido de venda + python scripts/csw_grafo_modulo.py CCT --versao 8.1 --cluster + python scripts/csw_grafo_modulo.py # pergunta prefixo e versao + +O irmao de `csw_grafo_cliente.py`: mesma mecanica de fechamento, escopo diferente. Ali o +ponto de partida e a customizacao de um cliente; aqui e um prefixo de modulo dentro de uma +arvore de versao — `CCT` pega os 99 diretorios `rotinas/CCT*` (11.386 rotinas na 8.1, o +subsistema textil), `CCPV` pega pedido de venda, e assim por diante. + +Classes NAO entram por regra de nome. O mapeamento rotina->pacote e irregular (`CCTCO` tem +`classescls/TCo`, mas `CCTTGT` nao tem `TTgt`), e adivinhar erra nas duas direcoes: perde +classe que existe e inventa pacote que nao existe. Quem decide e o fechamento — a classe +entra quando o codigo do modulo a referencia, que e tambem o unico caso em que ela importa +para este grafo. +""" +from __future__ import annotations + +import argparse +import os +import subprocess +import sys +import time +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO)) +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from csw_grafo_comum import ( # noqa: E402 + arvores_padrao, + coletar, + contar_stubs, + escrever_grafo, + externos_de, + fechar, + versoes_disponiveis, +) + +WORKSPACE_PADRAO = Path(os.environ.get("GRAPHIFY_CSW_WORKSPACE", r"C:\workspacecsw\projetos")) +OUT_PADRAO = Path(os.environ.get("GRAPHIFY_CSW_OUT", r"C:\workspacecsw\graphify-csw")) + + +def main() -> int: + ap = argparse.ArgumentParser( + description="Gera o grafo de um subsistema do ERP padrao, fechado por dependencia.") + ap.add_argument("prefixo", nargs="?", + help="prefixo dos modulos, ex.: CCT (textil), CCPV, CCFT") + ap.add_argument("--versao", help="versao do ERP (8.1, 7.5...). Padrao: pergunta") + ap.add_argument("--workspace", type=Path, default=WORKSPACE_PADRAO) + ap.add_argument("--out", type=Path, default=None, + help=f"padrao: {OUT_PADRAO}\\-") + ap.add_argument("--sem-fechamento", action="store_true", + help="so os modulos do prefixo (rapido, deixa muita aresta em stub)") + ap.add_argument("--comp", help="forca a pasta de componentes (ex.: COMP-7.0)") + ap.add_argument("--hops", type=int, default=1, metavar="N", + help="niveis de fechamento (padrao 1; 2 tira ~27%% dos stubs restantes)") + ap.add_argument("--cluster", action="store_true", help="roda cluster-only ao final") + ap.add_argument("--sequencial", action="store_true") + args = ap.parse_args() + + workspace: Path = args.workspace + prefixo = (args.prefixo or input("Prefixo do modulo (ex.: CCT): ")).strip().upper() + if not prefixo.isalnum(): + print(f"erro: prefixo invalido: {prefixo!r}", file=sys.stderr) + return 1 + + disponiveis = versoes_disponiveis(workspace) + versao = args.versao or (input(f"Versao do ERP {disponiveis}: ").strip()) + if versao not in disponiveis: + print(f"erro: versao {versao!r} nao existe em {workspace} ({disponiveis})", + file=sys.stderr) + return 1 + + erp = workspace / versao / f"csw{versao.replace('.', '')}" + rotinas = erp / "rotinas" + if not rotinas.is_dir(): + print(f"erro: nao achei {rotinas}", file=sys.stderr) + return 1 + + modulos = sorted(d for d in rotinas.iterdir() if d.is_dir() and d.name.startswith(prefixo)) + if not modulos: + exemplos = sorted({d.name[:4] for d in rotinas.iterdir() if d.is_dir()})[:20] + print(f"erro: nenhum modulo comeca com {prefixo!r} em {rotinas}", file=sys.stderr) + print(f"prefixos que existem: {', '.join(exemplos)}...", file=sys.stderr) + return 1 + + out: Path = args.out or (OUT_PADRAO / f"{versao}-{prefixo}") + (out / "graphify-out").mkdir(parents=True, exist_ok=True) + + from graphify.extract import extract + + t_inicio = time.time() + print(f"\n=== {prefixo}* na versao {versao} — {len(modulos)} modulos ===") + print(f" {', '.join(d.name for d in modulos[:12])}" + + (f" (+{len(modulos) - 12})" if len(modulos) > 12 else "")) + + alvos_modulo = [p for d in modulos for p in coletar(d)] + print(f"[1/4] extraindo os modulos ({len(alvos_modulo)} arquivos)...", flush=True) + t0 = time.time() + parcial = extract(alvos_modulo, cache_root=out, root=workspace, + parallel=not args.sequencial) + externos = externos_de(parcial) + print(f" {len(parcial['nodes'])} nos, {len(parcial['edges'])} arestas em " + f"{time.time() - t0:.0f}s — {len(externos)} nomes externos", flush=True) + + if args.sem_fechamento: + resultado, fechamento, faltam = parcial, set(), externos + else: + print(f"[2/4] fechando por dependencia (ERP + core + componentes, " + f"{args.hops} hop(s))...", flush=True) + arvores = arvores_padrao(workspace, versao, comp=args.comp) + print(" " + ", ".join(a.name for a in arvores if a.is_dir()), flush=True) + + def extrair(lista): + print(f"[3/4] extraindo {len(lista)} arquivos...", flush=True) + t0 = time.time() + r = extract(lista, cache_root=out, root=workspace, parallel=not args.sequencial) + print(f" {len(r['nodes'])} nos, {len(r['edges'])} arestas em " + f"{time.time() - t0:.0f}s", flush=True) + return r + + resultado, fechamento, faltam = fechar( + alvos_modulo, parcial, arvores, extrair, hops=args.hops) + + grafo = escrever_grafo(out, resultado, workspace) + em_stub = contar_stubs(resultado) + mb = grafo.stat().st_size / 1024 / 1024 + print(f"[4/4] gravado: {grafo} ({mb:.0f} MB)") + + if args.cluster: + print(" clusterizando...", flush=True) + env = dict(os.environ, GRAPHIFY_MAX_GRAPH_BYTES="4GB") + subprocess.run([sys.executable, "-m", "graphify", "cluster-only", str(out)], + cwd=str(REPO), env=env, check=False) + + total = len(resultado["edges"]) or 1 + print(f""" +=== RESUMO {prefixo}* {versao} === +modulos ............. {len(modulos)} ({len(alvos_modulo)} arquivos) +fechamento .......... {len(fechamento)} arquivos +nos / arestas ....... {len(resultado['nodes'])} / {len(resultado['edges'])} +arestas em stub ..... {em_stub} ({100 * em_stub / total:.1f}%) +graph.json .......... {grafo} ({mb:.0f} MB) +tempo ............... {time.time() - t_inicio:.0f}s + + set GRAPHIFY_MAX_GRAPH_BYTES=4GB + python -m graphify god-nodes --graph "{grafo}" --top 15 + python scripts/csw_quem_chama.py "{grafo}" "