@thebattlearena/corner 0.9.1-beta.70 → 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.
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/ring/agents/cornerman.md +20 -3
- package/ring/agents/wrestler.md +11 -0
- package/ring/hooks/hooks.json +20 -0
- package/ring/scripts/bout-stats-render.mjs +366 -0
- package/ring/scripts/bout-stats.mjs +360 -0
- package/ring/scripts/config.mjs +1 -1
- package/ring/scripts/contract-endpoints.mjs +163 -0
- package/ring/scripts/contract-preview.mjs +34 -14
- package/ring/scripts/fight.mjs +27 -0
- package/ring/scripts/findings.mjs +3 -1
- package/ring/scripts/format-gate.mjs +723 -0
- package/ring/scripts/format-worker.mjs +120 -0
- package/ring/scripts/front-routes.mjs +345 -0
- package/ring/scripts/judges-card.mjs +324 -3
- package/ring/scripts/lookout.mjs +22 -4
- package/ring/scripts/mask-guard.mjs +34 -1
- package/ring/scripts/page-embed.mjs +112 -0
- package/ring/scripts/play-by-play.mjs +155 -7
- package/ring/scripts/preparation-collect.mjs +177 -0
- package/ring/scripts/preparation.mjs +547 -0
- package/ring/scripts/round-bell.mjs +9 -2
- package/ring/scripts/tech-plan-log.mjs +81 -0
- package/ring/scripts/tech-plan.mjs +191 -0
- package/ring/scripts/verdicts-log.mjs +75 -0
- package/ring/scripts/verdicts.mjs +205 -0
- package/ring/skills/play-by-play/SKILL.md +235 -33
- package/ring/skills/play-by-play/templates/blocks.css +196 -0
- package/ring/skills/play-by-play/templates/chronicle.html +358 -55
- package/ring/skills/play-by-play/templates/endpoints-view.js +1153 -0
- package/ring/skills/play-by-play/templates/interview-form.js +516 -0
- package/ring/skills/play-by-play/templates/now-panel.js +719 -0
- package/ring/skills/referee/SKILL.md +136 -42
- package/ring/skills/referee/references/api-diagram.md +56 -0
- package/ring/skills/referee/references/precedents.md +11 -0
- package/ring/skills/referee/references/rulebook.md +65 -4
- package/ring/skills/referee/templates/api-diagram.html +115 -0
- package/ring/skills/referee/templates/escalation.md +7 -1
- package/ring/skills/referee/templates/round-bell-body.md +4 -0
- package/ring/skills/referee/templates/wrestler-brief.md +3 -0
- package/ring/skills/sparring/SKILL.md +10 -2
- package/ring/skills/sparring/templates/contrato.md +29 -0
- package/ring/skills/sparring/templates/rudo-brief.md +7 -0
- package/ring/skills/weigh-in/SKILL.md +6 -0
|
@@ -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));
|