@thebattlearena/corner 0.9.0 → 0.10.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.
Files changed (45) hide show
  1. package/package.json +1 -1
  2. package/plugin/.claude-plugin/plugin.json +1 -1
  3. package/ring/agents/cornerman.md +20 -3
  4. package/ring/agents/wrestler.md +11 -0
  5. package/ring/hooks/hooks.json +20 -0
  6. package/ring/scripts/bout-stats-render.mjs +366 -0
  7. package/ring/scripts/bout-stats.mjs +360 -0
  8. package/ring/scripts/config.mjs +1 -1
  9. package/ring/scripts/contract-endpoints.mjs +163 -0
  10. package/ring/scripts/contract-preview.mjs +34 -14
  11. package/ring/scripts/fight.mjs +27 -0
  12. package/ring/scripts/findings.mjs +3 -1
  13. package/ring/scripts/format-gate.mjs +723 -0
  14. package/ring/scripts/format-worker.mjs +120 -0
  15. package/ring/scripts/front-routes.mjs +345 -0
  16. package/ring/scripts/judges-card.mjs +324 -3
  17. package/ring/scripts/lookout.mjs +22 -4
  18. package/ring/scripts/mask-guard.mjs +34 -1
  19. package/ring/scripts/page-embed.mjs +112 -0
  20. package/ring/scripts/play-by-play.mjs +155 -7
  21. package/ring/scripts/preparation-collect.mjs +177 -0
  22. package/ring/scripts/preparation.mjs +547 -0
  23. package/ring/scripts/round-bell.mjs +9 -2
  24. package/ring/scripts/tech-plan-log.mjs +81 -0
  25. package/ring/scripts/tech-plan.mjs +191 -0
  26. package/ring/scripts/verdicts-log.mjs +75 -0
  27. package/ring/scripts/verdicts.mjs +205 -0
  28. package/ring/skills/play-by-play/SKILL.md +235 -33
  29. package/ring/skills/play-by-play/templates/blocks.css +196 -0
  30. package/ring/skills/play-by-play/templates/chronicle.html +358 -55
  31. package/ring/skills/play-by-play/templates/endpoints-view.js +1153 -0
  32. package/ring/skills/play-by-play/templates/interview-form.js +516 -0
  33. package/ring/skills/play-by-play/templates/now-panel.js +719 -0
  34. package/ring/skills/referee/SKILL.md +136 -42
  35. package/ring/skills/referee/references/api-diagram.md +56 -0
  36. package/ring/skills/referee/references/precedents.md +11 -0
  37. package/ring/skills/referee/references/rulebook.md +65 -4
  38. package/ring/skills/referee/templates/api-diagram.html +115 -0
  39. package/ring/skills/referee/templates/escalation.md +7 -1
  40. package/ring/skills/referee/templates/round-bell-body.md +4 -0
  41. package/ring/skills/referee/templates/wrestler-brief.md +3 -0
  42. package/ring/skills/sparring/SKILL.md +10 -2
  43. package/ring/skills/sparring/templates/contrato.md +29 -0
  44. package/ring/skills/sparring/templates/rudo-brief.md +7 -0
  45. package/ring/skills/weigh-in/SKILL.md +6 -0
