@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.
- 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,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
|
+
}
|
package/ring/scripts/config.mjs
CHANGED
|
@@ -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
|
-
*
|
|
267
|
-
* changed
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
383
|
-
|
|
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(
|
|
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
|
|
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,
|