Phase 6: train a second LoRA for real Penpot UI design capability #5
@@ -0,0 +1,382 @@
|
||||
"""Fase 6: ensambla la mezcla de entrenamiento del LoRA #2 (diseno en Penpot).
|
||||
|
||||
Corre LOCALMENTE (sin GPU, sin modelo, sin tokenizer). Determinista.
|
||||
|
||||
python3 scripts/07_build_lora2_mix.py
|
||||
|
||||
Produce tres archivos NUEVOS -- nunca toca data/train.jsonl ni data/eval.jsonl:
|
||||
|
||||
data/train_lora2.jsonl 900 ejemplos
|
||||
data/eval_lora2.jsonl ~100 ejemplos, estratificado por porcion
|
||||
data/calibration_v2.jsonl 256 ejemplos para la calibracion NVFP4
|
||||
|
||||
POR QUE data/train.jsonl QUEDA CONGELADO
|
||||
----------------------------------------
|
||||
Es la procedencia exacta del modelo que esta hoy en produccion y el baseline de la puerta 1
|
||||
(0.2750). Ademas su regeneracion NO es idempotente: `stratified_split()` de 05_build_dataset.py
|
||||
mezcla con un unico RNG sobre la lista concatenada, asi que cambiar el bucket de penpot
|
||||
reshufflearia el split de TODOS los buckets y el eval.jsonl dejaria de ser comparable. Este
|
||||
script lo LEE para muestrear replay, nunca lo escribe.
|
||||
|
||||
LA MEZCLA
|
||||
---------
|
||||
Porcion n %
|
||||
-------------------------------------------------------
|
||||
Diseno penpot nuevo (grupos B) 300 33.3
|
||||
Correcciones explicitas de API 120 13.3
|
||||
- forma correcta (grupos A, B9, B10) 60
|
||||
- recuperacion de error (grupo D) 40
|
||||
- preguntar a la API (grupo C) 20
|
||||
Replay de capacidad existente 480 53.3
|
||||
- replay general/coding 180
|
||||
- otros_mcps 120
|
||||
- skills_adherencia 70
|
||||
- penpot basicos YA CORREGIDOS 45
|
||||
- negativos/delegacion/manejo_errores 65
|
||||
|
||||
**53% de replay es deliberadamente conservador.** La banda razonable para replay en SFT
|
||||
secuencial es 20-50%; la restriccion del usuario es "sin perder nada actual" y la capacidad
|
||||
nueva es angosta, asi que se paga algo de velocidad de aprendizaje por anclaje.
|
||||
|
||||
**Dentro del replay se desvia a proposito de las proporciones de la v1** (37.5% de `replay`
|
||||
generico en vez de 49%, subiendo `otros_mcps` y `skills_adherencia`). La razon: la habilidad
|
||||
generica de chat/codigo ya esta anclada por el modelo base y no es lo que un gradiente
|
||||
penpot-heavy erosiona. Lo fragil es el delta fine-tuneado -- convenciones de tool-call de los
|
||||
otros MCPs y adherencia a skills -- que es justo lo que miden las puertas 2 y 3.
|
||||
|
||||
LOS 45 "PENPOT BASICOS" NO SALEN DE train.jsonl
|
||||
-----------------------------------------------
|
||||
El plan los lista dentro del replay, pero no pueden venir de `data/train.jsonl`: 21 de sus 41
|
||||
seeds de penpot ensenan `findShapeById(page, id)` y 5 usan `shape.layout`. Muestrear ese bucket
|
||||
seria re-ensenar exactamente el bug que la fase corrige (riesgo #13). El filtro de patrones
|
||||
prohibidos los eliminaria de todos modos; sacarlos del corpus NUEVO (los seeds simples de los
|
||||
grupos A y C, que cumplen el mismo rol de "penpot basico" pero con la API correcta) es la unica
|
||||
lectura coherente. Queda registrado aca porque es una desviacion consciente del texto del plan.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import sys
|
||||
from collections import Counter, defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
SEEDS_PENPOT = REPO_ROOT / "data" / "raw" / "seeds" / "penpot.jsonl"
|
||||
REPLAY_SOURCE = REPO_ROOT / "data" / "train.jsonl"
|
||||
TRAIN_OUT = Path(os.environ.get("TRAIN_LORA2_OUT", REPO_ROOT / "data" / "train_lora2.jsonl"))
|
||||
EVAL_OUT = Path(os.environ.get("EVAL_LORA2_OUT", REPO_ROOT / "data" / "eval_lora2.jsonl"))
|
||||
CALIB_OUT = Path(os.environ.get("CALIB_V2_OUT", REPO_ROOT / "data" / "calibration_v2.jsonl"))
|
||||
|
||||
SEED = 6006
|
||||
N_TRAIN = 900
|
||||
EVAL_FRACTION = 0.10
|
||||
N_CALIBRATION = 256
|
||||
|
||||
# grupo de seed -> porcion de la mezcla
|
||||
PORTION_OF_GROUP = {
|
||||
"A1": "api_forma_correcta", "A2": "api_forma_correcta", "A3": "api_forma_correcta",
|
||||
"A4": "api_forma_correcta", "A5": "api_forma_correcta",
|
||||
"B9": "api_forma_correcta", "B10": "api_forma_correcta",
|
||||
"B1": "diseno", "B2": "diseno", "B3": "diseno", "B4": "diseno",
|
||||
"B5": "diseno", "B6": "diseno", "B7": "diseno", "B8": "diseno",
|
||||
"C": "api_preguntar",
|
||||
"D": "api_recuperacion",
|
||||
}
|
||||
|
||||
PORTION_TARGETS = {
|
||||
"diseno": 300,
|
||||
"api_forma_correcta": 60,
|
||||
"api_recuperacion": 40,
|
||||
"api_preguntar": 20,
|
||||
}
|
||||
|
||||
# Replay: bucket de origen en data/train.jsonl -> cuantos ejemplos tomar.
|
||||
REPLAY_TARGETS = {
|
||||
"replay": 180,
|
||||
"otros_mcps": 120,
|
||||
"skills_adherencia": 70,
|
||||
"negativos_delegacion_errores": 65, # union de los tres buckets chicos
|
||||
}
|
||||
REPLAY_UNION_BUCKETS = ("negativos", "delegacion_subagentes", "manejo_errores")
|
||||
N_PENPOT_BASICOS = 45
|
||||
|
||||
# Patrones prohibidos. La fuente de verdad es scripts/07_lint_penpot_code.py; aca se re-declaran
|
||||
# los que aplican al SCAN de replay (no hace falta el set completo: lo que se busca es descartar
|
||||
# filas de train.jsonl que ensenen la API vieja).
|
||||
FORBIDDEN_REPLAY = [
|
||||
("findShapeById 2 args", re.compile(r"findShapeById\s*\([^)]*,")),
|
||||
(".layout inexistente", re.compile(r"\.layout\b(?!Child|Cell)")),
|
||||
("flex.appendChild", re.compile(r"\.flex\s*\.\s*appendChild\s*\(")),
|
||||
("createText sin argumento", re.compile(r"createText\s*\(\s*(''|\"\")?\s*\)")),
|
||||
("textAlign", re.compile(r"\.textAlign\s*=")),
|
||||
("importImage/createImage/filePath", re.compile(r"\b(importImage|import_image|createImage\s*\(|filePath)\b")),
|
||||
("setFont", re.compile(r"\.setFont\s*\(")),
|
||||
("fontSize numerico", re.compile(r"\.(fontSize|fontWeight|lineHeight|letterSpacing)\s*=\s*-?\d")),
|
||||
]
|
||||
|
||||
|
||||
def load_jsonl(path):
|
||||
out = []
|
||||
with open(path, encoding="utf-8") as f:
|
||||
for lineno, line in enumerate(f, start=1):
|
||||
line = line.strip()
|
||||
if line:
|
||||
out.append((lineno, json.loads(line)))
|
||||
return out
|
||||
|
||||
|
||||
def has_forbidden(example):
|
||||
"""True si el ejemplo serializado contiene algun patron de la API vieja."""
|
||||
blob = json.dumps(example, ensure_ascii=False)
|
||||
for _, rx in FORBIDDEN_REPLAY:
|
||||
if rx.search(blob):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def forbidden_hits(example):
|
||||
blob = json.dumps(example, ensure_ascii=False)
|
||||
return [name for name, rx in FORBIDDEN_REPLAY if rx.search(blob)]
|
||||
|
||||
|
||||
def paraphrase_variants(seed_ex):
|
||||
"""Expande un seed en sus variantes de prompt.
|
||||
|
||||
La variacion viene de `meta.paraphrases` -- reescrituras A MANO del turno del usuario -- y
|
||||
nunca de sustitucion automatica de valores. Es la leccion de la v1: `perturb_value()`
|
||||
reescribia solo `tool_calls.arguments` y dejaba los tool results y la respuesta final
|
||||
diciendo otra cosa, produciendo 30 ejemplos auto-contradictorios. Un payload de `code` de
|
||||
Penpot perturbado, ademas, es simplemente codigo roto.
|
||||
"""
|
||||
variants = [seed_ex]
|
||||
paraphrases = seed_ex.get("meta", {}).get("paraphrases") or []
|
||||
for text in paraphrases:
|
||||
clone = json.loads(json.dumps(seed_ex, ensure_ascii=False))
|
||||
for msg in clone["messages"]:
|
||||
if msg.get("role") == "user":
|
||||
msg["content"] = text
|
||||
break
|
||||
clone.setdefault("meta", {})["variation"] = "paraphrase"
|
||||
variants.append(clone)
|
||||
return variants
|
||||
|
||||
|
||||
def fill_to(pool, target, rng, label):
|
||||
"""Cicla sobre el pool (barajado por pasada) hasta llegar a `target` ejemplos."""
|
||||
if not pool:
|
||||
raise SystemExit(f"[ABORT] la porcion '{label}' no tiene ningun ejemplo de origen")
|
||||
out = []
|
||||
order = list(range(len(pool)))
|
||||
n_pass = 0
|
||||
while len(out) < target:
|
||||
if n_pass > 0:
|
||||
rng.shuffle(order)
|
||||
for i in order:
|
||||
if len(out) >= target:
|
||||
break
|
||||
ex = json.loads(json.dumps(pool[i], ensure_ascii=False))
|
||||
ex.setdefault("meta", {})["porcion"] = label
|
||||
if n_pass > 0:
|
||||
ex["meta"]["repeticion"] = n_pass
|
||||
out.append(ex)
|
||||
n_pass += 1
|
||||
print(f"[INFO] porcion '{label}': {len(pool)} ejemplos de origen -> {len(out)} "
|
||||
f"({n_pass} pasada(s))")
|
||||
return out
|
||||
|
||||
|
||||
def sample_replay(rows, target, rng, label):
|
||||
"""Muestra determinista SIN repeticion, ya filtrada de patrones prohibidos."""
|
||||
pool = [ex for _, ex in rows]
|
||||
if len(pool) < target:
|
||||
print(f"[WARN] replay '{label}': solo hay {len(pool)} filas limpias para un target de "
|
||||
f"{target}; se toman todas y se completa ciclando")
|
||||
return fill_to(pool, target, rng, f"replay_{label}")
|
||||
idx = list(range(len(pool)))
|
||||
rng.shuffle(idx)
|
||||
out = []
|
||||
for i in idx[:target]:
|
||||
ex = json.loads(json.dumps(pool[i], ensure_ascii=False))
|
||||
ex.setdefault("meta", {})["porcion"] = f"replay_{label}"
|
||||
out.append(ex)
|
||||
print(f"[INFO] replay '{label}': {len(pool)} filas limpias -> {len(out)} muestreadas")
|
||||
return out
|
||||
|
||||
|
||||
def stratified_split(examples, rng):
|
||||
by_portion = defaultdict(list)
|
||||
for ex in examples:
|
||||
by_portion[ex["meta"]["porcion"]].append(ex)
|
||||
train, eval_ = [], []
|
||||
for portion, rows in sorted(by_portion.items()):
|
||||
shuffled = rows[:]
|
||||
rng.shuffle(shuffled)
|
||||
n_eval = max(1, round(len(shuffled) * EVAL_FRACTION))
|
||||
eval_.extend(shuffled[:n_eval])
|
||||
train.extend(shuffled[n_eval:])
|
||||
print(f"[INFO] split '{portion}': {len(shuffled)} -> train={len(shuffled) - n_eval} "
|
||||
f"eval={n_eval}")
|
||||
rng.shuffle(train)
|
||||
rng.shuffle(eval_)
|
||||
return train, eval_
|
||||
|
||||
|
||||
def write_jsonl(path, rows):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
for ex in rows:
|
||||
f.write(json.dumps(ex, ensure_ascii=False))
|
||||
f.write("\n")
|
||||
print(f"[OK] {path} escrito: {len(rows)} ejemplos")
|
||||
|
||||
|
||||
def main():
|
||||
for path in (SEEDS_PENPOT, REPLAY_SOURCE):
|
||||
if not path.exists():
|
||||
raise SystemExit(f"[ABORT] falta {path}")
|
||||
|
||||
rng = random.Random(SEED)
|
||||
|
||||
# ---- 1. corpus nuevo de penpot ---------------------------------------------------------
|
||||
seeds = [ex for _, ex in load_jsonl(SEEDS_PENPOT)]
|
||||
print(f"[INFO] {SEEDS_PENPOT.name}: {len(seeds)} seeds")
|
||||
|
||||
dirty_seeds = [(i, forbidden_hits(ex)) for i, ex in enumerate(seeds, start=1)]
|
||||
# Los seeds de recuperacion de error contienen el patron equivocado a proposito; el lint ya
|
||||
# verifico que cada uno viene con su error real y su correccion posterior. Aca solo se avisa.
|
||||
flagged = [(i, hits) for i, hits in dirty_seeds if hits]
|
||||
if flagged:
|
||||
print(f"[INFO] {len(flagged)} seed(s) contienen un patron de la API vieja de forma "
|
||||
f"intencional (material de recuperacion de error): "
|
||||
f"{[i for i, _ in flagged]}")
|
||||
|
||||
by_group = defaultdict(list)
|
||||
for ex in seeds:
|
||||
grupo = ex.get("meta", {}).get("grupo")
|
||||
if grupo not in PORTION_OF_GROUP:
|
||||
raise SystemExit(
|
||||
f"[ABORT] seed con meta.grupo={grupo!r}, que no mapea a ninguna porcion. "
|
||||
f"Grupos conocidos: {sorted(PORTION_OF_GROUP)}"
|
||||
)
|
||||
by_group[grupo].append(ex)
|
||||
print(f"[INFO] seeds por grupo: {dict(sorted((g, len(v)) for g, v in by_group.items()))}")
|
||||
|
||||
pools = defaultdict(list)
|
||||
for grupo, rows in by_group.items():
|
||||
portion = PORTION_OF_GROUP[grupo]
|
||||
for ex in rows:
|
||||
pools[portion].extend(paraphrase_variants(ex))
|
||||
for portion, rows in sorted(pools.items()):
|
||||
print(f"[INFO] pool '{portion}': {len(rows)} variantes (seeds + parafraseos a mano)")
|
||||
|
||||
mixed = []
|
||||
for portion, target in PORTION_TARGETS.items():
|
||||
mixed.extend(fill_to(pools[portion], target, rng, portion))
|
||||
|
||||
# ---- 2. penpot basicos ya corregidos (del corpus NUEVO, ver docstring) -----------------
|
||||
basicos_pool = []
|
||||
for grupo in ("A1", "A2", "A3", "A4", "A5", "C"):
|
||||
basicos_pool.extend(by_group.get(grupo, []))
|
||||
mixed.extend(fill_to(basicos_pool, N_PENPOT_BASICOS, rng, "replay_penpot_basicos"))
|
||||
|
||||
# ---- 3. replay de train.jsonl, filtrado ------------------------------------------------
|
||||
replay_rows = load_jsonl(REPLAY_SOURCE)
|
||||
by_bucket = defaultdict(list)
|
||||
for lineno, ex in replay_rows:
|
||||
by_bucket[ex.get("meta", {}).get("bucket", "?")].append((lineno, ex))
|
||||
|
||||
dropped = Counter()
|
||||
clean_by_bucket = {}
|
||||
for bucket, rows in by_bucket.items():
|
||||
keep = []
|
||||
for lineno, ex in rows:
|
||||
hits = forbidden_hits(ex)
|
||||
if hits:
|
||||
dropped[bucket] += 1
|
||||
continue
|
||||
keep.append((lineno, ex))
|
||||
clean_by_bucket[bucket] = keep
|
||||
|
||||
print("[INFO] scan de patrones prohibidos sobre data/train.jsonl (riesgo #13, "
|
||||
"'el replay re-ensena el bug'):")
|
||||
for bucket in sorted(by_bucket):
|
||||
total = len(by_bucket[bucket])
|
||||
drop = dropped[bucket]
|
||||
print(f" {bucket:24} {total:5} filas, {drop:5} descartadas "
|
||||
f"({100.0 * drop / total if total else 0:.1f}%)")
|
||||
|
||||
for label, target in REPLAY_TARGETS.items():
|
||||
if label == "negativos_delegacion_errores":
|
||||
rows = []
|
||||
for b in REPLAY_UNION_BUCKETS:
|
||||
rows.extend(clean_by_bucket.get(b, []))
|
||||
else:
|
||||
rows = clean_by_bucket.get(label, [])
|
||||
mixed.extend(sample_replay(rows, target, rng, label))
|
||||
|
||||
# ---- 4. verificaciones duras -----------------------------------------------------------
|
||||
if len(mixed) != N_TRAIN:
|
||||
raise SystemExit(
|
||||
f"[ABORT] la mezcla tiene {len(mixed)} ejemplos, se esperaban {N_TRAIN}. "
|
||||
f"Revisar PORTION_TARGETS / REPLAY_TARGETS / N_PENPOT_BASICOS."
|
||||
)
|
||||
|
||||
# Cero patrones prohibidos en la mezcla final, EXCEPTO los seeds de recuperacion de error,
|
||||
# que se identifican mecanicamente: su porcion es 'api_recuperacion'.
|
||||
offenders = []
|
||||
for i, ex in enumerate(mixed):
|
||||
if ex["meta"].get("porcion") == "api_recuperacion":
|
||||
continue
|
||||
hits = forbidden_hits(ex)
|
||||
if hits:
|
||||
offenders.append((i, ex["meta"].get("porcion"), hits))
|
||||
if offenders:
|
||||
print(f"\n[ABORT] {len(offenders)} ejemplo(s) de la mezcla final contienen patrones de la "
|
||||
f"API vieja fuera de la porcion de recuperacion de error:")
|
||||
for i, portion, hits in offenders[:20]:
|
||||
print(f" #{i} (porcion={portion}): {hits}")
|
||||
sys.exit(1)
|
||||
print("[OK] cero patrones de la API vieja en la mezcla final "
|
||||
"(fuera de la porcion de recuperacion de error, donde son el material didactico)")
|
||||
|
||||
counts = Counter(ex["meta"]["porcion"] for ex in mixed)
|
||||
print("\n=== COMPOSICION DE LA MEZCLA ===")
|
||||
penpot_new = 0
|
||||
for portion, n in sorted(counts.items()):
|
||||
print(f" {portion:28} {n:4} ({100.0 * n / len(mixed):4.1f}%)")
|
||||
if not portion.startswith("replay"):
|
||||
penpot_new += n
|
||||
replay_n = len(mixed) - penpot_new
|
||||
print(f" {'-' * 28}")
|
||||
print(f" {'penpot nuevo':28} {penpot_new:4} ({100.0 * penpot_new / len(mixed):4.1f}%)")
|
||||
print(f" {'replay':28} {replay_n:4} ({100.0 * replay_n / len(mixed):4.1f}%)")
|
||||
|
||||
# ---- 5. split y escritura ---------------------------------------------------------------
|
||||
split_rng = random.Random(SEED)
|
||||
train, eval_ = stratified_split(mixed, split_rng)
|
||||
write_jsonl(TRAIN_OUT, train)
|
||||
write_jsonl(EVAL_OUT, eval_)
|
||||
|
||||
# ---- 6. calibracion ----------------------------------------------------------------------
|
||||
# 256 filas de la mezcla; el script de cuantizacion agrega 256 de ultrachat por su lado, para
|
||||
# llegar a las 512 de la receta que produjo el checkpoint de produccion (ver
|
||||
# quantize_nvfp4_v6_mixed_2048.log). Se muestrea de train_lora2 y no de train.jsonl a
|
||||
# proposito: si la calibracion no ve datos de diseno, NVFP4 puede lavar justo la capacidad
|
||||
# nueva (riesgo #9).
|
||||
calib_rng = random.Random(SEED + 1)
|
||||
calib_idx = list(range(len(train)))
|
||||
calib_rng.shuffle(calib_idx)
|
||||
calibration = [train[i] for i in calib_idx[:N_CALIBRATION]]
|
||||
calib_counts = Counter(ex["meta"]["porcion"] for ex in calibration)
|
||||
print("\n=== CALIBRACION v2 (distribucion por porcion) ===")
|
||||
for portion, n in sorted(calib_counts.items()):
|
||||
print(f" {portion:28} {n:4}")
|
||||
write_jsonl(CALIB_OUT, calibration)
|
||||
|
||||
print("\n[OK] mezcla del LoRA #2 construida. data/train.jsonl y data/eval.jsonl "
|
||||
"NO fueron modificados.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -155,12 +155,22 @@ FORBIDDEN = [
|
||||
|
||||
# `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: si el payload usa `X.appendChild(` con un solo argumento, tiene que haber
|
||||
# evidencia de flex en el mismo payload.
|
||||
# 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."""
|
||||
@@ -420,6 +430,9 @@ def main():
|
||||
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 []:
|
||||
@@ -460,7 +473,7 @@ def main():
|
||||
problems.append(f"{tag}: [{pname}] ...{frag}...\n -> {why}")
|
||||
|
||||
# appendChild sobre receptor sin evidencia de flex
|
||||
if (APPEND_ONE_ARG_RE.search(code) and not FLEX_EVIDENCE_RE.search(code)
|
||||
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 "
|
||||
@@ -478,13 +491,20 @@ def main():
|
||||
f"-- el servidor lo prohibe explicitamente (llega duplicado)"
|
||||
)
|
||||
|
||||
# R2: grises de placeholder en el codigo (nunca permitidos, ni en B8)
|
||||
for hx in HEX_RE.findall(code):
|
||||
if is_placeholder_grey(hx):
|
||||
# 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} en el `code` (invariante R2). "
|
||||
f"El gris solo puede aparecer como INPUT en los tool results del "
|
||||
f"grupo {GREY_INPUT_GROUP}."
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user