@thebattlearena/corner 0.9.1-beta.70 → 0.10.1-beta.78

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 (58) hide show
  1. package/README.md +46 -26
  2. package/package.json +1 -1
  3. package/plugin/.claude-plugin/plugin.json +1 -1
  4. package/ring/agents/cornerman.md +32 -6
  5. package/ring/agents/rudo.md +2 -1
  6. package/ring/agents/wrestler.md +19 -4
  7. package/ring/hooks/hooks.json +42 -0
  8. package/ring/scripts/bout-stats-render.mjs +366 -0
  9. package/ring/scripts/bout-stats.mjs +360 -0
  10. package/ring/scripts/brief-build.mjs +130 -0
  11. package/ring/scripts/brief-guard.mjs +154 -0
  12. package/ring/scripts/brief.mjs +272 -0
  13. package/ring/scripts/compact-hook.mjs +110 -0
  14. package/ring/scripts/config.mjs +25 -1
  15. package/ring/scripts/contract-endpoints.mjs +163 -0
  16. package/ring/scripts/contract-preview.mjs +34 -14
  17. package/ring/scripts/fight.mjs +27 -0
  18. package/ring/scripts/findings.mjs +82 -3
  19. package/ring/scripts/format-gate.mjs +723 -0
  20. package/ring/scripts/format-worker.mjs +120 -0
  21. package/ring/scripts/front-routes.mjs +345 -0
  22. package/ring/scripts/judges-card.mjs +324 -3
  23. package/ring/scripts/lookout.mjs +22 -4
  24. package/ring/scripts/mask-guard.mjs +34 -1
  25. package/ring/scripts/page-embed.mjs +112 -0
  26. package/ring/scripts/play-by-play.mjs +155 -7
  27. package/ring/scripts/preparation-collect.mjs +177 -0
  28. package/ring/scripts/preparation.mjs +547 -0
  29. package/ring/scripts/round-bell.mjs +9 -2
  30. package/ring/scripts/rule-ids.mjs +124 -0
  31. package/ring/scripts/tech-plan-log.mjs +81 -0
  32. package/ring/scripts/tech-plan.mjs +191 -0
  33. package/ring/scripts/verdicts-log.mjs +75 -0
  34. package/ring/scripts/verdicts.mjs +205 -0
  35. package/ring/skills/play-by-play/SKILL.md +235 -33
  36. package/ring/skills/play-by-play/templates/blocks.css +196 -0
  37. package/ring/skills/play-by-play/templates/chronicle.html +358 -55
  38. package/ring/skills/play-by-play/templates/endpoints-view.js +1153 -0
  39. package/ring/skills/play-by-play/templates/interview-form.js +516 -0
  40. package/ring/skills/play-by-play/templates/now-panel.js +719 -0
  41. package/ring/skills/referee/SKILL.md +181 -56
  42. package/ring/skills/referee/references/api-diagram.md +56 -0
  43. package/ring/skills/referee/references/precedents.md +11 -0
  44. package/ring/skills/referee/references/rulebook.md +97 -6
  45. package/ring/skills/referee/templates/api-diagram.html +115 -0
  46. package/ring/skills/referee/templates/cornerman-batch.md +17 -11
  47. package/ring/skills/referee/templates/cornerman-concept.md +28 -30
  48. package/ring/skills/referee/templates/cornerman-range.md +16 -11
  49. package/ring/skills/referee/templates/escalation.md +7 -1
  50. package/ring/skills/referee/templates/round-bell-body.md +4 -0
  51. package/ring/skills/referee/templates/wrestler-brief.md +28 -41
  52. package/ring/skills/referee/templates/wrestler-cutoff.md +4 -0
  53. package/ring/skills/referee/templates/wrestler-resume.md +4 -0
  54. package/ring/skills/sparring/SKILL.md +13 -4
  55. package/ring/skills/sparring/templates/contrato.md +29 -0
  56. package/ring/skills/sparring/templates/rudo-brief.md +7 -0
  57. package/ring/skills/title-defense/SKILL.md +2 -2
  58. package/ring/skills/weigh-in/SKILL.md +6 -0
