@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.
- package/.claude-plugin/plugin.json +1 -1
- package/VERSION +1 -1
- package/commands/build/onboard.md +25 -8
- package/dist/lib/normalize.js +11 -0
- package/dist/lib/state-bundle.js +15 -1
- package/docs/runtime/protocolo-cliente-runtime.md +26 -0
- package/package.json +1 -1
- package/scripts/lib/graph-bundle.py +165 -1
- package/scripts/tests/test-skill-ops.sh +118 -0
|
@@ -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.
|
|
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.
|
|
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
|
-
|
|
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).
|
|
257
|
-
|
|
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
|
|
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
|
|
269
|
-
|
|
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
|
package/dist/lib/normalize.js
CHANGED
|
@@ -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
|
}
|
package/dist/lib/state-bundle.js
CHANGED
|
@@ -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.
|
|
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
|