Files
graph_explorer/enrichers.cpp
T
egutierrez fce3f97d53 feat(enrichers): dispatcher multi-lang go|python|bash (issue 0033 fase A)
Extiende el sistema de enrichers para soportar varios lenguajes en el
mismo registro. El manifest gana dos campos opcionales:

  lang: python|go|bash    (default: python — retrocompat con los 5
                            enrichers existentes que no lo declaran)
  exec: run               (basename del script o binario; default "run")

EnricherSpec ahora lleva `lang`, `exec_basename`, `disabled` y
`disabled_reason`. parse_manifest lee los nuevos campos y aplica
defaults; resolve_run_path busca <dir>/<exec>{.py|.sh|.exe|<vacio>}
segun lang + plataforma. Si el ejecutable no existe (binario Go sin
compilar, script ausente), el spec queda en el registro pero
disabled — enrichers_for_type lo oculta del menu y jobs.cpp aborta
con mensaje claro si llega un job para uno disabled.

run_subprocess (POSIX y Windows) ramifica argv segun lang:
  - go    -> execv del binario directamente, sin python ni wsl.exe
  - bash  -> /bin/bash <run_path>  (en Windows: wsl.exe -- bash ...)
  - python -> python3 <run_path>   (default)

El call site en jobs.cpp resuelve run_path y lang via
ge::enricher_by_id() en lugar del hardcode "run.py". Los 5 enrichers
existentes siguen funcionando sin cambios — heredan lang: python por
default.

Tests pytest (22/22 verde):
  - 16 regresion: los 5 enrichers actuales siguen pasando.
  - 6 nuevos en test_dispatcher_lang.py: parser default a python,
    parser lee lang: bash, wire protocol identico para python y
    bash, enricher Go sin binario queda disabled, enricher real
    sigue funcionando tras el cambio.

NO incluye: runtime Python embebido (fase B) ni badges de lang en
la UI (fase C). El issue 0033 sigue abierto hasta cerrar las dos
fases restantes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 16:15:03 +02:00

266 lines
8.6 KiB
C++