@@ -0,0 +1,124 @@
1
+ // The rules a bout runs on, read from its rulebooks and precedent files: the ids that exist (so a
2
+ // finding cites a real one) and the one-line checklist every brief opens with. Zero-dependency.
3
+ import { existsSync, readFileSync } from 'node:fs';
4
+
5
+ /**
6
+ * `**R11. SRP, SOLID…**` → { id: 'R11', title }. The heading of every rule and precedent; a long
7
+ * heading wraps onto the next lines, so the bold runs across them until its closing `**`.
8
+ */
9
+ const ENTRY = /^\*\*([A-Z]{1,2}\d+)\.\s+([\s\S]+?)\*\*/gm;
10
+
11
+ /**
12
+ * What the human's reviews keep finding and line-by-line reviews let through (bout #1223 and the
13
+ * review Tapes of 11 nov 2026: optionals and ternaries dragged downstream, defensive code, code
14
+ * coupled to each channel, repositories off the host's standard, hand-written types, raw provider
15
+ * responses): every brief opens with these in one line each, before the full text. Ids a venue
16
+ * does not define are skipped.
17
+ */
18
+ export const ARCHITECTURE_CHECKLIST = [
19
+ 'R3',
20
+ 'R6',
21
+ 'R8',
22
+ 'R11',
23
+ 'R12',
24
+ 'R15',
25
+ 'R16',
26
+ 'R17',
27
+ 'R18',
28
+ 'R19',
29
+ 'R20',
30
+ 'R21',
31
+ 'R22',
32
+ 'R23',
33
+ 'R24',
34
+ 'R25',
35
+ 'R26',
36
+ 'R27',
37
+ 'U6',
38
+ 'U7',
39
+ 'P14',
40
+ 'P16',
41
+ ];
42
+
43
+ /** The id that marks a finding no rule covers; it becomes a proposal for the rulebook. */
44
+ export const NO_RULE = 'X0';
45
+
46
+ /** Every rule or precedent heading of a Markdown file. PURE. */
47
+ export function ruleEntries(markdown) {
48
+ return [...String(markdown ?? '').matchAll(ENTRY)].map(([, id, title]) => ({
49
+ id,
50
+ title: title.replace(/\s+/g, ' ').replace(/\.$/, ''),
51
+ }));
52
+ }
53
+
54
+ /**
55
+ * The rule files of a venue, in the order a brief embeds them: the rulebook, the venue's
56
+ * rulebooks, the bundled precedents, the venue's precedents and the host's (HPn, which win).
57
+ */
58
+ export function ruleFiles(config) {
59
+ return [
60
+ config.rulebookPath,
61
+ ...(config.venueRulebookPaths ?? []),
62
+ config.bundledPrecedentsPath,
63
+ ...(config.venuePrecedentsPaths ?? []),
64
+ config.precedentsPath,
65
+ ].filter((path) => path && existsSync(path));
66
+ }
67
+
68
+ /** The rule files with their text and the ids they define. */
69
+ export function loadRules(config) {
70
+ const sources = ruleFiles(config).map((path) => ({
71
+ path,
72
+ text: readFileSync(path, 'utf8'),
73
+ }));
74
+ const entries = sources.flatMap((source) => ruleEntries(source.text));
75
+ return { sources, entries, ids: new Set(entries.map((entry) => entry.id)) };
76
+ }
77
+
78
+ /** The checklist as Markdown lines, from whichever of its ids the rules define. PURE. */
79
+ export function checklistLines(entries, ids = ARCHITECTURE_CHECKLIST) {
80
+ const byId = new Map(entries.map((entry) => [entry.id, entry.title]));
81
+ return ids
82
+ .filter((id) => byId.has(id))
83
+ .map((id) => `- **${id}** ${byId.get(id)}`);
84
+ }
85
+
86
+ const ID = /\b[A-Z]{1,2}\d+\b/g;
87
+ const FORMAT_LINE = /(?:^|\s—\s)format\s—\s|NO VERIFICADO/;
88
+
89
+ /**
90
+ * The rule ids a finding line cites in its rule field: the second field of
91
+ * `<file:line> — <rule id> — <what> — <how>`, or of a table row `| file | rule | … |`. The field
92
+ * may carry a severity or a note around the ids (`BLOCKER (P16/PR11)`, `X0 (security)`); only the
93
+ * ids count. Null when the field names none. PURE.
94
+ */
95
+ export function citedRules(line) {
96
+ const text = String(line).trim();
97
+ const fields = text.startsWith('|')
98
+ ? text
99
+ .split('|')
100
+ .map((field) => field.trim())
101
+ .filter(Boolean)
102
+ : text.split(/\s+—\s+/);
103
+ const field = (fields[1] ?? '').replace(/`/g, '').trim();
104
+ const ids = field.match(ID);
105
+ return ids ? [...new Set(ids)] : null;
106
+ }
107
+
108
+ /**
109
+ * The finding lines that do not cite a real rule, with why: no rule field, or ids no loaded rule
110
+ * defines. A format line and `X0` (no rule covers it) are valid. PURE.
111
+ */
112
+ export function uncitedFindings(lines, ids) {
113
+ return lines
114
+ .map((line) => ({ line, cited: citedRules(line) }))
115
+ .filter(({ line }) => !FORMAT_LINE.test(line))
116
+ .map(({ line, cited }) => {
117
+ if (!cited) return { line, reason: 'no rule id in its second field' };
118
+ const unknown = cited.filter((id) => id !== NO_RULE && !ids.has(id));
119
+ return unknown.length
120
+ ? { line, reason: `no rule ${unknown.join(', ')} in this venue` }
121
+ : null;
122
+ })
123
+ .filter(Boolean);
124
+ }
@@ -0,0 +1,81 @@
1
+ // Reads and writes a bout's technical plan (tech-plan.mjs): `judges-card.mjs plan` changes a
2
+ // point's state or adds one, `page-verdicts` adds each judge's «se mejora», and `play-by-play.mjs
3
+ // embed --plan` puts the plan into the chronicle. Zero-dependency.
4
+ import {
5
+ appendFileSync,
6
+ existsSync,
7
+ readFileSync,
8
+ writeFileSync,
9
+ } from 'node:fs';
10
+ import { join } from 'node:path';
11
+
12
+ import { readState } from './config.mjs';
13
+ import { scriptJson } from './contract-endpoints.mjs';
14
+ import {
15
+ parsePlan,
16
+ parsePlanLog,
17
+ PLAN,
18
+ PLAN_LOG,
19
+ planForPage,
20
+ planItem,
21
+ planState,
22
+ withPlanItem,
23
+ withPlanState,
24
+ } from './tech-plan.mjs';
25
+
26
+ const read = (path) => (existsSync(path) ? readFileSync(path, 'utf8') : null);
27
+
28
+ /** `plan <pr> <n|title> <state> [--ref <sha|round-bell>]`: a new state, stamped in plan.jsonl. */
29
+ export function setPlanState(
30
+ dir,
31
+ target,
32
+ state,
33
+ ref = null,
34
+ at = new Date().toISOString(),
35
+ ) {
36
+ const file = join(dir, PLAN);
37
+ const markdown = read(file);
38
+ if (markdown === null) throw new Error(`no ${PLAN} in ${dir}`);
39
+ const item = planItem(parsePlan(markdown), target);
40
+ const next = planState(state);
41
+ writeFileSync(file, withPlanState(markdown, item, next));
42
+ appendFileSync(
43
+ join(dir, PLAN_LOG),
44
+ `${JSON.stringify({ at, n: item.n, title: item.title, from: item.state, to: next, ref })}\n`,
45
+ );
46
+ return { ...item, from: item.state, to: next };
47
+ }
48
+
49
+ /** Adds a point to the plan (creating the file when it does not exist); returns whether it was new. */
50
+ export function addPlanItem(dir, point, at = new Date().toISOString()) {
51
+ const file = join(dir, PLAN);
52
+ const markdown =
53
+ read(file) ?? `# El plan del técnico · #${readState(dir).pr}\n`;
54
+ const next = withPlanItem(markdown, point);
55
+ if (next === markdown) return false;
56
+ writeFileSync(file, next);
57
+ appendFileSync(
58
+ join(dir, PLAN_LOG),
59
+ `${JSON.stringify({ at, title: point.title, from: null, to: point.state ?? 'pendiente', ref: point.origins?.join(', ') ?? null })}\n`,
60
+ );
61
+ return true;
62
+ }
63
+
64
+ /** The block after `<!-- ring:tech-plan #<pr> -->`: the container and the plan as JSON. */
65
+ export function planBlock(dir) {
66
+ const markdown = read(join(dir, PLAN));
67
+ if (markdown === null) throw new Error(`no ${PLAN} in ${dir}`);
68
+ const pr = readState(dir).pr;
69
+ const plan = {
70
+ pr,
71
+ ...planForPage(markdown, parsePlanLog(read(join(dir, PLAN_LOG)))),
72
+ };
73
+ const total = plan.groups.reduce((sum, group) => sum + group.total, 0);
74
+ return {
75
+ pr,
76
+ html: [
77
+ `<div class="tech-plan" data-ring-plan="${pr}"><p class="endpoints-wait">El plan del técnico (${total} puntos) se dibuja al abrir la página.</p></div>`,
78
+ `<script type="application/json" id="ring-plan-${pr}">${scriptJson(plan)}</script>`,
79
+ ].join('\n'),
80
+ };
81
+ }
@@ -0,0 +1,191 @@
1
+ // «El plan del técnico»: what the wrestler has to do for the deliverable, per bout, in
2
+ // `<bout dir>/plan-tecnico.md`: `## <group>` and bullets `- [<state>] **<title>** <detail> ·
3
+ // origen: <ids>`, the states being pendiente · espera al juez · en curso · hecho. The origin cites
4
+ // findings (B1, F8…), decisions (D1…), questions (E1-3), verdicts or the sparring's card. A group
5
+ // whose name says «tickets» holds what stays out of the delivery. `judges-card.mjs plan` changes a
6
+ // state without editing the file by hand and keeps each change with its time in `plan.jsonl`; a
7
+ // judge's «se mejora» enters the plan as a new point. PURE and zero-dependency.
8
+
9
+ export const PLAN = 'plan-tecnico.md';
10
+ export const PLAN_LOG = 'plan.jsonl';
11
+ export const PLAN_STATES = ['pendiente', 'espera al juez', 'en curso', 'hecho'];
12
+ const ITEM = /^(\s*-\s+)\[([^\]]+)\]\s+\*\*(.+?)\*\*\s*(.*)$/;
13
+
14
+ /** Checks a state name, accepting the English ones the referee may type. PURE. */
15
+ export function planState(value) {
16
+ const aliases = {
17
+ pending: 'pendiente',
18
+ waiting: 'espera al juez',
19
+ 'in progress': 'en curso',
20
+ doing: 'en curso',
21
+ done: 'hecho',
22
+ };
23
+ const state =
24
+ aliases[String(value).toLowerCase()] ?? String(value).toLowerCase();
25
+ if (!PLAN_STATES.includes(state))
26
+ throw new Error(
27
+ `"${value}" is not a plan state (${PLAN_STATES.join(' · ')})`,
28
+ );
29
+ return state;
30
+ }
31
+
32
+ const isTickets = (name) => /tickets?/i.test(name);
33
+
34
+ /**
35
+ * The plan: its title, its groups with their points (numbered across the plan, in order), and the
36
+ * tickets left out of the delivery. A point's origin ids come from `· origen: a, b`. PURE.
37
+ */
38
+ export function parsePlan(markdown) {
39
+ const plan = { title: null, groups: [], tickets: [] };
40
+ let group = null;
41
+ let n = 0;
42
+ String(markdown ?? '')
43
+ .split('\n')
44
+ .forEach((line, index) => {
45
+ const title = /^#\s+(.+)$/.exec(line);
46
+ if (title && !plan.title) return void (plan.title = title[1].trim());
47
+ const heading = /^##\s+(.+)$/.exec(line);
48
+ if (heading) {
49
+ group = {
50
+ name: heading[1].trim(),
51
+ tickets: isTickets(heading[1]),
52
+ items: [],
53
+ };
54
+ if (!group.tickets) plan.groups.push(group);
55
+ return;
56
+ }
57
+ if (!group) return;
58
+ if (group.tickets) {
59
+ const ticket = /^\s*-\s+(.+)$/.exec(line);
60
+ if (ticket) plan.tickets.push(ticket[1].trim());
61
+ return;
62
+ }
63
+ const item = ITEM.exec(line);
64
+ if (!item) return;
65
+ const [rest, origin] = item[4].split(/\s+·\s+origen:\s*/);
66
+ n += 1;
67
+ group.items.push({
68
+ n,
69
+ state: item[2].trim().toLowerCase(),
70
+ title: item[3].trim(),
71
+ detail: rest.trim(),
72
+ origins: origin
73
+ ? origin
74
+ .split(/,\s*/)
75
+ .map((id) => id.trim())
76
+ .filter(Boolean)
77
+ : [],
78
+ line: index,
79
+ });
80
+ });
81
+ return plan;
82
+ }
83
+
84
+ const itemsOf = (plan) => plan.groups.flatMap((group) => group.items);
85
+
86
+ /** The point `target` names: its number, or a piece of its title (only one may match). PURE. */
87
+ export function planItem(plan, target) {
88
+ const items = itemsOf(plan);
89
+ if (/^\d+$/.test(String(target))) {
90
+ const found = items.find((item) => item.n === Number(target));
91
+ if (!found)
92
+ throw new Error(`the plan has no point ${target} (1–${items.length})`);
93
+ return found;
94
+ }
95
+ const needle = String(target).toLowerCase();
96
+ const matches = items.filter((item) =>
97
+ item.title.toLowerCase().includes(needle),
98
+ );
99
+ if (matches.length !== 1)
100
+ throw new Error(
101
+ matches.length
102
+ ? `"${target}" matches ${matches.length} points: ${matches.map((item) => `${item.n} ${item.title}`).join('; ')}`
103
+ : `no point of the plan says "${target}"`,
104
+ );
105
+ return matches[0];
106
+ }
107
+
108
+ /** The plan's Markdown with one point in a new state; nothing else changes. PURE. */
109
+ export function withPlanState(markdown, item, state) {
110
+ const lines = String(markdown).split('\n');
111
+ lines[item.line] = lines[item.line].replace(
112
+ /\[[^\]]+\]/,
113
+ `[${planState(state)}]`,
114
+ );
115
+ return lines.join('\n');
116
+ }
117
+
118
+ /**
119
+ * The plan's Markdown with a new point at the end of `group` (created before the tickets when it
120
+ * does not exist), unless a point already cites the same origin. PURE.
121
+ */
122
+ export function withPlanItem(
123
+ markdown,
124
+ { group, title, detail = '', origins = [], state = 'pendiente' },
125
+ ) {
126
+ const plan = parsePlan(markdown);
127
+ if (
128
+ origins.length &&
129
+ itemsOf(plan).some((item) =>
130
+ origins.every((id) => item.origins.includes(id)),
131
+ )
132
+ )
133
+ return markdown;
134
+ const bullet = `- [${planState(state)}] **${title}**${detail ? ` ${detail}` : ''}${origins.length ? ` · origen: ${origins.join(', ')}` : ''}`;
135
+ const lines = String(markdown).replace(/\n+$/, '').split('\n');
136
+ const existing = plan.groups.find((entry) => entry.name === group);
137
+ if (existing?.items.length) {
138
+ lines.splice(existing.items.at(-1).line + 1, 0, bullet);
139
+ return `${lines.join('\n')}\n`;
140
+ }
141
+ const headingAt = lines.findIndex((line) => line.trim() === `## ${group}`);
142
+ if (headingAt !== -1) {
143
+ lines.splice(headingAt + 1, 0, bullet);
144
+ return `${lines.join('\n')}\n`;
145
+ }
146
+ const ticketsAt = lines.findIndex((line) => /^##\s+.*tickets?/i.test(line));
147
+ const block = [`## ${group}`, bullet, ''];
148
+ if (ticketsAt === -1)
149
+ return `${[...lines, '', ...block].join('\n').replace(/\n+$/, '')}\n`;
150
+ lines.splice(ticketsAt, 0, ...block);
151
+ return `${lines.join('\n')}\n`;
152
+ }
153
+
154
+ /** The plan log's entries, skipping lines that are not JSON. PURE. */
155
+ export function parsePlanLog(text) {
156
+ return String(text ?? '')
157
+ .split('\n')
158
+ .filter((line) => line.trim())
159
+ .map((line) => {
160
+ try {
161
+ return JSON.parse(line);
162
+ } catch {
163
+ return null;
164
+ }
165
+ })
166
+ .filter(Boolean);
167
+ }
168
+
169
+ /**
170
+ * The plan for the page: each point with its last change (`since`, `ref`) from the log, matched by
171
+ * title, and each group's count of points done. PURE.
172
+ */
173
+ export function planForPage(markdown, log) {
174
+ const plan = parsePlan(markdown);
175
+ const last = {};
176
+ log.forEach((entry) => (last[entry.title] = entry));
177
+ return {
178
+ title: plan.title,
179
+ tickets: plan.tickets,
180
+ groups: plan.groups.map((group) => ({
181
+ name: group.name,
182
+ done: group.items.filter((item) => item.state === 'hecho').length,
183
+ total: group.items.length,
184
+ items: group.items.map(({ line, ...item }) => ({
185
+ ...item,
186
+ since: last[item.title]?.at ?? null,
187
+ ref: last[item.title]?.ref ?? null,
188
+ })),
189
+ })),
190
+ };
191
+ }
@@ -0,0 +1,75 @@
1
+ // Logs the judge's verdicts on the contract's endpoints, read from the chronicle page, into the
2
+ // bout (`judges-card.mjs page-verdicts`): each new verdict at the time the judge gave it, the
3
+ // contract state the judge's view saved, and each «se mejora» as a contract finding, so it travels
4
+ // to the wrestler in the rebound. Zero-dependency; the rules are verdicts.mjs.
5
+ import { appendFileSync, existsSync, readFileSync } from 'node:fs';
6
+ import { join } from 'node:path';
7
+
8
+ import { contractSourcesHash } from './contract-endpoints.mjs';
9
+ import { readLedger, withFindings, writeLedger } from './findings.mjs';
10
+ import { routesSourcesHash } from './front-routes.mjs';
11
+ import { addPlanItem } from './tech-plan-log.mjs';
12
+ import { record } from './live.mjs';
13
+ import {
14
+ improveFinding,
15
+ pageVerdictDocs,
16
+ pageVerdictEntries,
17
+ parseVerdicts,
18
+ VERDICTS,
19
+ verdictReport,
20
+ verdictSummary,
21
+ } from './verdicts.mjs';
22
+
23
+ export function logPageVerdicts(dir, pr, listing) {
24
+ const file = join(dir, VERDICTS);
25
+ const entries = parseVerdicts(
26
+ existsSync(file) ? readFileSync(file, 'utf8') : '',
27
+ );
28
+ const fresh = pageVerdictEntries(entries, pageVerdictDocs(listing), { pr });
29
+ for (const entry of fresh) appendFileSync(file, `${JSON.stringify(entry)}\n`);
30
+ const improve = fresh.filter(
31
+ (entry) => entry.kind === 'verdict' && entry.verdict === 'improve',
32
+ );
33
+ const added = improve.length
34
+ ? writeLedger(
35
+ dir,
36
+ withFindings(readLedger(dir), {
37
+ source: 'contract',
38
+ range: null,
39
+ upTo: null,
40
+ texts: improve.map(improveFinding),
41
+ at: new Date().toISOString(),
42
+ }).ledger,
43
+ )
44
+ : null;
45
+ // Each «se mejora» is also a point of the technical plan, with the verdict as its origin.
46
+ const planned = improve.filter((entry) =>
47
+ addPlanItem(dir, {
48
+ group: 'Lo que pidió el juez',
49
+ title: `${entry.method} ${entry.path}`,
50
+ detail: entry.comment,
51
+ origins: [entry.key],
52
+ }),
53
+ ).length;
54
+ for (const entry of fresh.filter((item) => item.kind === 'verdict'))
55
+ record(dir, {
56
+ actor: 'juez',
57
+ state: 'done',
58
+ label:
59
+ entry.verdict === 'approved'
60
+ ? 'aprobó un endpoint'
61
+ : 'pidió mejorar un endpoint',
62
+ detail: `${entry.method} ${entry.path}${entry.comment ? ` · ${entry.comment}` : ''}`,
63
+ });
64
+ const summary = verdictSummary([...entries, ...fresh], {
65
+ api: contractSourcesHash(dir),
66
+ routes: routesSourcesHash(dir),
67
+ });
68
+ return {
69
+ logged: fresh.filter((entry) => entry.kind === 'verdict').length,
70
+ findings: improve.length,
71
+ planned,
72
+ ledgerRevision: added ? added.revision : null,
73
+ report: verdictReport(summary),
74
+ };
75
+ }
@@ -0,0 +1,205 @@
1
+ // The judge's verdict on each endpoint of the contract, given from the chronicle page («Endpoints
2
+ // del contrato»: «Se aprueba» or «Se mejora», with a comment and images). The page keeps them in its
3
+ // db (collection `veredictos`), one doc per operation, each with the fingerprint (`hash`) of the
4
+ // operation's contract when the judge gave it; the judge's own view also saves the contract's state
5
+ // (`pr<n>-contrato`: the fingerprint of every operation and of all the embedded spec files). A
6
+ // verdict counts only while its hash is the operation's current one: a rewritten contract asks
7
+ // for a new one. The referee reads the collection with ArtifactData and logs it
8
+ // (`judges-card.mjs page-verdicts`) in `<bout dir>/verdicts.jsonl`; each «se mejora» becomes a
9
+ // contract finding. PURE and zero-dependency.
10
+
11
+ export const VERDICTS = 'verdicts.jsonl';
12
+
13
+ /** The verdict and state docs of the page's listing, wherever it nests them. PURE. */
14
+ export function pageVerdictDocs(listing) {
15
+ const verdicts = [];
16
+ const states = [];
17
+ const walk = (node) => {
18
+ if (!node || typeof node !== 'object') return;
19
+ if (Array.isArray(node)) return node.forEach(walk);
20
+ if (node.kind === 'contract-state' && node.ops)
21
+ return void states.push(node);
22
+ if (
23
+ ['approved', 'improve'].includes(node.verdict) &&
24
+ node.method &&
25
+ (node.path || node.url) &&
26
+ node.key
27
+ )
28
+ return void verdicts.push(node);
29
+ Object.values(node).forEach(walk);
30
+ };
31
+ walk(listing);
32
+ return { verdicts, states };
33
+ }
34
+
35
+ /** The ledger's entries, skipping lines that are not JSON. PURE. */
36
+ export function parseVerdicts(text) {
37
+ return String(text ?? '')
38
+ .split('\n')
39
+ .filter((line) => line.trim())
40
+ .map((line) => {
41
+ try {
42
+ return JSON.parse(line);
43
+ } catch {
44
+ return null;
45
+ }
46
+ })
47
+ .filter((entry) => entry && ['verdict', 'state'].includes(entry.kind));
48
+ }
49
+
50
+ /**
51
+ * What to log from the page for bout `pr`: the contract state the judge's view saved last (when
52
+ * newer than the logged one) and every verdict not logged yet, oldest first. PURE.
53
+ */
54
+ export function pageVerdictEntries(entries, docs, { pr }) {
55
+ const logged = new Set(entries.map((entry) => entry.source).filter(Boolean));
56
+ const mine = (doc) => Number(doc.pr) === Number(pr);
57
+ const fresh = [];
58
+ // The latest state of each block the page judges: `api` (the endpoints) and `routes`.
59
+ const latestByBlock = {};
60
+ docs.states.filter(mine).forEach((state) => {
61
+ const block = state.block ?? 'api';
62
+ const previous = latestByBlock[block];
63
+ if (
64
+ !previous ||
65
+ Date.parse(state.computedAt) > Date.parse(previous.computedAt)
66
+ )
67
+ latestByBlock[block] = state;
68
+ });
69
+ Object.entries(latestByBlock)
70
+ .sort(([a], [b]) => a.localeCompare(b))
71
+ .forEach(([block, state]) => {
72
+ const source = `page:state:${block}@${state.computedAt}`;
73
+ if (logged.has(source)) return;
74
+ fresh.push({
75
+ kind: 'state',
76
+ block,
77
+ at: state.computedAt,
78
+ sourcesHash: state.sourcesHash,
79
+ ops: state.ops,
80
+ source,
81
+ });
82
+ });
83
+ docs.verdicts
84
+ .filter(mine)
85
+ .map((doc) => ({ doc, source: `page:${doc.key}@${doc.at}` }))
86
+ .filter(({ source }) => !logged.has(source))
87
+ .sort((a, b) => Date.parse(a.doc.at) - Date.parse(b.doc.at))
88
+ .forEach(({ doc, source }) =>
89
+ fresh.push({
90
+ kind: 'verdict',
91
+ key: doc.key,
92
+ method: doc.method,
93
+ path: doc.path ?? doc.url,
94
+ specFile: doc.specFile ?? null,
95
+ hash: doc.hash,
96
+ verdict: doc.verdict,
97
+ comment: String(doc.comment ?? '').trim(),
98
+ attachments: Array.isArray(doc.attachments) ? doc.attachments : [],
99
+ at: doc.at,
100
+ source,
101
+ }),
102
+ );
103
+ return fresh;
104
+ }
105
+
106
+ /**
107
+ * Where the contract stands with the judge, from the ledger: the last state the page saved and,
108
+ * per operation of it, the latest verdict, current only when its hash is the operation's. `total`
109
+ * counts the operations added or changed; `approved` those with a current «se aprueba»; `improve`
110
+ * the current «se mejora» (the pending list); `stale` the verdicts the contract outgrew. With
111
+ * `sourcesHash` (the embedded spec files now), a state of other files is reported as `outdated`.
112
+ * Null without a state. PURE.
113
+ */
114
+ export function verdictSummary(entries, sourcesHashes = null) {
115
+ const latestByBlock = {};
116
+ entries
117
+ .filter((entry) => entry.kind === 'state')
118
+ .forEach((entry) => (latestByBlock[entry.block ?? 'api'] = entry));
119
+ const blocks = Object.entries(latestByBlock);
120
+ if (!blocks.length) return null;
121
+ const hashes =
122
+ typeof sourcesHashes === 'string'
123
+ ? { api: sourcesHashes }
124
+ : (sourcesHashes ?? {});
125
+ const state = {
126
+ at: blocks
127
+ .map(([, entry]) => entry.at)
128
+ .sort()
129
+ .at(-1),
130
+ ops: Object.assign({}, ...blocks.map(([, entry]) => entry.ops ?? {})),
131
+ };
132
+ // A hash passed as null is a read that failed: the block counts as outdated, never as verified.
133
+ const outdatedBlocks = blocks
134
+ .filter(
135
+ ([block, entry]) =>
136
+ Object.hasOwn(hashes, block) &&
137
+ (hashes[block] === null || entry.sourcesHash !== hashes[block]),
138
+ )
139
+ .map(([block]) => block);
140
+ const latest = {};
141
+ entries
142
+ .filter((entry) => entry.kind === 'verdict')
143
+ .forEach((entry) => (latest[entry.key] = entry));
144
+ const ops = Object.entries(state.ops ?? {}).filter(([, op]) =>
145
+ ['added', 'changed'].includes(op.status),
146
+ );
147
+ const rows = ops.map(([key, op]) => {
148
+ const verdict = latest[key];
149
+ const current = verdict && verdict.hash === op.hash ? verdict : null;
150
+ return { key, method: op.method, path: op.path, op, verdict, current };
151
+ });
152
+ const pick = (verdict) =>
153
+ rows
154
+ .filter((row) => row.current?.verdict === verdict)
155
+ .map(({ key, method, path, current }) => ({
156
+ key,
157
+ method,
158
+ path,
159
+ at: current.at,
160
+ comment: current.comment,
161
+ attachments: current.attachments,
162
+ }));
163
+ const approved = pick('approved');
164
+ return {
165
+ at: state.at,
166
+ outdated: outdatedBlocks.length > 0,
167
+ outdatedBlocks,
168
+ total: rows.length,
169
+ approved: approved.length,
170
+ improve: pick('improve'),
171
+ stale: rows
172
+ .filter((row) => row.verdict && !row.current)
173
+ .map(({ key, method, path }) => ({ key, method, path })),
174
+ unanswered: rows
175
+ .filter((row) => !row.verdict)
176
+ .map(({ key, method, path }) => ({ key, method, path })),
177
+ complete: rows.length > 0 && approved.length === rows.length,
178
+ };
179
+ }
180
+
181
+ /** The contract finding a «se mejora» becomes: it travels to the wrestler in the rebound. PURE. */
182
+ export const improveFinding = (entry) =>
183
+ `FIX contract ${entry.method} ${entry.path}: ${entry.comment || '(sin comentario)'} (se mejora, el juez, ${entry.at}${entry.attachments.length ? `, imágenes ${entry.attachments.join(', ')}` : ''})`;
184
+
185
+ /** The line page-verdicts prints on where the contract stands. PURE. */
186
+ export function verdictReport(summary) {
187
+ if (!summary)
188
+ return 'no contract state from the page yet: the judge has to open the chronicle once the contract is embedded';
189
+ if (summary.outdated)
190
+ return `the page's contract state (${summary.at}) is not the contract embedded now: the judge has to open the republished chronicle before the verdicts count`;
191
+ const counts = `${summary.approved} of ${summary.total} approved · ${summary.improve.length} se mejora · ${summary.stale.length} outdated · ${summary.unanswered.length} without a verdict`;
192
+ if (summary.complete)
193
+ return `${counts}. Every operation is approved with its current fingerprint: approve-contract can run on these contract files.`;
194
+ return counts;
195
+ }
196
+
197
+ /**
198
+ * The scale of the weigh-in: green only with the judge's `approve-contract` and, when the page
199
+ * has boards to judge (`reviewable`: changed endpoints or routes), every operation of them approved
200
+ * with its current fingerprint. A reviewable bout without a verdict state yet is not complete.
201
+ * `reviewable` defaults to whether there is a state. PURE.
202
+ */
203
+ export const madeWeight = (scale, summary, reviewable = Boolean(summary)) =>
204
+ scale.madeWeight &&
205
+ (!reviewable || Boolean(summary?.complete && !summary.outdated));