@@ -0,0 +1,1153 @@
1
+ // «Endpoints del contrato», drawn in the chronicle page from the spec files `play-by-play.mjs embed`
2
+ // wrote into it (<script type="application/json" id="ring-spec-<pr>">). Parses the YAML with
3
+ // js-yaml, follows `$ref` across files and shows every operation added, changed or removed
4
+ // against the base as a card (method chip, path, summary, status), filterable, with its scopes,
5
+ // parameters, body and responses as compact tables when unfolded.
6
+ //
7
+ // The judge gives each added or changed operation a verdict, «Se aprueba» or «Se mejora» (a
8
+ // required comment, images), saved in the page's db (collection `veredictos`, doc
9
+ // `pr<n>-<METHOD>-<path>-<spec>`) with the fingerprint (`hash`) of the operation's contract: its
10
+ // block of the spec with every schema it expands. A verdict counts only while that fingerprint is
11
+ // the current one, so a rewritten contract asks again. The judge's view also saves the contract's
12
+ // state (`pr<n>-contrato`: every operation's fingerprint and the fingerprint of all the embedded
13
+ // files) so the referee can tell, without parsing YAML, which verdicts are current
14
+ // (`judges-card.mjs page-verdicts`). «Pendientes del contrato» lists the current «se mejora»; the
15
+ // weigh-in's scale (`[data-ring-scale="<pr>"]`) reads «N de M aprobados» until every operation is
16
+ // approved and the judge's approve-contract is on record (`data-approved="true"`).
17
+ import yaml from 'https://cdn.jsdelivr.net/npm/js-yaml@4.1.0/dist/js-yaml.mjs';
18
+
19
+ // The page's bus (see interview-form.js): each board registers its items and how to judge them.
20
+ const ring = (window.ringPage ??= {
21
+ questions: new Map(),
22
+ boards: new Map(),
23
+ scales: {},
24
+ changed() {
25
+ document.dispatchEvent(new CustomEvent('ring:change'));
26
+ },
27
+ });
28
+
29
+ const METHODS = [
30
+ 'get',
31
+ 'put',
32
+ 'post',
33
+ 'delete',
34
+ 'patch',
35
+ 'options',
36
+ 'head',
37
+ 'trace',
38
+ ];
39
+ const MAX_DEPTH = 7;
40
+ const STATUS = { added: 'nueva', changed: 'cambia', removed: 'se quita' };
41
+
42
+ const el = (tag, attrs = {}, ...children) => {
43
+ const node = document.createElement(tag);
44
+ Object.entries(attrs).forEach(([key, value]) => {
45
+ if (value === undefined || value === null || value === false) return;
46
+ if (key === 'text') return void (node.textContent = value);
47
+ if (key.startsWith('on'))
48
+ return void node.addEventListener(key.slice(2), value);
49
+ node.setAttribute(key, value === true ? '' : value);
50
+ });
51
+ children
52
+ .flat(Infinity)
53
+ .filter((child) => child !== null && child !== undefined && child !== false)
54
+ .forEach((child) => node.append(child));
55
+ return node;
56
+ };
57
+
58
+ const dirOf = (path) => path.split('/').slice(0, -1).join('/');
59
+ const normalize = (path) => {
60
+ const out = [];
61
+ path.split('/').forEach((part) => {
62
+ if (part === '..') return void out.pop();
63
+ if (part && part !== '.') out.push(part);
64
+ });
65
+ return out.join('/');
66
+ };
67
+
68
+ /** One side (base or head) of a spec root: parsed files and `$ref` resolution across them. */
69
+ function side(files, key) {
70
+ const parsed = new Map();
71
+ const doc = (path) => {
72
+ if (parsed.has(path)) return parsed.get(path);
73
+ const entry = files[path];
74
+ const text = entry ? (entry.same ? entry.base : entry[key]) : null;
75
+ let value = null;
76
+ try {
77
+ value = text == null ? null : yaml.load(text);
78
+ } catch {
79
+ value = null;
80
+ }
81
+ parsed.set(path, value);
82
+ return value;
83
+ };
84
+ const pointer = (value, hash) =>
85
+ hash
86
+ .split('/')
87
+ .filter(Boolean)
88
+ .map((part) =>
89
+ decodeURIComponent(part.replace(/~1/g, '/').replace(/~0/g, '~')),
90
+ )
91
+ .reduce((node, part) => (node == null ? null : node[part]), value);
92
+ const target = (ref, file) => {
93
+ const [filePart, hash = ''] = String(ref).split('#');
94
+ const path = filePart ? normalize(`${dirOf(file)}/${filePart}`) : file;
95
+ return {
96
+ node: pointer(doc(path), hash),
97
+ file: path,
98
+ name: hash.split('/').pop() || filePart,
99
+ };
100
+ };
101
+ const deref = (node, file, seen = new Set(), depth = 0) => {
102
+ if (Array.isArray(node))
103
+ return node.map((item) => deref(item, file, seen, depth));
104
+ if (!node || typeof node !== 'object') return node;
105
+ if (typeof node.$ref === 'string') {
106
+ const key = `${file}#${node.$ref}`;
107
+ const found = target(node.$ref, file);
108
+ if (seen.has(key) || depth > 40 || found.node == null)
109
+ return { $refName: found.name };
110
+ const resolved = deref(
111
+ found.node,
112
+ found.file,
113
+ new Set([...seen, key]),
114
+ depth + 1,
115
+ );
116
+ return resolved &&
117
+ typeof resolved === 'object' &&
118
+ !Array.isArray(resolved)
119
+ ? { ...resolved, $refName: found.name }
120
+ : resolved;
121
+ }
122
+ return Object.fromEntries(
123
+ Object.entries(node).map(([k, v]) => [
124
+ k,
125
+ deref(v, file, seen, depth + 1),
126
+ ]),
127
+ );
128
+ };
129
+ const operations = (root) => {
130
+ const spec = doc(root);
131
+ const out = {};
132
+ Object.entries(spec?.paths ?? {}).forEach(([path, raw]) => {
133
+ const item = deref(raw, root);
134
+ METHODS.filter((method) => item?.[method]).forEach((method) => {
135
+ out[`${method.toUpperCase()} ${path}`] = {
136
+ ...item[method],
137
+ parameters: [
138
+ ...(item.parameters ?? []),
139
+ ...(item[method].parameters ?? []),
140
+ ],
141
+ };
142
+ });
143
+ });
144
+ return { out, security: spec?.security ?? null };
145
+ };
146
+ return { operations };
147
+ }
148
+
149
+ /** Keys sorted at every level, so the same contract always gives the same fingerprint. */
150
+ const stable = (value) => {
151
+ if (Array.isArray(value)) return `[${value.map(stable).join(',')}]`;
152
+ if (value && typeof value === 'object')
153
+ return `{${Object.keys(value)
154
+ .sort()
155
+ .map((key) => `${JSON.stringify(key)}:${stable(value[key])}`)
156
+ .join(',')}}`;
157
+ return JSON.stringify(value ?? null);
158
+ };
159
+ const sha256 = async (text) => {
160
+ const digest = await crypto.subtle.digest(
161
+ 'SHA-256',
162
+ new TextEncoder().encode(text),
163
+ );
164
+ return [...new Uint8Array(digest)]
165
+ .map((byte) => byte.toString(16).padStart(2, '0'))
166
+ .join('');
167
+ };
168
+ const slug = (text) =>
169
+ String(text)
170
+ .replace(/[{}]/g, '')
171
+ .replace(/[^A-Za-z0-9]+/g, '_')
172
+ .replace(/^_+|_+$/g, '');
173
+ const fmt = (iso) => {
174
+ try {
175
+ return new Date(iso).toLocaleString('es-MX', {
176
+ day: 'numeric',
177
+ month: 'short',
178
+ hour: '2-digit',
179
+ minute: '2-digit',
180
+ });
181
+ } catch {
182
+ return '';
183
+ }
184
+ };
185
+
186
+ const typeOf = (schema) => {
187
+ if (!schema || typeof schema !== 'object') return '';
188
+ const types = [].concat(schema.type ?? []);
189
+ const base = types.filter((type) => type !== 'null').join(' | ');
190
+ const name = base === 'array' ? `${typeOf(schema.items) || 'any'}[]` : base;
191
+ return `${name || schema.$refName || (schema.oneOf ? 'oneOf' : schema.anyOf ? 'anyOf' : schema.allOf ? 'allOf' : '')}${schema.format ? ` (${schema.format})` : ''}`;
192
+ };
193
+ const isNullable = (schema) =>
194
+ Boolean(schema) &&
195
+ (schema.nullable === true || [].concat(schema.type ?? []).includes('null'));
196
+
197
+ /** A schema flattened into rows: field (indented by depth), type, nullability, required, values. */
198
+ function schemaRows(schema, depth = 0, rows = [], prefix = '') {
199
+ if (!schema || typeof schema !== 'object' || depth > MAX_DEPTH) return rows;
200
+ ['allOf', 'oneOf', 'anyOf']
201
+ .filter((key) => Array.isArray(schema[key]))
202
+ .forEach((key) =>
203
+ schema[key].forEach((part, index) => {
204
+ if (key !== 'allOf')
205
+ rows.push({
206
+ depth,
207
+ name: `${prefix}${key}[${index}]`,
208
+ schema: part,
209
+ combo: true,
210
+ });
211
+ schemaRows(part, key === 'allOf' ? depth : depth + 1, rows, prefix);
212
+ }),
213
+ );
214
+ const target = [].concat(schema.type ?? []).includes('array')
215
+ ? schema.items
216
+ : schema;
217
+ const required = new Set(target?.required ?? []);
218
+ Object.entries(target?.properties ?? {}).forEach(([name, field]) => {
219
+ rows.push({
220
+ depth,
221
+ name,
222
+ schema: field ?? {},
223
+ required: required.has(name),
224
+ });
225
+ const inner = field?.items ?? field;
226
+ if (inner?.properties || inner?.allOf || inner?.oneOf || inner?.anyOf)
227
+ schemaRows(field, depth + 1, rows);
228
+ });
229
+ return rows;
230
+ }
231
+
232
+ function schemaTable(schema) {
233
+ if (!schema) return null;
234
+ const rows = schemaRows(schema);
235
+ if (!rows.length)
236
+ return el(
237
+ 'p',
238
+ { class: 'muted' },
239
+ el('code', { text: typeOf(schema) || 'sin esquema' }),
240
+ isNullable(schema) ? ' · nulo' : '',
241
+ );
242
+ return el(
243
+ 'div',
244
+ { class: 'tbl schema-tbl' },
245
+ el(
246
+ 'table',
247
+ {},
248
+ el(
249
+ 'thead',
250
+ {},
251
+ el(
252
+ 'tr',
253
+ {},
254
+ ['Campo', 'Tipo', 'Nulo', 'Req.', 'Valores · descripción'].map(
255
+ (text) => el('th', { text }),
256
+ ),
257
+ ),
258
+ ),
259
+ el(
260
+ 'tbody',
261
+ {},
262
+ rows.map((row) => {
263
+ const values = row.schema.enum ?? row.schema.items?.enum;
264
+ return el(
265
+ 'tr',
266
+ {},
267
+ el(
268
+ 'td',
269
+ { style: `padding-left:${12 + row.depth * 16}px` },
270
+ el('code', { text: row.name }),
271
+ ),
272
+ el('td', {}, el('code', { text: typeOf(row.schema) })),
273
+ el('td', { text: isNullable(row.schema) ? 'sí' : '' }),
274
+ el('td', {
275
+ class: row.required ? 'req' : '',
276
+ text: row.required ? 'sí' : '',
277
+ }),
278
+ el(
279
+ 'td',
280
+ {},
281
+ values
282
+ ? el('code', { text: values.map(String).join(' | ') })
283
+ : null,
284
+ values && row.schema.description ? ' · ' : '',
285
+ row.schema.description ?? '',
286
+ ),
287
+ );
288
+ }),
289
+ ),
290
+ ),
291
+ );
292
+ }
293
+
294
+ const bodySchema = (content) => {
295
+ const [type, media] = Object.entries(content ?? {})[0] ?? [];
296
+ return media ? { type, schema: media.schema } : null;
297
+ };
298
+
299
+ function operationBody(operation, security) {
300
+ const scopes = (operation.security ?? security ?? []).flatMap((entry) =>
301
+ Object.entries(entry).map(
302
+ ([name, list]) => `${name}${list?.length ? `: ${list.join(', ')}` : ''}`,
303
+ ),
304
+ );
305
+ const body = el('div', { class: 'op-body' });
306
+ if (operation.description)
307
+ body.append(el('p', { class: 'desc', text: operation.description }));
308
+ body.append(
309
+ el(
310
+ 'p',
311
+ {},
312
+ el('b', { text: 'Scopes: ' }),
313
+ scopes.length ? scopes.join(' · ') : 'ninguno declarado',
314
+ ),
315
+ );
316
+ if (operation.parameters?.length) {
317
+ body.append(el('h5', { text: 'Parámetros' }));
318
+ const label = (param) =>
319
+ `${param.name ?? param.$refName ?? '?'} · ${param.in ?? ''}`;
320
+ body.append(
321
+ schemaTable({
322
+ type: 'object',
323
+ required: operation.parameters
324
+ .filter((param) => param.required)
325
+ .map(label),
326
+ properties: Object.fromEntries(
327
+ operation.parameters.map((param) => [
328
+ label(param),
329
+ {
330
+ ...(param.schema ?? {}),
331
+ description: param.description ?? param.schema?.description,
332
+ },
333
+ ]),
334
+ ),
335
+ }),
336
+ );
337
+ }
338
+ const request = bodySchema(operation.requestBody?.content);
339
+ if (request) {
340
+ body.append(
341
+ el('h5', {
342
+ text: `Cuerpo · ${request.type}${operation.requestBody.required ? ' · requerido' : ''}`,
343
+ }),
344
+ );
345
+ body.append(schemaTable(request.schema));
346
+ }
347
+ Object.entries(operation.responses ?? {}).forEach(([code, response]) => {
348
+ const media = bodySchema(response?.content);
349
+ body.append(
350
+ el(
351
+ 'h5',
352
+ { class: /^[45]|default/i.test(code) ? 'err' : '' },
353
+ `${code} · `,
354
+ response?.description ?? response?.$refName ?? '',
355
+ ),
356
+ );
357
+ if (media) body.append(schemaTable(media.schema));
358
+ });
359
+ return body;
360
+ }
361
+
362
+ const waitClaude = () =>
363
+ new Promise((resolve) => {
364
+ let waited = 0;
365
+ const tick = () => {
366
+ if (window.claude && window.claude.use) return resolve(window.claude);
367
+ if ((waited += 200) > 10000) return resolve(null);
368
+ setTimeout(tick, 200);
369
+ };
370
+ tick();
371
+ });
372
+ const capabilities = (async () => {
373
+ const claude = await waitClaude();
374
+ if (!claude) return { db: null, user: null, assets: null, canEdit: false };
375
+ const db = await claude.use('db');
376
+ const user = await claude.use('user');
377
+ const assets = await claude.use('assets');
378
+ const canEdit = user ? await user.canEdit() : false;
379
+ return { db, user, assets, canEdit };
380
+ })();
381
+
382
+ /** Saves the judge's verdict on one operation or route; throws a message the form can show. */
383
+ async function writeVerdict(op, pr, verdict, comment = '', attachments = []) {
384
+ const caps = await capabilities;
385
+ if (!caps.db || !caps.canEdit)
386
+ throw new Error('Sólo el juez (quien edita la página) da el veredicto');
387
+ if (verdict === 'improve' && !String(comment).trim())
388
+ throw new Error('Escribe qué hay que mejorar');
389
+ try {
390
+ await caps.db
391
+ .collection('veredictos')
392
+ .doc(op.key)
393
+ .set({
394
+ kind: 'verdict',
395
+ key: op.key,
396
+ pr,
397
+ method: op.method,
398
+ path: op.path,
399
+ specFile: op.specFile,
400
+ hash: op.hash,
401
+ verdict,
402
+ comment: verdict === 'improve' ? String(comment).trim() : '',
403
+ attachments: verdict === 'improve' ? attachments.slice() : [],
404
+ at: new Date().toISOString(),
405
+ by: caps.user ? await caps.user.id() : null,
406
+ });
407
+ } catch (error) {
408
+ throw new Error(`No se guardó (${error.code || 'error'})`);
409
+ }
410
+ }
411
+
412
+ /** The verdict box of one card: its state and, for the judge, the two buttons and the form. */
413
+ function verdictBox(op, ctx) {
414
+ const box = el('div', { class: 'verdict' });
415
+ const state = el('p', { class: 'verdict-state' });
416
+ const form = el('div', { class: 'ans-form improve', hidden: true });
417
+ const comment = el('textarea', {
418
+ placeholder: '¿Qué hay que mejorar? (obligatorio)',
419
+ });
420
+ const file = el('input', {
421
+ type: 'file',
422
+ accept: 'image/*',
423
+ multiple: true,
424
+ hidden: true,
425
+ });
426
+ const thumbs = el('div', { class: 'thumbs' });
427
+ const status = el('span', { class: 'status' });
428
+ const attached = [];
429
+ const save = async (verdict) => {
430
+ status.textContent = 'Guardando…';
431
+ try {
432
+ await writeVerdict(op, ctx.pr, verdict, comment.value, attached);
433
+ status.textContent = 'Guardado';
434
+ form.hidden = true;
435
+ } catch (error) {
436
+ status.textContent = error.message;
437
+ }
438
+ };
439
+ const addFiles = async (files) => {
440
+ const caps = await capabilities;
441
+ if (!caps.assets)
442
+ return void (status.textContent =
443
+ 'Adjuntar sólo funciona para quien puede editar la página');
444
+ for (const picked of files) {
445
+ if (!picked.type.startsWith('image/')) continue;
446
+ status.textContent = `Subiendo ${picked.name}…`;
447
+ try {
448
+ const up = await caps.assets.upload(picked);
449
+ attached.push(up.id);
450
+ thumbs.append(el('img', { src: up.url, alt: picked.name }));
451
+ status.textContent = '';
452
+ } catch (error) {
453
+ status.textContent = `No se pudo subir ${picked.name} (${error.code || 'error'})`;
454
+ }
455
+ }
456
+ };
457
+ const drop = el('div', {
458
+ class: 'drop',
459
+ tabindex: '0',
460
+ text: 'Arrastra imágenes aquí o haz clic para adjuntar',
461
+ onclick: () => file.click(),
462
+ onkeydown: (event) => ['Enter', ' '].includes(event.key) && file.click(),
463
+ ondragover: (event) => {
464
+ event.preventDefault();
465
+ drop.classList.add('over');
466
+ },
467
+ ondragleave: () => drop.classList.remove('over'),
468
+ ondrop: (event) => {
469
+ event.preventDefault();
470
+ drop.classList.remove('over');
471
+ addFiles([...event.dataTransfer.files]);
472
+ },
473
+ });
474
+ file.addEventListener('change', () => addFiles([...file.files]));
475
+ form.append(
476
+ comment,
477
+ drop,
478
+ file,
479
+ thumbs,
480
+ el(
481
+ 'div',
482
+ { class: 'row' },
483
+ el('button', {
484
+ type: 'button',
485
+ class: 'save',
486
+ text: 'Guardar «se mejora»',
487
+ onclick: () => save('improve'),
488
+ }),
489
+ ),
490
+ );
491
+ const buttons = el(
492
+ 'div',
493
+ { class: 'verdict-buttons', hidden: true },
494
+ el('button', {
495
+ type: 'button',
496
+ class: 'approve',
497
+ text: 'Se aprueba',
498
+ onclick: () => save('approved'),
499
+ }),
500
+ el('button', {
501
+ type: 'button',
502
+ class: 'improve-btn',
503
+ text: 'Se mejora',
504
+ onclick: () => (form.hidden = !form.hidden),
505
+ }),
506
+ status,
507
+ );
508
+ box.append(state, buttons, form);
509
+ capabilities.then((caps) => (buttons.hidden = !(caps.db && caps.canEdit)));
510
+ const paint = (verdict) => {
511
+ state.textContent = '';
512
+ if (!verdict)
513
+ return void state.append(
514
+ el('span', { class: 'v none', text: 'Sin veredicto' }),
515
+ );
516
+ const current = verdict.hash === op.hash;
517
+ const label = verdict.verdict === 'approved' ? 'Se aprueba' : 'Se mejora';
518
+ state.append(
519
+ el('span', {
520
+ class: `v ${current ? verdict.verdict : 'stale'}`,
521
+ text: current ? label : `${label} · desactualizado`,
522
+ }),
523
+ ` Juez · ${fmt(verdict.at)}`,
524
+ verdict.comment ? ` — ${verdict.comment}` : '',
525
+ );
526
+ (verdict.attachments ?? []).forEach((id) =>
527
+ state.append(el('img', { src: `/_blob/${id}`, alt: 'Adjunto del juez' })),
528
+ );
529
+ };
530
+ paint(null);
531
+ return { box, paint };
532
+ }
533
+
534
+ /** The endpoints of a spec block as board items. */
535
+ async function specItems(data) {
536
+ const items = [];
537
+ for (const { root, files } of data.roots) {
538
+ const base = side(files, 'base').operations(root);
539
+ const head = side(files, 'head').operations(root);
540
+ const status = (key) =>
541
+ !(key in base.out)
542
+ ? 'added'
543
+ : !(key in head.out)
544
+ ? 'removed'
545
+ : stable(base.out[key]) === stable(head.out[key])
546
+ ? null
547
+ : 'changed';
548
+ for (const key of [
549
+ ...new Set([...Object.keys(head.out), ...Object.keys(base.out)]),
550
+ ]) {
551
+ const kind = status(key);
552
+ if (!kind) continue;
553
+ const [method, path] = key.split(' ');
554
+ const operation = kind === 'removed' ? base.out[key] : head.out[key];
555
+ const security = (kind === 'removed' ? base : head).security;
556
+ items.push({
557
+ key: `pr${data.pr}-${method}-${slug(path)}-${slug(
558
+ root
559
+ .split('/')
560
+ .pop()
561
+ .replace(/\.[^.]+$/, ''),
562
+ )}`,
563
+ method,
564
+ chip: method,
565
+ chipClass: method.toLowerCase(),
566
+ path,
567
+ specFile: root,
568
+ tag: data.roots.length > 1 ? root.split('/').pop() : null,
569
+ summary: operation.summary ?? '',
570
+ status: kind,
571
+ body: () => operationBody(operation, security),
572
+ hash: (await sha256(stable(operation))).slice(0, 16),
573
+ });
574
+ }
575
+ }
576
+ return items;
577
+ }
578
+
579
+ const list = (title, values, code = true) =>
580
+ values.length
581
+ ? [
582
+ el('h5', { text: title }),
583
+ el(
584
+ 'p',
585
+ {},
586
+ values.flatMap((value, index) => [
587
+ index ? ', ' : '',
588
+ code ? el('code', { text: value }) : value,
589
+ ]),
590
+ ),
591
+ ]
592
+ : [];
593
+
594
+ /** A route's detail: what it reads from the URL, the files it reaches, what it calls in the back. */
595
+ function routeBody(route, data) {
596
+ const body = el('div', { class: 'op-body' });
597
+ if (route.kind === 'global')
598
+ body.append(
599
+ el('p', { class: 'desc', text: 'Cambia para todas las rutas.' }),
600
+ );
601
+ if (route.sparringOnly)
602
+ body.append(
603
+ el('p', {
604
+ class: 'desc warn',
605
+ text: 'Es del sparring: no debería llegar a la entrega.',
606
+ }),
607
+ );
608
+ const lines = route.files.reduce(
609
+ (sum, file) => ({
610
+ added: sum.added + file.added,
611
+ deleted: sum.deleted + file.deleted,
612
+ }),
613
+ { added: 0, deleted: 0 },
614
+ );
615
+ body.append(
616
+ el(
617
+ 'p',
618
+ {},
619
+ el('b', { text: 'Archivo: ' }),
620
+ el('code', { text: route.file }),
621
+ ` · ${route.files.length} archivo(s) que cambian, +${lines.added} / −${lines.deleted} líneas`,
622
+ ),
623
+ );
624
+ body.append(
625
+ ...list('Segmentos dinámicos', route.dynamic),
626
+ ...list(
627
+ 'Query params',
628
+ route.params.map((name) => `?${name}=`),
629
+ ),
630
+ );
631
+ body.append(
632
+ ...list('Componentes que cambian', route.components),
633
+ ...list('Hooks que cambian', route.hooks),
634
+ ...list('Servicios que cambian', route.services),
635
+ );
636
+ body.append(
637
+ ...(route.calls.length
638
+ ? list('Llamadas al back', route.calls)
639
+ : [
640
+ el('h5', { text: 'Llamadas al back' }),
641
+ el('p', {
642
+ class: 'muted',
643
+ text: 'No se pudieron leer de estos archivos; ver los servicios.',
644
+ }),
645
+ ]),
646
+ );
647
+ if (data.previewUrl && route.kind === 'page' && route.status !== 'removed')
648
+ body.append(
649
+ el(
650
+ 'p',
651
+ {},
652
+ el('a', {
653
+ href: `${data.previewUrl.replace(/\/$/, '')}${route.url.replace(/ \(layout\)$/, '')}`,
654
+ target: '_blank',
655
+ rel: 'noopener',
656
+ text: 'Abrir en el preview',
657
+ }),
658
+ ),
659
+ );
660
+ return body;
661
+ }
662
+
663
+ /** The routes of a frontend block as board items, and its navigation changes. */
664
+ function routeItems(data) {
665
+ return data.routes.map((route) => ({
666
+ key: route.key,
667
+ method:
668
+ route.kind === 'api'
669
+ ? 'API ROUTE'
670
+ : route.kind === 'global'
671
+ ? 'GLOBAL'
672
+ : 'PÁGINA',
673
+ chip:
674
+ route.kind === 'api'
675
+ ? 'API ROUTE'
676
+ : route.kind === 'global'
677
+ ? 'GLOBAL'
678
+ : 'PÁGINA',
679
+ chipClass: `route-${route.kind}`,
680
+ path: route.url,
681
+ specFile: route.file,
682
+ tag: route.sparringOnly ? 'sparring' : null,
683
+ summary: route.params.length
684
+ ? route.params.map((name) => `?${name}=`).join(' ')
685
+ : '',
686
+ status: route.status,
687
+ body: () => routeBody(route, data),
688
+ hash: route.hash,
689
+ }));
690
+ }
691
+
692
+ // The weigh-in's scale counts every board of its bout (endpoints and routes).
693
+ const scaleTotals = {};
694
+ function updateScale(pr) {
695
+ const scale = document.querySelector(`[data-ring-scale="${pr}"]`);
696
+ if (!scale) return;
697
+ const boards = Object.values(scaleTotals[pr] ?? {});
698
+ const approved = boards.reduce((sum, board) => sum + board.approved, 0);
699
+ const total = boards.reduce((sum, board) => sum + board.total, 0);
700
+ const what = boards.map((board) => board.noun).join(' y ');
701
+ const judge = scale.dataset.approved === 'true';
702
+ const complete = total > 0 && approved === total;
703
+ scale.className = `scale ${complete && judge ? 'green' : 'red'}`;
704
+ scale.querySelector('b').textContent =
705
+ complete && judge ? 'Da el peso' : 'No da el peso';
706
+ scale.querySelector('span').textContent = complete
707
+ ? judge
708
+ ? `${approved} de ${total} aprobados (${what}) y el contrato aprobado por el juez.`
709
+ : `${approved} de ${total} aprobados (${what}); falta que el juez apruebe el contrato (approve-contract).`
710
+ : `${approved} de ${total} aprobados (${what}).`;
711
+ ring.scales[pr] = { approved, total, green: complete && judge, judge };
712
+ ring.changed();
713
+ }
714
+
715
+ /** A board of items with the judge's verdict on each: filters, progress, pending list, cards. */
716
+ async function board(
717
+ container,
718
+ { pr, block, noun, sourcesHash, items, before = [] },
719
+ ) {
720
+ container.textContent = '';
721
+ const ctx = { pr };
722
+ const judged = items.filter((item) => item.status !== 'removed');
723
+ const verdicts = {};
724
+ let filter = 'all';
725
+ const progress = el(
726
+ 'div',
727
+ { class: 'ep-progress' },
728
+ el('b'),
729
+ el('span'),
730
+ el('div', { class: 'bar' }, el('i')),
731
+ );
732
+ const pending = el('div', { class: 'ep-pending' });
733
+ const filters = [
734
+ ['all', 'Todas'],
735
+ ['added', 'Nuevas'],
736
+ ['changed', 'Cambian'],
737
+ ['removed', 'Se quitan'],
738
+ ['none', 'Sin veredicto'],
739
+ ['approved', 'Se aprueba'],
740
+ ['improve', 'Se mejora'],
741
+ ['stale', 'Desactualizadas'],
742
+ ];
743
+ const toolbar = el(
744
+ 'div',
745
+ { class: 'ep-filters', role: 'group', 'aria-label': `Filtrar ${noun}` },
746
+ filters.map(([value, label]) =>
747
+ el('button', {
748
+ type: 'button',
749
+ 'data-filter': value,
750
+ 'aria-pressed': value === filter ? 'true' : 'false',
751
+ text: label,
752
+ onclick: () => {
753
+ filter = value;
754
+ toolbar
755
+ .querySelectorAll('button')
756
+ .forEach((button) =>
757
+ button.setAttribute(
758
+ 'aria-pressed',
759
+ button.dataset.filter === value ? 'true' : 'false',
760
+ ),
761
+ );
762
+ refresh();
763
+ },
764
+ }),
765
+ ),
766
+ );
767
+ container.append(progress, pending, ...before, toolbar);
768
+ const verdictOf = (item) => {
769
+ const verdict = verdicts[item.key];
770
+ if (!verdict) return 'none';
771
+ return verdict.hash === item.hash ? verdict.verdict : 'stale';
772
+ };
773
+ const cards = items.map((item) => {
774
+ const verdict = item.status === 'removed' ? null : verdictBox(item, ctx);
775
+ const card = el(
776
+ 'details',
777
+ { class: `op ${item.status}`, 'data-status': item.status, id: item.key },
778
+ el(
779
+ 'summary',
780
+ {},
781
+ el('b', { class: `m ${item.chipClass}`, text: item.chip }),
782
+ el('code', { class: 'path', text: item.path }),
783
+ el('span', { class: 'sum', text: item.summary }),
784
+ item.tag ? el('span', { class: 'spec-tag', text: item.tag }) : null,
785
+ el('span', {
786
+ class: `badge ${item.status}`,
787
+ text: STATUS[item.status],
788
+ }),
789
+ verdict ? el('span', { class: 'vdot' }) : null,
790
+ ),
791
+ item.body(),
792
+ verdict?.box,
793
+ );
794
+ return { item, card, verdict };
795
+ });
796
+ cards.forEach(({ card }) => container.append(card));
797
+
798
+ function refresh() {
799
+ const count = (value) =>
800
+ judged.filter((item) => verdictOf(item) === value).length;
801
+ const approved = count('approved');
802
+ const total = judged.length;
803
+ progress.children[0].textContent = `${approved} de ${total} ${noun} ${noun === 'rutas' ? 'aprobadas' : 'aprobados'}`;
804
+ progress.children[1].textContent = ` · ${count('improve')} se mejora · ${count('stale')} desactualizadas · ${count('none')} sin veredicto`;
805
+ progress.querySelector('i').style.width =
806
+ `${total ? (approved / total) * 100 : 0}%`;
807
+ cards.forEach(({ item, card, verdict }) => {
808
+ const value = item.status === 'removed' ? 'removed' : verdictOf(item);
809
+ card.dataset.verdict = value;
810
+ card.hidden = !(
811
+ filter === 'all' ||
812
+ filter === item.status ||
813
+ filter === value
814
+ );
815
+ if (verdict) verdict.paint(verdicts[item.key] ?? null);
816
+ });
817
+ pending.textContent = '';
818
+ const improving = judged.filter((item) => verdictOf(item) === 'improve');
819
+ if (improving.length)
820
+ pending.append(
821
+ el('h4', { text: `Pendientes del contrato · ${improving.length}` }),
822
+ el(
823
+ 'ul',
824
+ {},
825
+ improving.map((item) => {
826
+ const verdict = verdicts[item.key];
827
+ return el(
828
+ 'li',
829
+ {},
830
+ el('b', { class: `m ${item.chipClass}`, text: item.chip }),
831
+ ' ',
832
+ el('code', { text: item.path }),
833
+ ` · ${fmt(verdict.at)} — ${verdict.comment}`,
834
+ (verdict.attachments ?? []).map((id) =>
835
+ el('img', { src: `/_blob/${id}`, alt: 'Adjunto del juez' }),
836
+ ),
837
+ );
838
+ }),
839
+ ),
840
+ );
841
+ scaleTotals[pr] = {
842
+ ...(scaleTotals[pr] ?? {}),
843
+ [block]: { approved, total, noun },
844
+ };
845
+ ring.boards.set(`${pr}-${block}`, {
846
+ pr,
847
+ block,
848
+ noun,
849
+ approved,
850
+ total,
851
+ items: judged.map((item) => ({
852
+ key: item.key,
853
+ chip: item.chip,
854
+ chipClass: item.chipClass,
855
+ path: item.path,
856
+ summary: item.summary,
857
+ status: item.status,
858
+ verdict: verdictOf(item),
859
+ save: (verdict, comment, attachments) =>
860
+ writeVerdict(item, pr, verdict, comment, attachments),
861
+ })),
862
+ });
863
+ updateScale(pr);
864
+ }
865
+ refresh();
866
+
867
+ const caps = await capabilities;
868
+ if (!caps.db) return;
869
+ const collection = caps.db.collection('veredictos');
870
+ const stateId = `pr${pr}-${block === 'routes' ? 'rutas' : 'contrato'}`;
871
+ collection.where('pr', '==', pr).onSnapshot(
872
+ (snap) => {
873
+ let state = null;
874
+ snap.docs.forEach((doc) => {
875
+ const value = doc.data();
876
+ if (value?.kind === 'verdict') verdicts[value.key] = value;
877
+ if (
878
+ value?.kind === 'contract-state' &&
879
+ (value.block ?? 'api') === block
880
+ )
881
+ state = value;
882
+ });
883
+ refresh();
884
+ if (!caps.canEdit || (state && state.sourcesHash === sourcesHash)) return;
885
+ const ops = Object.fromEntries(
886
+ items.map((item) => [
887
+ item.key,
888
+ {
889
+ method: item.method,
890
+ path: item.path,
891
+ specFile: item.specFile,
892
+ status: item.status,
893
+ hash: item.hash,
894
+ },
895
+ ]),
896
+ );
897
+ collection
898
+ .doc(stateId)
899
+ .set({
900
+ kind: 'contract-state',
901
+ block,
902
+ pr,
903
+ sourcesHash,
904
+ ops,
905
+ computedAt: new Date().toISOString(),
906
+ })
907
+ .catch(() => {});
908
+ },
909
+ () => {},
910
+ );
911
+ }
912
+
913
+ const failed = (container, error) =>
914
+ container.append(
915
+ el('p', {
916
+ class: 'muted',
917
+ text: `No se pudo dibujar el contrato: ${error.message}`,
918
+ }),
919
+ );
920
+
921
+ document.querySelectorAll('[data-ring-spec]').forEach((container) => {
922
+ const source = document.getElementById(
923
+ `ring-spec-${container.dataset.ringSpec}`,
924
+ );
925
+ (async () => {
926
+ const data = JSON.parse(source.textContent);
927
+ const items = await specItems(data);
928
+ await board(container, {
929
+ pr: data.pr,
930
+ block: 'api',
931
+ noun: 'endpoints',
932
+ sourcesHash: await sha256(source.textContent),
933
+ items,
934
+ });
935
+ })().catch((error) => failed(container, error));
936
+ });
937
+
938
+ document.querySelectorAll('[data-ring-routes]').forEach((container) => {
939
+ const source = document.getElementById(
940
+ `ring-routes-${container.dataset.ringRoutes}`,
941
+ );
942
+ (async () => {
943
+ const data = JSON.parse(source.textContent);
944
+ const navigation = data.navigation.length
945
+ ? [
946
+ el(
947
+ 'div',
948
+ { class: 'nav-changes' },
949
+ el('h4', { text: 'Navegación que cambia' }),
950
+ el(
951
+ 'ul',
952
+ {},
953
+ data.navigation.map((entry) =>
954
+ el(
955
+ 'li',
956
+ {},
957
+ el('code', { text: entry.path }),
958
+ entry.added.length
959
+ ? ` · entra ${entry.added.join(', ')}`
960
+ : '',
961
+ entry.removed.length
962
+ ? ` · sale ${entry.removed.join(', ')}`
963
+ : '',
964
+ entry.moved.length
965
+ ? ` · se mueve ${entry.moved.join(', ')}`
966
+ : '',
967
+ ),
968
+ ),
969
+ ),
970
+ ),
971
+ ]
972
+ : [];
973
+ await board(container, {
974
+ pr: data.pr,
975
+ block: 'routes',
976
+ noun: 'rutas',
977
+ sourcesHash: await sha256(source.textContent),
978
+ items: routeItems(data),
979
+ before: navigation,
980
+ });
981
+ })().catch((error) => failed(container, error));
982
+ });
983
+
984
+ // «El plan del técnico»: the points the wrestler has to do, by group, with their state, filterable,
985
+ // each origin linked to the card, question or element of the page that bears its id.
986
+ const PLAN_STATES = [
987
+ ['pendiente', 'Pendiente'],
988
+ ['espera al juez', 'Espera al juez'],
989
+ ['en curso', 'En curso'],
990
+ ['hecho', 'Hecho'],
991
+ ];
992
+ /** Inline Markdown as DOM nodes: bold (which may hold code), code spans, italics; text stays text. */
993
+ function inlineNodes(text) {
994
+ const plain = (part) =>
995
+ part.split(/(`[^`]+`)/).flatMap((piece) => {
996
+ if (/^`[^`]+`$/.test(piece))
997
+ return [el('code', { text: piece.slice(1, -1) })];
998
+ return piece
999
+ .split(/(\*[^*\s][^*]*\*)/)
1000
+ .map((bit) =>
1001
+ /^\*[^*\s][^*]*\*$/.test(bit)
1002
+ ? el('em', { text: bit.slice(1, -1) })
1003
+ : bit,
1004
+ );
1005
+ });
1006
+ return String(text ?? '')
1007
+ .split(/(\*\*.+?\*\*)/)
1008
+ .flatMap((part) =>
1009
+ /^\*\*.+\*\*$/.test(part)
1010
+ ? [el('strong', {}, plain(part.slice(2, -2)))]
1011
+ : plain(part),
1012
+ )
1013
+ .filter((node) => node !== '');
1014
+ }
1015
+ const stateClass = (state) => `st-${state.replace(/\s+/g, '-')}`;
1016
+ function originLink(pr, id) {
1017
+ const question = /^(E\d+)-(\d+)$/.exec(id);
1018
+ const target = question ? `pr${pr}-${question[1]}-q${question[2]}` : id;
1019
+ return document.getElementById(target)
1020
+ ? el('a', { href: `#${target}`, text: id })
1021
+ : el('span', { text: id });
1022
+ }
1023
+ function renderPlan(container, plan) {
1024
+ container.textContent = '';
1025
+ let filter = 'all';
1026
+ const items = plan.groups.flatMap((group) => group.items);
1027
+ const toolbar = el(
1028
+ 'div',
1029
+ { class: 'ep-filters', role: 'group', 'aria-label': 'Filtrar el plan' },
1030
+ [['all', 'Todos'], ...PLAN_STATES].map(([value, label]) =>
1031
+ el('button', {
1032
+ type: 'button',
1033
+ 'data-filter': value,
1034
+ 'aria-pressed': value === 'all' ? 'true' : 'false',
1035
+ text: `${label} · ${value === 'all' ? items.length : items.filter((item) => item.state === value).length}`,
1036
+ onclick: () => {
1037
+ filter = value;
1038
+ toolbar
1039
+ .querySelectorAll('button')
1040
+ .forEach((button) =>
1041
+ button.setAttribute(
1042
+ 'aria-pressed',
1043
+ button.dataset.filter === value ? 'true' : 'false',
1044
+ ),
1045
+ );
1046
+ container
1047
+ .querySelectorAll('li.plan-item')
1048
+ .forEach(
1049
+ (row) =>
1050
+ (row.hidden = !(
1051
+ filter === 'all' || row.dataset.state === filter
1052
+ )),
1053
+ );
1054
+ container
1055
+ .querySelectorAll('section.plan-group')
1056
+ .forEach(
1057
+ (section) =>
1058
+ (section.hidden = ![
1059
+ ...section.querySelectorAll('li.plan-item'),
1060
+ ].some((row) => !row.hidden)),
1061
+ );
1062
+ },
1063
+ }),
1064
+ ),
1065
+ );
1066
+ container.append(toolbar);
1067
+ plan.groups.forEach((group) => {
1068
+ container.append(
1069
+ el(
1070
+ 'section',
1071
+ { class: 'plan-group' },
1072
+ el(
1073
+ 'div',
1074
+ { class: 'ep-progress' },
1075
+ el('b', { text: group.name }),
1076
+ el('span', { text: ` · ${group.done} de ${group.total} hechos` }),
1077
+ el(
1078
+ 'div',
1079
+ { class: 'bar' },
1080
+ el('i', {
1081
+ style: `width:${group.total ? (group.done / group.total) * 100 : 0}%`,
1082
+ }),
1083
+ ),
1084
+ ),
1085
+ el(
1086
+ 'ul',
1087
+ { class: 'plan-items' },
1088
+ group.items.map((item) =>
1089
+ el(
1090
+ 'li',
1091
+ { class: 'plan-item', 'data-state': item.state },
1092
+ el('span', {
1093
+ class: `st ${stateClass(item.state)}`,
1094
+ text: item.state,
1095
+ }),
1096
+ el('b', {}, `${item.n}. `, inlineNodes(item.title)),
1097
+ item.detail ? [' ', ...inlineNodes(item.detail)] : '',
1098
+ item.origins.length
1099
+ ? el(
1100
+ 'span',
1101
+ { class: 'origins' },
1102
+ ' · origen: ',
1103
+ item.origins.flatMap((id, index) => [
1104
+ index ? ', ' : '',
1105
+ originLink(plan.pr, id),
1106
+ ]),
1107
+ )
1108
+ : null,
1109
+ item.since
1110
+ ? el('span', {
1111
+ class: 'since',
1112
+ text: ` · ${fmt(item.since)}${item.ref ? ` · ${item.ref}` : ''}`,
1113
+ })
1114
+ : null,
1115
+ ),
1116
+ ),
1117
+ ),
1118
+ ),
1119
+ );
1120
+ });
1121
+ if (plan.tickets.length)
1122
+ container.append(
1123
+ el(
1124
+ 'div',
1125
+ { class: 'plan-tickets' },
1126
+ el('h4', { text: 'Fuera de esta entrega (tickets)' }),
1127
+ el(
1128
+ 'ul',
1129
+ {},
1130
+ plan.tickets.map((ticket) => el('li', {}, inlineNodes(ticket))),
1131
+ ),
1132
+ ),
1133
+ );
1134
+ }
1135
+
1136
+ document.querySelectorAll('[data-ring-plan]').forEach((container) => {
1137
+ try {
1138
+ renderPlan(
1139
+ container,
1140
+ JSON.parse(
1141
+ document.getElementById(`ring-plan-${container.dataset.ringPlan}`)
1142
+ .textContent,
1143
+ ),
1144
+ );
1145
+ } catch (error) {
1146
+ container.append(
1147
+ el('p', {
1148
+ class: 'muted',
1149
+ text: `No se pudo dibujar el plan: ${error.message}`,
1150
+ }),
1151
+ );
1152
+ }
1153
+ });