@trycore/spec-build-harness 0.12.0 → 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.
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
3
3
  "name": "trycore-spec-build-harness",
4
4
  "displayName": "Trycore — Spec & Build Harness",
5
- "version": "0.12.0",
5
+ "version": "0.13.0",
6
6
  "description": "Arnés de construcción de dos loops (slice por épica + release gate) para Claude Code, con gates de calidad, estado compartido y OpenSpec. Compañero de @trycore/spec-product-flow. Agnóstico al proyecto.",
7
7
  "author": {
8
8
  "name": "Trycore",
package/VERSION CHANGED
@@ -1 +1 @@
1
- 0.12.0
1
+ 0.13.0
@@ -243,30 +243,47 @@ en el runtime. **Importarlo es un acto de gobierno**: la superficie exige sesió
243
243
  el token de agente. El arnés **prepara y valida**; una persona sube.
244
244
 
245
245
  1. Extrae el grafo de los artefactos de discovery ya leídos (`docs/03-backlog/epicas.md`,
246
- `docs/02-user-story-map/`, `docs/04-historias/`) a un JSON:
246
+ `docs/02-user-story-map/`, `docs/04-historias/`) a un JSON. **Las dependencias las extrae
247
+ el script, no tú** (paso 2): puebla `depends_on` con lo que veas, pero **nunca lo dejes
248
+ vacío por defecto** — un grafo sin aristas no falla, solo empobrece el Lienzo en silencio
249
+ (issue #56: 41/41 épicas llegaron al hub con `depends_on: []`).
247
250
 
248
251
  ```json
249
252
  {"project_ref": "<nombre del proyecto>",
250
253
  "epics": [{"code": "EP-001", "title": "…", "layer": "foundational",
251
254
  "files_scope": ["src/core/**"], "depends_on": [],
252
- "stories": [{"id": "HU-001", "title": "…"}]}],
253
- "release_lines": [{"id": "R1-mvp", "epics": ["EP-001"]}]}
255
+ "stories": [{"id": "HU-001", "title": "…"}]},
256
+ {"code": "EP-002", "title": "", "layer": "business",
257
+ "files_scope": ["src/pagos/**"], "depends_on": ["EP-001"],
258
+ "stories": [{"id": "HU-011", "title": "…"}]}],
259
+ "release_lines": [{"id": "R1-mvp", "epics": ["EP-001", "EP-002"]}]}
254
260
  ```
255
261
 
256
- 2. Normalízalo y valídalo (determinista, nunca sube nada). La salida va a
257
- `.claude/state/graph-bundle.json` para que `trycore-build migrate` la encuentre sola:
262
+ 2. Normalízalo y valídalo (determinista, nunca sube nada). **Pasa siempre `--from-docs`**: el
263
+ script relee el campo `**Depende de**` de cada sección de épica y **une** ese grafo al del
264
+ JSON — es la única lectura fiable de las dependencias, que la metodología escribe como
265
+ prosa. La salida va a `.claude/state/graph-bundle.json` para que `trycore-build migrate` la
266
+ encuentre sola:
258
267
 
259
268
  ```bash
260
- python3 .claude/scripts/lib/graph-bundle.py < /tmp/epics.json > .claude/state/graph-bundle.json
269
+ python3 .claude/scripts/lib/graph-bundle.py --from-docs docs/03-backlog/epicas.md \
270
+ < /tmp/epics.json > .claude/state/graph-bundle.json
261
271
  ```
262
272
 
273
+ Para inspeccionar solo lo extraído (sin armar el bundle):
274
+ `python3 .claude/scripts/lib/graph-bundle.py --print-deps docs/03-backlog/epicas.md`.
275
+ Si una épica declara el campo estructurado `depende_de: [EP-001]`, ese gana sobre la prosa.
276
+
263
277
  El script emite las épicas en el **formato exacto del hub**: `layer` en MAYÚSCULA
264
278
  (`FOUNDATIONAL`|`BUSINESS` — un layer inválido rechaza el grafo ENTERO), historias con
265
279
  `code`, y `release_line` (string) por épica.
266
280
 
267
281
  3. **Lee los avisos** (`warnings` del bundle y stderr): capa ausente, dependencia inexistente,
268
- ciclo, línea de release que referencia una épica desconocida. Corrígelos en discovery el arnés
269
- **no inventa** el grafo ni edita `epicas.md` (salvo el carve-out de la épica caparazón, Fase 2c).
282
+ ciclo, línea de release que referencia una épica desconocida, épica que está en los docs pero
283
+ no en tu JSON, épica sin campo `Depende de`. Contrasta la línea `info: N aristas en el
284
+ grafo` con lo que declara el backlog: **0 aristas en un backlog con dependencias es el
285
+ síntoma de #56**. Corrígelos en discovery — el arnés **no inventa** el grafo ni edita
286
+ `epicas.md` (salvo el carve-out de la épica caparazón, Fase 2c).
270
287
 
271
288
  4. **Entrega al ADMIN un solo fichero.** Si vas a migrar historial, corre `trycore-build migrate`:
272
289
  embebe el grafo como sección `graph` del bundle de estado (un único fichero para la pantalla de
@@ -1118,6 +1118,17 @@ export function verifyNormalizedBundle(bundle) {
1118
1118
  message: 'source_key debe tener entre 1 y 200 caracteres (LegacyImported, event_catalog.py:446)',
1119
1119
  });
1120
1120
  }
