@trycore/spec-build-harness 0.14.2 → 0.15.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.
@@ -21,10 +21,17 @@ grafo de forma DETERMINISTA — regex de códigos `EP-…` sobre el campo `Depen
21
21
  sección de épica, excluyendo la propia — y lo une a lo que traiga el JSON de entrada. Si la
22
22
  épica declara el campo estructurado `depende_de: [EP-001, …]`, ese gana sobre la prosa.
23
23
 
24
+ [EP-OR-17] `--for-sync` emite la PROYECCIÓN DE CONTRATO del carril `graph-sync`: `{"epics":[…]}`
25
+ y nada más (`GraphSyncBundle` es `extra="forbid"`; las claves de preparación son un 422). En ese
26
+ modo lo que aquí es aviso pasa a ser RECHAZO con rc 2 — el import de admin lo revisa un humano
27
+ antes de subirlo, pero una propuesta de re-sync viaja sola, y el hub rechaza el grafo ENTERO ante
28
+ una sola épica mal formada.
29
+
24
30
  Uso:
25
31
  python3 graph-bundle.py < epics.json > bundle.json # avisos por stderr
26
32
  python3 graph-bundle.py --from-docs docs/03-backlog/epicas.md < epics.json > bundle.json
27
33
  python3 graph-bundle.py --print-deps docs/03-backlog/epicas.md # solo el grafo extraído
34
+ python3 graph-bundle.py --for-sync --from-docs docs/03-backlog/epicas.md < epics.json
28
35
 
29
36
  Entrada (stdin, JSON — formato de discovery, sin cambios):
30
37
  {"project_ref": "…",
@@ -38,7 +45,9 @@ el criterio del arnés — nada se pierde en silencio, nada bloquea la preparaci
38
45
 
39
46
  Códigos de salida: 0 = bundle generado (con o sin avisos) · 1 = entrada ilegible
40
47
  (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).
48
+ grafo sin aristas no falla solo — empobrece en silencio, que es justo lo que causó #56)
49
+ · 2 = solo con `--for-sync`: el bundle no cumple el contrato del hub (un motivo por línea en
50
+ stderr, cada uno nombrando la épica y el campo).
42
51
  """
43
52
  import json
44
53
  import re
@@ -127,11 +136,15 @@ def leer_dependencias_de_docs(ruta):
127
136
 
128
137
 
129
138
  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}
139
+ """({from_docs, print_deps, for_sync}, error) — sin dependencias, mismo estilo que el resto."""
140
+ opciones = {"from_docs": None, "print_deps": None, "for_sync": False}
132
141
  i = 0
133
142
  while i < len(argv):
134
143
  arg = argv[i]
144
+ if arg == "--for-sync":
145
+ opciones["for_sync"] = True
146
+ i += 1
147
+ continue
135
148
  if arg in ("--from-docs", "--print-deps"):
136
149
  if i + 1 >= len(argv) or argv[i + 1].startswith("--"):
137
150
  return opciones, "%s exige la ruta del fichero de épicas" % arg
@@ -145,6 +158,100 @@ def parsear_argumentos(argv):
145
158
  return opciones, None
146
159
 
147
160
 
161
+ # ── Proyección de contrato para el carril `graph-sync` [EP-OR-17] ─────────────────────
162
+ # `GraphSyncBundle` (event_catalog.py del hub) es `extra="forbid"` y admite SOLO `epics`: las
163
+ # claves de preparación (`bundle_version`, `kind`, `project_ref`, `generated_at`,
164
+ # `generated_by`, `depends_on_source`, `warnings`) son un 422 garantizado.
165
+ #
166
+ # Y aquí los avisos se vuelven RECHAZOS. El bundle de preparación puede seguir adelante con
167
+ # `layer` ausente porque un ADMIN lo revisa antes de subirlo; el carril de sync no tiene humano
168
+ # en medio, y el hub rechaza el grafo ENTERO ante una sola épica mal formada. Un aviso que nadie
169
+ # lee es un 422 con pasos extra.
170
+ MAX_EPICAS = 200
171
+ MAX_HISTORIAS = 100
172
+ MAX_FILES_SCOPE = 200
173
+ MAX_DEPENDS_ON = 50
174
+ LARGOS = {"code": 50, "title": 300, "release_line": 100, "docs_ref": 500,
175
+ "files_scope_item": 500, "depends_on_item": 50}
176
+
177
+
178
+ def emitir_para_sync(epicas):
179
+ """Imprime `{"epics":[…]}` en el formato del contrato; rc 2 si algo no lo cumple."""
180
+ errores = []
181
+ if not epicas:
182
+ errores.append("el bundle no tiene ni una épica: no hay nada que proponer")
183
+ if len(epicas) > MAX_EPICAS:
184
+ errores.append("%d épicas: el contrato admite %d como máximo"
185
+ % (len(epicas), MAX_EPICAS))
186
+ salida = []
187
+ for code in sorted(epicas):
188
+ e = epicas[code]
189
+ if len(code) > LARGOS["code"]:
190
+ errores.append("%s: el `code` excede %d caracteres" % (code, LARGOS["code"]))
191
+ if not e.get("title"):
192
+ errores.append("%s: sin `title` (el contrato lo exige no vacío)" % code)
193
+ elif len(e["title"]) > LARGOS["title"]:
194
+ errores.append("%s: el `title` excede %d caracteres" % (code, LARGOS["title"]))
195
+ if e.get("layer") not in VALID_LAYERS:
196
+ errores.append("%s: `layer` es %r y el contrato exige FOUNDATIONAL o BUSINESS "
197
+ "(declárala en los docs de discovery)" % (code, e.get("layer")))
198
+ fs = e.get("files_scope") or []
199
+ if len(fs) > MAX_FILES_SCOPE:
200
+ errores.append("%s: %d entradas en `files_scope` y el contrato admite %d"
201
+ % (code, len(fs), MAX_FILES_SCOPE))
202
+ for x in fs:
203
+ if not x or len(x) > LARGOS["files_scope_item"]:
204
+ errores.append("%s: una entrada de `files_scope` está vacía o excede %d "
205
+ "caracteres" % (code, LARGOS["files_scope_item"]))
206
+ break
207
+ deps = e.get("depends_on") or []
208
+ if len(deps) > MAX_DEPENDS_ON:
209
+ errores.append("%s: %d entradas en `depends_on` y el contrato admite %d"
210
+ % (code, len(deps), MAX_DEPENDS_ON))
211
+ for x in deps:
212
+ if not x or len(x) > LARGOS["depends_on_item"]:
213
+ errores.append("%s: una entrada de `depends_on` está vacía o excede %d "
214
+ "caracteres" % (code, LARGOS["depends_on_item"]))
215
+ break
216
+ historias = e.get("stories") or []
217
+ if len(historias) > MAX_HISTORIAS:
218
+ errores.append("%s: %d historias y el contrato admite %d por épica"
219
+ % (code, len(historias), MAX_HISTORIAS))
220
+ hs = []
221
+ for h in historias:
222
+ hcode = h.get("code") or ""
223
+ if not hcode or len(hcode) > LARGOS["code"]:
224
+ errores.append("%s: una historia no tiene `code` o excede %d caracteres"
225
+ % (code, LARGOS["code"]))
226
+ continue
227
+ htitle = h.get("title") or ""
228
+ if not htitle:
229
+ errores.append("%s: la historia %s no tiene `title`" % (code, hcode))
230
+ continue
231
+ hs.append({"code": hcode, "title": htitle[:LARGOS["title"]]})
232
+ proyectada = {"code": code, "title": (e.get("title") or "")[:LARGOS["title"]],
233
+ "layer": e.get("layer"),
234
+ "files_scope": fs, "depends_on": deps, "stories": hs}
235
+ # Opcionales: solo viajan si tienen valor. `null` es legal en el contrato, pero omitirlos
236
+ # deja más corto el delta que el humano revisa en la consola.
237
+ if e.get("release_line"):
238
+ proyectada["release_line"] = e["release_line"][:LARGOS["release_line"]]
239
+ if isinstance(e.get("priority"), int):
240
+ proyectada["priority"] = e["priority"]
241
+ if e.get("docs_ref"):
242
+ proyectada["docs_ref"] = e["docs_ref"][:LARGOS["docs_ref"]]
243
+ salida.append(proyectada)
244
+ if errores:
245
+ print("graph-bundle --for-sync: el bundle NO cumple el contrato del hub "
246
+ "(no se envía nada):", file=sys.stderr)
247
+ for x in errores:
248
+ print(" ⛔ %s" % x, file=sys.stderr)
249
+ return 2
250
+ json.dump({"epics": salida}, sys.stdout, indent=2, ensure_ascii=False)
251
+ sys.stdout.write("\n")
252
+ return 0
253
+
254
+
148
255
  def main():
149
256
  avisos = []
150
257
  opciones, error = parsear_argumentos(sys.argv[1:])
@@ -322,6 +429,14 @@ def main():
322
429
  "(el hub guarda UNA línea por épica)"
323
430
  % (c, epicas[c]["release_line"], r["id"]))
324
431
 
432
+ # [EP-OR-17] El carril de sync se lleva la proyección de contrato y NADA más: los avisos
433
+ # siguen saliendo por stderr (arriba se han ido acumulando), pero el `warnings` del bundle
434
+ # de preparación no existe en `GraphSyncBundle` y viajaría como un 422.
435
+ if opciones["for_sync"]:
436
+ for a in avisos:
437
+ print("aviso: %s" % a, file=sys.stderr)
438
+ return emitir_para_sync(epicas)
439
+
325
440
  bundle = {
326
441
  "bundle_version": BUNDLE_VERSION,
327
442
  "kind": "graph",
@@ -177,8 +177,9 @@ fi
177
177
  # grafo son estado derivado del hub, igual que los de arriba: mismo blindaje.
178
178
  if [ -f "$GI" ] \
179
179
  && grep -q '^\.claude/state/epic-proposals\.json$' "$GI" \
180
+ && grep -q '^\.claude/state/graph-sync-proposals\.json$' "$GI" \
180
181
  && grep -q '^\.claude/state/graph-status\.json$' "$GI"; then
181
- echo "OK install: .gitignore protege epic-proposals/graph-status"
182
+ echo "OK install: .gitignore protege epic-proposals/graph-sync-proposals/graph-status"
182
183
  else
183
184
  echo "FAIL install: .gitignore no protege epic-proposals.json/graph-status.json"
184
185
  fail=1
@@ -320,6 +320,23 @@ echo '{"title":"Notificaciones","objective":"avisar al usuario"}' > "$TMP/prop.j
320
320
  CID="$(runtime_enqueue_direct epic_proposed POST /projects/p-1/epic-proposals "$TMP/prop.json")"
321
321
  [ -n "$CID" ] && echo "OK runtime_enqueue_direct devuelve el client_event_id" \
322
322
  || { echo "FAIL runtime_enqueue_direct sin id"; fail=1; }
323
+
324
+ # [EP-OR-17] El cid FIJABLE es lo que hace idempotente el reintento del re-sync: el hub
325
+ # deduplica por client_event_id, así que re-encolar tras un 409 no crea una segunda propuesta.
326
+ cid_fijo="$(runtime_enqueue_direct graph_sync_proposed POST /p/1 "$TMP/prop.json" 1 "ak-1:graph-sync:3")"
327
+ [ "$cid_fijo" = "ak-1:graph-sync:3" ] \
328
+ && echo "OK enqueue_direct devuelve el cid fijado" \
329
+ || { echo "FAIL cid fijado ('$cid_fijo')"; fail=1; }
330
+ n_fijo="$(grep -l '"client_event_id": "ak-1:graph-sync:3"' "$CLAUDE_PROJECT_DIR/.claude/state/outbox"/*.json 2>/dev/null | wc -l | tr -d ' ')"
331
+ [ "$n_fijo" = 1 ] && echo "OK el cid fijado se persiste en el fichero encolado" \
332
+ || { echo "FAIL cid no persistido ($n_fijo)"; fail=1; }
333
+ cid_uuid="$(runtime_enqueue_direct graph_sync_proposed POST /p/1 "$TMP/prop.json" 1)"
334
+ [ -n "$cid_uuid" ] && [ "$cid_uuid" != "ak-1:graph-sync:3" ] \
335
+ && echo "OK sin 6º argumento sigue generando uuid (carril de épicas intacto)" \
336
+ || { echo "FAIL regresión del uuid ('$cid_uuid')"; fail=1; }
337
+ # Los dos encolados de prueba se retiran: los bloques de abajo cuentan ficheros de la cola.
338
+ rm -f "$CLAUDE_PROJECT_DIR/.claude/state/outbox"/*"$cid_fijo".json \
339
+ "$CLAUDE_PROJECT_DIR/.claude/state/outbox"/*"$cid_uuid".json
323
340
  f="$(find "$CLAUDE_PROJECT_DIR/.claude/state/outbox" -maxdepth 1 -name '*.json' | head -1)"
324
341
  python3 -c "
325
342
  import json
@@ -490,10 +507,37 @@ PY
490
507
  )"
491
508
  [ "$has" = NO ] && echo "OK sin estampado pedido, el cuerpo no cambia" || { echo "FAIL estampado no pedido"; fail=1; }
492
509
 
493
- # 409 stale_graph en el carril directo: se anota el desfase y la petición SIGUE en la cola
494
- # (la refresca el heartbeat, #61), no se pierde ni se aparta a la primera.
510
+ # [EP-OR-17] El hub NUNCA responde `reason: stale_graph`: emite
511
+ # `{"detail":{"error":"graph_version_stale","graph_version":N}}` (un HTTPException de FastAPI
512
+ # anida SIEMPRE bajo `detail`). El helper acepta las dos formas — la real y la plana legacy —
513
+ # y ninguna otra.
514
+ gv="$(runtime_stale_graph_version '{"detail":{"error":"graph_version_stale","message":"x","graph_version":88}}')" && rcx=0 || rcx=$?
515
+ [ "$rcx" = 0 ] && [ "$gv" = 88 ] \
516
+ && echo "OK stale: detail.error anidado (forma real del hub)" \
517
+ || { echo "FAIL detail.error anidado (rc $rcx, gv '$gv')"; fail=1; }
518
+ gv="$(runtime_stale_graph_version '{"reason":"stale_graph","graph_version":45}')" && rcx=0 || rcx=$?
519
+ [ "$rcx" = 0 ] && [ "$gv" = 45 ] \
520
+ && echo "OK stale: reason en raiz (forma legacy)" \
521
+ || { echo "FAIL reason en raiz (rc $rcx, gv '$gv')"; fail=1; }
522
+ gv="$(runtime_stale_graph_version '{"detail":{"error":"graph_version_stale"}}')" && rcx=0 || rcx=$?
523
+ [ "$rcx" = 0 ] && [ -z "$gv" ] \
524
+ && echo "OK stale sin graph_version -> version vacia, sigue siendo stale" \
525
+ || { echo "FAIL stale sin version (rc $rcx, gv '$gv')"; fail=1; }
526
+ runtime_stale_graph_version '{"detail":"el client_event_id ya identifica otro agregado"}' >/dev/null && rcx=0 || rcx=$?
527
+ [ "$rcx" = 1 ] && echo "OK un 409 con detail string NO es grafo rancio" \
528
+ || { echo "FAIL detail string tratado como stale (rc $rcx)"; fail=1; }
529
+ runtime_stale_graph_version '{"detail":{"error":"context_drift"}}' >/dev/null && rcx=0 || rcx=$?
530
+ [ "$rcx" = 1 ] && echo "OK otro error anidado NO es grafo rancio" \
531
+ || { echo "FAIL otro error tratado como stale (rc $rcx)"; fail=1; }
532
+ runtime_stale_graph_version 'esto no es json' >/dev/null && rcx=0 || rcx=$?
533
+ [ "$rcx" = 1 ] && echo "OK respuesta ilegible NO es grafo rancio" \
534
+ || { echo "FAIL respuesta ilegible tratada como stale (rc $rcx)"; fail=1; }
535
+
536
+ # 409 de grafo rancio en el carril directo: se anota el desfase y la petición SIGUE en la cola
537
+ # (la refresca el heartbeat, #61), no se pierde ni se aparta a la primera. El cuerpo es el REAL
538
+ # del hub — con la forma plana este bloque pasaba en verde contra un contrato inexistente.
495
539
  cat > "$TMP/routes-d.json" <<'JSON'
496
- {"POST /projects/p-1/epic-proposals": {"status": 409, "body": {"reason": "stale_graph", "graph_version": 60}},
540
+ {"POST /projects/p-1/epic-proposals": {"status": 409, "body": {"detail": {"error": "graph_version_stale", "message": "el grafo avanzó", "graph_version": 60}}},
497
541
  "POST /events": {"status": 200, "body": {"accepted": [], "duplicates": [], "rejected": []}}}
498
542
  JSON
499
543
  runtime_graph_clear_stale
@@ -580,6 +624,8 @@ print(json.load(open('$rej12')).get('rejected_reason',''))" 2>/dev/null | grep -
580
624
  || echo "OK la razon del rechazo no inventa un desfase de grafo"
581
625
 
582
626
  # [Minor 6] "3 rechazos CONSECUTIVOS" es literal: un transitorio intercalado reinicia la racha.
627
+ # Este bloque usa a propósito la forma PLANA (`reason: stale_graph`): es la compatibilidad hacia
628
+ # atrás con una instancia anterior al contrato, y tiene que seguir entendiéndose.
583
629
  cat > "$TMP/routes-d.json" <<'JSON'
584
630
  {"POST /projects/p-1/epic-proposals": {"status": 409, "body": {"reason": "stale_graph", "graph_version": 70}},
585
631
  "POST /events": {"status": 200, "body": {"accepted": [], "duplicates": [], "rejected": []}}}