@trycore/spec-build-harness 0.8.5 → 0.11.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/GOVERNANCE.md +27 -4
- package/INSTALL.md +27 -5
- package/METODOLOGIA.md +55 -5
- package/README.md +39 -6
- package/VERSION +1 -1
- package/agents/build/build-orchestrator.md +33 -7
- package/agents/build/dor-dod-gatekeeper.md +13 -5
- package/agents/build/wiring-adversarial-verifier.md +52 -5
- package/commands/build/architect.md +1 -1
- package/commands/build/claim.md +46 -0
- package/commands/build/escalate.md +36 -0
- package/commands/build/front.md +9 -3
- package/commands/build/onboard.md +75 -14
- package/commands/build/prototype.md +3 -2
- package/commands/build/reflect.md +60 -40
- package/commands/build/release.md +10 -7
- package/commands/build/resume.md +33 -13
- package/commands/build/slice.md +32 -27
- package/commands/build/status.md +35 -0
- package/commands/build/work.md +11 -8
- package/config/build-config.template.json +4 -0
- package/dist/cli.js +32 -0
- package/dist/commands/doctor.js +42 -0
- package/dist/commands/init.js +84 -1
- package/dist/commands/migrate.js +153 -0
- package/dist/commands/status.js +34 -0
- package/dist/lib/normalize.js +1123 -0
- package/dist/lib/paths.js +6 -0
- package/dist/lib/runtime-client.js +196 -0
- package/dist/lib/settings-merge.js +3 -3
- package/dist/lib/state-bundle.js +150 -0
- package/docs/commands.md +25 -8
- package/docs/getting-started.md +1 -0
- package/docs/hooks.md +114 -27
- package/docs/runtime/guia-modo-dual-y-migracion.md +143 -0
- package/docs/runtime/plan-migracion-harness-v0.9.md +11 -0
- package/docs/runtime/protocolo-cliente-runtime.md +120 -35
- package/hooks/build/build-gate-check.sh +21 -0
- package/hooks/build/context-monitor.sh +82 -15
- package/hooks/build/context-sync.sh +192 -0
- package/hooks/build/design-source-guard.sh +30 -2
- package/hooks/build/dual-compare.sh +92 -0
- package/hooks/build/event-emitter.sh +32 -0
- package/hooks/build/gitflow-guard.sh +164 -14
- package/hooks/build/heartbeat.sh +259 -0
- package/hooks/build/lib/agent-context.sh +139 -0
- package/hooks/build/lib/config.sh +27 -0
- package/hooks/build/lib/projection.sh +71 -0
- package/hooks/build/lib/runtime-client.sh +625 -0
- package/hooks/build/lib/runtime-ops.sh +227 -0
- package/hooks/build/lib/state-io.sh +5 -18
- package/hooks/build/load-build-state.sh +64 -2
- package/hooks/build/reflect-nudge.sh +15 -0
- package/hooks/build/release-gate-nudge.sh +15 -0
- package/hooks/build/release-ops.sh +171 -0
- package/hooks/build/scaffold-guard.sh +29 -2
- package/hooks/build/session-start.sh +103 -0
- package/hooks/build/session-stop.sh +22 -0
- package/hooks/build/slice-ops.sh +948 -0
- package/hooks/build/stack-guard.sh +8 -0
- package/hooks/build/statusline-bridge.sh +24 -3
- package/hooks/build-harness.json +16 -0
- package/package.json +3 -3
- package/scripts/check-agnostic.sh +3 -1
- package/scripts/check-pack-clean.sh +31 -0
- package/scripts/check-runtime-purity.sh +43 -0
- package/scripts/denylist.txt +4 -0
- package/scripts/lib/front-plan.py +4 -0
- package/scripts/lib/graph-bundle.py +181 -0
- package/scripts/runtime-purity-allow.txt +5 -0
- package/scripts/smoke-test.sh +1 -1
- package/scripts/tests/lib/http-stub.py +46 -0
- package/scripts/tests/test-baseline-verdict.sh +92 -0
- package/scripts/tests/test-config.sh +25 -0
- package/scripts/tests/test-hooks-runtime.sh +828 -0
- package/scripts/tests/test-install.sh +103 -0
- package/scripts/tests/test-runtime-client.sh +298 -0
- package/scripts/tests/test-schema.sh +29 -1
- package/scripts/tests/test-skill-ops.sh +1367 -0
- package/skills/building-a-micro-change/SKILL.md +22 -4
- package/skills/building-a-slice/SKILL.md +55 -21
- package/skills/building-a-slice/assets/baseline-verdict.sh +172 -0
- package/skills/building-a-slice/references/dod.md +12 -3
- package/skills/building-a-slice/references/dor.md +3 -2
- package/skills/building-a-slice/references/evidence-budget.md +51 -0
- package/skills/building-a-slice/references/exploration-fanout.md +1 -1
- package/skills/building-a-slice/references/gitflow.md +1 -1
- package/skills/building-a-slice/references/regression-baseline.md +67 -0
- package/skills/building-a-slice/references/runtime-protocol.md +75 -0
- package/skills/building-a-slice/references/state-protocol.md +12 -1
- package/skills/building-a-slice/workflows/README.md +7 -3
- package/skills/building-a-slice/workflows/explore-fanout.workflow.js +3 -3
- package/skills/building-a-slice/workflows/wiring-verify.workflow.js +26 -4
- package/skills/managing-parallel-front/SKILL.md +32 -16
- package/skills/openspec-archive-change/SKILL.md +15 -0
- package/skills/prototyping-screens/SKILL.md +9 -5
- package/skills/releasing-a-version/SKILL.md +26 -16
- package/skills/releasing-a-version/references/release-dod.md +7 -5
- package/skills/releasing-a-version/workflows/README.md +2 -1
- package/skills/releasing-a-version/workflows/release-gate.workflow.js +6 -5
- package/skills/setup-architecture/SKILL.md +4 -2
- package/state/README.md +16 -1
- package/state/build-state.schema.json +2 -1
- package/templates/CLAUDE.md.template +16 -0
- package/templates/settings-hooks.template.json +8 -4
- package/internal/skills/auditar-arnes/SKILL.md +0 -29
|
@@ -0,0 +1,1123 @@
|
|
|
1
|
+
// normalize.ts — normaliza el build-state.json legacy a la forma de eventos
|
|
2
|
+
// del catálogo v2 que exige POST /orchestrator/projects/{id}/import/bundle
|
|
3
|
+
// (docs/superpowers/specs/2026-08-20-cliente-migrate-normalizacion-eventos-design.md).
|
|
4
|
+
//
|
|
5
|
+
// Catálogo v2 e invariantes embebidos desde trycore-ia-hub/backend/app/modules/
|
|
6
|
+
// orchestrator/ (event_catalog.py, domain.py, release_domain.py,
|
|
7
|
+
// release_gate_domain.py, front_domain.py, project_facts_domain.py,
|
|
8
|
+
// import_service.py), re-verificados 2026-08-28 contra el import real del primer piloto real
|
|
9
|
+
// (issue #40). Si el catálogo del hub cambia, esta copia se desincroniza — no
|
|
10
|
+
// hay forma de importarlo en build-time entre repos distintos.
|
|
11
|
+
export const PHASE_ORDER = [
|
|
12
|
+
'dor', 'change', 'red', 'green', 'refactor', 'smoke', 'api', 'data', 'dod', 'pr', 'archived',
|
|
13
|
+
];
|
|
14
|
+
/** Gates del inner loop que el reducer del slice acepta (hub domain.py:130-133);
|
|
15
|
+
* cualquier otro nombre revienta con «gate desconocido» (domain.py:422). */
|
|
16
|
+
export const SLICE_GATES = [
|
|
17
|
+
'dor', 'coherence_link', 'tdd', 'journey_smoke', 'fidelity',
|
|
18
|
+
'api', 'data', 'wiring_verified', 'dod',
|
|
19
|
+
];
|
|
20
|
+
/** Los seis gates del Release Gate (hub release_gate_domain.py:20-22). El
|
|
21
|
+
* import del hub no los valida por nombre, pero `estado_de_gates` solo itera
|
|
22
|
+
* estos seis: un gate ajeno sería una fila muerta que nadie audita. */
|
|
23
|
+
export const RELEASE_GATES = [
|
|
24
|
+
'security', 'smell', 'ux', 'coherence', 'stack_arch', 'integration',
|
|
25
|
+
];
|
|
26
|
+
/** `_MAX_NOTE` del catálogo del hub (event_catalog.py:37): todo campo de texto
|
|
27
|
+
* libre (note/evidence/cause/reason/summary/motivo) rechaza con
|
|
28
|
+
* `string_too_long` por encima de 2000 caracteres. */
|
|
29
|
+
export const MAX_NOTE = 2000;
|
|
30
|
+
const TRUNCATION_MARKER = ' … [truncado por migración]';
|
|
31
|
+
/** Trunca una nota al límite del hub conservando un marcador de truncado
|
|
32
|
+
* (defecto 5 del primer piloto real: 6 entradas rechazadas por `string_too_long`). */
|
|
33
|
+
export function truncateNote(text) {
|
|
34
|
+
if (text.length <= MAX_NOTE)
|
|
35
|
+
return text;
|
|
36
|
+
return text.slice(0, MAX_NOTE - TRUNCATION_MARKER.length) + TRUNCATION_MARKER;
|
|
37
|
+
}
|
|
38
|
+
// Evidencia ACUÑADA de migración: el invariante 3 del hub (domain.py:424-425 y
|
|
39
|
+
// 452-453) exige evidence en todo veredicto/wiring passing, también en el
|
|
40
|
+
// histórico (validar_secuencia usa el reducer real, import_domain.py:126-172).
|
|
41
|
+
// El build-state.json legacy solo guarda booleanos, así que la evidencia
|
|
42
|
+
// declara honestamente su origen en vez de inventar una ejecución.
|
|
43
|
+
export const MIGRATION_GATE_EVIDENCE = 'veredicto original true en build-state.json del harness legacy; detalle en la nota del slice (evidencia acuñada por trycore-build migrate)';
|
|
44
|
+
export const MIGRATION_WIRING_EVIDENCE = 'status passing en build-state.json del harness legacy, sin evidencia textual registrada (acuñada por trycore-build migrate)';
|
|
45
|
+
export const MIGRATION_RELEASE_EVIDENCE = 'veredicto original true en releases[] de build-state.json del harness legacy (evidencia acuñada por trycore-build migrate)';
|
|
46
|
+
/** Tipos que ESTE normalizador emite por agregado (subset de lo que valida el
|
|
47
|
+
* hub). Lo usa `validateNormalizedBundle` como red contra bugs del emisor. */
|
|
48
|
+
export const EVENT_TYPES_BY_AGGREGATE = {
|
|
49
|
+
slice: new Set([
|
|
50
|
+
'slice_opened', 'phase_advanced', 'gate_verdict', 'wiring_checklist_seeded',
|
|
51
|
+
'wiring_item_updated', 'progress_noted', 'slice_archived', 'slice_parked',
|
|
52
|
+
'slice_escalated', 'slice_cancelled',
|
|
53
|
+
]),
|
|
54
|
+
release: new Set(['release_opened', 'release_gate_verdict', 'release_closed']),
|
|
55
|
+
front: new Set(['front_opened', 'front_member_added', 'front_drained', 'front_closed']),
|
|
56
|
+
};
|
|
57
|
+
const SECOND_MS = 1000;
|
|
58
|
+
const EPOCH_ISO = new Date(0).toISOString();
|
|
59
|
+
function toIso(value, fallback) {
|
|
60
|
+
if (typeof value === 'string') {
|
|
61
|
+
const d = new Date(value);
|
|
62
|
+
if (!Number.isNaN(d.getTime()))
|
|
63
|
+
return d.toISOString();
|
|
64
|
+
}
|
|
65
|
+
return fallback;
|
|
66
|
+
}
|
|
67
|
+
function ensureStrictlyIncreasing(events) {
|
|
68
|
+
let prevMs = -Infinity;
|
|
69
|
+
return events.map((ev) => {
|
|
70
|
+
let ms = new Date(ev.occurred_at).getTime();
|
|
71
|
+
if (Number.isNaN(ms) || ms <= prevMs)
|
|
72
|
+
ms = prevMs === -Infinity ? Date.parse(EPOCH_ISO) : prevMs + SECOND_MS;
|
|
73
|
+
prevMs = ms;
|
|
74
|
+
return { ...ev, occurred_at: new Date(ms).toISOString() };
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
function firstNonBlankString(...values) {
|
|
78
|
+
for (const v of values) {
|
|
79
|
+
if (typeof v === 'string' && v.trim() !== '')
|
|
80
|
+
return v;
|
|
81
|
+
}
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
function hasArchivedAs(entry) {
|
|
85
|
+
return typeof entry.archived_as === 'string' && entry.archived_as.trim() !== '';
|
|
86
|
+
}
|
|
87
|
+
function closingKind(entry) {
|
|
88
|
+
// Defecto 3 (EP-039): `archived_as` presente ⇒ el slice CERRÓ aunque falte
|
|
89
|
+
// `phase` — sin el cierre completo queda «en construcción» en el hub,
|
|
90
|
+
// reclamable por la cola e irreversible tras el import (el guard
|
|
91
|
+
// anti-superposición, import_domain.py:175-192, rechaza re-importarlo).
|
|
92
|
+
if (entry.phase === 'archived' || hasArchivedAs(entry))
|
|
93
|
+
return 'archived';
|
|
94
|
+
// Defecto 4 (EP-037): el estado legacy aparca con `phase: "parked"`; el hub
|
|
95
|
+
// lo modela como status con su evento propio `slice_parked` (event_catalog.py:156-158).
|
|
96
|
+
if (entry.phase === 'parked' || entry.status === 'parked')
|
|
97
|
+
return 'parked';
|
|
98
|
+
if (entry.status === 'cancelled' || entry.phase === 'cancelled')
|
|
99
|
+
return 'cancelled';
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
/** Hasta qué índice de fase avanza esta entrada. `archived`/`archived_as`
|
|
103
|
+
* avanzan hasta `pr`: `slice_archived` exige estar en pr (domain.py:499-500). */
|
|
104
|
+
function pipelineTargetIdx(entry) {
|
|
105
|
+
const phase = typeof entry.phase === 'string' ? entry.phase : null;
|
|
106
|
+
if (phase && PHASE_ORDER.includes(phase)) {
|
|
107
|
+
return phase === 'archived' ? PHASE_ORDER.indexOf('pr') : PHASE_ORDER.indexOf(phase);
|
|
108
|
+
}
|
|
109
|
+
if (hasArchivedAs(entry))
|
|
110
|
+
return PHASE_ORDER.indexOf('pr');
|
|
111
|
+
return -1;
|
|
112
|
+
}
|
|
113
|
+
function openingEvent(entry, epicCode) {
|
|
114
|
+
return {
|
|
115
|
+
event_type: 'slice_opened',
|
|
116
|
+
payload: { epic_code: epicCode },
|
|
117
|
+
occurred_at: toIso(entry.updated_at, EPOCH_ISO),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
function phaseAdvancedEvents(entry, state) {
|
|
121
|
+
const target = pipelineTargetIdx(entry);
|
|
122
|
+
if (target <= state.phaseIdx)
|
|
123
|
+
return [];
|
|
124
|
+
const anchor = toIso(entry.updated_at, EPOCH_ISO);
|
|
125
|
+
const events = [];
|
|
126
|
+
for (let i = state.phaseIdx + 1; i <= target; i++) {
|
|
127
|
+
events.push({ event_type: 'phase_advanced', payload: { to: PHASE_ORDER[i] }, occurred_at: anchor });
|
|
128
|
+
}
|
|
129
|
+
state.phaseIdx = target;
|
|
130
|
+
return events;
|
|
131
|
+
}
|
|
132
|
+
function degradedGateNote(gate, reason, anchor) {
|
|
133
|
+
return {
|
|
134
|
+
event_type: 'progress_noted',
|
|
135
|
+
payload: {
|
|
136
|
+
note: truncateNote(`[migración] gate "${gate}"=true en build-state.json legacy no importable como veredicto: ${reason} — conservado como nota`),
|
|
137
|
+
},
|
|
138
|
+
occurred_at: anchor,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
function gateVerdictEvents(entry, epicCode, state, warnings) {
|
|
142
|
+
const gates = entry.gates && typeof entry.gates === 'object' ? entry.gates : {};
|
|
143
|
+
const anchor = toIso(entry.updated_at, EPOCH_ISO);
|
|
144
|
+
const events = [];
|
|
145
|
+
// Orden CANÓNICO del catálogo, no el del objeto legacy: wiring_verified va
|
|
146
|
+
// antes que dod en SLICE_GATES, que es justo lo que el invariante 4 exige.
|
|
147
|
+
for (const gate of SLICE_GATES) {
|
|
148
|
+
if (gates[gate] !== true)
|
|
149
|
+
continue;
|
|
150
|
+
if (gate === 'dod' && !state.gatesTrue.has('wiring_verified')) {
|
|
151
|
+
// Invariante 4 del hub (domain.py:427-428): dod PASS sin wiring_verified
|
|
152
|
+
// cerrado rechaza la ENTRADA entera. Se degrada solo el veredicto, sin
|
|
153
|
+
// acuñar un wiring_verified que nunca corrió (eso sería forjar un
|
|
154
|
+
// veredicto, no acuñar evidencia).
|
|
155
|
+
events.push(degradedGateNote('dod', 'wiring_verified nunca cerró en este slice (invariante 4 del hub)', anchor));
|
|
156
|
+
warnings.push(`history[${epicCode}]: dod=true sin wiring_verified — degradado a progress_noted (invariante 4 del hub); el gate dod queda abierto en el hub`);
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
events.push({
|
|
160
|
+
event_type: 'gate_verdict',
|
|
161
|
+
payload: { gate, verdict: 'PASS', evidence: MIGRATION_GATE_EVIDENCE },
|
|
162
|
+
occurred_at: anchor,
|
|
163
|
+
});
|
|
164
|
+
state.gatesTrue.add(gate);
|
|
165
|
+
}
|
|
166
|
+
// Defecto 8: gates legacy fuera del catálogo (caso real: `change`) revientan
|
|
167
|
+
// con «gate desconocido» (domain.py:422). Se degradan a progress_noted
|
|
168
|
+
// preservando el dato — nada se pierde, nada se forja.
|
|
169
|
+
for (const [gate, value] of Object.entries(gates)) {
|
|
170
|
+
if (value !== true || SLICE_GATES.includes(gate))
|
|
171
|
+
continue;
|
|
172
|
+
events.push(degradedGateNote(gate, 'no existe en el catálogo de gates del slice del hub', anchor));
|
|
173
|
+
warnings.push(`history[${epicCode}]: gate legacy "${gate}" fuera del catálogo del hub (${SLICE_GATES.join(', ')}) — degradado a progress_noted`);
|
|
174
|
+
}
|
|
175
|
+
return events;
|
|
176
|
+
}
|
|
177
|
+
/** Defecto 7 (EP-038): los estados legacy nombran el ítem con `item_id`, `id`
|
|
178
|
+
* o `item` según la era; item_id vacío revienta en el reducer (domain.py:450). */
|
|
179
|
+
function resolveWiringItemId(item) {
|
|
180
|
+
return String(firstNonBlankString(item.item_id, item.id, item.item) ?? '').trim();
|
|
181
|
+
}
|
|
182
|
+
function wiringEvents(entry, epicCode, state, warnings, unmapped) {
|
|
183
|
+
const checklist = Array.isArray(entry.wiring_checklist) ? entry.wiring_checklist : [];
|
|
184
|
+
if (checklist.length === 0)
|
|
185
|
+
return [];
|
|
186
|
+
const anchor = toIso(entry.updated_at, EPOCH_ISO);
|
|
187
|
+
const events = [];
|
|
188
|
+
const resolved = [];
|
|
189
|
+
checklist.forEach((rawItem, j) => {
|
|
190
|
+
const item = typeof rawItem === 'object' && rawItem !== null ? rawItem : {};
|
|
191
|
+
const id = resolveWiringItemId(item);
|
|
192
|
+
if (!id) {
|
|
193
|
+
warnings.push(`history[${epicCode}]: wiring_checklist[${j}] sin item_id/id/item resolvible — cae a unmapped (el hub exige item_id, domain.py:450)`);
|
|
194
|
+
unmapped.push({ source_key: `history[${epicCode}].wiring_checklist[${j}]`, original: rawItem, occurred_at: anchor });
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
resolved.push({ id, item });
|
|
198
|
+
});
|
|
199
|
+
// La siembra solo declara ítems NUEVOS de la secuencia fusionada: re-sembrar
|
|
200
|
+
// un ítem lo devolvería a failing en el fold del hub (domain.py:471-484)
|
|
201
|
+
// pisando un passing anterior. Al menos un ítem (event_catalog.py:125).
|
|
202
|
+
const fresh = resolved.filter(({ id }) => !state.seededItemIds.has(id));
|
|
203
|
+
if (fresh.length > 0) {
|
|
204
|
+
events.push({
|
|
205
|
+
event_type: 'wiring_checklist_seeded',
|
|
206
|
+
payload: {
|
|
207
|
+
items: fresh.map(({ id, item }) => ({
|
|
208
|
+
item_id: id,
|
|
209
|
+
kind: String(item.kind ?? 'unknown'),
|
|
210
|
+
ref: String(item.ref ?? ''),
|
|
211
|
+
})),
|
|
212
|
+
},
|
|
213
|
+
occurred_at: anchor,
|
|
214
|
+
});
|
|
215
|
+
fresh.forEach(({ id }) => state.seededItemIds.add(id));
|
|
216
|
+
}
|
|
217
|
+
for (const { id, item } of resolved) {
|
|
218
|
+
if (typeof item.status !== 'string')
|
|
219
|
+
continue;
|
|
220
|
+
const payload = { item_id: id, status: item.status };
|
|
221
|
+
const originalEvidence = firstNonBlankString(item.evidence);
|
|
222
|
+
if (item.status === 'passing') {
|
|
223
|
+
// Invariante 3 sobre wiring (domain.py:452-453): passing ⇒ evidence.
|
|
224
|
+
payload.evidence = originalEvidence ? truncateNote(originalEvidence) : MIGRATION_WIRING_EVIDENCE;
|
|
225
|
+
}
|
|
226
|
+
else if (originalEvidence) {
|
|
227
|
+
payload.evidence = truncateNote(originalEvidence);
|
|
228
|
+
}
|
|
229
|
+
events.push({ event_type: 'wiring_item_updated', payload, occurred_at: anchor });
|
|
230
|
+
}
|
|
231
|
+
return events;
|
|
232
|
+
}
|
|
233
|
+
function progressNotedEvents(entry) {
|
|
234
|
+
const log = Array.isArray(entry.progress_log) ? entry.progress_log : [];
|
|
235
|
+
const fallback = toIso(entry.updated_at, EPOCH_ISO);
|
|
236
|
+
return log
|
|
237
|
+
.filter((p) => typeof p === 'object' && p !== null && typeof p.note === 'string')
|
|
238
|
+
.map((p) => ({
|
|
239
|
+
event_type: 'progress_noted',
|
|
240
|
+
// Defecto 5: notas >2000 revientan con string_too_long (_MAX_NOTE,
|
|
241
|
+
// event_catalog.py:37 y 134-135) — se truncan con marcador.
|
|
242
|
+
payload: { note: truncateNote(p.note) },
|
|
243
|
+
occurred_at: toIso(p.at, fallback),
|
|
244
|
+
}));
|
|
245
|
+
}
|
|
246
|
+
function closingEvents(entry, state) {
|
|
247
|
+
const anchor = toIso(entry.updated_at, EPOCH_ISO);
|
|
248
|
+
const kind = closingKind(entry);
|
|
249
|
+
if (kind === 'archived') {
|
|
250
|
+
// slice_archived exige phase pr (domain.py:499-500); si el cuerpo no llegó
|
|
251
|
+
// (p. ej. archived_as sin phase con datos raros), se completa aquí.
|
|
252
|
+
const events = [];
|
|
253
|
+
const prIdx = PHASE_ORDER.indexOf('pr');
|
|
254
|
+
for (let i = state.phaseIdx + 1; i <= prIdx; i++) {
|
|
255
|
+
events.push({ event_type: 'phase_advanced', payload: { to: PHASE_ORDER[i] }, occurred_at: anchor });
|
|
256
|
+
}
|
|
257
|
+
state.phaseIdx = Math.max(state.phaseIdx, prIdx);
|
|
258
|
+
events.push({ event_type: 'slice_archived', payload: {}, occurred_at: anchor });
|
|
259
|
+
return events;
|
|
260
|
+
}
|
|
261
|
+
if (kind === 'parked') {
|
|
262
|
+
// slice_parked exige reason no vacía (domain.py:372-373) y ≤2000
|
|
263
|
+
// (event_catalog.py:156-158).
|
|
264
|
+
const reason = firstNonBlankString(entry.reason, entry.parked_reason, entry.notes)
|
|
265
|
+
?? 'sin motivo registrado en build-state.json legacy (migración)';
|
|
266
|
+
return [{ event_type: 'slice_parked', payload: { reason: truncateNote(reason) }, occurred_at: anchor }];
|
|
267
|
+
}
|
|
268
|
+
if (kind === 'cancelled') {
|
|
269
|
+
// Cancelar exige pasar por ESCALATED (domain.py:393-394): el estado legacy
|
|
270
|
+
// cancelaba directo, así que se emite la escalada intermedia con su causa.
|
|
271
|
+
const cause = firstNonBlankString(entry.reason, entry.notes)
|
|
272
|
+
?? 'cancelado en el harness legacy sin causa registrada (migración)';
|
|
273
|
+
return [
|
|
274
|
+
{ event_type: 'slice_escalated', payload: { cause: truncateNote(cause) }, occurred_at: anchor },
|
|
275
|
+
{ event_type: 'slice_cancelled', payload: {}, occurred_at: anchor },
|
|
276
|
+
];
|
|
277
|
+
}
|
|
278
|
+
return [];
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* Fusiona todas las entradas de `history[]` de UNA épica en una sola secuencia
|
|
282
|
+
* continua: un único slice_opened al inicio, un único cierre al final (de la
|
|
283
|
+
* entrada de MAYOR índice, nunca por updated_at — spec §6.1). El reducer del
|
|
284
|
+
* servidor prohíbe reabrir un slice tras un cierre, así que las entradas
|
|
285
|
+
* intermedias solo aportan su cuerpo (fases/gates/wiring/progreso), sin abrir
|
|
286
|
+
* ni cerrar; las fases NUNCA se re-avanzan (orden estricto, domain.py:404-406).
|
|
287
|
+
*/
|
|
288
|
+
export function normalizeHistoryGroup(epicCode, entries, warnings = [], unmapped = []) {
|
|
289
|
+
const objs = entries.filter((e) => typeof e === 'object' && e !== null && !Array.isArray(e));
|
|
290
|
+
if (objs.length === 0)
|
|
291
|
+
return null;
|
|
292
|
+
const state = { phaseIdx: 0, gatesTrue: new Set(), seededItemIds: new Set() };
|
|
293
|
+
const events = [openingEvent(objs[0], epicCode)];
|
|
294
|
+
objs.forEach((entry, i) => {
|
|
295
|
+
if (hasArchivedAs(entry) && entry.phase !== 'archived') {
|
|
296
|
+
warnings.push(`history[${epicCode}]: archived_as presente sin phase "archived" — se emite el cierre completo (fases hasta pr + slice_archived); sin él, el slice quedaría reclamable en el hub y el import es irreversible (guard anti-superposición)`);
|
|
297
|
+
}
|
|
298
|
+
events.push(...phaseAdvancedEvents(entry, state));
|
|
299
|
+
events.push(...gateVerdictEvents(entry, epicCode, state, warnings));
|
|
300
|
+
events.push(...wiringEvents(entry, epicCode, state, warnings, unmapped));
|
|
301
|
+
events.push(...progressNotedEvents(entry));
|
|
302
|
+
if (i === objs.length - 1)
|
|
303
|
+
events.push(...closingEvents(entry, state));
|
|
304
|
+
});
|
|
305
|
+
return { epic_code: epicCode, events: ensureStrictlyIncreasing(events), normalized: true };
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* Agrupa history[] por épica preservando el ORDEN DE ÍNDICE de aparición
|
|
309
|
+
* (nunca por updated_at — spec §6.1) y fusiona cada grupo en una entrada.
|
|
310
|
+
* La clave legacy `epic` se mapea con warning explícito (dentro de una entrada
|
|
311
|
+
* de history solo puede significar el código de épica — riesgo de falso
|
|
312
|
+
* positivo acotado y visible); sin clave reconocible cae a unmapped con warning.
|
|
313
|
+
*/
|
|
314
|
+
export function normalizeHistory(historyRaw) {
|
|
315
|
+
const groups = new Map();
|
|
316
|
+
const order = [];
|
|
317
|
+
const unmapped = [];
|
|
318
|
+
const warnings = [];
|
|
319
|
+
historyRaw.forEach((raw, i) => {
|
|
320
|
+
const obj = typeof raw === 'object' && raw !== null && !Array.isArray(raw) ? raw : null;
|
|
321
|
+
let epicCode = obj && typeof obj.epica === 'string' && obj.epica !== '' ? obj.epica : null;
|
|
322
|
+
if (!epicCode && obj && typeof obj.epic === 'string' && obj.epic !== '') {
|
|
323
|
+
epicCode = obj.epic;
|
|
324
|
+
warnings.push(`history[${i}]: clave legacy "epic" — la clave esperada es "epica"; entrada mapeada a la épica "${obj.epic}"`);
|
|
325
|
+
}
|
|
326
|
+
if (!epicCode) {
|
|
327
|
+
warnings.push(`history[${i}]: sin clave "epica" (ni la legacy "epic") reconocible — la entrada cae a unmapped`);
|
|
328
|
+
unmapped.push({ source_key: `history[${i}]`, original: raw, occurred_at: toIso(obj?.updated_at, EPOCH_ISO) });
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
if (!groups.has(epicCode)) {
|
|
332
|
+
groups.set(epicCode, []);
|
|
333
|
+
order.push(epicCode);
|
|
334
|
+
}
|
|
335
|
+
groups.get(epicCode).push(obj);
|
|
336
|
+
});
|
|
337
|
+
const entries = [];
|
|
338
|
+
for (const epicCode of order) {
|
|
339
|
+
const merged = normalizeHistoryGroup(epicCode, groups.get(epicCode), warnings, unmapped);
|
|
340
|
+
if (merged)
|
|
341
|
+
entries.push(merged);
|
|
342
|
+
}
|
|
343
|
+
return { entries, unmapped, warnings };
|
|
344
|
+
}
|
|
345
|
+
/** Plantilla del ACTA del workaround del piloto: honesta sobre el origen y
|
|
346
|
+
* sobre por qué los gates quedan sin declarar. */
|
|
347
|
+
export const PRE_HARNESS_ACTA = 'ACTA de migración (pre-arnés): épica construida antes de instalar el arnés; '
|
|
348
|
+
+ 'verificación agregada (suite en verde + comportamiento en producción), gates sin declarar '
|
|
349
|
+
+ 'a propósito — la distinción frente a las épicas archivadas con gates cerrados es deliberada. '
|
|
350
|
+
+ 'Declarada por el humano en la lista de trycore-build migrate --pre-harness.';
|
|
351
|
+
/**
|
|
352
|
+
* Parsea y valida el fichero de --pre-harness: `{"epics": [{"code", "note"?}],
|
|
353
|
+
* "as_of"?}`. Estricto con las claves (una lista confirmada por un humano no
|
|
354
|
+
* admite typos silenciosos) y con duplicados. `as_of` opcional ancla los
|
|
355
|
+
* timestamps sintéticos; sin él se usa epoch — NUNCA el reloj (la
|
|
356
|
+
* normalización es determinista, misma regla que el resto de este fichero).
|
|
357
|
+
*/
|
|
358
|
+
export function parsePreHarnessList(raw) {
|
|
359
|
+
const errors = [];
|
|
360
|
+
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
|
|
361
|
+
return { list: null, errors: ['el fichero pre-arnés no es un objeto JSON (forma esperada: {"epics": [{"code": "EP-001", "note": "…"}]})'] };
|
|
362
|
+
}
|
|
363
|
+
const obj = raw;
|
|
364
|
+
for (const key of Object.keys(obj)) {
|
|
365
|
+
if (key !== 'epics' && key !== 'as_of') {
|
|
366
|
+
errors.push(`clave desconocida en la raíz: "${key}" (solo se admiten "epics" y "as_of")`);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
if (!Array.isArray(obj.epics)) {
|
|
370
|
+
errors.push('falta "epics" (array de épicas confirmadas por el humano)');
|
|
371
|
+
return { list: null, errors };
|
|
372
|
+
}
|
|
373
|
+
if (obj.epics.length === 0) {
|
|
374
|
+
errors.push('la lista "epics" está vacía — declara al menos una épica pre-arnés (o no uses --pre-harness)');
|
|
375
|
+
}
|
|
376
|
+
let asOf = EPOCH_ISO;
|
|
377
|
+
if (obj.as_of !== undefined) {
|
|
378
|
+
const d = typeof obj.as_of === 'string' ? new Date(obj.as_of) : new Date(NaN);
|
|
379
|
+
if (Number.isNaN(d.getTime())) {
|
|
380
|
+
errors.push(`"as_of" no es una fecha ISO válida: ${JSON.stringify(obj.as_of)}`);
|
|
381
|
+
}
|
|
382
|
+
else {
|
|
383
|
+
asOf = d.toISOString();
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
const epics = [];
|
|
387
|
+
const seen = new Set();
|
|
388
|
+
obj.epics.forEach((rawEpic, i) => {
|
|
389
|
+
if (typeof rawEpic !== 'object' || rawEpic === null || Array.isArray(rawEpic)) {
|
|
390
|
+
errors.push(`epics[${i}] no es un objeto (forma esperada: {"code": "EP-001", "note": "…"})`);
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
const epic = rawEpic;
|
|
394
|
+
for (const key of Object.keys(epic)) {
|
|
395
|
+
if (key !== 'code' && key !== 'note') {
|
|
396
|
+
errors.push(`epics[${i}]: clave desconocida "${key}" (solo se admiten "code" y "note")`);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
const code = typeof epic.code === 'string' ? epic.code.trim() : '';
|
|
400
|
+
if (!code) {
|
|
401
|
+
errors.push(`epics[${i}]: "code" vacío o ausente`);
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
if (seen.has(code)) {
|
|
405
|
+
errors.push(`epics[${i}]: código duplicado en la lista: "${code}"`);
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
seen.add(code);
|
|
409
|
+
let note;
|
|
410
|
+
if (epic.note !== undefined) {
|
|
411
|
+
if (typeof epic.note !== 'string' || epic.note.trim() === '') {
|
|
412
|
+
errors.push(`epics[${i}] (${code}): "note" debe ser texto no vacío — omítela si no aplica`);
|
|
413
|
+
}
|
|
414
|
+
else {
|
|
415
|
+
note = epic.note;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
epics.push({ code, ...(note !== undefined ? { note } : {}) });
|
|
419
|
+
});
|
|
420
|
+
if (errors.length > 0)
|
|
421
|
+
return { list: null, errors };
|
|
422
|
+
return { list: { epics, asOf }, errors };
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* Genera la entrada de historia sintética de UNA épica pre-arnés: el patrón
|
|
426
|
+
* exacto del workaround — `slice_opened` → `phase_advanced` por cada fase
|
|
427
|
+
* hasta `pr` → `progress_noted` con el ACTA (incorporando la nota del humano
|
|
428
|
+
* si viene) → `slice_archived`. SIN `gate_verdict` alguno: declarar gates que
|
|
429
|
+
* nunca corrieron sería forjar veredictos, no migrar historia. Timestamps
|
|
430
|
+
* anclados en `anchorIso` y espaciados por `ensureStrictlyIncreasing` —
|
|
431
|
+
* deterministas, sin reloj.
|
|
432
|
+
*/
|
|
433
|
+
export function normalizePreHarnessEpic(epic, anchorIso) {
|
|
434
|
+
const events = [
|
|
435
|
+
{ event_type: 'slice_opened', payload: { epic_code: epic.code }, occurred_at: anchorIso },
|
|
436
|
+
];
|
|
437
|
+
const prIdx = PHASE_ORDER.indexOf('pr');
|
|
438
|
+
for (let i = 1; i <= prIdx; i++) {
|
|
439
|
+
events.push({ event_type: 'phase_advanced', payload: { to: PHASE_ORDER[i] }, occurred_at: anchorIso });
|
|
440
|
+
}
|
|
441
|
+
const acta = epic.note ? `${PRE_HARNESS_ACTA} Nota del humano: ${epic.note}` : PRE_HARNESS_ACTA;
|
|
442
|
+
events.push({ event_type: 'progress_noted', payload: { note: truncateNote(acta) }, occurred_at: anchorIso });
|
|
443
|
+
events.push({ event_type: 'slice_archived', payload: {}, occurred_at: anchorIso });
|
|
444
|
+
return { epic_code: epic.code, events: ensureStrictlyIncreasing(events), normalized: true };
|
|
445
|
+
}
|
|
446
|
+
// ─── releases[] → agregado `release` ──────────────────────────────────────────
|
|
447
|
+
function releaseEvents(release, key, warnings) {
|
|
448
|
+
const anchor = toIso(release.created_at, EPOCH_ISO);
|
|
449
|
+
const epicCodes = Array.isArray(release.epicas) ? release.epicas.map(String) : [];
|
|
450
|
+
const events = [
|
|
451
|
+
{ event_type: 'release_opened', payload: { release_line: key, epic_codes: epicCodes }, occurred_at: anchor },
|
|
452
|
+
];
|
|
453
|
+
const unmapped = [];
|
|
454
|
+
const gates = release.gates && typeof release.gates === 'object' ? release.gates : {};
|
|
455
|
+
for (const [gate, verdict] of Object.entries(gates)) {
|
|
456
|
+
if (!RELEASE_GATES.includes(gate)) {
|
|
457
|
+
warnings.push(`releases[${key}]: gate "${gate}" fuera del Release Gate del hub (${RELEASE_GATES.join(', ')}) — preservado en unmapped`);
|
|
458
|
+
unmapped.push({ source_key: `releases[${key}].gates[${gate}]`, original: { gate, value: verdict }, occurred_at: anchor });
|
|
459
|
+
continue;
|
|
460
|
+
}
|
|
461
|
+
const payload = { gate, verdict: verdict === true ? 'PASS' : 'FAIL' };
|
|
462
|
+
// Defecto 6: el fold del hub degrada PASS-sin-evidencia a BLOQUEANTE
|
|
463
|
+
// (release_gate_domain.py:93-96), irreparable retroactivamente — misma
|
|
464
|
+
// acuñación de evidencia de migración que en los gates del slice.
|
|
465
|
+
if (verdict === true)
|
|
466
|
+
payload.evidence = MIGRATION_RELEASE_EVIDENCE;
|
|
467
|
+
events.push({ event_type: 'release_gate_verdict', payload, occurred_at: anchor });
|
|
468
|
+
}
|
|
469
|
+
if (release.status === 'failed' || release.status === 'passed') {
|
|
470
|
+
events.push({
|
|
471
|
+
event_type: 'release_closed',
|
|
472
|
+
payload: { status: release.status === 'failed' ? 'FAILED' : 'PASSED', epic_codes: epicCodes },
|
|
473
|
+
occurred_at: anchor,
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
const notes = Array.isArray(release.notes) ? release.notes : [];
|
|
477
|
+
notes.forEach((note, i) => {
|
|
478
|
+
unmapped.push({ source_key: `releases[${key}].notes[${i}]`, original: note, occurred_at: anchor });
|
|
479
|
+
});
|
|
480
|
+
return { events: ensureStrictlyIncreasing(events), unmapped };
|
|
481
|
+
}
|
|
482
|
+
/** `notes[]` de un release no tiene tipo en el catálogo del agregado `release`
|
|
483
|
+
* (event_catalog.py no declara ninguno) — se preserva en `unmapped[]` en vez
|
|
484
|
+
* de descartarse (spec §7: "nada se pierde, nada bloquea"). La clave legacy
|
|
485
|
+
* `release` se mapea a `release_id` con warning explícito; una entrada sin
|
|
486
|
+
* ninguna de las dos cae a unmapped con warning, nunca en silencio. */
|
|
487
|
+
export function normalizeReleases(releasesRaw) {
|
|
488
|
+
const entries = [];
|
|
489
|
+
const unmapped = [];
|
|
490
|
+
const warnings = [];
|
|
491
|
+
releasesRaw.forEach((raw, i) => {
|
|
492
|
+
const obj = typeof raw === 'object' && raw !== null && !Array.isArray(raw) ? raw : null;
|
|
493
|
+
let key = obj && typeof obj.release_id === 'string' && obj.release_id !== '' ? obj.release_id : null;
|
|
494
|
+
if (!key && obj && typeof obj.release === 'string' && obj.release !== '') {
|
|
495
|
+
key = obj.release;
|
|
496
|
+
warnings.push(`releases[${i}]: clave legacy "release" — la clave esperada es "release_id"; entrada mapeada a "${obj.release}"`);
|
|
497
|
+
}
|
|
498
|
+
if (!obj || !key) {
|
|
499
|
+
warnings.push(`releases[${i}]: sin clave "release_id" (ni la legacy "release") reconocible — la entrada cae a unmapped`);
|
|
500
|
+
unmapped.push({ source_key: `releases[${i}]`, original: raw, occurred_at: toIso(obj?.created_at, EPOCH_ISO) });
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
const { events, unmapped: relUnmapped } = releaseEvents(obj, key, warnings);
|
|
504
|
+
entries.push({ key, events, normalized: true });
|
|
505
|
+
unmapped.push(...relUnmapped);
|
|
506
|
+
});
|
|
507
|
+
return { entries, unmapped, warnings };
|
|
508
|
+
}
|
|
509
|
+
// ─── parallel_front → agregado `front` ────────────────────────────────────────
|
|
510
|
+
export function normalizeFront(frontRaw, warnings = [], unmapped = []) {
|
|
511
|
+
if (typeof frontRaw !== 'object' || frontRaw === null)
|
|
512
|
+
return null;
|
|
513
|
+
const front = frontRaw;
|
|
514
|
+
const anchor = toIso(front.opened_at, EPOCH_ISO);
|
|
515
|
+
const members = Array.isArray(front.members) ? front.members : [];
|
|
516
|
+
const events = [
|
|
517
|
+
{
|
|
518
|
+
event_type: 'front_opened',
|
|
519
|
+
payload: {
|
|
520
|
+
members: members.map((m) => String(m.epica ?? '')),
|
|
521
|
+
merge_order: Array.isArray(front.merge_order) ? front.merge_order : [],
|
|
522
|
+
asignaciones: members.map((m) => ({
|
|
523
|
+
epic_code: String(m.epica ?? ''),
|
|
524
|
+
worktree: String(m.worktree ?? ''),
|
|
525
|
+
assignee: String(m.assignee ?? ''),
|
|
526
|
+
})),
|
|
527
|
+
},
|
|
528
|
+
occurred_at: anchor,
|
|
529
|
+
},
|
|
530
|
+
];
|
|
531
|
+
if (front.status === 'drained') {
|
|
532
|
+
events.push({ event_type: 'front_drained', payload: {}, occurred_at: anchor });
|
|
533
|
+
}
|
|
534
|
+
else if (front.status === 'closed') {
|
|
535
|
+
// front_closed exige DRAINING previo Y todos los miembros integrados con
|
|
536
|
+
// front_member_integrated (front_domain.py:382-398); el estado legacy no
|
|
537
|
+
// registra esas integraciones y forjarlas inventaría merges. Se importa
|
|
538
|
+
// hasta DRAINING y el cierre queda preservado en unmapped.
|
|
539
|
+
events.push({ event_type: 'front_drained', payload: {}, occurred_at: anchor });
|
|
540
|
+
warnings.push('parallel_front: status "closed" no importable sin las integraciones por miembro que exige el hub (front_domain.py:387-398) — se importa hasta DRAINING; el cierre queda en unmapped');
|
|
541
|
+
unmapped.push({ source_key: 'parallel_front.status', original: 'closed', occurred_at: anchor });
|
|
542
|
+
}
|
|
543
|
+
return { key: 'parallel_front', events: ensureStrictlyIncreasing(events), normalized: true };
|
|
544
|
+
}
|
|
545
|
+
// ─── hechos de proyecto → agregado `project` ──────────────────────────────────
|
|
546
|
+
// Catálogo de hechos del hub (project_facts_domain.py:27-37, FACT_A_COLUMNA):
|
|
547
|
+
// un `fact` fuera de esta lista se rechaza por entrada (import_service.py:449-453).
|
|
548
|
+
const HUB_FACT_RULES = {
|
|
549
|
+
project_kind: { kind: 'machine_text', max: 20 },
|
|
550
|
+
harness_phase: { kind: 'machine_text', max: 50 },
|
|
551
|
+
foundation: { kind: 'boolean' },
|
|
552
|
+
scaffold_confirmed: { kind: 'boolean' },
|
|
553
|
+
design_source_confirmed: { kind: 'boolean' },
|
|
554
|
+
design_source_ref: { kind: 'string_or_null' },
|
|
555
|
+
};
|
|
556
|
+
function confirmedBoolean(value) {
|
|
557
|
+
if (typeof value === 'boolean')
|
|
558
|
+
return value;
|
|
559
|
+
if (typeof value === 'object' && value !== null && typeof value.confirmed === 'boolean') {
|
|
560
|
+
return value.confirmed;
|
|
561
|
+
}
|
|
562
|
+
return null;
|
|
563
|
+
}
|
|
564
|
+
function confirmedAt(value, fallback) {
|
|
565
|
+
if (typeof value === 'object' && value !== null)
|
|
566
|
+
return toIso(value.confirmed_at, fallback);
|
|
567
|
+
return fallback;
|
|
568
|
+
}
|
|
569
|
+
/** Mapea los hechos del build-state.json legacy al catálogo del hub
|
|
570
|
+
* (FACT_A_COLUMNA): `scaffold.confirmed`→`scaffold_confirmed`,
|
|
571
|
+
* `design_source.{confirmed,source}`→`design_source_{confirmed,ref}`. Lo que
|
|
572
|
+
* el hub no conoce (`project_kind_source`) cae a unmapped con warning en vez
|
|
573
|
+
* de viajar y rechazarse como «hecho de proyecto desconocido». Busca cada
|
|
574
|
+
* clave en la raíz y, si falta, en `raw.facts` (forma de estados intermedios). */
|
|
575
|
+
export function normalizeFacts(raw, fallbackAt, warnings = [], unmapped = []) {
|
|
576
|
+
const nested = raw.facts && typeof raw.facts === 'object' && !Array.isArray(raw.facts)
|
|
577
|
+
? raw.facts
|
|
578
|
+
: {};
|
|
579
|
+
const lookup = (key) => (key in raw ? raw[key] : nested[key]);
|
|
580
|
+
const has = (key) => key in raw || key in nested;
|
|
581
|
+
const facts = [];
|
|
582
|
+
const push = (fact, value, occurredAt) => {
|
|
583
|
+
facts.push({ fact, value, occurred_at: occurredAt, normalized: true });
|
|
584
|
+
};
|
|
585
|
+
const drop = (key, value, reason) => {
|
|
586
|
+
warnings.push(`facts: "${key}" ${reason} — cae a unmapped`);
|
|
587
|
+
unmapped.push({ source_key: `facts[${key}]`, original: value, occurred_at: fallbackAt });
|
|
588
|
+
};
|
|
589
|
+
for (const key of ['project_kind', 'harness_phase']) {
|
|
590
|
+
if (!has(key))
|
|
591
|
+
continue;
|
|
592
|
+
const value = lookup(key);
|
|
593
|
+
if (value === null || value === undefined)
|
|
594
|
+
continue;
|
|
595
|
+
const max = HUB_FACT_RULES[key].max;
|
|
596
|
+
if (typeof value !== 'string' || value === '' || value.length > max) {
|
|
597
|
+
drop(key, value, `no cabe en su columna del hub (texto no vacío de hasta ${max} caracteres, project_facts_domain.py:58-62)`);
|
|
598
|
+
continue;
|
|
599
|
+
}
|
|
600
|
+
push(key, value, fallbackAt);
|
|
601
|
+
}
|
|
602
|
+
if (has('foundation')) {
|
|
603
|
+
const value = lookup('foundation');
|
|
604
|
+
const confirmed = confirmedBoolean(value);
|
|
605
|
+
if (confirmed === null) {
|
|
606
|
+
if (value !== null && value !== undefined) {
|
|
607
|
+
drop('foundation', value, 'no es booleano ni trae confirmed booleano (el hub exige booleano, project_facts_domain.py:56-57)');
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
else {
|
|
611
|
+
push('foundation', confirmed, confirmedAt(value, fallbackAt));
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
if (has('scaffold')) {
|
|
615
|
+
const value = lookup('scaffold');
|
|
616
|
+
const confirmed = confirmedBoolean(value);
|
|
617
|
+
if (confirmed === null) {
|
|
618
|
+
if (value !== null && value !== undefined)
|
|
619
|
+
drop('scaffold', value, 'sin confirmed booleano mapeable a scaffold_confirmed');
|
|
620
|
+
}
|
|
621
|
+
else {
|
|
622
|
+
push('scaffold_confirmed', confirmed, confirmedAt(value, fallbackAt));
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
if (has('design_source')) {
|
|
626
|
+
const value = lookup('design_source');
|
|
627
|
+
const confirmed = confirmedBoolean(value);
|
|
628
|
+
const at = confirmedAt(value, fallbackAt);
|
|
629
|
+
if (confirmed !== null)
|
|
630
|
+
push('design_source_confirmed', confirmed, at);
|
|
631
|
+
const ref = typeof value === 'object' && value !== null
|
|
632
|
+
? firstNonBlankString(value.source, value.ref)
|
|
633
|
+
: null;
|
|
634
|
+
if (ref)
|
|
635
|
+
push('design_source_ref', ref, at);
|
|
636
|
+
if (confirmed === null && !ref && value !== null && value !== undefined) {
|
|
637
|
+
drop('design_source', value, 'sin confirmed booleano ni source mapeables');
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
if (has('project_kind_source')) {
|
|
641
|
+
const value = lookup('project_kind_source');
|
|
642
|
+
if (value !== null && value !== undefined) {
|
|
643
|
+
drop('project_kind_source', value, 'no existe en el catálogo de hechos del hub (FACT_A_COLUMNA, project_facts_domain.py:27-37)');
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
return facts;
|
|
647
|
+
}
|
|
648
|
+
// ─── validación local del emisor (red contra bugs del normalizador) ───────────
|
|
649
|
+
function checkSequence(errors, aggregate, key, events) {
|
|
650
|
+
if (events.length === 0)
|
|
651
|
+
errors.push(`${key}: sin eventos`);
|
|
652
|
+
for (const ev of events) {
|
|
653
|
+
if (!EVENT_TYPES_BY_AGGREGATE[aggregate].has(ev.event_type)) {
|
|
654
|
+
errors.push(`${key}: event_type "${ev.event_type}" no existe en el catálogo del agregado "${aggregate}"`);
|
|
655
|
+
}
|
|
656
|
+
if (Number.isNaN(new Date(ev.occurred_at).getTime())) {
|
|
657
|
+
errors.push(`${key}: occurred_at inválido: "${ev.occurred_at}"`);
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
/** Validación local previa al volcado (spec §9): mismo catálogo que valida el
|
|
662
|
+
* servidor, sin replicar la forma de payload campo a campo (eso lo cubre
|
|
663
|
+
* `verifyNormalizedBundle`, que sí re-implementa los invariantes del hub). */
|
|
664
|
+
export function validateNormalizedBundle(bundle) {
|
|
665
|
+
const errors = [];
|
|
666
|
+
bundle.history.forEach((h) => checkSequence(errors, 'slice', `history[${h.epic_code}]`, h.events));
|
|
667
|
+
bundle.releases.forEach((r) => checkSequence(errors, 'release', `releases[${r.key}]`, r.events));
|
|
668
|
+
bundle.fronts.forEach((f) => checkSequence(errors, 'front', `fronts[${f.key}]`, f.events));
|
|
669
|
+
return errors;
|
|
670
|
+
}
|
|
671
|
+
// ─── verify: los invariantes del hub, offline (migrate --verify) ──────────────
|
|
672
|
+
//
|
|
673
|
+
// Réplica en seco de lo que el hub aplica en el import (`validar_secuencia`
|
|
674
|
+
// conduce el reducer REAL de cada agregado, import_domain.py:126-172): la misma
|
|
675
|
+
// entrada que rechazaría el servidor debe salir aquí ANTES de entregar el
|
|
676
|
+
// bundle al ADMIN. Cada regla cita el punto del hub que la exige.
|
|
677
|
+
/** Tipos por agregado tal como los declara el hub (event_catalog.py:522-594,
|
|
678
|
+
* TIPOS_POR_AGREGADO) — la enumeración del catálogo es cerrada. */
|
|
679
|
+
const HUB_TYPES = {
|
|
680
|
+
slice: new Set([
|
|
681
|
+
'slice_opened', 'slice_claimed', 'phase_advanced', 'phase_reverted', 'gate_verdict',
|
|
682
|
+
'wiring_item_updated', 'wiring_checklist_seeded', 'checkpoint_recorded', 'progress_noted',
|
|
683
|
+
'handoff_recorded', 'slice_submitted', 'slice_archived', 'slice_reflected', 'slice_parked',
|
|
684
|
+
'slice_resumed', 'slice_escalated', 'slice_requeued', 'slice_cancelled', 'lease_renewed',
|
|
685
|
+
'lease_expired', 'lease_reaped', 'context_drift_detected', 'context_synced',
|
|
686
|
+
]),
|
|
687
|
+
release: new Set(['release_opened', 'release_gate_verdict', 'release_closed']),
|
|
688
|
+
front: new Set(['front_opened', 'front_member_added', 'front_member_integrated', 'front_drained', 'front_closed']),
|
|
689
|
+
};
|
|
690
|
+
/** Claves de payload que admite cada schema del catálogo: `extra="forbid"`
|
|
691
|
+
* (event_catalog.py:52-53) rechaza el evento entero ante una clave no
|
|
692
|
+
* declarada. Los `null` no viajan (payload_sin_nulos, import_domain.py:120-123). */
|
|
693
|
+
const HUB_PAYLOAD_KEYS = {
|
|
694
|
+
slice_opened: new Set(['epic_id', 'epic_code', 'retries_budget', 'source']),
|
|
695
|
+
slice_claimed: new Set(['agent_id', 'manifest_hash']),
|
|
696
|
+
phase_advanced: new Set(['to', 'source']),
|
|
697
|
+
phase_reverted: new Set(['to', 'gate', 'source']),
|
|
698
|
+
gate_verdict: new Set(['gate', 'verdict', 'evidence', 'cause', 'details_ref', 'source']),
|
|
699
|
+
wiring_item_updated: new Set(['item_id', 'status', 'evidence', 'kind', 'ref', 'source']),
|
|
700
|
+
wiring_checklist_seeded: new Set(['items', 'source']),
|
|
701
|
+
checkpoint_recorded: new Set(['branch', 'commit_sha', 'summary', 'source']),
|
|
702
|
+
progress_noted: new Set(['note', 'source']),
|
|
703
|
+
handoff_recorded: new Set(['note', 'resume_hint', 'stopped_at', 'source']),
|
|
704
|
+
slice_submitted: new Set([]),
|
|
705
|
+
slice_archived: new Set([]),
|
|
706
|
+
slice_parked: new Set(['reason']),
|
|
707
|
+
slice_resumed: new Set(['retries_budget']),
|
|
708
|
+
slice_escalated: new Set(['cause']),
|
|
709
|
+
slice_requeued: new Set(['retries_budget']),
|
|
710
|
+
slice_cancelled: new Set([]),
|
|
711
|
+
release_opened: new Set(['release_line', 'epic_codes']),
|
|
712
|
+
release_gate_verdict: new Set(['gate', 'verdict', 'evidence']),
|
|
713
|
+
release_closed: new Set(['status', 'motivo', 'epic_codes']),
|
|
714
|
+
front_opened: new Set(['members', 'merge_order', 'asignaciones']),
|
|
715
|
+
front_member_added: new Set(['epic_code', 'merge_order', 'worktree', 'assignee']),
|
|
716
|
+
front_member_integrated: new Set(['epic_code', 'merge_commit', 'resmoke_ok', 'evidence']),
|
|
717
|
+
front_drained: new Set([]),
|
|
718
|
+
front_closed: new Set([]),
|
|
719
|
+
};
|
|
720
|
+
const RELEASE_VERDICTS = new Set(['PASS', 'FAIL', 'PARTIAL', 'TOOL_ERROR']);
|
|
721
|
+
function isBlank(value) {
|
|
722
|
+
return typeof value !== 'string' || value.trim() === '';
|
|
723
|
+
}
|
|
724
|
+
function checkNoteLimit(add, label, value) {
|
|
725
|
+
if (typeof value === 'string' && value.length > MAX_NOTE) {
|
|
726
|
+
add(`${label} supera el límite de ${MAX_NOTE} caracteres del hub (string_too_long; _MAX_NOTE, event_catalog.py:37)`);
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
function checkCommon(add, aggregate, i, ev) {
|
|
730
|
+
if (!HUB_TYPES[aggregate].has(ev.event_type)) {
|
|
731
|
+
add(`evento ${i}: "${ev.event_type}" no es un tipo del agregado ${aggregate} (la enumeración del catálogo es cerrada, import_domain.py:144-149)`);
|
|
732
|
+
return false;
|
|
733
|
+
}
|
|
734
|
+
if (Number.isNaN(new Date(ev.occurred_at).getTime())) {
|
|
735
|
+
add(`evento ${i} (${ev.event_type}): occurred_at inválido: "${ev.occurred_at}"`);
|
|
736
|
+
}
|
|
737
|
+
const allowed = HUB_PAYLOAD_KEYS[ev.event_type];
|
|
738
|
+
if (allowed) {
|
|
739
|
+
for (const key of Object.keys(ev.payload ?? {})) {
|
|
740
|
+
if (ev.payload[key] === null)
|
|
741
|
+
continue;
|
|
742
|
+
if (!allowed.has(key)) {
|
|
743
|
+
add(`evento ${i} (${ev.event_type}): clave "${key}" fuera del schema del catálogo (extra="forbid", event_catalog.py:52-53)`);
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
return true;
|
|
748
|
+
}
|
|
749
|
+
function verifySliceSequence(events, add) {
|
|
750
|
+
if (events.length === 0) {
|
|
751
|
+
add('sin eventos: el hub no tiene nada que importar');
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
let phase = 'dor';
|
|
755
|
+
let status = 'ACTIVE';
|
|
756
|
+
const gatesTrue = new Set();
|
|
757
|
+
const rejected = new Set();
|
|
758
|
+
const isTerminal = () => status === 'CANCELLED' || phase === 'archived';
|
|
759
|
+
events.forEach((ev, i) => {
|
|
760
|
+
const payload = (ev.payload ?? {});
|
|
761
|
+
if (!checkCommon(add, 'slice', i, ev))
|
|
762
|
+
return;
|
|
763
|
+
const t = ev.event_type;
|
|
764
|
+
if (i === 0 && t !== 'slice_opened') {
|
|
765
|
+
add(`evento 0: el primer evento debe ser slice_opened, no ${t} (domain.py:287)`);
|
|
766
|
+
}
|
|
767
|
+
if (i > 0 && t === 'slice_opened') {
|
|
768
|
+
add(`evento ${i}: slice_opened solo puede ser el primer evento (domain.py:299)`);
|
|
769
|
+
return;
|
|
770
|
+
}
|
|
771
|
+
if (t === 'slice_opened' || t === 'lease_reaped')
|
|
772
|
+
return;
|
|
773
|
+
if (isTerminal() && t !== 'slice_archived' && t !== 'slice_reflected') {
|
|
774
|
+
add(`evento ${i} (${t}): ilegal sobre un slice terminal — en terminal solo aplican slice_archived, slice_reflected y lease_reaped (domain.py:318-324)`);
|
|
775
|
+
return;
|
|
776
|
+
}
|
|
777
|
+
switch (t) {
|
|
778
|
+
case 'phase_advanced': {
|
|
779
|
+
const to = payload.to;
|
|
780
|
+
const idx = PHASE_ORDER.indexOf(phase);
|
|
781
|
+
const next = idx + 1 < PHASE_ORDER.length ? PHASE_ORDER[idx + 1] : null;
|
|
782
|
+
if (status !== 'ACTIVE')
|
|
783
|
+
add(`evento ${i}: solo un slice ACTIVE avanza de fase (status=${status}, domain.py:397-398)`);
|
|
784
|
+
if (to !== next) {
|
|
785
|
+
add(`evento ${i}: orden estricto de fases (invariante 1, domain.py:404-406): ${phase} → ${String(to)} es ilegal; siguiente válida: ${String(next)}`);
|
|
786
|
+
}
|
|
787
|
+
if (typeof to === 'string' && PHASE_ORDER.includes(to))
|
|
788
|
+
phase = to;
|
|
789
|
+
break;
|
|
790
|
+
}
|
|
791
|
+
case 'phase_reverted': {
|
|
792
|
+
const to = payload.to;
|
|
793
|
+
const gate = payload.gate;
|
|
794
|
+
if (typeof to !== 'string' || !PHASE_ORDER.includes(to) || PHASE_ORDER.indexOf(to) >= PHASE_ORDER.indexOf(phase)) {
|
|
795
|
+
add(`evento ${i}: phase_reverted debe retroceder a una fase anterior (domain.py:412-413)`);
|
|
796
|
+
}
|
|
797
|
+
else {
|
|
798
|
+
phase = to;
|
|
799
|
+
}
|
|
800
|
+
if (typeof gate !== 'string' || !rejected.has(gate)) {
|
|
801
|
+
add(`evento ${i}: retroceso solo por rechazo de gate explícito (invariante 1, domain.py:414-416): el gate ${JSON.stringify(gate)} no tiene un FAIL registrado`);
|
|
802
|
+
}
|
|
803
|
+
break;
|
|
804
|
+
}
|
|
805
|
+
case 'gate_verdict': {
|
|
806
|
+
const gate = String(payload.gate ?? '');
|
|
807
|
+
const verdict = payload.verdict;
|
|
808
|
+
if (status !== 'ACTIVE')
|
|
809
|
+
add(`evento ${i}: solo un slice ACTIVE procesa gate_verdict (status=${status}, domain.py:397-398)`);
|
|
810
|
+
if (!SLICE_GATES.includes(gate)) {
|
|
811
|
+
add(`evento ${i}: gate desconocido "${gate}" — el reducer del slice solo acepta ${SLICE_GATES.join(', ')} (domain.py:422)`);
|
|
812
|
+
break;
|
|
813
|
+
}
|
|
814
|
+
checkNoteLimit(add, `evento ${i} (gate_verdict): evidence`, payload.evidence);
|
|
815
|
+
checkNoteLimit(add, `evento ${i} (gate_verdict): cause`, payload.cause);
|
|
816
|
+
if (verdict === 'PASS') {
|
|
817
|
+
if (isBlank(payload.evidence)) {
|
|
818
|
+
add(`evento ${i}: gate_verdict PASS de "${gate}" sin evidence — passing ⇒ evidence obligatoria (invariante 3, domain.py:424-425)`);
|
|
819
|
+
}
|
|
820
|
+
if (gate === 'dod' && !gatesTrue.has('wiring_verified')) {
|
|
821
|
+
add(`evento ${i}: dod PASS sin wiring_verified cerrado — wiring_verified es prerequisito duro de dod (invariante 4, domain.py:427-428)`);
|
|
822
|
+
}
|
|
823
|
+
gatesTrue.add(gate);
|
|
824
|
+
rejected.delete(gate);
|
|
825
|
+
}
|
|
826
|
+
else if (verdict === 'FAIL') {
|
|
827
|
+
if (gatesTrue.has(gate) && isBlank(payload.cause)) {
|
|
828
|
+
add(`evento ${i}: tumbar el gate cerrado "${gate}" exige cause (invariante 2, ratchet, domain.py:432-434)`);
|
|
829
|
+
}
|
|
830
|
+
gatesTrue.delete(gate);
|
|
831
|
+
rejected.add(gate);
|
|
832
|
+
}
|
|
833
|
+
else {
|
|
834
|
+
add(`evento ${i}: veredicto ${JSON.stringify(verdict)} no promueve el gate "${gate}": parcial o fallo de herramienta es bloqueante (invariante 5, domain.py:441-445)`);
|
|
835
|
+
}
|
|
836
|
+
break;
|
|
837
|
+
}
|
|
838
|
+
case 'wiring_item_updated': {
|
|
839
|
+
if (status !== 'ACTIVE')
|
|
840
|
+
add(`evento ${i}: solo un slice ACTIVE procesa wiring_item_updated (status=${status}, domain.py:397-398)`);
|
|
841
|
+
if (isBlank(payload.item_id)) {
|
|
842
|
+
add(`evento ${i}: wiring_item_updated exige item_id no vacío (domain.py:450)`);
|
|
843
|
+
}
|
|
844
|
+
checkNoteLimit(add, `evento ${i} (wiring_item_updated): evidence`, payload.evidence);
|
|
845
|
+
if (payload.status === 'passing' && isBlank(payload.evidence)) {
|
|
846
|
+
add(`evento ${i}: wiring passing sin evidence — passing ⇒ evidence obligatoria (invariante 3, domain.py:452-453)`);
|
|
847
|
+
}
|
|
848
|
+
break;
|
|
849
|
+
}
|
|
850
|
+
case 'wiring_checklist_seeded': {
|
|
851
|
+
const items = Array.isArray(payload.items) ? payload.items : [];
|
|
852
|
+
if (items.length === 0) {
|
|
853
|
+
add(`evento ${i}: wiring_checklist_seeded exige al menos un ítem (event_catalog.py:125)`);
|
|
854
|
+
}
|
|
855
|
+
items.forEach((item, j) => {
|
|
856
|
+
const it = typeof item === 'object' && item !== null ? item : {};
|
|
857
|
+
for (const field of ['item_id', 'kind', 'ref']) {
|
|
858
|
+
if (typeof it[field] !== 'string') {
|
|
859
|
+
add(`evento ${i}: items[${j}] sin "${field}" (los tres campos son exigidos, event_catalog.py:111-118)`);
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
});
|
|
863
|
+
break;
|
|
864
|
+
}
|
|
865
|
+
case 'progress_noted': {
|
|
866
|
+
if (typeof payload.note !== 'string')
|
|
867
|
+
add(`evento ${i}: progress_noted exige note (event_catalog.py:134-135)`);
|
|
868
|
+
checkNoteLimit(add, `evento ${i} (progress_noted): note`, payload.note);
|
|
869
|
+
break;
|
|
870
|
+
}
|
|
871
|
+
case 'handoff_recorded': {
|
|
872
|
+
if (typeof payload.note !== 'string')
|
|
873
|
+
add(`evento ${i}: handoff_recorded exige note (event_catalog.py:138-139)`);
|
|
874
|
+
checkNoteLimit(add, `evento ${i} (handoff_recorded): note`, payload.note);
|
|
875
|
+
checkNoteLimit(add, `evento ${i} (handoff_recorded): resume_hint`, payload.resume_hint);
|
|
876
|
+
checkNoteLimit(add, `evento ${i} (handoff_recorded): stopped_at`, payload.stopped_at);
|
|
877
|
+
break;
|
|
878
|
+
}
|
|
879
|
+
case 'checkpoint_recorded': {
|
|
880
|
+
if (typeof payload.branch !== 'string' || typeof payload.commit_sha !== 'string') {
|
|
881
|
+
add(`evento ${i}: checkpoint_recorded exige branch y commit_sha (event_catalog.py:128-131)`);
|
|
882
|
+
}
|
|
883
|
+
checkNoteLimit(add, `evento ${i} (checkpoint_recorded): summary`, payload.summary);
|
|
884
|
+
break;
|
|
885
|
+
}
|
|
886
|
+
case 'slice_parked': {
|
|
887
|
+
if (status !== 'ACTIVE')
|
|
888
|
+
add(`evento ${i}: no se puede aparcar desde ${status} (domain.py:368)`);
|
|
889
|
+
if (isBlank(payload.reason))
|
|
890
|
+
add(`evento ${i}: slice_parked exige reason no vacía (invariante 7, domain.py:372-373)`);
|
|
891
|
+
checkNoteLimit(add, `evento ${i} (slice_parked): reason`, payload.reason);
|
|
892
|
+
status = 'PARKED';
|
|
893
|
+
break;
|
|
894
|
+
}
|
|
895
|
+
case 'slice_resumed': {
|
|
896
|
+
if (status !== 'PARKED')
|
|
897
|
+
add(`evento ${i}: slice_resumed solo aplica a un slice PARKED (domain.py:376)`);
|
|
898
|
+
status = 'ACTIVE';
|
|
899
|
+
break;
|
|
900
|
+
}
|
|
901
|
+
case 'slice_escalated': {
|
|
902
|
+
if (status !== 'ACTIVE' && status !== 'PARKED')
|
|
903
|
+
add(`evento ${i}: no se escala desde ${status} (domain.py:383)`);
|
|
904
|
+
if (isBlank(payload.cause))
|
|
905
|
+
add(`evento ${i}: slice_escalated exige cause no vacía (domain.py:384-385)`);
|
|
906
|
+
checkNoteLimit(add, `evento ${i} (slice_escalated): cause`, payload.cause);
|
|
907
|
+
status = 'ESCALATED';
|
|
908
|
+
break;
|
|
909
|
+
}
|
|
910
|
+
case 'slice_requeued': {
|
|
911
|
+
if (status !== 'ESCALATED')
|
|
912
|
+
add(`evento ${i}: slice_requeued solo aplica a ESCALATED (domain.py:388)`);
|
|
913
|
+
status = 'ACTIVE';
|
|
914
|
+
break;
|
|
915
|
+
}
|
|
916
|
+
case 'slice_cancelled': {
|
|
917
|
+
if (status !== 'ESCALATED')
|
|
918
|
+
add(`evento ${i}: cancelar exige pasar por ESCALATED (domain.py:393-394)`);
|
|
919
|
+
status = 'CANCELLED';
|
|
920
|
+
break;
|
|
921
|
+
}
|
|
922
|
+
case 'slice_submitted': {
|
|
923
|
+
if (!gatesTrue.has('dod'))
|
|
924
|
+
add(`evento ${i}: submit exige el gate dod cerrado (domain.py:487-488)`);
|
|
925
|
+
break;
|
|
926
|
+
}
|
|
927
|
+
case 'slice_archived': {
|
|
928
|
+
if (phase !== 'pr')
|
|
929
|
+
add(`evento ${i}: archivar exige estar en la fase pr (domain.py:499-500), no en ${phase}`);
|
|
930
|
+
phase = 'archived';
|
|
931
|
+
break;
|
|
932
|
+
}
|
|
933
|
+
default:
|
|
934
|
+
// Telemetría/lease/contexto: hechos anotables, no transiciones (domain.py:326-364).
|
|
935
|
+
break;
|
|
936
|
+
}
|
|
937
|
+
});
|
|
938
|
+
}
|
|
939
|
+
function verifyReleaseSequence(events, add) {
|
|
940
|
+
if (events.length === 0) {
|
|
941
|
+
add('sin eventos: el hub no tiene nada que importar');
|
|
942
|
+
return;
|
|
943
|
+
}
|
|
944
|
+
let closed = false;
|
|
945
|
+
events.forEach((ev, i) => {
|
|
946
|
+
const payload = (ev.payload ?? {});
|
|
947
|
+
if (!checkCommon(add, 'release', i, ev))
|
|
948
|
+
return;
|
|
949
|
+
const t = ev.event_type;
|
|
950
|
+
if (i === 0) {
|
|
951
|
+
if (t !== 'release_opened') {
|
|
952
|
+
add(`evento 0: el primer evento del agregado release debe ser release_opened, no ${t} (release_domain.py:124-127)`);
|
|
953
|
+
}
|
|
954
|
+
else if (isBlank(payload.release_line)) {
|
|
955
|
+
add('evento 0: release_opened exige release_line (event_catalog.py:327; el import deriva de ahí la fila de identidad, import_service.py:349-360)');
|
|
956
|
+
}
|
|
957
|
+
return;
|
|
958
|
+
}
|
|
959
|
+
if (t === 'release_opened') {
|
|
960
|
+
add(`evento ${i}: release_opened solo puede ser el primer evento (release_domain.py:151)`);
|
|
961
|
+
return;
|
|
962
|
+
}
|
|
963
|
+
if (closed) {
|
|
964
|
+
add(`evento ${i} (${t}): ilegal sobre una release ya cerrada (release_domain.py:155-160)`);
|
|
965
|
+
return;
|
|
966
|
+
}
|
|
967
|
+
if (t === 'release_gate_verdict') {
|
|
968
|
+
const gate = String(payload.gate ?? '');
|
|
969
|
+
const verdict = payload.verdict;
|
|
970
|
+
if (!RELEASE_GATES.includes(gate)) {
|
|
971
|
+
add(`evento ${i}: gate "${gate}" no pertenece al Release Gate (${RELEASE_GATES.join(', ')}, release_gate_domain.py:20-22 y 68-71)`);
|
|
972
|
+
}
|
|
973
|
+
if (typeof verdict !== 'string' || !RELEASE_VERDICTS.has(verdict)) {
|
|
974
|
+
add(`evento ${i}: veredicto ${JSON.stringify(verdict)} desconocido: PASS | FAIL | PARTIAL | TOOL_ERROR (release_gate_domain.py:32 y 72-75)`);
|
|
975
|
+
}
|
|
976
|
+
checkNoteLimit(add, `evento ${i} (release_gate_verdict): evidence`, payload.evidence);
|
|
977
|
+
if (verdict === 'PASS' && isBlank(payload.evidence)) {
|
|
978
|
+
add(`evento ${i}: release_gate_verdict PASS de "${gate}" sin evidence — el fold del hub lo degrada a BLOQUEANTE, irreparable retroactivamente (release_gate_domain.py:93-96)`);
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
else if (t === 'release_closed') {
|
|
982
|
+
const st = payload.status;
|
|
983
|
+
if (st !== 'PASSED' && st !== 'FAILED') {
|
|
984
|
+
add(`evento ${i}: cerrar una release exige PASSED o FAILED, no ${JSON.stringify(st)} (release_domain.py:179-181)`);
|
|
985
|
+
}
|
|
986
|
+
checkNoteLimit(add, `evento ${i} (release_closed): motivo`, payload.motivo);
|
|
987
|
+
closed = true;
|
|
988
|
+
}
|
|
989
|
+
});
|
|
990
|
+
}
|
|
991
|
+
function verifyFrontSequence(events, add) {
|
|
992
|
+
if (events.length === 0) {
|
|
993
|
+
add('sin eventos: el hub no tiene nada que importar');
|
|
994
|
+
return;
|
|
995
|
+
}
|
|
996
|
+
let frontStatus = 'ACTIVE';
|
|
997
|
+
const memberCodes = new Set();
|
|
998
|
+
const integrated = new Set();
|
|
999
|
+
events.forEach((ev, i) => {
|
|
1000
|
+
const payload = (ev.payload ?? {});
|
|
1001
|
+
if (!checkCommon(add, 'front', i, ev))
|
|
1002
|
+
return;
|
|
1003
|
+
const t = ev.event_type;
|
|
1004
|
+
if (i === 0) {
|
|
1005
|
+
if (t !== 'front_opened') {
|
|
1006
|
+
add(`evento 0: el primer evento del agregado front debe ser front_opened, no ${t} (front_domain.py:296-300)`);
|
|
1007
|
+
}
|
|
1008
|
+
else {
|
|
1009
|
+
(Array.isArray(payload.members) ? payload.members : []).forEach((m) => memberCodes.add(String(m)));
|
|
1010
|
+
}
|
|
1011
|
+
return;
|
|
1012
|
+
}
|
|
1013
|
+
if (t === 'front_opened') {
|
|
1014
|
+
add(`evento ${i}: front_opened solo puede ser el primer evento (front_domain.py:330)`);
|
|
1015
|
+
return;
|
|
1016
|
+
}
|
|
1017
|
+
if (frontStatus === 'CLOSED') {
|
|
1018
|
+
add(`evento ${i} (${t}): ilegal sobre un front cerrado (front_domain.py:331-334)`);
|
|
1019
|
+
return;
|
|
1020
|
+
}
|
|
1021
|
+
if (t === 'front_member_added') {
|
|
1022
|
+
const code = String(payload.epic_code ?? '');
|
|
1023
|
+
if (frontStatus !== 'ACTIVE')
|
|
1024
|
+
add(`evento ${i}: un front ${frontStatus} no admite miembros nuevos (front_domain.py:337-340)`);
|
|
1025
|
+
if (memberCodes.has(code))
|
|
1026
|
+
add(`evento ${i}: ${code} ya es miembro de este front (front_domain.py:342-345)`);
|
|
1027
|
+
memberCodes.add(code);
|
|
1028
|
+
}
|
|
1029
|
+
else if (t === 'front_member_integrated') {
|
|
1030
|
+
const code = String(payload.epic_code ?? '');
|
|
1031
|
+
if (!memberCodes.has(code))
|
|
1032
|
+
add(`evento ${i}: ${code} no es miembro de este front (front_domain.py:367-368)`);
|
|
1033
|
+
checkNoteLimit(add, `evento ${i} (front_member_integrated): evidence`, payload.evidence);
|
|
1034
|
+
if (payload.resmoke_ok === true)
|
|
1035
|
+
integrated.add(code);
|
|
1036
|
+
}
|
|
1037
|
+
else if (t === 'front_drained') {
|
|
1038
|
+
if (frontStatus !== 'ACTIVE')
|
|
1039
|
+
add(`evento ${i}: un front ${frontStatus} no se puede drenar (front_domain.py:382-383)`);
|
|
1040
|
+
frontStatus = 'DRAINING';
|
|
1041
|
+
}
|
|
1042
|
+
else if (t === 'front_closed') {
|
|
1043
|
+
if (frontStatus !== 'DRAINING')
|
|
1044
|
+
add(`evento ${i}: cerrar exige drenar primero (ACTIVE → DRAINING → CLOSED, front_domain.py:387-390)`);
|
|
1045
|
+
const pending = [...memberCodes].filter((c) => !integrated.has(c));
|
|
1046
|
+
if (pending.length > 0)
|
|
1047
|
+
add(`evento ${i}: quedan miembros sin integrar: ${pending.join(', ')} (front_domain.py:391-397)`);
|
|
1048
|
+
frontStatus = 'CLOSED';
|
|
1049
|
+
}
|
|
1050
|
+
});
|
|
1051
|
+
}
|
|
1052
|
+
function verifyFact(fact, add) {
|
|
1053
|
+
const rule = HUB_FACT_RULES[fact.fact];
|
|
1054
|
+
if (!rule) {
|
|
1055
|
+
add(`hecho de proyecto desconocido: "${fact.fact}" (conocidos: ${Object.keys(HUB_FACT_RULES).sort().join(', ')}; import_service.py:449-453)`);
|
|
1056
|
+
return;
|
|
1057
|
+
}
|
|
1058
|
+
if (Number.isNaN(new Date(fact.occurred_at).getTime())) {
|
|
1059
|
+
add(`occurred_at inválido: "${fact.occurred_at}"`);
|
|
1060
|
+
}
|
|
1061
|
+
const v = fact.value;
|
|
1062
|
+
if (rule.kind === 'boolean' && typeof v !== 'boolean') {
|
|
1063
|
+
add(`"${fact.fact}" es booleano en el hub (project_facts_domain.py:56-57, import_service.py:458-460)`);
|
|
1064
|
+
}
|
|
1065
|
+
else if (rule.kind === 'machine_text' && (typeof v !== 'string' || v === '' || v.length > (rule.max ?? MAX_NOTE))) {
|
|
1066
|
+
add(`"${fact.fact}" es texto no vacío de hasta ${rule.max} caracteres en el hub (project_facts_domain.py:47 y 58-62)`);
|
|
1067
|
+
}
|
|
1068
|
+
else if (rule.kind === 'string_or_null' && !(v === null || typeof v === 'string')) {
|
|
1069
|
+
add(`"${fact.fact}" es texto (o null) en el hub (import_service.py:461-462)`);
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
/**
|
|
1073
|
+
* Valida el bundle YA normalizado contra los invariantes del hub SIN red:
|
|
1074
|
+
* evidence obligatoria en passing (invariante 3), límites `_MAX_NOTE`,
|
|
1075
|
+
* catálogo cerrado de gates y eventos, item_id no vacío, orden estricto de
|
|
1076
|
+
* fases, wiring_verified→dod (invariante 4), máquinas de estados de
|
|
1077
|
+
* slice/release/front y catálogo de hechos de proyecto. Devuelve las
|
|
1078
|
+
* violaciones con su entrada de origen; vacío = el hub no tendría motivo de
|
|
1079
|
+
* rechazo conocido.
|
|
1080
|
+
*/
|
|
1081
|
+
export function verifyNormalizedBundle(bundle) {
|
|
1082
|
+
const violations = [];
|
|
1083
|
+
const scoped = (source_key) => (message) => {
|
|
1084
|
+
violations.push({ source_key, message });
|
|
1085
|
+
};
|
|
1086
|
+
const seenKeys = new Set();
|
|
1087
|
+
const checkDuplicate = (key) => {
|
|
1088
|
+
// El import rechaza la clave de entrada duplicada (import_service.py:128-137).
|
|
1089
|
+
if (seenKeys.has(key))
|
|
1090
|
+
violations.push({ source_key: key, message: 'clave de entrada duplicada en el bundle (import_service.py:128-137)' });
|
|
1091
|
+
seenKeys.add(key);
|
|
1092
|
+
};
|
|
1093
|
+
for (const h of bundle.history) {
|
|
1094
|
+
const key = `history[${h.epic_code}]`;
|
|
1095
|
+
checkDuplicate(key);
|
|
1096
|
+
verifySliceSequence(h.events, scoped(key));
|
|
1097
|
+
}
|
|
1098
|
+
for (const r of bundle.releases) {
|
|
1099
|
+
const key = `releases[${r.key}]`;
|
|
1100
|
+
checkDuplicate(key);
|
|
1101
|
+
verifyReleaseSequence(r.events, scoped(key));
|
|
1102
|
+
}
|
|
1103
|
+
for (const f of bundle.fronts) {
|
|
1104
|
+
const key = `fronts[${f.key}]`;
|
|
1105
|
+
checkDuplicate(key);
|
|
1106
|
+
verifyFrontSequence(f.events, scoped(key));
|
|
1107
|
+
}
|
|
1108
|
+
for (const fact of bundle.facts) {
|
|
1109
|
+
const key = `facts[${fact.fact}]`;
|
|
1110
|
+
checkDuplicate(key);
|
|
1111
|
+
verifyFact(fact, scoped(key));
|
|
1112
|
+
}
|
|
1113
|
+
(bundle.unmapped ?? []).forEach((u, i) => {
|
|
1114
|
+
// LegacyImported exige source_key de 1..200 caracteres (event_catalog.py:446).
|
|
1115
|
+
if (typeof u.source_key !== 'string' || u.source_key.length < 1 || u.source_key.length > 200) {
|
|
1116
|
+
violations.push({
|
|
1117
|
+
source_key: `unmapped[${i}]`,
|
|
1118
|
+
message: 'source_key debe tener entre 1 y 200 caracteres (LegacyImported, event_catalog.py:446)',
|
|
1119
|
+
});
|
|
1120
|
+
}
|
|
1121
|
+
});
|
|
1122
|
+
return violations;
|
|
1123
|
+
}
|