#include "enrichers.h"
#include <algorithm>
#include <cctype>
#include <cstdio>
#include <cstring>
#include <dirent.h>
#include <fstream>
#include <sstream>
#include <sys/stat.h>
namespace ge {
namespace {
std::vector<EnricherSpec> g_enrichers;
std::string strip(const std::string& s) {
size_t a = 0, b = s.size();
while (a < b && std::isspace((unsigned char)s[a])) ++a;
while (b > a && std::isspace((unsigned char)s[b - 1])) --b;
return s.substr(a, b - a);
}
std::string strip_quotes(const std::string& s) {
if (s.size() >= 2) {
if ((s.front() == '"' && s.back() == '"') ||
(s.front() == '\'' && s.back() == '\'')) {
return s.substr(1, s.size() - 2);
}
}
return s;
}
std::string lower(std::string s) {
for (auto& c : s) c = (char)std::tolower((unsigned char)c);
return s;
}
// Parsea una lista inline `[a, b, c]` o "[Webpage, Url]". Tolerante a
// espacios y a comillas simples/dobles dentro. NO soporta listas
// multi-linea — el manifest las usa siempre inline.
std::vector<std::string> parse_inline_list(const std::string& v) {
std::vector<std::string> out;
std::string s = strip(v);
if (s.size() < 2 || s.front() != '[' || s.back() != ']') return out;
s = s.substr(1, s.size() - 2);
std::string token;
auto flush = [&]() {
std::string t = strip_quotes(strip(token));
if (!t.empty()) out.push_back(std::move(t));
token.clear();
};
for (char c : s) {
if (c == ',') flush();
else token.push_back(c);
}
flush();
return out;
}
// Manifest YAML soportado (subset):
// id: fetch_webpage
// name: "Fetch web page"
// description: "..."
// applies_to: [Webpage, Url]
// lang: python <- issue 0033: go|python|bash (default python)
// exec: run <- basename del binario/script (default "run")
// params: <- v1 ignora bloque
// - { name: timeout_s, ... }
//
// Las claves anidadas bajo `params:` (y otros bloques con valor vacio
// seguido de lineas indentadas) se ignoran.
bool parse_manifest(const std::string& path, EnricherSpec* out) {
std::ifstream f(path);
if (!f) return false;
std::string line;
bool in_skip_block = false;
while (std::getline(f, line)) {
// Strip CR de Windows.
if (!line.empty() && line.back() == '\r') line.pop_back();
// Linea blanca o comentario.
std::string trim = strip(line);
if (trim.empty() || trim.front() == '#') continue;
// Si la linea NO empieza con whitespace, salimos del bloque skip.
bool indented = !line.empty() && std::isspace((unsigned char)line.front());
if (!indented) in_skip_block = false;
if (in_skip_block) continue;
size_t colon = trim.find(':');
if (colon == std::string::npos) continue;
std::string key = strip(trim.substr(0, colon));
std::string val = strip(trim.substr(colon + 1));
if (key == "id") out->id = strip_quotes(val);
else if (key == "name") out->name = strip_quotes(val);
else if (key == "description") out->description = strip_quotes(val);
else if (key == "applies_to") out->applies_to = parse_inline_list(val);
else if (key == "lang") out->lang = lower(strip_quotes(val));
else if (key == "exec") out->exec_basename = strip_quotes(val);
else if (key == "params" && val.empty()) in_skip_block = true;
else if (key == "emits" && val.empty()) in_skip_block = true;
else if (key == "relations" && val.empty()) in_skip_block = true;
}
// Defaults — preservan retrocompat con manifests existentes que no
// declaran lang/exec.
if (out->lang.empty()) out->lang = "python";
if (out->exec_basename.empty()) out->exec_basename = "run";
// Validar lang reconocido. Manifests con lang invalido se cargan
// pero quedan disabled — asi la UI puede informar y el usuario
// arregla el manifest.
if (out->lang != "python" && out->lang != "go" && out->lang != "bash") {
out->disabled = true;
out->disabled_reason = "lang invalido: '" + out->lang + "'";
}
return !out->id.empty();
}
// Resuelve el path al ejecutable/script segun lang + plataforma.
// Devuelve "" si no encuentra el archivo y rellena `reason`.
std::string resolve_run_path(const std::string& dir,
const EnricherSpec& spec,
std::string* reason) {
#ifdef _WIN32
const char sep = '\\';
const char* go_ext = ".exe";
#else
const char sep = '/';
const char* go_ext = "";
#endif
auto exists = [](const std::string& p) {
struct stat st{};
return stat(p.c_str(), &st) == 0 && !S_ISDIR(st.st_mode);
};
std::string base = dir + sep + spec.exec_basename;
if (spec.lang == "python") {
std::string p = base + ".py";
if (exists(p)) return p;
if (reason) *reason = "no existe " + p;
return "";
}
if (spec.lang == "bash") {
std::string p = base + ".sh";
if (exists(p)) return p;
if (reason) *reason = "no existe " + p;
return "";
}
if (spec.lang == "go") {
// En Windows: <base>.exe. En Linux: <base> (sin extension).
std::string p = base + go_ext;
if (exists(p)) return p;
if (reason) {
*reason = "binario Go no compilado: " + p
+ " (corre el build script del enricher)";
}
return "";
}
if (reason) *reason = "lang no soportado";
return "";
}
} // namespace
int enrichers_load(const char* enrichers_dir) {
g_enrichers.clear();
if (!enrichers_dir || !*enrichers_dir) return -1;
// En Windows los UNC paths esperan backslashes consistentes; mixed
// separators (`\\wsl$\<distro>\foo/bar`) confunden a opendir de MinGW.
std::string dir = enrichers_dir;
#ifdef _WIN32
for (char& c : dir) if (c == '/') c = '\\';
#endif
DIR* d = opendir(dir.c_str());
if (!d) {
std::fprintf(stderr, "[enrichers] opendir failed: %s\n", dir.c_str());
return -1;
}
struct dirent* ent;
while ((ent = readdir(d)) != nullptr) {
if (ent->d_name[0] == '.') continue;
#ifdef _WIN32
const char sep = '\\';
#else
const char sep = '/';
#endif
std::string sub = dir + sep + ent->d_name;
struct stat st{};
if (stat(sub.c_str(), &st) != 0 || !S_ISDIR(st.st_mode)) continue;
std::string manifest = sub + sep + "manifest.yaml";
if (stat(manifest.c_str(), &st) != 0) continue;
EnricherSpec spec;
if (!parse_manifest(manifest, &spec)) {
std::fprintf(stderr, "[enrichers] parse failed: %s\n", manifest.c_str());
continue;
}
// Resolver el ejecutable segun lang. Si falla (binario Go no
// compilado, script ausente, etc.) registramos el spec como
// disabled — sigue apareciendo en `enrichers_all()` para que
// la UI pueda mostrar warning, pero `enrichers_for_type` lo
// oculta del menu de ejecucion.
std::string reason;
std::string run_path = resolve_run_path(sub, spec, &reason);
if (run_path.empty()) {
spec.disabled = true;
if (spec.disabled_reason.empty()) spec.disabled_reason = reason;
std::fprintf(stderr, "[enrichers] %s deshabilitado: %s\n",
spec.id.c_str(), spec.disabled_reason.c_str());
}
spec.run_path = run_path;
g_enrichers.push_back(std::move(spec));
}
closedir(d);
std::sort(g_enrichers.begin(), g_enrichers.end(),
[](const EnricherSpec& a, const EnricherSpec& b) {
return a.name < b.name;
});
return (int)g_enrichers.size();
}
const std::vector<EnricherSpec>& enrichers_all() {
return g_enrichers;
}
std::vector<EnricherSpec> enrichers_for_type(const char* type_ref) {
std::vector<EnricherSpec> out;
if (!type_ref || !*type_ref) return out;
std::string want = lower(type_ref);
for (const auto& e : g_enrichers) {
if (e.disabled) continue; // no ofrecer enrichers no resueltos
if (e.applies_to.empty()) {
out.push_back(e);
continue;
}
for (const auto& t : e.applies_to) {
if (lower(t) == want) { out.push_back(e); break; }
}
}
return out;
}
const EnricherSpec* enricher_by_id(const char* id) {
if (!id || !*id) return nullptr;
for (const auto& e : g_enrichers) {
if (e.id == id) return &e;
}
return nullptr;
}
} // namespace ge