Files
qwen3-6-lora/scripts/07_lint_penpot_code.py
T
aleleba 7a1577b9d7 Phase 6.4.25c: correct flex.appendChild -- live verification shows it works
Live probing against Penpot 2.16.2 (repeated, on a dedicated scratch page,
cleaned up afterwards) shows board.flex.appendChild(shape) is a real
function, distinct from board.appendChild, arity 1, throws nothing, and
preserves visual insertion order -- unlike board.appendChild, which still
inserts at the front as documented. The prior "broken" claim traces to the
MCP server's own high_level_overview() text (CRITICAL / BROKEN / NEVER use),
never verified with a live throw, and possibly confused with a real doc bug
that mislabels the grid 3-arg example as board.flex.

Removes flex.appendChild from the lint's FORBIDDEN patterns and from the
gate 5 forbidden-behavior veto, updates PENPOT_API_VERIFIED.md with the
verified facts, and drops the now-obsolete pattern from the replay filters
in 05_build_dataset.py and 07_build_lora2_mix.py.
2026-08-04 17:40:16 +00:00

668 lines
31 KiB
Python

"""Fase 6: lint estatico del bucket de Penpot. Hard-fail sobre patrones de API prohibidos,
grises de placeholder, tool results fabricados y desbalance de cobertura.
Corre LOCALMENTE. No necesita GPU, ni el modelo, ni el tokenizer -- es a proposito: tiene que
poder correrse cientos de veces mientras se escriben los seeds, sin depender de spark.
python3 scripts/07_lint_penpot_code.py [archivo.jsonl ...]
Sin argumentos lintea data/raw/seeds/penpot.jsonl. Con argumentos lintea los archivos dados
(util para lintear las partes de data/raw/seeds/_parts/ antes de concatenarlas).
POR QUE EXISTE
--------------
Los 41 seeds viejos ensenaban tres formas de API que no existen (findShapeById de 2 argumentos,
shape.layout, createText() sin argumento) y contenian resultados de penpot_api_info fabricados a
mano que afirmaban hechos falsos como si los hubiera dicho el servidor. Nada lo detectaba: el
dataset se veia perfectamente valido. Este script convierte cada una de esas fallas en un error
mecanico, para que un corpus a medio autorar falle ruidosamente en vez de reproducir el problema.
`node --check` es opcional: si node no esta instalado se avisa y se saltea esa verificacion (el
resto del lint corre igual). Con node presente, cada payload se envuelve en una funcion async y
se verifica que parsee.
"""
import json
import re
import shutil
import subprocess
import sys
import tempfile
from collections import Counter
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
SCHEMAS = REPO_ROOT / "data" / "schemas"
API_DOCS = SCHEMAS / "penpot_api_docs.md"
API_ERRORS = SCHEMAS / "penpot_errors.md"
SYSTEM_PROMPT = SCHEMAS / "penpot_system_prompt.md"
PENPOT_TOOLS = SCHEMAS / "penpot.json"
DEFAULT_TARGET = REPO_ROOT / "data" / "raw" / "seeds" / "penpot.jsonl"
# Los tool results de execute_code de este grupo muestran, a proposito, un estado inicial lleno
# de grises de placeholder: es el INPUT que el asistente audita y repara. Es el unico lugar donde
# un gris puede aparecer, y nunca en el `code` que el asistente escribe.
GREY_INPUT_GROUP = "B8"
# Umbrales de cobertura. Existen para que un corpus a medio autorar falle: sin esto, es
# perfectamente posible escribir 96 seeds que reproduzcan el desbalance actual (1 addFlexLayout,
# 0 addGridLayout, 0 layoutChild) y que el lint pase igual.
COVERAGE_MIN = {
"addGridLayout": 5,
"shadows": 8,
"uploadMediaUrl": 6,
"layoutChild.horizontalSizing": 6,
"addFlexLayout": 12,
"borderRadius": 8,
"fillColorGradient": 4,
"library.local": 5,
"export_shape": 8,
}
MIN_UNIQUE_PAYLOADS = 130
MAX_HIGH_LEVEL_OVERVIEW_CALLS = 2
SYSTEM_SHARE_RANGE = (0.20, 0.45) # ~30% objetivo, con margen
UUID4_RE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$", re.I)
HEX_RE = re.compile(r"#[0-9a-fA-F]{6}\b")
TOY_ID_RE = re.compile(r"^(r|fb|s|shape|board|txt|t)[-_]?\d{1,3}$", re.I)
# --------------------------------------------------------------------------------------------
# Patrones prohibidos en los payloads de `code`
# --------------------------------------------------------------------------------------------
# Cada entrada: (nombre, regex, explicacion). La explicacion se imprime con el hallazgo, porque
# un lint que solo dice "linea 42: PATRON_7" obliga a ir a leer el lint para entender el error.
FORBIDDEN = [
(
"findShapeById con 2 argumentos",
re.compile(r"findShapeById\s*\([^)]*,"),
"findShapeById tiene aridad 1. La forma de 2 argumentos devuelve null SIN lanzar, y "
"revienta en la linea siguiente. Usar findShapeById(id).",
),
(
"propiedad .layout inexistente",
re.compile(r"\.layout\b(?!Child|Cell)"),
"shape.layout no existe ('layout' in shape === false). Son board.flex y board.grid. "
"La clave `layout` que ves en la salida de shapeStructure() es del OUTPUT del helper, "
"no una propiedad del shape.",
),
(
"fontSize/fontWeight/lineHeight/letterSpacing numerico",
re.compile(r"\.(fontSize|fontWeight|lineHeight|letterSpacing)\s*=\s*-?\d"),
"Son strings: fontSize = '48', no fontSize = 48.",
),
(
"textAlign",
re.compile(r"\.textAlign\s*="),
"Asignar textAlign LANZA (`Cannot add property textAlign, object is not extensible`) y "
"mata todo el execute_code. La propiedad es `align`.",
),
(
"propiedad color en Text",
re.compile(r"\b(text|txt|t|label|title|heading)\d*\s*\.\s*color\s*="),
"Text no tiene propiedad `color` y el objeto no es extensible: lanza. El color va en fills.",
),
(
"import_image / importImage / createImage / filePath",
re.compile(r"\b(importImage|import_image|createImage\s*\(|filePath)\b"),
"No existen en este deployment. Imagenes: await penpot.uploadMediaUrl(name, url).",
),
(
"asignacion a propiedad read-only",
re.compile(r"\.(width|height|parentX|parentY|boardX|boardY|bounds)\s*=(?!=)"),
"width/height/parentX/parentY/boardX/boardY/bounds son READ-ONLY. Usar resize(w,h) y "
"penpotUtils.setParentXY(shape, x, y).",
),
(
"shorthand gap",
re.compile(r"\.gap\s*="),
"No existe el shorthand `gap`. Son rowGap y columnGap.",
),
(
"Shadow.color como Fill",
re.compile(r"shadows\s*=\s*\[[^\]]*?color\s*:\s*\{[^}]*fillColor"),
"Shadow.color es un Color ({color, opacity}), no un Fill ({fillColor, fillOpacity}).",
),
(
"typography.setFont",
re.compile(r"\.setFont\s*\("),
"setFont figura en el tipo pero NO existe en el runtime (`t.setFont is not a function`). "
"Setear las propiedades de tipografia una por una.",
),
(
"createText sin argumento util",
re.compile(r"createText\s*\(\s*(''|\"\")?\s*\)"),
"createText() sin argumento y createText('') devuelven null. Es la forma que usa el "
"ejemplo de la documentacion oficial, y es la causa directa de la falla en produccion.",
),
(
"generateStyle con withChildren",
re.compile(r"withChildren\s*:"),
"La opcion real es `includeChildren`. `withChildren` (que usa el overview) se ignora en "
"silencio y el CSS sale sin los hijos.",
),
(
"layoutChild.horizontalSizing = 'fixed' o 'fit-content'",
re.compile(r"layoutChild\s*\.\s*(horizontal|vertical)Sizing\s*=\s*['\"](fixed|fit-content)['\"]"),
"Los valores de layoutChild son 'fill' | 'auto' | 'fix'. 'fit-content' es vocabulario de "
"FlexLayout (aplica al board contenedor), y 'fixed' no existe en ninguno de los dos.",
),
]
# `appendChild` solo es correcto sobre un board con flex (o `grid.appendChild(s, r, c)`). Sobre un
# padre sin layout hay que usar insertChild. No se puede resolver estaticamente en general, asi que
# la heuristica es exigir evidencia de flex.
#
# El alcance de esa evidencia es EL SEED ENTERO, no el payload. Un seed multi-llamada arma el board
# con flex en la primera llamada y guarda los helpers en `storage`; para cuando la segunda llamada
# hace `main.appendChild(...)`, ni `addFlexLayout(` ni `.flex` aparecen en ese payload. Buscar solo
# dentro del payload marcaba como error justo el patron de persistencia en `storage` que la
# descripcion de `execute_code` pide explicitamente.
APPEND_ONE_ARG_RE = re.compile(r"(\w+)\s*\.\s*appendChild\s*\(\s*[^,()]+\s*\)")
FLEX_EVIDENCE_RE = re.compile(r"addFlexLayout\s*\(|\.flex\b")
GRID_APPEND_RE = re.compile(r"\.grid\s*\.\s*appendChild\s*\(")
# Un gris en el `code` es un problema cuando se APLICA, no cuando se BUSCA. Los seeds de reparacion
# del grupo B8 tienen que poder detectar los grises que van a reemplazar, y para eso necesitan
# nombrarlos. Se permite unicamente en ese grupo y solo en contexto de comparacion/pertenencia.
GREY_COMPARISON_RE = re.compile(r"===|==|!==|!=|\.includes\s*\(|\.indexOf\s*\(|\.some\s*\(|\.has\s*\(")
def is_placeholder_grey(hex_str):
"""R2: saturacion <= 10 Y 100 <= max <= 220. Los casi-negros y casi-blancos pasan."""
r = int(hex_str[1:3], 16)
g = int(hex_str[3:5], 16)
b = int(hex_str[5:7], 16)
mx, mn = max(r, g, b), min(r, g, b)
sat = 0 if mx == 0 else 100.0 * (mx - mn) / mx
return sat <= 10 and 100 <= mx <= 220
def load_jsonl(path):
rows = []
with open(path, encoding="utf-8") as f:
for lineno, raw in enumerate(f, start=1):
raw = raw.strip()
if not raw:
continue
try:
rows.append((lineno, json.loads(raw), len(raw)))
except json.JSONDecodeError as e:
raise SystemExit(f"[FATAL] {path.name}:{lineno}: JSON invalido: {e}")
return rows
def load_doc_lines(path):
"""Todas las lineas no vacias del archivo de captura, para el chequeo de subconjunto."""
return [ln.rstrip() for ln in path.read_text(encoding="utf-8").splitlines() if ln.strip()]
def extract_system_block():
"""El bloque verbatim de penpot_system_prompt.md, seccion 'Bloque completo, verbatim'."""
text = SYSTEM_PROMPT.read_text(encoding="utf-8")
marker = "## Bloque completo, verbatim"
idx = text.index(marker)
fence_start = text.index("```", idx) + 3
fence_start = text.index("\n", fence_start) + 1
fence_end = text.index("```", fence_start)
return text[fence_start:fence_end].rstrip("\n")
def extract_error_strings():
"""Los strings de error verbatim de penpot_errors.md: todo `codigo` dentro de una celda de
tabla de la columna 'Mensaje verbatim', mas los de las tablas de fallos silenciosos."""
text = API_ERRORS.read_text(encoding="utf-8")
out = set()
for line in text.splitlines():
if not line.startswith("|"):
continue
cells = [c.strip() for c in line.strip().strip("|").split("|")]
for cell in cells:
for m in re.finditer(r"`([^`]+)`", cell):
out.add(m.group(1))
return out
def extract_return_keys(code):
"""Claves de primer nivel del ultimo `return { ... }` del payload.
Devuelve None si el payload no termina en un return de objeto literal (p.ej. retorna un
array, una expresion, o nada) -- en ese caso el chequeo de claves no aplica.
"""
idx = code.rfind("return {")
if idx == -1:
idx = code.rfind("return{")
if idx == -1:
return None
start = code.index("{", idx)
depth = 0
keys = []
i = start
in_str = None
buf_depth_zero = []
while i < len(code):
ch = code[i]
if in_str:
if ch == "\\":
i += 2
continue
if ch == in_str:
in_str = None
elif ch in "\"'`":
in_str = ch
elif ch in "{[(":
depth += 1
elif ch in "}])":
depth -= 1
if depth == 0 and ch == "}":
break
elif depth == 1:
buf_depth_zero.append((i, ch))
i += 1
if depth != 0:
return None
# Reconstruir el nivel 1 y sacar las claves antes de cada ':'
level1 = "".join(ch for _, ch in buf_depth_zero)
for part in level1.split(","):
if ":" not in part:
# shorthand `{ foo, bar }`
name = part.strip()
if re.fullmatch(r"[A-Za-z_$][\w$]*", name):
keys.append(name)
continue
key = part.split(":", 1)[0].strip().strip("'\"")
if re.fullmatch(r"[A-Za-z_$][\w$]*", key):
keys.append(key)
return keys or None
def node_check(payloads):
"""Verifica que cada payload parsee, envuelto en una funcion async (el servidor evalua el
codigo como cuerpo de funcion, por eso `await` y `return` de nivel superior son validos)."""
node = shutil.which("node")
if not node:
print("[WARN] node no esta instalado -- se saltea la verificacion de sintaxis JS")
return []
problems = []
with tempfile.TemporaryDirectory() as td:
for tag, code in payloads:
f = Path(td) / "chk.mjs"
f.write_text("async function __penpot_payload__(){\n" + code + "\n}\n", encoding="utf-8")
r = subprocess.run([node, "--check", str(f)], capture_output=True, text=True)
if r.returncode != 0:
first = (r.stderr.strip().splitlines() or ["error desconocido"])
msg = next((ln for ln in first if "SyntaxError" in ln), first[-1])
problems.append(f"{tag}: node --check fallo: {msg.strip()}")
return problems
def main():
targets = [Path(a) for a in sys.argv[1:]] or [DEFAULT_TARGET]
for p in targets:
if not p.exists():
raise SystemExit(f"[FATAL] no existe: {p}")
doc_lines = set(load_doc_lines(API_DOCS))
doc_order = load_doc_lines(API_DOCS)
doc_index = {}
for i, ln in enumerate(doc_order):
doc_index.setdefault(ln, []).append(i)
error_strings = extract_error_strings()
system_block = extract_system_block()
canonical_tools = {t["name"]: t for t in json.loads(PENPOT_TOOLS.read_text(encoding="utf-8"))}
problems = []
warnings = []
payloads = [] # (tag, code) para node --check
payload_texts = [] # para unicidad
coverage = Counter()
n_seeds = 0
n_with_system = 0
n_overview_calls = 0
n_intentional_mistakes = 0
max_line_len = 0
grupo_counts = Counter()
for path in targets:
for lineno, ex, raw_len in load_jsonl(path):
n_seeds += 1
tag = f"{path.name}:{lineno}"
max_line_len = max(max_line_len, raw_len)
grupo = ex.get("meta", {}).get("grupo", "?")
grupo_counts[grupo] += 1
if ex.get("meta", {}).get("bucket") != "penpot":
problems.append(f"{tag}: meta.bucket debe ser 'penpot'")
msgs = ex.get("messages", [])
if not msgs:
problems.append(f"{tag}: sin messages")
continue
# ---- mensaje system verbatim -------------------------------------------------
if msgs[0].get("role") == "system":
n_with_system += 1
if msgs[0].get("content", "").strip() != system_block.strip():
problems.append(
f"{tag}: el mensaje system no es el bloque verbatim de "
f"penpot_system_prompt.md (parafraseado o recortado)"
)
# ---- tools copiadas byte a byte ----------------------------------------------
for tool in ex.get("tools") or []:
name = tool.get("name")
if name not in canonical_tools:
problems.append(f"{tag}: tool '{name}' no existe en data/schemas/penpot.json")
continue
canon = canonical_tools[name]
if tool.get("description") != canon.get("description"):
problems.append(
f"{tag}: la description de la tool '{name}' fue recortada o reformulada; "
f"tiene que ser byte a byte la de penpot.json"
)
if tool.get("parameters") != canon.get("parameters"):
problems.append(f"{tag}: el JSON Schema de '{name}' no coincide con penpot.json")
# ---- biyeccion tool_call_id <-> mensajes tool --------------------------------
call_ids, result_ids = [], []
for m in msgs:
for tc in m.get("tool_calls") or []:
call_ids.append(tc.get("id"))
if m.get("role") == "tool":
result_ids.append(m.get("tool_call_id"))
if sorted(filter(None, call_ids)) != sorted(filter(None, result_ids)):
problems.append(
f"{tag}: los tool_call_id no son una biyeccion "
f"(llamadas={call_ids} resultados={result_ids})"
)
if len(set(call_ids)) != len(call_ids):
problems.append(f"{tag}: tool_call_id duplicados: {call_ids}")
# ---- assistant: reasoning_content y content --------------------------------
for i, m in enumerate(msgs):
if m.get("role") != "assistant":
continue
if not (m.get("reasoning_content") or "").strip():
problems.append(f"{tag}: turno assistant #{i} sin reasoning_content")
if m.get("tool_calls") and (m.get("content") or "") != "":
problems.append(
f"{tag}: turno assistant #{i} tiene tool_calls y content no vacio "
f"(el content visible va en el ULTIMO assistant, sin tool_calls)"
)
if msgs[-1].get("role") != "assistant" or msgs[-1].get("tool_calls"):
problems.append(f"{tag}: la trayectoria no cierra con un assistant sin tool_calls")
# ---- payloads de code ---------------------------------------------------------
# Los seeds de recuperacion de error (grupos A1 y D) TIENEN que contener el patron
# equivocado: es el material que ensena a diagnosticarlo. La excepcion no se declara
# a mano con una bandera en meta (eso seria un escape hatch que apaga el lint), se
# deriva mecanicamente: un payload puede traer un patron prohibido si y solo si
# (a) su tool result es un string de error de la allow-list, y
# (b) un payload POSTERIOR del mismo seed hace lo mismo SIN el patron.
# Es decir: el error tiene que haber sido real y tiene que haber sido corregido.
error_result_ids = {
m.get("tool_call_id")
for m in msgs
if m.get("role") == "tool" and (m.get("content") or "").strip() in error_strings
}
ordered_calls = []
for m in msgs:
for tc in m.get("tool_calls") or []:
if tc.get("function", {}).get("name") == "execute_code":
args = tc.get("function", {}).get("arguments")
ordered_calls.append(
(tc.get("id"), args.get("code", "") if isinstance(args, dict) else "")
)
def is_corrected_mistake(call_id, rx):
"""El patron aparece en una llamada que fallo, y una llamada posterior lo evita."""
if call_id not in error_result_ids:
return False
seen = False
for cid, code_ in ordered_calls:
if seen and not rx.search(code_):
return True
if cid == call_id:
seen = True
return False
seed_code_blob = "\n".join(code for _, code in ordered_calls)
seed_has_flex = bool(FLEX_EVIDENCE_RE.search(seed_code_blob))
code_by_call = {}
for m in msgs:
for tc in m.get("tool_calls") or []:
fn = tc.get("function", {})
args = fn.get("arguments")
if not isinstance(args, dict):
problems.append(
f"{tag}: tool_calls.function.arguments tiene que ser un dict JSON, "
f"no {type(args).__name__}"
)
continue
if fn.get("name") == "high_level_overview":
n_overview_calls += 1
if fn.get("name") == "export_shape":
coverage["export_shape"] += 1
for k in args:
if k not in ("shapeId", "format", "mode"):
problems.append(
f"{tag}: export_shape con el argumento inventado '{k}' "
f"(el schema solo acepta shapeId/format/mode)"
)
if fn.get("name") != "execute_code":
continue
code = args.get("code", "")
code_by_call[tc.get("id")] = code
payloads.append((tag, code))
payload_texts.append(code)
# patrones prohibidos
for pname, rx, why in FORBIDDEN:
if not rx.search(code):
continue
if is_corrected_mistake(tc.get("id"), rx):
n_intentional_mistakes += 1
continue
for mt in rx.finditer(code):
frag = code[max(0, mt.start() - 30):mt.end() + 30].replace("\n", " ")
problems.append(f"{tag}: [{pname}] ...{frag}...\n -> {why}")
# appendChild sobre receptor sin evidencia de flex
if (APPEND_ONE_ARG_RE.search(code) and not seed_has_flex
and not GRID_APPEND_RE.search(code)):
problems.append(
f"{tag}: usa `X.appendChild(shape)` sin ninguna evidencia de flex en el "
f"payload. appendChild de 1 argumento solo es correcto en boards con "
f"flex; para un padre sin layout va "
f"parent.insertChild(parent.children.length, shape)."
)
# console.log de algo que tambien se retorna
ret_keys = extract_return_keys(code) or []
for logged in re.findall(r"console\.log\s*\(\s*([A-Za-z_$][\w$]*)", code):
if logged in ret_keys:
problems.append(
f"{tag}: console.log('{logged}') de algo que tambien se retorna "
f"-- el servidor lo prohibe explicitamente (llega duplicado)"
)
# R2: grises de placeholder en el codigo. Un gris que se APLICA es siempre un
# error; uno que se BUSCA es legitimo, y solo en el grupo de reparacion, que
# necesita nombrar los grises que va a reemplazar.
for code_line in code.split("\n"):
for hx in HEX_RE.findall(code_line):
if not is_placeholder_grey(hx):
continue
if grupo == GREY_INPUT_GROUP and GREY_COMPARISON_RE.search(code_line):
continue
problems.append(
f"{tag}: gris de placeholder {hx} aplicado en el `code` "
f"(invariante R2). El gris solo puede aparecer como INPUT en los "
f"tool results del grupo {GREY_INPUT_GROUP}, o nombrado en una "
f"comparacion dentro de ese mismo grupo para detectarlo."
)
# cobertura
for key in ("addGridLayout", "addFlexLayout", "uploadMediaUrl",
"borderRadius", "fillColorGradient"):
if key in code:
coverage[key] += 1
if re.search(r"shadows\s*=", code):
coverage["shadows"] += 1
if re.search(r"layoutChild\s*\.\s*horizontalSizing", code):
coverage["layoutChild.horizontalSizing"] += 1
if "library.local" in code:
coverage["library.local"] += 1
# ---- tool results -------------------------------------------------------------
for m in msgs:
if m.get("role") != "tool":
continue
name = m.get("name")
content = m.get("content") or ""
cid = m.get("tool_call_id")
if name in ("penpot_api_info", "high_level_overview"):
# subconjunto de lineas de la captura, en orden original
lines = [ln.rstrip() for ln in content.splitlines() if ln.strip()]
missing = [ln for ln in lines if ln not in doc_lines]
if missing:
problems.append(
f"{tag}: resultado de {name} con {len(missing)} linea(s) ausentes de "
f"penpot_api_docs.md -- documentacion FABRICADA. Primera: "
f"{missing[0][:110]!r}"
)
else:
last = -1
for ln in lines:
positions = [i for i in doc_index[ln] if i > last]
if not positions:
problems.append(
f"{tag}: resultado de {name} con lineas fuera del orden "
f"original de la captura (en {ln[:70]!r})"
)
break
last = positions[0]
elif name == "execute_code":
code = code_by_call.get(cid)
stripped = content.strip()
looks_json = stripped.startswith("{") or stripped.startswith("[")
if not looks_json:
# tiene que ser un error real de la allow-list
if stripped not in error_strings:
problems.append(
f"{tag}: resultado de execute_code que no es JSON y no esta en "
f"penpot_errors.md: {stripped[:110]!r}"
)
else:
try:
parsed = json.loads(stripped)
except json.JSONDecodeError as e:
problems.append(f"{tag}: resultado de execute_code con JSON invalido: {e}")
parsed = None
if isinstance(parsed, dict) and code is not None:
ret_keys = extract_return_keys(code)
if ret_keys is not None:
if sorted(parsed.keys()) != sorted(ret_keys):
problems.append(
f"{tag}: las claves del resultado {sorted(parsed.keys())} "
f"no coinciden con las del `return {{...}}` del payload "
f"{sorted(ret_keys)}"
)
# ids de juguete
for val in re.findall(r'"([^"]{1,24})"', stripped):
if TOY_ID_RE.fullmatch(val):
warnings.append(
f"{tag}: id con forma de juguete {val!r} en un tool result; "
f"los ids reales tienen forma de UUIDv4"
)
for val in re.findall(r'"(?:id|shapeId|boardId|rootId)"\s*:\s*"([^"]+)"', stripped):
if not UUID4_RE.match(val):
problems.append(
f"{tag}: id {val!r} en un tool result no tiene forma de UUIDv4 "
f"-- entrena al modelo a esperar ids de juguete"
)
# R2 en tool results: solo el grupo de reparacion puede traer grises
if grupo != GREY_INPUT_GROUP:
for hx in HEX_RE.findall(content):
if is_placeholder_grey(hx):
problems.append(
f"{tag}: gris de placeholder {hx} en un tool result del grupo "
f"{grupo}; solo el grupo {GREY_INPUT_GROUP} puede traerlos, "
f"como estado inicial a reparar"
)
# ---- chequeos globales -----------------------------------------------------------------
dupes = [c for c, n in Counter(payload_texts).items() if n > 1]
if dupes:
problems.append(
f"[GLOBAL] {len(dupes)} payload(s) de `code` duplicados exactos. Primero: "
f"{dupes[0][:120]!r}"
)
unique_payloads = len(set(payload_texts))
is_full_corpus = len(targets) == 1 and targets[0].resolve() == DEFAULT_TARGET.resolve()
print(f"\n=== SEEDS: {n_seeds} | payloads de code: {len(payload_texts)} "
f"(unicos: {unique_payloads}) ===")
print(f"=== linea mas larga: {max_line_len} caracteres ===")
print(f"=== grupos: {dict(sorted(grupo_counts.items()))} ===")
share = (n_with_system / n_seeds) if n_seeds else 0
print(f"=== seeds con mensaje system: {n_with_system}/{n_seeds} ({share:.0%}) ===")
print(f"=== errores intencionales (patron prohibido + error real + correccion posterior): "
f"{n_intentional_mistakes} ===")
print("=== cobertura por categoria ===")
for key, minimum in sorted(COVERAGE_MIN.items()):
got = coverage[key]
mark = "ok " if got >= minimum else "BAJO"
print(f" [{mark}] {key}: {got} (minimo {minimum})")
if is_full_corpus:
for key, minimum in COVERAGE_MIN.items():
if coverage[key] < minimum:
problems.append(
f"[COBERTURA] '{key}': {coverage[key]} seeds, minimo {minimum}. Un corpus a "
f"medio autorar reproduce el desbalance actual sin que nada lo note."
)
if unique_payloads < MIN_UNIQUE_PAYLOADS:
problems.append(
f"[GLOBAL] solo {unique_payloads} payloads unicos de execute_code, minimo "
f"{MIN_UNIQUE_PAYLOADS} (hoy el dataset viejo tiene 36)"
)
if not SYSTEM_SHARE_RANGE[0] <= share <= SYSTEM_SHARE_RANGE[1]:
problems.append(
f"[GLOBAL] share de seeds con mensaje system = {share:.0%}, fuera del rango "
f"{SYSTEM_SHARE_RANGE[0]:.0%}-{SYSTEM_SHARE_RANGE[1]:.0%} (objetivo ~30%)"
)
if n_overview_calls > MAX_HIGH_LEVEL_OVERVIEW_CALLS:
problems.append(
f"[GLOBAL] {n_overview_calls} llamadas a high_level_overview, maximo "
f"{MAX_HIGH_LEVEL_OVERVIEW_CALLS} (su propia descripcion prohibe llamarlo dos veces)"
)
else:
print("[INFO] modo parcial (no se lintea el corpus completo): los umbrales globales de "
"cobertura, unicidad y share de system NO se aplican")
problems.extend(node_check(payloads))
if warnings:
print(f"\n=== {len(warnings)} ADVERTENCIA(S) ===")
for w in warnings[:40]:
print(f" ! {w}")
if problems:
print(f"\n=== {len(problems)} PROBLEMA(S) ===")
for p in problems:
print(f" - {p}")
print(f"\n[LINT FAIL] {len(problems)} problema(s)")
sys.exit(1)
print("\n[LINT OK] 0 problemas")
sys.exit(0)
if __name__ == "__main__":
main()