@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,120 @@
1
+ #!/usr/bin/env node
2
+ // The batch behind `format-gate.mjs check`: which of many files the repo's format would change, in
3
+ // one process instead of two per file. Reads JSON on stdin { root, tsconfig, sort, prettier, files:
4
+ // [{ file, text }] } (absolute paths; `text` is the content to judge, e.g. as committed) and prints
5
+ // { changed: [file], failed: [file] }.
6
+ //
7
+ // sort-imports runs ONCE over copies of every TypeScript file, outside the repo. prettier runs
8
+ // through its own API, loaded from the repo's node_modules (the same package its binary is; ring
9
+ // adds no dependency), with each file's own path, so its config, overrides and .prettierignore
10
+ // apply; a file prettier ignores or cannot infer a parser for is judged by sort-imports alone.
11
+ import { execFileSync } from 'node:child_process';
12
+ import {
13
+ mkdirSync,
14
+ mkdtempSync,
15
+ readFileSync,
16
+ realpathSync,
17
+ rmSync,
18
+ writeFileSync,
19
+ } from 'node:fs';
20
+ import { createRequire } from 'node:module';
21
+ import { tmpdir } from 'node:os';
22
+ import { basename, extname, join } from 'node:path';
23
+
24
+ const SORTABLE = ['.ts', '.tsx'];
25
+
26
+ /** The sorted text of every TypeScript file, by one sort-imports call over copies. */
27
+ function sorted({ root, tsconfig, sort, files }) {
28
+ const out = new Map(files.map(({ file, text }) => [file, text]));
29
+ const ts = files.filter(({ file }) =>
30
+ SORTABLE.includes(extname(file).toLowerCase()),
31
+ );
32
+ if (!sort || !ts.length) return out;
33
+ const dir = mkdtempSync(join(tmpdir(), 'ring-format-batch-'));
34
+ try {
35
+ const copies = ts.map(({ file, text }, index) => {
36
+ const copy = join(dir, String(index), basename(file));
37
+ mkdirSync(join(dir, String(index)));
38
+ writeFileSync(copy, text);
39
+ return { file, copy };
40
+ });
41
+ execFileSync(
42
+ sort,
43
+ [
44
+ ...(tsconfig ? ['--tsconfig', tsconfig] : []),
45
+ '--write',
46
+ ...copies.map(({ copy }) => copy),
47
+ ],
48
+ { cwd: root, stdio: 'pipe', maxBuffer: 64 * 1024 * 1024 },
49
+ );
50
+ copies.forEach(({ file, copy }) =>
51
+ out.set(file, readFileSync(copy, 'utf8')),
52
+ );
53
+ } finally {
54
+ rmSync(dir, { recursive: true, force: true });
55
+ }
56
+ return out;
57
+ }
58
+
59
+ /** The repo's prettier module, from the package its binary belongs to, or null. */
60
+ function prettierApi(bin) {
61
+ try {
62
+ return createRequire(realpathSync(bin))('prettier');
63
+ } catch {
64
+ return null;
65
+ }
66
+ }
67
+
68
+ /** prettier through the CLI, one file at a time: the fallback when the API does not load. */
69
+ const viaCli = (root, bin, file, text) =>
70
+ execFileSync(bin, ['--stdin-filepath', file], {
71
+ cwd: root,
72
+ input: text,
73
+ encoding: 'utf8',
74
+ stdio: ['pipe', 'pipe', 'pipe'],
75
+ maxBuffer: 64 * 1024 * 1024,
76
+ });
77
+
78
+ async function main() {
79
+ const input = JSON.parse(readFileSync(0, 'utf8'));
80
+ const { root, prettier: bin, files } = input;
81
+ const changed = [];
82
+ const failed = [];
83
+ let afterSort;
84
+ try {
85
+ afterSort = sorted(input);
86
+ } catch {
87
+ // sort-imports could not run on the batch: every TypeScript file is unjudged, so failed.
88
+ afterSort = new Map(files.map(({ file, text }) => [file, text]));
89
+ files
90
+ .filter(({ file }) => SORTABLE.includes(extname(file).toLowerCase()))
91
+ .forEach(({ file }) => failed.push(file));
92
+ }
93
+ const api = bin ? prettierApi(bin) : null;
94
+ const ignorePath = join(root, '.prettierignore');
95
+ for (const { file, text } of files) {
96
+ if (failed.includes(file)) continue;
97
+ let out = afterSort.get(file);
98
+ try {
99
+ if (api) {
100
+ const info = await api.getFileInfo(file, {
101
+ ignorePath,
102
+ resolveConfig: true,
103
+ });
104
+ if (!info.ignored && info.inferredParser) {
105
+ const config = (await api.resolveConfig(file)) ?? {};
106
+ out = await api.format(out, { ...config, filepath: file });
107
+ }
108
+ } else if (bin) out = viaCli(root, bin, file, out);
109
+ if (out !== text) changed.push(file);
110
+ } catch {
111
+ failed.push(file);
112
+ }
113
+ }
114
+ process.stdout.write(JSON.stringify({ changed, failed }));
115
+ }
116
+
117
+ main().catch((error) => {
118
+ process.stderr.write(String(error?.message ?? error));
119
+ process.exit(1);
120
+ });
@@ -0,0 +1,345 @@
1
+ // «Rutas del frontend» for the weigh-in of a frontend bout: every Next.js route the deliverable
2
+ // adds, changes or removes against the stack base (pages router `pages/**`, app router
3
+ // `app/**/page|layout|route`), as its URL, with the query params it reads, the changed files it
4
+ // reaches through its imports (components, hooks, services), the back-end calls those files make
5
+ // when they can be read (`/v1/…` literals, `sdk.<resource>.<method>(`), its files and lines, and the
6
+ // fingerprint the judge's verdict is tied to. `_app`, `_document` and the root layout change every
7
+ // route: they come as `global`. The navigation entries (sidebar, menu) whose links change come
8
+ // apart. Read from git and the worktree only, text-based: nothing is parsed or run. Zero-dependency.
9
+ import { execFileSync } from 'node:child_process';
10
+ import { createHash } from 'node:crypto';
11
+ import { existsSync, readFileSync, statSync } from 'node:fs';
12
+ import { posix } from 'node:path';
13
+
14
+ import { readState } from './config.mjs';
15
+ import { scriptJson } from './contract-endpoints.mjs';
16
+
17
+ const ROUTE_FILE = /\.(tsx|ts|jsx|js|mdx)$/;
18
+ const PAGES = /^(?:src\/)?pages\/(.+)$/;
19
+ const APP =
20
+ /^(?:src\/)?app\/(?:(.+)\/)?(page|layout|route)\.(?:tsx|ts|jsx|js)$/;
21
+ const GLOBAL_PAGES = /^_(app|document|error)$/;
22
+ const NAVIGATION = /(sidebar|navigation|nav[-_.]|menu|navbar)/i;
23
+ const MAX_REACH = 80;
24
+
25
+ /**
26
+ * The route a changed file is, or null: `{ kind: page|api|global, url }`. Pages router:
27
+ * `pages/a/[id]/index.tsx` → `/a/[id]`, `pages/api/x.ts` → API route `/api/x`, `_app`/`_document`
28
+ * → global. App router: `app/(group)/a/page.tsx` → `/a`, `route.ts` → API route, the root layout →
29
+ * global, a nested layout → `/a (layout)`. PURE.
30
+ */
31
+ export function routeOf(path) {
32
+ if (!ROUTE_FILE.test(path)) return null;
33
+ const pages = PAGES.exec(path);
34
+ if (pages) {
35
+ const bare = pages[1].replace(ROUTE_FILE, '');
36
+ if (GLOBAL_PAGES.test(bare))
37
+ return { kind: 'global', url: `pages/${bare}` };
38
+ const url = `/${bare}`.replace(/\/index$/, '') || '/';
39
+ return {
40
+ kind: url.startsWith('/api/') || url === '/api' ? 'api' : 'page',
41
+ url,
42
+ };
43
+ }
44
+ const app = APP.exec(path);
45
+ if (!app) return null;
46
+ const segments = (app[1] ?? '')
47
+ .split('/')
48
+ .filter((part) => part && !/^\(.*\)$/.test(part) && !part.startsWith('@'));
49
+ const url = `/${segments.join('/')}`;
50
+ if (app[2] === 'route') return { kind: 'api', url };
51
+ if (app[2] === 'layout' && segments.length === 0)
52
+ return { kind: 'global', url: 'app/layout' };
53
+ if (app[2] === 'layout') return { kind: 'page', url: `${url} (layout)` };
54
+ return { kind: 'page', url };
55
+ }
56
+
57
+ /** Names after `query.` that are not URL params: react-query results and string or array methods. */
58
+ const NOT_PARAMS = new Set(
59
+ 'data isLoading isError error refetch isFetching isFetchingNextPage status isSuccess isPending mutate mutateAsync fetchNextPage hasNextPage length replace find trim split includes toLowerCase toUpperCase startsWith endsWith slice map filter some every indexOf match test push join'.split(
60
+ ' ',
61
+ ),
62
+ );
63
+
64
+ /**
65
+ * The query params a file reads from the URL: `router.query.x`, `query.x` or `{ x } = query` in a
66
+ * file that uses the router, and `searchParams.get('x')`. PURE.
67
+ */
68
+ export function queryParams(text) {
69
+ const source = String(text);
70
+ const found = new Set();
71
+ const add = (name) =>
72
+ name && /^\w+$/.test(name) && !NOT_PARAMS.has(name) && found.add(name);
73
+ for (const [, name] of source.matchAll(
74
+ /searchParams\??\.get\(\s*['"`](\w+)['"`]/g,
75
+ ))
76
+ add(name);
77
+ if (!/\buseRouter\b|\brouter\.query\b/.test(source)) return [...found].sort();
78
+ for (const [, name] of source.matchAll(/\bquery\.(\w+)/g)) add(name);
79
+ for (const [, names] of source.matchAll(
80
+ /\{([^}]+)\}\s*=\s*(?:\w+\.)?query\b/g,
81
+ ))
82
+ names.split(',').forEach((part) =>
83
+ add(
84
+ part
85
+ .split(':')[0]
86
+ .trim()
87
+ .replace(/^\.\.\./, ''),
88
+ ),
89
+ );
90
+ return [...found].sort();
91
+ }
92
+
93
+ /** The back-end calls a file makes, when they can be read: `/v1/…` literals and `sdk.x.y(` calls. PURE. */
94
+ export function backendCalls(text) {
95
+ const found = new Set();
96
+ for (const [literal] of String(text).matchAll(
97
+ /\/v\d+\/[A-Za-z0-9_\-/{}$.:]*/g,
98
+ ))
99
+ found.add(literal.replace(/[.:]+$/, ''));
100
+ for (const [, resource, method] of String(text).matchAll(
101
+ /\b(?:sdk|sdkClient|client|apiClient)\(?\)?\.(\w+)\.(\w+)\(/g,
102
+ ))
103
+ found.add(`sdk.${resource}.${method}()`);
104
+ return [...found].sort();
105
+ }
106
+
107
+ /** The import specifiers of a file (static, dynamic and re-exports). PURE. */
108
+ export function importsOf(text) {
109
+ return [
110
+ ...String(text).matchAll(
111
+ /(?:import|export)\s[^'"]*?from\s*['"]([^'"]+)['"]|import\(\s*['"]([^'"]+)['"]\s*\)/g,
112
+ ),
113
+ ].map((match) => match[1] ?? match[2]);
114
+ }
115
+
116
+ /** The repo file an import resolves to among `exists`, with `@/` and `src/` as `src/`. PURE. */
117
+ export function resolveImport(from, specifier, exists) {
118
+ const base = specifier.startsWith('.')
119
+ ? posix.normalize(posix.join(posix.dirname(from), specifier))
120
+ : /^(@|src)\//.test(specifier)
121
+ ? `src/${specifier.replace(/^(@|src)\//, '')}`
122
+ : null;
123
+ if (!base) return null;
124
+ const candidates = [
125
+ base,
126
+ ...['.tsx', '.ts', '.jsx', '.js'].map((ext) => `${base}${ext}`),
127
+ ...['tsx', 'ts', 'jsx', 'js'].map((ext) => `${base}/index.${ext}`),
128
+ ];
129
+ return candidates.find(exists) ?? null;
130
+ }
131
+
132
+ /** The links (`href`, `path`, `url`) a navigation file's diff adds and removes. PURE. */
133
+ export function navigationChanges(diff) {
134
+ const links = (sign) => [
135
+ ...new Set(
136
+ String(diff)
137
+ .split('\n')
138
+ .filter(
139
+ (line) =>
140
+ line.startsWith(sign) && !line.startsWith(`${sign}${sign}${sign}`),
141
+ )
142
+ .flatMap((line) =>
143
+ [
144
+ ...line.matchAll(
145
+ /\b(?:href|path|url|to)\s*[:=]\s*['"`](\/[^'"`]*)['"`]/g,
146
+ ),
147
+ ].map((match) => match[1]),
148
+ ),
149
+ ),
150
+ ];
151
+ const added = links('+');
152
+ const removed = links('-');
153
+ return {
154
+ added: added.filter((link) => !removed.includes(link)),
155
+ removed: removed.filter((link) => !added.includes(link)),
156
+ moved: added.filter((link) => removed.includes(link)),
157
+ };
158
+ }
159
+
160
+ const git = (worktree, args) =>
161
+ execFileSync('git', ['-C', worktree, ...args], {
162
+ encoding: 'utf8',
163
+ stdio: ['ignore', 'pipe', 'pipe'],
164
+ maxBuffer: 64 * 1024 * 1024,
165
+ });
166
+ const tryGit = (worktree, args) => {
167
+ try {
168
+ return git(worktree, args);
169
+ } catch {
170
+ return null;
171
+ }
172
+ };
173
+ const sha = (text) => createHash('sha256').update(text).digest('hex');
174
+
175
+ /** The routes a frontend bout's worktree changes against its stack base. */
176
+ export function collectFrontRoutes(boutDir) {
177
+ const state = readState(boutDir);
178
+ const { worktree } = state;
179
+ if (!worktree || !existsSync(worktree))
180
+ throw new Error(`the worktree of #${state.pr} is gone (${worktree})`);
181
+ const ref = tryGit(worktree, [
182
+ 'rev-parse',
183
+ '--verify',
184
+ '-q',
185
+ `origin/${state.base}`,
186
+ ])
187
+ ? `origin/${state.base}`
188
+ : state.base;
189
+ const changed = new Map();
190
+ for (const line of git(worktree, [
191
+ 'diff',
192
+ '--name-status',
193
+ '--no-renames',
194
+ ref,
195
+ ])
196
+ .split('\n')
197
+ .filter(Boolean)) {
198
+ const [code, path] = line.split('\t');
199
+ changed.set(path, { status: code[0] });
200
+ }
201
+ for (const line of git(worktree, ['diff', '--numstat', '--no-renames', ref])
202
+ .split('\n')
203
+ .filter(Boolean)) {
204
+ const [added, deleted, path] = line.split('\t');
205
+ if (changed.has(path))
206
+ Object.assign(changed.get(path), {
207
+ added: Number(added) || 0,
208
+ deleted: Number(deleted) || 0,
209
+ });
210
+ }
211
+ for (const path of git(worktree, [
212
+ 'ls-files',
213
+ '--others',
214
+ '--exclude-standard',
215
+ ])
216
+ .split('\n')
217
+ .filter(Boolean)) {
218
+ if (changed.has(path) || !ROUTE_FILE.test(path)) continue;
219
+ const text = readFileSync(posix.join(worktree, path), 'utf8');
220
+ changed.set(path, {
221
+ status: 'A',
222
+ added: text.split('\n').length,
223
+ deleted: 0,
224
+ });
225
+ }
226
+ const head = (path) =>
227
+ existsSync(posix.join(worktree, path))
228
+ ? readFileSync(posix.join(worktree, path), 'utf8')
229
+ : null;
230
+ const atBase = (path) => tryGit(worktree, ['show', `${ref}:${path}`]);
231
+ const textOf = (path) =>
232
+ (changed.get(path)?.status === 'D' ? atBase(path) : head(path)) ?? '';
233
+ const exists = (path) => {
234
+ if (changed.has(path)) return true;
235
+ try {
236
+ return statSync(posix.join(worktree, path)).isFile();
237
+ } catch {
238
+ return false;
239
+ }
240
+ };
241
+
242
+ /** The changed files a route reaches through its imports (itself first). */
243
+ const reach = (file) => {
244
+ const seen = [file];
245
+ const queue = [file];
246
+ while (queue.length && seen.length < MAX_REACH) {
247
+ const current = queue.shift();
248
+ for (const specifier of importsOf(textOf(current))) {
249
+ const target = resolveImport(current, specifier, exists);
250
+ if (!target || seen.includes(target) || !changed.has(target)) continue;
251
+ seen.push(target);
252
+ queue.push(target);
253
+ }
254
+ }
255
+ return seen;
256
+ };
257
+ const STATUS = { A: 'added', M: 'changed', D: 'removed' };
258
+ const routes = [...changed.entries()]
259
+ .map(([file, change]) => ({ file, change, route: routeOf(file) }))
260
+ .filter(({ route, change }) => route && STATUS[change.status])
261
+ .map(({ file, change, route }) => {
262
+ const files = reach(file);
263
+ const texts = files.map((path) => textOf(path));
264
+ const kindOf = (path) =>
265
+ /\/(services|api|lib|sdk)\//.test(path)
266
+ ? 'services'
267
+ : /\/hooks?\//.test(path) || /\/use[A-Z]\w*\.tsx?$/.test(path)
268
+ ? 'hooks'
269
+ : 'components';
270
+ const group = (kind) =>
271
+ files.slice(1).filter((path) => kindOf(path) === kind);
272
+ return {
273
+ key: `pr${state.pr}-ROUTE-${route.url.replace(/[^A-Za-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'root'}`,
274
+ kind: route.kind,
275
+ url: route.url,
276
+ file,
277
+ status: STATUS[change.status],
278
+ sparringOnly: /sparring/i.test(file),
279
+ dynamic: [...route.url.matchAll(/\[\.{0,3}(\w+)\]/g)].map(
280
+ (match) => match[1],
281
+ ),
282
+ params: [...new Set(texts.flatMap(queryParams))].sort(),
283
+ components: group('components'),
284
+ hooks: group('hooks'),
285
+ services: group('services'),
286
+ calls: [...new Set(texts.flatMap(backendCalls))].sort(),
287
+ files: files.map((path) => ({
288
+ path,
289
+ status: changed.get(path).status,
290
+ added: changed.get(path).added ?? 0,
291
+ deleted: changed.get(path).deleted ?? 0,
292
+ })),
293
+ hash: sha(
294
+ files
295
+ .map((path, index) => `${path}\n${texts[index]}`)
296
+ .join('\n\u0000\n'),
297
+ ).slice(0, 16),
298
+ };
299
+ })
300
+ .sort(
301
+ (a, b) =>
302
+ (a.kind === 'global') - (b.kind === 'global') ||
303
+ a.url.localeCompare(b.url),
304
+ );
305
+ const navigation = [...changed.keys()]
306
+ .filter(
307
+ (path) =>
308
+ NAVIGATION.test(path) && ROUTE_FILE.test(path) && !routeOf(path),
309
+ )
310
+ .map((path) => ({
311
+ path,
312
+ ...navigationChanges(git(worktree, ['diff', ref, '--', path])),
313
+ }))
314
+ .filter(
315
+ (entry) =>
316
+ entry.added.length || entry.removed.length || entry.moved.length,
317
+ );
318
+ return {
319
+ pr: state.pr,
320
+ base: ref,
321
+ previewUrl: state.previewUrl ?? null,
322
+ routes,
323
+ navigation,
324
+ };
325
+ }
326
+
327
+ /** The fingerprint of the routes a page embeds for this bout, or null when they cannot be read. */
328
+ export function routesSourcesHash(boutDir) {
329
+ try {
330
+ return sha(scriptJson(collectFrontRoutes(boutDir)));
331
+ } catch {
332
+ return null;
333
+ }
334
+ }
335
+
336
+ /** The block after `<!-- ring:front-routes #<pr> -->`: the container and the routes as JSON. PURE. */
337
+ export function routesBlock(routes) {
338
+ const fallback = routes.routes.length
339
+ ? `Las rutas del frontend se dibujan al abrir la página (${routes.routes.length} contra ${routes.base}).`
340
+ : `Ninguna ruta cambia contra ${routes.base}.`;
341
+ return [
342
+ `<div class="endpoints routes" data-ring-routes="${routes.pr}"><p class="endpoints-wait">${fallback}</p></div>`,
343
+ `<script type="application/json" id="ring-routes-${routes.pr}">${scriptJson(routes)}</script>`,
344
+ ].join('\n');
345
+ }