@trycore/spec-build-harness 0.11.1 → 0.13.0

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.
@@ -14,8 +14,17 @@ como string POR ÉPICA (las `release_lines` de la entrada se pliegan aquí). As
14
14
  objeto sirve para la pantalla de import del grafo y para embeberse como sección `graph`
15
15
  del bundle de estado (`trycore-build migrate`).
16
16
 
17
+ [issue #56] Las dependencias entre épicas viven como PROSA en `docs/03-backlog/epicas.md`
18
+ (`**Depende de**: EP-001 (…)`), y delegar su lectura al modelo las dejó VACÍAS en el piloto
19
+ (41/41 épicas con `depends_on: []`, Lienzo sin una sola arista). `--from-docs` extrae ese
20
+ grafo de forma DETERMINISTA — regex de códigos `EP-…` sobre el campo `Depende de` de cada
21
+ sección de épica, excluyendo la propia — y lo une a lo que traiga el JSON de entrada. Si la
22
+ épica declara el campo estructurado `depende_de: [EP-001, …]`, ese gana sobre la prosa.
23
+
17
24
  Uso:
18
25
  python3 graph-bundle.py < epics.json > bundle.json # avisos por stderr
26
+ python3 graph-bundle.py --from-docs docs/03-backlog/epicas.md < epics.json > bundle.json
27
+ python3 graph-bundle.py --print-deps docs/03-backlog/epicas.md # solo el grafo extraído
19
28
 
20
29
  Entrada (stdin, JSON — formato de discovery, sin cambios):
21
30
  {"project_ref": "…",
@@ -27,9 +36,12 @@ Salida (stdout, JSON): el bundle normalizado y DETERMINISTA (orden estable por c
27
36
  los problemas no bloquean: se listan como avisos en stderr y en `warnings` del bundle, con
28
37
  el criterio del arnés — nada se pierde en silencio, nada bloquea la preparación.
29
38
 
30
- Códigos de salida: 0 = bundle generado (con o sin avisos) · 1 = entrada ilegible.
39
+ Códigos de salida: 0 = bundle generado (con o sin avisos) · 1 = entrada ilegible
40
+ (incluye una ruta `--from-docs` que no se puede leer: se pidió extraer y no se pudo, y un
41
+ grafo sin aristas no falla solo — empobrece en silencio, que es justo lo que causó #56).
31
42
  """
32
43
  import json
44
+ import re
33
45
  import sys
34
46
  import datetime
35
47
 
@@ -43,8 +55,128 @@ def norm_list(v):
43
55
  return [x for x in v if isinstance(x, str)] if isinstance(v, list) else []
44
56
 
45
57
 
58
+ # ── Extractor determinista de dependencias desde los docs de discovery [issue #56] ──
59
+ # Códigos de épica: EP-001 y también los compuestos del estilo EP-OR-08.
60
+ RE_CODIGO = re.compile(r"\bEP-(?:[A-Za-z]{1,6}-)?\d{1,4}\b")
61
+ # Encabezado de sección de épica: `## EP-001 — Título` (cualquier nivel).
62
+ RE_ENCABEZADO = re.compile(r"^\s{0,3}(#{1,6})\s+(.*)$")
63
+ # El campo en prosa que escribe la metodología: `**Depende de**: …` (puede ir a mitad de
64
+ # línea, p. ej. `**Anexo A**: Fase 3 · **Depende de**: EP-002`).
65
+ RE_DEPENDE_DE = re.compile(r"depende\s+de", re.IGNORECASE)
66
+ # El campo estructurado (frontmatter o cuerpo): `depende_de: [EP-001, EP-002]`.
67
+ RE_DEPENDE_DE_ESTRUCTURADO = re.compile(r"^\s*(?:-\s*)?depende_de\s*:", re.IGNORECASE)
68
+ # Fin del párrafo del campo: encabezado, regla horizontal, viñeta/cita/tabla o campo nuevo.
69
+ RE_FIN_DE_CAMPO = re.compile(r"^\s*(?:#{1,6}\s|-{3,}\s*$|\*{3,}\s*$|[-*+>|]\s|\*\*)")
70
+
71
+
72
+ def extraer_dependencias(texto):
73
+ """Devuelve ({codigo: [deps ordenadas]}, {códigos con sección}, avisos).
74
+
75
+ Solo entran las épicas que DECLARAN el campo (en prosa o estructurado): "no lo declara"
76
+ y "declara que no depende de nada" son cosas distintas y ninguna se inventa aquí.
77
+ """
78
+ avisos = []
79
+ lineas = texto.splitlines()
80
+ # (código de la épica, línea donde abre su sección)
81
+ secciones = []
82
+ for i, linea in enumerate(lineas):
83
+ m = RE_ENCABEZADO.match(linea)
84
+ if not m:
85
+ continue
86
+ codigos = RE_CODIGO.findall(m.group(2))
87
+ if codigos:
88
+ secciones.append((codigos[0], i))
89
+ if not secciones:
90
+ avisos.append("no se reconoció ninguna sección de épica en los docs "
91
+ "(se esperaban encabezados del estilo `## EP-001 — Título`)")
92
+ return {}, set(), avisos
93
+
94
+ deps = {}
95
+ for n, (codigo, inicio) in enumerate(secciones):
96
+ fin = secciones[n + 1][1] if n + 1 < len(secciones) else len(lineas)
97
+ cuerpo = lineas[inicio + 1:fin]
98
+ prosa, estructurado = None, None
99
+ for j, linea in enumerate(cuerpo):
100
+ if estructurado is None and RE_DEPENDE_DE_ESTRUCTURADO.match(linea):
101
+ estructurado = RE_CODIGO.findall(linea.split(":", 1)[1])
102
+ continue
103
+ if prosa is not None:
104
+ continue
105
+ m = RE_DEPENDE_DE.search(linea)
106
+ if not m:
107
+ continue
108
+ # El campo puede continuar en las líneas siguientes hasta el fin del párrafo.
109
+ trozos = [linea[m.end():]]
110
+ for siguiente in cuerpo[j + 1:]:
111
+ if not siguiente.strip() or RE_FIN_DE_CAMPO.match(siguiente):
112
+ break
113
+ trozos.append(siguiente)
114
+ prosa = RE_CODIGO.findall(" ".join(trozos))
115
+ # El campo estructurado es determinista: gana sobre la heurística de la prosa.
116
+ encontrado = estructurado if estructurado is not None else prosa
117
+ if encontrado is None:
118
+ continue
119
+ deps[codigo] = sorted({c for c in encontrado if c != codigo})
120
+ return deps, {c for c, _ in secciones}, avisos
121
+
122
+
123
+ def leer_dependencias_de_docs(ruta):
124
+ """Lee el fichero de épicas; lanza IOError/OSError si no se puede (rc 1 arriba)."""
125
+ with open(ruta, encoding="utf-8") as fh:
126
+ return extraer_dependencias(fh.read())
127
+
128
+
129
+ def parsear_argumentos(argv):
130
+ """({from_docs, print_deps}, error) — sin dependencias, mismo estilo que el resto."""
131
+ opciones = {"from_docs": None, "print_deps": None}
132
+ i = 0
133
+ while i < len(argv):
134
+ arg = argv[i]
135
+ if arg in ("--from-docs", "--print-deps"):
136
+ if i + 1 >= len(argv) or argv[i + 1].startswith("--"):
137
+ return opciones, "%s exige la ruta del fichero de épicas" % arg
138
+ opciones["from_docs" if arg == "--from-docs" else "print_deps"] = argv[i + 1]
139
+ i += 2
140
+ continue
141
+ return opciones, "opción desconocida: %s" % arg
142
+ if opciones["from_docs"] and opciones["print_deps"]:
143
+ # Ignorar una de las dos en silencio es exactamente el error que corrige #56.
144
+ return opciones, "--from-docs y --print-deps son excluyentes"
145
+ return opciones, None
146
+
147
+
46
148
  def main():
47
149
  avisos = []
150
+ opciones, error = parsear_argumentos(sys.argv[1:])
151
+ if error:
152
+ print("graph-bundle: %s" % error, file=sys.stderr)
153
+ return 1
154
+
155
+ # `--print-deps`: solo el grafo extraído (diagnóstico; no toca stdin ni arma bundle).
156
+ if opciones["print_deps"]:
157
+ try:
158
+ deps, _, avisos_docs = leer_dependencias_de_docs(opciones["print_deps"])
159
+ except (IOError, OSError) as e:
160
+ print("graph-bundle: no se pudo leer %s: %s" % (opciones["print_deps"], e),
161
+ file=sys.stderr)
162
+ return 1
163
+ json.dump(deps, sys.stdout, indent=2, ensure_ascii=False, sort_keys=True)
164
+ sys.stdout.write("\n")
165
+ for a in avisos_docs:
166
+ print("aviso: %s" % a, file=sys.stderr)
167
+ return 0
168
+
169
+ deps_docs = None
170
+ if opciones["from_docs"]:
171
+ try:
172
+ deps_docs, secciones_docs, avisos_docs = \
173
+ leer_dependencias_de_docs(opciones["from_docs"])
174
+ except (IOError, OSError) as e:
175
+ print("graph-bundle: no se pudo leer %s: %s" % (opciones["from_docs"], e),
176
+ file=sys.stderr)
177
+ return 1
178
+ avisos.extend(avisos_docs)
179
+
48
180
  try:
49
181
  raw = json.load(sys.stdin)
50
182
  except Exception as e:
@@ -113,6 +245,36 @@ def main():
113
245
  epica["docs_ref"] = e["docs_ref"]
114
246
  epicas[code] = epica
115
247
 
248
+ # [issue #56] Las dependencias de los docs se UNEN a las declaradas: el extractor es la
249
+ # fuente fiable, y lo que ya venía en el JSON no se pierde (se avisa si no está en docs).
250
+ if deps_docs is not None:
251
+ aristas = 0
252
+ for code, e in epicas.items():
253
+ if code not in deps_docs:
254
+ if code in secciones_docs:
255
+ avisos.append("%s tiene sección en %s pero no declara el campo "
256
+ "`Depende de`: sus dependencias quedan como vinieran en el "
257
+ "JSON" % (code, opciones["from_docs"]))
258
+ else:
259
+ avisos.append("%s no aparece como sección de épica en %s: sus "
260
+ "dependencias no se pudieron extraer de los docs"
261
+ % (code, opciones["from_docs"]))
262
+ continue
263
+ solo_json = [d for d in e["depends_on"] if d not in deps_docs[code]]
264
+ if solo_json:
265
+ avisos.append("%s: %s viene del JSON de entrada pero no del campo `Depende de` "
266
+ "de los docs: verifica cuál manda"
267
+ % (code, ", ".join(solo_json)))
268
+ e["depends_on"] = sorted(set(e["depends_on"]) | set(deps_docs[code]))
269
+ aristas += len(e["depends_on"])
270
+ for code in sorted(deps_docs):
271
+ if code not in epicas:
272
+ avisos.append("%s está en %s pero no en el JSON de entrada: el grafo del hub "
273
+ "quedaría sin esa épica" % (code, opciones["from_docs"]))
274
+ print("info: dependencias extraídas de %s -> %d épicas con campo `Depende de`, "
275
+ "%d aristas en el grafo" % (opciones["from_docs"], len(deps_docs), aristas),
276
+ file=sys.stderr)
277
+
116
278
  for code, e in epicas.items():
117
279
  for dep in e["depends_on"]:
118
280
  if dep not in epicas:
@@ -167,6 +329,8 @@ def main():
167
329
  "generated_at": datetime.datetime.now(datetime.timezone.utc)
168
330
  .strftime("%Y-%m-%dT%H:%M:%SZ"),
169
331
  "generated_by": "trycore-build-harness/graph-bundle.py",
332
+ "depends_on_source": ("docs:%s" % opciones["from_docs"]) if deps_docs is not None
333
+ else "input",
170
334
  "epics": [epicas[c] for c in sorted(epicas)],
171
335
  "warnings": avisos,
172
336
  }