@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.
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,360 @@
1
+ #!/usr/bin/env node
2
+ // The numbers of a bout for the final edition of its chronicle: what the lines are made of, the
3
+ // rhythm of the commits (by day, by hour, by conventional type) and the referee's book (decisions,
4
+ // rebounds, findings, contracts, fouls, round-bells). Read-only: git in the wrestler's worktree and
5
+ // the bout dir. The pure parts are exported and tested; the CLI prints JSON or, with --html, the
6
+ // "El cierre en números" block ready for the chronicle template. Zero-dependency.
7
+ //
8
+ // node bout-stats.mjs <pr> [--tz <Area/City>] [--repo <owner/name>] [--html] [--lang es|en]
9
+ // [--moments <json|file>]
10
+ //
11
+ // --moments takes an array of { at, title, tag } (tag: one of the chronicle's event types); the
12
+ // skill picks them from the play-by-play JSON. Every time comes from the records, never invented.
13
+ import { execFileSync } from 'node:child_process';
14
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
15
+ import { basename, join } from 'node:path';
16
+
17
+ import { renderStats } from './bout-stats-render.mjs';
18
+ import {
19
+ classifyDecision,
20
+ foulTarget,
21
+ parseDecisionLine,
22
+ parseReboundLine,
23
+ } from './chronicle.mjs';
24
+ import {
25
+ boutDir,
26
+ isMain,
27
+ projectDir,
28
+ readState,
29
+ repoSlug,
30
+ slugFromRemote,
31
+ } from './config.mjs';
32
+ import { counts, readLedger } from './findings.mjs';
33
+
34
+ const LOCKFILES = [
35
+ 'package-lock.json',
36
+ 'yarn.lock',
37
+ 'pnpm-lock.yaml',
38
+ 'bun.lockb',
39
+ 'npm-shrinkwrap.json',
40
+ 'Cargo.lock',
41
+ 'go.sum',
42
+ 'poetry.lock',
43
+ 'Pipfile.lock',
44
+ 'Gemfile.lock',
45
+ 'composer.lock',
46
+ ];
47
+ const TEST_PATH = /(^|\/)(__tests__|__mocks__|__fixtures__|tests?|spec)\//;
48
+ const TEST_FILE =
49
+ /\.(test|spec|e2e-spec)\.[cm]?[jt]sx?$|_test\.(go|py)$|(^|\/)test_[^/]+\.py$/;
50
+ const CODE_FILE =
51
+ /\.([cm]?[jt]sx?|py|go|rb|java|kt|kts|swift|rs|cs|php|vue|svelte|scala|ex|exs|dart|m|mm|c|cc|cpp|h|hpp|sql)$/;
52
+
53
+ /** Which part of the bout a changed path belongs to: tests, lockfile, code or other. PURE. */
54
+ export function lineBucket(path) {
55
+ const file = String(path);
56
+ if (LOCKFILES.includes(basename(file))) return 'lockfile';
57
+ if (TEST_PATH.test(file) || TEST_FILE.test(file)) return 'tests';
58
+ if (CODE_FILE.test(file)) return 'code';
59
+ return 'other';
60
+ }
61
+
62
+ /**
63
+ * Totals of `git diff --numstat` output, split by bucket. A binary file ("-" counts) counts as a
64
+ * file with no lines. A rename (`a => b`, `{a => b}`) is bucketed by its new path. PURE.
65
+ */
66
+ export function numstatTotals(text) {
67
+ const empty = () => ({ added: 0, deleted: 0, files: 0 });
68
+ const buckets = {
69
+ code: empty(),
70
+ tests: empty(),
71
+ lockfile: empty(),
72
+ other: empty(),
73
+ };
74
+ const total = empty();
75
+ for (const line of String(text).split('\n').filter(Boolean)) {
76
+ const [added, deleted, ...rest] = line.split('\t');
77
+ const path = rest
78
+ .join('\t')
79
+ .replace(/\{[^{}]* => ([^{}]*)\}/g, '$1')
80
+ .replace(/^.* => /, '');
81
+ const bucket = buckets[lineBucket(path)];
82
+ const plus = Number(added) || 0;
83
+ const minus = Number(deleted) || 0;
84
+ for (const target of [bucket, total]) {
85
+ target.added += plus;
86
+ target.deleted += minus;
87
+ target.files += 1;
88
+ }
89
+ }
90
+ return { ...total, buckets };
91
+ }
92
+
93
+ const TYPE_ORDER = [
94
+ 'feat',
95
+ 'fix',
96
+ 'refactor',
97
+ 'test',
98
+ 'docs',
99
+ 'perf',
100
+ 'style',
101
+ 'build',
102
+ 'ci',
103
+ 'chore',
104
+ ];
105
+
106
+ /** The conventional type of a commit subject (`feat(mobile): …` → feat), or "other". PURE. */
107
+ export function commitType(subject) {
108
+ const type = /^([a-z]+)(\([^)]*\))?!?:/i
109
+ .exec(String(subject).trim())?.[1]
110
+ ?.toLowerCase();
111
+ return TYPE_ORDER.includes(type) ? type : 'other';
112
+ }
113
+
114
+ const dayKey = (at, timeZone) =>
115
+ new Intl.DateTimeFormat('en-CA', {
116
+ timeZone,
117
+ year: 'numeric',
118
+ month: '2-digit',
119
+ day: '2-digit',
120
+ }).format(new Date(at));
121
+ const hourOf = (at, timeZone) =>
122
+ Number(
123
+ new Intl.DateTimeFormat('en-GB', {
124
+ timeZone,
125
+ hour: '2-digit',
126
+ hour12: false,
127
+ })
128
+ .format(new Date(at))
129
+ .replace('24', '00'),
130
+ );
131
+
132
+ /** Days between two YYYY-MM-DD keys, both included, so a quiet day still shows as zero. PURE. */
133
+ function daysBetween(first, last) {
134
+ const days = [];
135
+ for (
136
+ let at = Date.parse(`${first}T12:00:00Z`);
137
+ at <= Date.parse(`${last}T12:00:00Z`);
138
+ at += 86_400_000
139
+ )
140
+ days.push(new Date(at).toISOString().slice(0, 10));
141
+ return days;
142
+ }
143
+
144
+ /**
145
+ * The rhythm of `git log --format=%cI%x09%s` lines in a time zone: commits per local day (quiet
146
+ * days included), per local hour (24 slots) and per conventional type (fixed order, "other" last).
147
+ * PURE.
148
+ */
149
+ export function commitRhythm(log, timeZone) {
150
+ const commits = String(log)
151
+ .split('\n')
152
+ .filter(Boolean)
153
+ .map((line) => {
154
+ const [at, ...subject] = line.split('\t');
155
+ return { at, subject: subject.join('\t') };
156
+ })
157
+ .filter((commit) => !Number.isNaN(Date.parse(commit.at)));
158
+ const byHour = Array.from({ length: 24 }, () => 0);
159
+ const perDay = new Map();
160
+ const perType = new Map();
161
+ for (const commit of commits) {
162
+ byHour[hourOf(commit.at, timeZone)] += 1;
163
+ const day = dayKey(commit.at, timeZone);
164
+ perDay.set(day, (perDay.get(day) ?? 0) + 1);
165
+ const type = commitType(commit.subject);
166
+ perType.set(type, (perType.get(type) ?? 0) + 1);
167
+ }
168
+ const keys = [...perDay.keys()].sort();
169
+ const byDay = keys.length
170
+ ? daysBetween(keys[0], keys.at(-1)).map((day) => ({
171
+ day,
172
+ n: perDay.get(day) ?? 0,
173
+ }))
174
+ : [];
175
+ const byType = [...TYPE_ORDER, 'other']
176
+ .filter((type) => perType.has(type))
177
+ .map((type) => ({ type, n: perType.get(type) }));
178
+ return { total: commits.length, byDay, byHour, byType };
179
+ }
180
+
181
+ /**
182
+ * The referee's book from the ledgers: decisions (distinct ids, the judge's among them), contracts
183
+ * the judge approved, fouls, rebounds (closed and open), first and last recorded time. PURE.
184
+ */
185
+ export function ledgerCounts(decisionsText, reboundsText) {
186
+ const decisions = String(decisionsText)
187
+ .split('\n')
188
+ .map(parseDecisionLine)
189
+ .filter((entry) => entry?.id);
190
+ const rebounds = String(reboundsText)
191
+ .split('\n')
192
+ .map(parseReboundLine)
193
+ .filter(Boolean);
194
+ const ids = (list) => new Set(list.map((entry) => entry.id)).size;
195
+ const fouls = decisions.filter(
196
+ (entry) => classifyDecision(entry) === 'falta',
197
+ );
198
+ // As applyFoulReferences in the chronicle: a line marking an earlier decision of the ledger
199
+ // counts as that decision's foul, so two marks of the same D<n> are one foul.
200
+ const logged = new Set(decisions.map((entry) => entry.id));
201
+ const foulOf = (entry) => {
202
+ const target = foulTarget(entry.text);
203
+ return target && target !== entry.id && logged.has(target)
204
+ ? target
205
+ : entry.id;
206
+ };
207
+ // ISO times with different offsets do not sort as strings: sort by the instant.
208
+ const times = [...decisions, ...rebounds]
209
+ .map((entry) => entry.at)
210
+ .filter((at) => at && !Number.isNaN(Date.parse(at)))
211
+ .sort((a, b) => Date.parse(a) - Date.parse(b));
212
+ return {
213
+ decisions: ids(decisions.filter((entry) => /^D\d+$/.test(entry.id))),
214
+ judgeDecisions: ids(
215
+ decisions.filter((entry) => classifyDecision(entry) === 'juez'),
216
+ ),
217
+ contracts: ids(
218
+ decisions.filter((entry) => /contract approved/i.test(entry.text)),
219
+ ),
220
+ fouls: new Set(fouls.map(foulOf)).size,
221
+ rebounds: {
222
+ total: rebounds.length,
223
+ closed: rebounds.filter((entry) => entry.closed).length,
224
+ },
225
+ first: times[0] ?? null,
226
+ last: times.at(-1) ?? null,
227
+ };
228
+ }
229
+
230
+ /** Round numbers from the bout dir's `round-<n>-<slug>.md` files (one per round-bell). PURE. */
231
+ export function roundBells(files) {
232
+ return new Set(
233
+ files.map((file) => /^round-(\d+)-/.exec(file)?.[1]).filter(Boolean),
234
+ ).size;
235
+ }
236
+
237
+ /** "134 h 54" between two ISO times. PURE. */
238
+ export function durationLabel(first, last) {
239
+ if (!first || !last) return null;
240
+ const minutes = Math.max(
241
+ 0,
242
+ Math.floor((Date.parse(last) - Date.parse(first)) / 60_000),
243
+ );
244
+ return `${Math.floor(minutes / 60)} h ${String(minutes % 60).padStart(2, '0')}`;
245
+ }
246
+
247
+ /** Moments for the highlight reel: { at, title, tag } sorted by time, the time checked. PURE. */
248
+ export function readMoments(items) {
249
+ if (!Array.isArray(items))
250
+ throw new Error('--moments: expected a JSON array');
251
+ return items
252
+ .map((item) => {
253
+ if (Number.isNaN(Date.parse(item?.at)))
254
+ throw new Error(`--moments: "${item?.title}" has no valid time`);
255
+ return {
256
+ at: item.at,
257
+ title: String(item.title ?? ''),
258
+ tag: String(item.tag ?? 'campana'),
259
+ };
260
+ })
261
+ .sort((a, b) => Date.parse(a.at) - Date.parse(b.at));
262
+ }
263
+
264
+ const run = (args, cwd) =>
265
+ execFileSync('git', ['-C', cwd, ...args], {
266
+ encoding: 'utf8',
267
+ stdio: ['ignore', 'pipe', 'pipe'],
268
+ maxBuffer: 64 * 1024 * 1024,
269
+ });
270
+
271
+ /** Everything above for one bout, read from git and its dir. */
272
+ export function collectStats(dir, timeZone) {
273
+ const state = readState(dir);
274
+ const read = (name) =>
275
+ existsSync(join(dir, name)) ? readFileSync(join(dir, name), 'utf8') : '';
276
+ const notes = [];
277
+ const book = ledgerCounts(read('decisions.md'), read('rebounds.md'));
278
+ const worktreeOk = Boolean(state.worktree && existsSync(state.worktree));
279
+ if (!worktreeOk)
280
+ notes.push(
281
+ `git: the wrestler's worktree ${state.worktree} is gone; lines and commits are missing`,
282
+ );
283
+ const base = `origin/${state.base}`;
284
+ const lines = worktreeOk
285
+ ? numstatTotals(
286
+ run(['diff', '--numstat', `${base}...HEAD`], state.worktree),
287
+ )
288
+ : null;
289
+ const rhythm = worktreeOk
290
+ ? commitRhythm(
291
+ run(
292
+ ['log', '--no-merges', '--format=%cI%x09%s', `${base}..HEAD`],
293
+ state.worktree,
294
+ ),
295
+ timeZone,
296
+ )
297
+ : null;
298
+ return {
299
+ pr: state.pr ?? null,
300
+ timezone: timeZone,
301
+ head: worktreeOk
302
+ ? run(['rev-parse', '--short=9', 'HEAD'], state.worktree).trim()
303
+ : null,
304
+ duration: {
305
+ first: book.first,
306
+ last: book.last,
307
+ label: durationLabel(book.first, book.last),
308
+ },
309
+ lines,
310
+ rhythm,
311
+ book: {
312
+ ...book,
313
+ findings: counts(readLedger(dir)),
314
+ roundBells: roundBells(readdirSync(dir)),
315
+ },
316
+ notes,
317
+ };
318
+ }
319
+
320
+ function option(argv, name) {
321
+ const index = argv.indexOf(`--${name}`);
322
+ return index === -1 ? null : argv[index + 1];
323
+ }
324
+
325
+ function main([pr, ...argv]) {
326
+ if (!pr)
327
+ throw new Error(
328
+ 'usage: bout-stats.mjs <pr> [--tz <zone>] [--repo <owner/name>] [--html] [--lang es|en] [--moments <json|file>]',
329
+ );
330
+ const repo = option(argv, 'repo');
331
+ const timeZone =
332
+ option(argv, 'tz') ?? Intl.DateTimeFormat().resolvedOptions().timeZone;
333
+ const dir = boutDir(repo ? slugFromRemote(repo) : repoSlug(projectDir()), pr);
334
+ if (!existsSync(join(dir, 'state.json')))
335
+ throw new Error(`no bout for #${pr} in ${dir}`);
336
+ const stats = collectStats(dir, timeZone);
337
+ if (!argv.includes('--html')) return JSON.stringify(stats, null, 2);
338
+ const momentsValue = option(argv, 'moments');
339
+ const moments = momentsValue
340
+ ? readMoments(
341
+ JSON.parse(
342
+ /^\s*\[/.test(momentsValue)
343
+ ? momentsValue
344
+ : readFileSync(momentsValue, 'utf8'),
345
+ ),
346
+ )
347
+ : [];
348
+ return renderStats(stats, { lang: option(argv, 'lang') ?? 'es', moments });
349
+ }
350
+
351
+ if (isMain(import.meta.url)) {
352
+ try {
353
+ console.log(main(process.argv.slice(2)));
354
+ } catch (error) {
355
+ console.error(
356
+ `bout-stats: ${String(error.stderr || error.message).trim()}`,
357
+ );
358
+ process.exit(1);
359
+ }
360
+ }
@@ -712,7 +712,7 @@ function listDirs(dir) {
712
712
 
713
713
  /** The bout records the chronicle narrates; play-by-play.html and state.json are not records. */
714
714
  const CHRONICLED =
715
- /^(decisions|rebounds|title-defense|judges-card)\.md$|^round-.*\.md$/;
715
+ /^(decisions|rebounds|title-defense|judges-card)\.md$|^round-.*\.md$|^(interview|verdicts|plan)\.jsonl$|^plan-tecnico\.md$/;
716
716
 
717
717
  /**
718
718
  * Is the bout's play-by-play behind its records? Only once it has a link (`playByPlayUrl`): a
@@ -0,0 +1,163 @@
1
+ // «Endpoints del contrato» for the chronicle's weigh-in: what the judge has to see to sign the API
2
+ // contract, inside the one Artifact page and without a server. The plugin is zero-dependency and
3
+ // cannot parse YAML in Node, so this side only gathers the spec files (every file a changed spec
4
+ // root reaches through `$ref`, as the base has it and as the worktree proposes it, the same
5
+ // proposal contract-preview.mjs serves) and writes them into the page as JSON; the page parses and
6
+ // renders them in the browser (templates/endpoints-view.js, js-yaml from the CDN), operation by
7
+ // operation, with the schemas expanded. Regenerating replaces the block, never duplicates it.
8
+ import { execFileSync } from 'node:child_process';
9
+ import { createHash } from 'node:crypto';
10
+ import { existsSync, readFileSync } from 'node:fs';
11
+ import { join } from 'node:path';
12
+
13
+ import { loadConfig, readState } from './config.mjs';
14
+ import { globToRegExp } from './contract.mjs';
15
+ import { isSpecRoot, reachable, specRoots } from './contract-preview.mjs';
16
+
17
+ const show = (worktree, ref, path) => {
18
+ try {
19
+ return execFileSync('git', ['-C', worktree, 'show', `${ref}:${path}`], {
20
+ encoding: 'utf8',
21
+ stdio: ['ignore', 'pipe', 'pipe'],
22
+ maxBuffer: 64 * 1024 * 1024,
23
+ });
24
+ } catch {
25
+ return null;
26
+ }
27
+ };
28
+
29
+ const refExists = (worktree, ref) => {
30
+ try {
31
+ execFileSync('git', ['-C', worktree, 'rev-parse', '--verify', '-q', ref], {
32
+ stdio: 'ignore',
33
+ });
34
+ return true;
35
+ } catch {
36
+ return false;
37
+ }
38
+ };
39
+
40
+ /**
41
+ * The spec roots whose reachable files differ between `read.base` and `read.head`, each with those
42
+ * files: `{ path: { base, head } }`, a side null when the file does not exist there and `head`
43
+ * omitted (`same: true`) when it did not change, so an untouched fragment travels once. PURE apart
44
+ * from `read`.
45
+ */
46
+ export function specSources(roots, read) {
47
+ return roots
48
+ .map((root) => {
49
+ const paths = new Set([
50
+ ...reachable([root], read.head),
51
+ ...reachable([root], read.base),
52
+ ]);
53
+ const files = Object.fromEntries(
54
+ [...paths].sort().map((path) => {
55
+ const base = read.base(path);
56
+ const head = read.head(path);
57
+ return [path, base === head ? { base, same: true } : { base, head }];
58
+ }),
59
+ );
60
+ const changed = Object.values(files).some((file) => !file.same);
61
+ return changed ? { root, files } : null;
62
+ })
63
+ .filter(Boolean);
64
+ }
65
+
66
+ /**
67
+ * The spec roots the PR deletes: files gone from the worktree that the config's `apiSpec` names
68
+ * and that were an OpenAPI root at the base. `specRoots` only sees the worktree, so without these
69
+ * a deleted root never reaches the judge. PURE apart from `baseHead`.
70
+ */
71
+ export function deletedSpecRoots(deleted, apiSpec, baseHead) {
72
+ const named = (path) =>
73
+ apiSpec.some((entry) => {
74
+ const spec = entry.replace(/^\.\//, '');
75
+ return /[*?]/.test(spec) ? globToRegExp(spec).test(path) : spec === path;
76
+ });
77
+ return deleted.filter(
78
+ (path) => named(path) && isSpecRoot(path, baseHead(path) ?? ''),
79
+ );
80
+ }
81
+
82
+ /** The bout's changed spec roots against its stack base, read from its worktree. */
83
+ export function collectContractSources(boutDir) {
84
+ const state = readState(boutDir);
85
+ const { worktree, base } = state;
86
+ if (!worktree || !existsSync(worktree))
87
+ throw new Error(`the worktree of #${state.pr} is gone (${worktree})`);
88
+ const ref = refExists(worktree, `origin/${base}`) ? `origin/${base}` : base;
89
+ const { config } = loadConfig(worktree);
90
+ const head = (path) => {
91
+ const file = join(worktree, path);
92
+ return existsSync(file) ? readFileSync(file, 'utf8') : null;
93
+ };
94
+ const atBase = (path) => show(worktree, ref, path);
95
+ const deleted = execFileSync(
96
+ 'git',
97
+ [
98
+ '-C',
99
+ worktree,
100
+ 'diff',
101
+ '--name-only',
102
+ '--no-renames',
103
+ '--diff-filter=D',
104
+ ref,
105
+ ],
106
+ { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] },
107
+ )
108
+ .split('\n')
109
+ .filter(Boolean);
110
+ const roots = [
111
+ ...new Set([
112
+ ...specRoots(worktree, config),
113
+ ...deletedSpecRoots(deleted, config.apiSpec, (path) =>
114
+ atBase(path)?.slice(0, 2048),
115
+ ),
116
+ ]),
117
+ ];
118
+ return {
119
+ pr: state.pr,
120
+ base: ref,
121
+ roots: specSources(roots, { head, base: atBase }),
122
+ };
123
+ }
124
+
125
+ /** JSON safe inside a `<script>` element. PURE. */
126
+ const LINE_SEPARATOR = String.fromCharCode(0x2028);
127
+ const PARAGRAPH_SEPARATOR = String.fromCharCode(0x2029);
128
+ export const scriptJson = (value) =>
129
+ JSON.stringify(value)
130
+ .replace(/</g, '\\u003c')
131
+ .split(LINE_SEPARATOR)
132
+ .join('\\u2028')
133
+ .split(PARAGRAPH_SEPARATOR)
134
+ .join('\\u2029');
135
+
136
+ /**
137
+ * The block that goes after `<!-- ring:api-contract #<pr> -->`: the container the page renders
138
+ * into, a line for a reader without scripts, and the sources as JSON. PURE.
139
+ */
140
+ export function endpointsBlock(sources) {
141
+ const roots = sources.roots.map((entry) => entry.root).join(', ');
142
+ const fallback = sources.roots.length
143
+ ? `Los endpoints del contrato se dibujan al abrir la página (${roots} contra ${sources.base}).`
144
+ : `Ningún spec cambia contra ${sources.base}.`;
145
+ return [
146
+ `<div class="endpoints" data-ring-spec="${sources.pr}"><p class="endpoints-wait">${fallback}</p></div>`,
147
+ `<script type="application/json" id="ring-spec-${sources.pr}">${scriptJson(sources)}</script>`,
148
+ ].join('\n');
149
+ }
150
+
151
+ /**
152
+ * The fingerprint of the spec files a page embeds for this bout (SHA-256 of the JSON exactly as
153
+ * written into the page, which the page hashes too), or null when they cannot be read.
154
+ */
155
+ export function contractSourcesHash(boutDir) {
156
+ try {
157
+ return createHash('sha256')
158
+ .update(scriptJson(collectContractSources(boutDir)))
159
+ .digest('hex');
160
+ } catch {
161
+ return null;
162
+ }
163
+ }
@@ -17,6 +17,8 @@
17
17
  // When a database model changes, the referee draws the change as an entity-relationship diagram
18
18
  // (references/db-diagram.md) in <bout dir>/db-diagram.html; the page shows it above the model
19
19
  // diff, which folds into a "Code diff" disclosure, and warns when a model file changed after it.
20
+ // The endpoints get theirs the same way (references/api-diagram.md, <bout dir>/api-diagram.html):
21
+ // shown above "What changes", whose diff folds under it, and stale when a spec file changed after.
20
22
  //
21
23
  // The diff runs in two places from one module (spec-diff.mjs): the page parses both specs (YAML
22
24
  // via js-yaml) and computes operations, schemas and error responses; the server computes the same
@@ -129,7 +131,7 @@ function worktreeFiles(worktree) {
129
131
  }
130
132
 
131
133
  /** The spec roots the config names: its plain paths, and the OpenAPI roots its globs match. */
132
- function specRoots(worktree, config) {
134
+ export function specRoots(worktree, config) {
133
135
  const files = worktreeFiles(worktree);
134
136
  const head = (path) => {
135
137
  try {
@@ -241,6 +243,9 @@ function dbDiff(worktree, ref, config) {
241
243
  /** The referee's diagram of the model change, drawn per references/db-diagram.md. */
242
244
  export const DIAGRAM_FILE = 'db-diagram.html';
243
245
 
246
+ /** The referee's diagram of the endpoints, drawn per references/api-diagram.md. */
247
+ export const API_DIAGRAM_FILE = 'api-diagram.html';
248
+
244
249
  /**
245
250
  * When a changed model file was last written, in ms: its mtime, or for a deleted file the time of
246
251
  * the commit that deleted it. A deletion not committed yet has no date: `Infinity`, so it always
@@ -263,12 +268,12 @@ function writtenAt(worktree, path) {
263
268
  }
264
269
 
265
270
  /**
266
- * The diagram of the model change, or `null` when the referee has not drawn one: `stale` lists the
267
- * changed model files written (or deleted) after it, so the judge knows it may not match the diff
268
- * below.
271
+ * A diagram of the change (the models by default, or `API_DIAGRAM_FILE`), or `null` when the
272
+ * referee has not drawn it: `stale` lists the changed files written (or deleted) after it, so the
273
+ * judge knows it may not match the diff below.
269
274
  */
270
- export function diagramState(dir, worktree, files) {
271
- const diagram = join(dir, DIAGRAM_FILE);
275
+ export function diagramState(dir, worktree, files, file = DIAGRAM_FILE) {
276
+ const diagram = join(dir, file);
272
277
  if (!existsSync(diagram)) return null;
273
278
  const drawnAt = statSync(diagram).mtimeMs;
274
279
  const stale = files
@@ -319,7 +324,7 @@ export function page(specs, first = 0) {
319
324
  pre { overflow-x: auto; border: 1px solid var(--line); border-radius: 6px; padding: 8px; font-size: 12px; }
320
325
  pre span { display: block; white-space: pre; }
321
326
  #swagger { background: #fff; border-radius: 6px; }
322
- #diagram { width: 100%; min-height: 320px; border: 1px solid var(--line); border-radius: 6px; background: #fff; }
327
+ .diagram { width: 100%; min-height: 320px; border: 1px solid var(--line); border-radius: 6px; background: #fff; }
323
328
  .warn { background: var(--chg); border-radius: 6px; padding: 8px; }
324
329
  details { margin-top: 12px; } summary { cursor: pointer; }
325
330
  </style></head>
@@ -364,7 +369,14 @@ export function page(specs, first = 0) {
364
369
  html += list('Spec files changed', specFiles.files.map((f) => f.status + ' ' + f.path), '') +
365
370
  (specFiles.diff ? '<details><summary>Spec text diff</summary><pre>' + specLines + '</pre></details>' : '');
366
371
  }
367
- document.getElementById('changes').innerHTML = html || 'No API changes against the base.';
372
+ const apiStale = data.apiDiagram && data.apiDiagram.stale.length
373
+ ? '<p class="warn">Changed after the diagram was drawn: ' + esc(data.apiDiagram.stale.join(', ')) + '. Ask the referee to redraw it.</p>'
374
+ : '';
375
+ const changes = html || 'No API changes against the base.';
376
+ document.getElementById('changes').innerHTML = data.apiDiagram
377
+ ? apiStale + '<iframe class="diagram" src="/api-diagram" sandbox="allow-same-origin" title="Endpoints of the contract"></iframe>' +
378
+ '<details><summary>Code diff</summary>' + changes + '</details>'
379
+ : changes;
368
380
  const db = data.db;
369
381
  const lines = db.diff.split('\\n').map((l) => '<span class="' + (l.startsWith('+') ? 'add' : l.startsWith('-') ? 'del' : '') + '">' + esc(l) + '</span>').join('');
370
382
  const code = list('Files', db.files.map((f) => f.status + ' ' + f.path), '') +
@@ -374,13 +386,13 @@ export function page(specs, first = 0) {
374
386
  ? '<p class="warn">Changed after the diagram was drawn: ' + esc(db.diagram.stale.join(', ')) + '. Ask the referee to redraw it.</p>'
375
387
  : '';
376
388
  const drawn = db.diagram
377
- ? stale + '<iframe id="diagram" src="/db-diagram" sandbox="allow-same-origin" title="Database model change"></iframe>' +
389
+ ? stale + '<iframe class="diagram" src="/db-diagram" sandbox="allow-same-origin" title="Database model change"></iframe>' +
378
390
  '<details><summary>Code diff</summary>' + code + '</details>'
379
391
  : code;
380
392
  const dbBox = document.getElementById('db');
381
393
  dbBox.innerHTML = db.files.length ? drawn : 'No database-model changes against the base.';
382
- const frame = document.getElementById('diagram');
383
- if (frame) frame.addEventListener('load', () => { frame.style.height = frame.contentDocument.documentElement.scrollHeight + 'px'; });
394
+ for (const frame of document.querySelectorAll('iframe.diagram'))
395
+ frame.addEventListener('load', () => { frame.style.height = frame.contentDocument.documentElement.scrollHeight + 'px'; });
384
396
  </script>
385
397
  </body></html>`;
386
398
  }
@@ -447,8 +459,11 @@ export function handler({ dir, worktree, base, config }) {
447
459
  : send(200, contentType(path), text);
448
460
  }
449
461
  if (url.pathname === '/favicon.ico') return send(204, 'text/plain', '');
450
- if (url.pathname === '/db-diagram') {
451
- const diagram = join(dir, DIAGRAM_FILE);
462
+ if (url.pathname === '/db-diagram' || url.pathname === '/api-diagram') {
463
+ const diagram = join(
464
+ dir,
465
+ url.pathname === '/db-diagram' ? DIAGRAM_FILE : API_DIAGRAM_FILE,
466
+ );
452
467
  return existsSync(diagram)
453
468
  ? send(200, 'text/html; charset=utf-8', readFileSync(diagram))
454
469
  : send(404, 'text/plain', 'not found');
@@ -467,6 +482,7 @@ export function handler({ dir, worktree, base, config }) {
467
482
  : send(200, contentType(spec), text);
468
483
  }
469
484
  if (url.pathname === '/diff.json') {
485
+ const specFiles = specFilesDiff(worktree, ref, config);
470
486
  const body = {
471
487
  spec,
472
488
  specs,
@@ -476,7 +492,11 @@ export function handler({ dir, worktree, base, config }) {
476
492
  ? serverSpecDiff(spec, atBase(spec), proposed(spec))
477
493
  : null,
478
494
  db: withDiagram(dbDiff(worktree, ref, config)),
479
- specFiles: specFilesDiff(worktree, ref, config),
495
+ specFiles,
496
+ // Like the database's: no diagram without a spec change, so an old one never shows.
497
+ apiDiagram: specFiles.files.length
498
+ ? diagramState(dir, worktree, specFiles.files, API_DIAGRAM_FILE)
499
+ : null,
480
500
  };
481
501
  return send(
482
502
  200,