1121
+ // ImportBundleIn tipa `original` como dict: un escalar responde 422
1122
+ // `dict_type` en la validación pydantic y tumba el bundle ENTERO, antes de
1123
+ // la persistencia por-entrada (issue #55). El bundle sale envuelto de
1124
+ // `wireOriginal`; esto es la red que lo detecta en seco si alguien vuelve a
1125
+ // emitir un escalar por otra vía.
1126
+ if (typeof u.original !== 'object' || u.original === null || Array.isArray(u.original)) {
1127
+ violations.push({
1128
+ source_key: `unmapped[${i}]`,
1129
+ message: 'original debe ser un objeto JSON: el hub lo tipa como dict y responde 422 dict_type sobre el bundle entero (ImportBundleIn)',
1130
+ });
1131
+ }
1121
1132
  });
1122
1133
  return violations;
1123
1134
  }
@@ -72,6 +72,20 @@ export function graphEpicsWithoutRepresentation(graph, representedCodes) {
72
72
  .map((e) => (typeof e.code === 'string' ? e.code : ''))
73
73
  .filter((code) => code !== '' && !represented.has(code));
74
74
  }
75
+ /** El hub tipa `unmapped[].original` como dict (`ImportBundleIn`): un escalar
76
+ * responde 422 `dict_type` y tumba el bundle ENTERO en la validación pydantic,
77
+ * antes de la persistencia por-entrada — observado en el piloto con
78
+ * `facts[project_kind_source]: "auto"` (issue #55), pero lo emiten cuatro
79
+ * sitios (fact fuera de catálogo, elemento de history[] no-objeto, nota de
80
+ * release y `parallel_front.status: "closed"`). Los normalizadores conservan
81
+ * el valor legacy tal cual — la envoltura pertenece al cable y solo alcanza a
82
+ * lo que no es ya un objeto JSON, para no anidar dos veces lo que ya viaja bien. */
83
+ function wireOriginal(entry) {
84
+ const { original } = entry;
85
+ if (typeof original === 'object' && original !== null && !Array.isArray(original))
86
+ return entry;
87
+ return { ...entry, original: { value: original } };
88
+ }
75
89
  export function buildStateBundle(raw, projectRef, preHarness) {
76
90
  const warnings = [];
77
91
  const st = typeof raw === 'object' && raw !== null && !Array.isArray(raw) ? raw : {};
@@ -94,7 +108,7 @@ export function buildStateBundle(raw, projectRef, preHarness) {
94
108
  const fronts = front ? [front] : [];
95
109
  const factsUnmapped = [];
96
110
  const facts = normalizeFacts(st, EPOCH_ISO, warnings, factsUnmapped);
97
- const unmapped = [...historyUnmapped, ...releasesUnmapped, ...frontUnmapped, ...factsUnmapped];
111
+ const unmapped = [...historyUnmapped, ...releasesUnmapped, ...frontUnmapped, ...factsUnmapped].map(wireOriginal);
98
112
  // Épicas pre-arnés (issue #42): lista CONFIRMADA por el humano — entrada
99
113
  // sintética archivada sin gates por épica (patrón del workaround del piloto).
100
114
  // Conflicto obvio ⇒ error, no fusión: una épica declarada pre-arnés que YA
@@ -201,3 +201,29 @@ reducer **no bloquea** el trabajo local: se avisa y queda como discrepancia del
201
201
  **Lo que el agente NO hace** (superficies humanas, con PDP): cerrar una release, planificar/abrir/
202
202
  drenar/cerrar un front, subir el bundle de import del grafo y publicar contexto. El arnés prepara,
203
203
  propone y reporta; la persona decide en la consola del hub.
204
+
205
+ ---
206
+
207
+ ## 11. Bundle de import histórico (`POST /projects/{id}/import/bundle`)
208
+
209
+ Lo produce `trycore-build migrate` (`src/lib/state-bundle.ts`) y lo sube una persona con sesión de
210
+ ADMIN. La validación del hub es **pydantic sobre el cuerpo entero**: un solo campo mal tipado
211
+ responde 422 y rechaza el bundle completo **antes** de la persistencia por-entrada — no hay
212
+ degradación parcial. De ahí que el contrato de las secciones "de rescate" importe tanto como el de
213
+ los eventos.
214
+
215
+ `unmapped[]` — todo lo que el estado legacy trae y el catálogo del hub no sabe nombrar (spec §7:
216
+ *nada se pierde, nada bloquea*):
217
+
218
+ | Campo | Tipo exigido | Nota |
219
+ |---|---|---|
220
+ | `source_key` | string, 1..200 | `LegacyImported`, `event_catalog.py:446` |
221
+ | `original` | **dict** | un escalar responde 422 `dict_type` (issue #55) |
222
+ | `occurred_at` | ISO-8601 UTC | ancla del dato legacy, no la hora de la migración |
223
+
224
+ El estado legacy guarda escalares en cuatro de los sitios que caen aquí (fact fuera de catálogo,
225
+ elemento de `history[]` no-objeto, nota de release, `parallel_front.status: "closed"`), así que
226
+ `buildStateBundle` **envuelve** todo `original` que no sea ya un objeto JSON como `{"value": …}`.
227
+ La envoltura es del cable: los normalizadores conservan el valor legacy tal cual, y un `original`
228
+ que ya es dict viaja intacto (nunca se anida dos veces). `migrate --verify` comprueba el invariante
229
+ en seco, sin red.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trycore/spec-build-harness",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "Arnés agéntico de construcción de Trycore para Claude Code: pipeline de dos loops (slice por épica + release gate) con gates de calidad, estado compartido y OpenSpec. Compañero de @trycore/spec-product-flow. Agnóstico al proyecto.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
  }
@@ -1593,4 +1593,122 @@ echo 'no-json' | python3 "$GB" > /dev/null 2> "$TMP/gb3.err"
1593
1593
  rc=$?
1594
1594
  [ "$rc" = 1 ] && ! grep -q "Traceback" "$TMP/gb3.err" && echo "OK graph-bundle con entrada ilegible -> rc 1 limpio" || { echo "FAIL entrada ilegible ($rc)"; fail=1; }
1595
1595
 
1596
+ # ══════════ graph-bundle.py --from-docs / --print-deps [issue #56] ══════════
1597
+ # El grafo llegaba al hub con `depends_on: []` (41/41 épicas en el piloto): la
1598
+ # extracción vivía en el LLM y el ejemplo de onboard anclaba en []. El extractor
1599
+ # es ahora DETERMINISTA y lee la prosa de `docs/03-backlog/epicas.md`.
1600
+ mkdir -p "$TMP/docs"
1601
+ cat > "$TMP/docs/epicas.md" <<'MD'
1602
+ # Backlog — épicas
1603
+
1604
+ **Dependencias entre épicas**: EP-999 (esto está FUERA de toda sección: se ignora)
1605
+
1606
+ ## EP-001 — Cimiento
1607
+
1608
+ **Depende de**: nada.
1609
+
1610
+ **Resumen**: base técnica.
1611
+
1612
+ ---
1613
+
1614
+ ## EP-002 — Cobros
1615
+
1616
+ **Anexo A**: Fase 3 · **Depende de**: **EP-001** (base de datos), EP-404 (no existe en el grafo)
1617
+
1618
+ **Resumen**: cobra.
1619
+
1620
+ ## EP-003 — Reportes
1621
+
1622
+ **Depende de**: EP-002 (pipeline de export),
1623
+ EP-001 (cimiento). Ninguna bloquea: todas archivadas.
1624
+
1625
+ **Resumen**: reporta.
1626
+
1627
+ ## EP-004 — Estructurada
1628
+
1629
+ depende_de: [EP-003]
1630
+
1631
+ **Depende de**: EP-001 en prosa — gana el campo estructurado.
1632
+
1633
+ ## EP-005 — Autorreferente
1634
+
1635
+ **Depende de**: EP-005 (a sí misma: se excluye), EP-001
1636
+ MD
1637
+
1638
+ # 1) --print-deps: extracción pura, determinista, inspeccionable.
1639
+ out="$(python3 "$GB" --print-deps "$TMP/docs/epicas.md" 2> "$TMP/gb4.err")"
1640
+ rc=$?
1641
+ # `**Depende de**: nada.` es una declaración, no una ausencia: entra con lista vacía.
1642
+ esperado='{"EP-001": [], "EP-002": ["EP-001", "EP-404"], "EP-003": ["EP-001", "EP-002"], "EP-004": ["EP-003"], "EP-005": ["EP-001"]}'
1643
+ [ "$rc" = 0 ] && [ "$(echo "$out" | python3 -c 'import json,sys;print(json.dumps(json.load(sys.stdin),sort_keys=True))')" = "$esperado" ] \
1644
+ && echo "OK --print-deps extrae de la prosa (multilínea, campo estructurado, sin autorreferencia)" \
1645
+ || { echo "FAIL --print-deps ($rc) -> $out"; fail=1; }
1646
+
1647
+ # 2) --from-docs puebla el grafo aunque el JSON del modelo traiga depends_on vacíos.
1648
+ cat > "$TMP/epics56.json" <<'JSON'
1649
+ {"project_ref":"acme","epics":[
1650
+ {"code":"EP-001","title":"Cimiento","layer":"foundational","depends_on":[]},
1651
+ {"code":"EP-002","title":"Cobros","layer":"business","depends_on":[]},
1652
+ {"code":"EP-003","title":"Reportes","layer":"business","depends_on":[]},
1653
+ {"code":"EP-004","title":"Estructurada","layer":"business","depends_on":["EP-002"]}
1654
+ ]}
1655
+ JSON
1656
+ out="$(python3 "$GB" --from-docs "$TMP/docs/epicas.md" < "$TMP/epics56.json" 2> "$TMP/gb5.err")"
1657
+ rc=$?
1658
+ [ "$rc" = 0 ] && [ "$(echo "$out" | python3 -c 'import json,sys;d=json.load(sys.stdin);print(";".join(e["code"]+"="+",".join(e["depends_on"]) for e in d["epics"]))')" = "EP-001=;EP-002=EP-001,EP-404;EP-003=EP-001,EP-002;EP-004=EP-002,EP-003" ] \
1659
+ && echo "OK --from-docs puebla depends_on desde los docs (unión con lo declarado)" \
1660
+ || { echo "FAIL --from-docs ($rc) -> $out"; fail=1; }
1661
+ grep -q "EP-404" "$TMP/gb5.err" \
1662
+ && echo "OK --from-docs mantiene el aviso de dependencia inexistente" || { echo "FAIL sin aviso de dep inexistente con --from-docs"; fail=1; }
1663
+ grep -q "EP-005" "$TMP/gb5.err" \
1664
+ && echo "OK --from-docs avisa de la épica que está en docs pero no en el JSON" || { echo "FAIL sin aviso de épica ausente del JSON"; fail=1; }
1665
+ echo "$out" | grep -q '"depends_on_source"' \
1666
+ && echo "OK el bundle deja traza de la fuente de dependencias" || { echo "FAIL el bundle no declara depends_on_source"; fail=1; }
1667
+
1668
+ # 3) Los ciclos que vienen de la prosa se cazan igual (el aviso ya existía).
1669
+ cat > "$TMP/docs/ciclo.md" <<'MD'
1670
+ ## EP-010 — A
1671
+ **Depende de**: EP-011
1672
+ ## EP-011 — B
1673
+ **Depende de**: EP-010
1674
+ MD
1675
+ echo '{"epics":[{"code":"EP-010","title":"A","layer":"business"},{"code":"EP-011","title":"B","layer":"business"}]}' \
1676
+ | python3 "$GB" --from-docs "$TMP/docs/ciclo.md" > /dev/null 2> "$TMP/gb6.err"
1677
+ rc=$?
1678
+ [ "$rc" = 0 ] && grep -qi "ciclo" "$TMP/gb6.err" \
1679
+ && echo "OK --from-docs + ciclo de la prosa -> aviso, sin bloquear" || { echo "FAIL ciclo desde docs ($rc)"; fail=1; }
1680
+
1681
+ # 4) Ruta ilegible: rc 1 con mensaje, jamás traceback ni silencio (el fallo del piloto
1682
+ # fue precisamente un grafo vacío que no falló).
1683
+ echo '{"epics":[]}' | python3 "$GB" --from-docs "$TMP/docs/no-existe.md" > /dev/null 2> "$TMP/gb7.err"
1684
+ rc=$?
1685
+ [ "$rc" = 1 ] && ! grep -q "Traceback" "$TMP/gb7.err" \
1686
+ && echo "OK --from-docs con ruta ilegible -> rc 1 limpio" || { echo "FAIL ruta ilegible ($rc)"; fail=1; }
1687
+ # Flag sin argumento: mismo trato.
1688
+ echo '{"epics":[]}' | python3 "$GB" --from-docs > /dev/null 2> "$TMP/gb8.err"
1689
+ [ "$?" = 1 ] && ! grep -q "Traceback" "$TMP/gb8.err" \
1690
+ && echo "OK --from-docs sin argumento -> rc 1 limpio" || { echo "FAIL --from-docs sin argumento"; fail=1; }
1691
+
1692
+ # 4-bis) Sección presente pero SIN el campo: aviso distinto al de sección ausente
1693
+ # (un backlog que dibuja sus dependencias en ASCII no es un backlog sin épicas).
1694
+ printf '## EP-020 — Con seccion, sin campo\n\n**Resumen**: x.\n' > "$TMP/docs/sin-campo.md"
1695
+ echo '{"epics":[{"code":"EP-020","title":"X","layer":"business"},{"code":"EP-021","title":"Y","layer":"business"}]}' \
1696
+ | python3 "$GB" --from-docs "$TMP/docs/sin-campo.md" > /dev/null 2> "$TMP/gb10.err"
1697
+ grep -q "EP-020 tiene sección" "$TMP/gb10.err" && grep -q "EP-021 no aparece como sección" "$TMP/gb10.err" \
1698
+ && echo "OK distingue sección sin campo de épica ausente de los docs" || { echo "FAIL avisos de cobertura de docs"; fail=1; }
1699
+
1700
+ # 4-ter) Las dos banderas juntas son un error explícito (ignorar una en silencio es el
1701
+ # patrón que causó #56).
1702
+ echo '{"epics":[]}' | python3 "$GB" --from-docs "$TMP/docs/epicas.md" --print-deps "$TMP/docs/epicas.md" > /dev/null 2> "$TMP/gb11.err"
1703
+ [ "$?" = 1 ] && grep -qi "excluyentes" "$TMP/gb11.err" \
1704
+ && echo "OK --from-docs y --print-deps juntas -> rc 1 explícito" || { echo "FAIL banderas excluyentes"; fail=1; }
1705
+
1706
+ # 5) Sin sección de épica reconocible: aviso explícito (no silencio).
1707
+ printf 'texto sin encabezados de epica\n' > "$TMP/docs/vacio.md"
1708
+ echo '{"epics":[{"code":"EP-001","title":"X","layer":"business"}]}' \
1709
+ | python3 "$GB" --from-docs "$TMP/docs/vacio.md" > /dev/null 2> "$TMP/gb9.err"
1710
+ grep -qi "ninguna secci" "$TMP/gb9.err" \
1711
+ && echo "OK avisa cuando los docs no traen secciones de épica" || { echo "FAIL sin aviso de docs sin épicas"; fail=1; }
1712
+
1713
+
1596
1714
  exit $fail