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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/README.md +46 -26
  2. package/package.json +1 -1
  3. package/plugin/.claude-plugin/plugin.json +1 -1
  4. package/ring/agents/cornerman.md +32 -6
  5. package/ring/agents/rudo.md +2 -1
  6. package/ring/agents/wrestler.md +19 -4
  7. package/ring/hooks/hooks.json +42 -0
  8. package/ring/scripts/bout-stats-render.mjs +366 -0
  9. package/ring/scripts/bout-stats.mjs +360 -0
  10. package/ring/scripts/brief-build.mjs +130 -0
  11. package/ring/scripts/brief-guard.mjs +154 -0
  12. package/ring/scripts/brief.mjs +272 -0
  13. package/ring/scripts/compact-hook.mjs +110 -0
  14. package/ring/scripts/config.mjs +25 -1
  15. package/ring/scripts/contract-endpoints.mjs +163 -0
  16. package/ring/scripts/contract-preview.mjs +34 -14
  17. package/ring/scripts/fight.mjs +27 -0
  18. package/ring/scripts/findings.mjs +82 -3
  19. package/ring/scripts/format-gate.mjs +723 -0
  20. package/ring/scripts/format-worker.mjs +120 -0
  21. package/ring/scripts/front-routes.mjs +345 -0
  22. package/ring/scripts/judges-card.mjs +324 -3
  23. package/ring/scripts/lookout.mjs +22 -4
  24. package/ring/scripts/mask-guard.mjs +34 -1
  25. package/ring/scripts/page-embed.mjs +112 -0
  26. package/ring/scripts/play-by-play.mjs +155 -7
  27. package/ring/scripts/preparation-collect.mjs +177 -0
  28. package/ring/scripts/preparation.mjs +547 -0
  29. package/ring/scripts/round-bell.mjs +9 -2
  30. package/ring/scripts/rule-ids.mjs +124 -0
  31. package/ring/scripts/tech-plan-log.mjs +81 -0
  32. package/ring/scripts/tech-plan.mjs +191 -0
  33. package/ring/scripts/verdicts-log.mjs +75 -0
  34. package/ring/scripts/verdicts.mjs +205 -0
  35. package/ring/skills/play-by-play/SKILL.md +235 -33
  36. package/ring/skills/play-by-play/templates/blocks.css +196 -0
  37. package/ring/skills/play-by-play/templates/chronicle.html +358 -55
  38. package/ring/skills/play-by-play/templates/endpoints-view.js +1153 -0
  39. package/ring/skills/play-by-play/templates/interview-form.js +516 -0
  40. package/ring/skills/play-by-play/templates/now-panel.js +719 -0
  41. package/ring/skills/referee/SKILL.md +181 -56
  42. package/ring/skills/referee/references/api-diagram.md +56 -0
  43. package/ring/skills/referee/references/precedents.md +11 -0
  44. package/ring/skills/referee/references/rulebook.md +97 -6
  45. package/ring/skills/referee/templates/api-diagram.html +115 -0
  46. package/ring/skills/referee/templates/cornerman-batch.md +17 -11
  47. package/ring/skills/referee/templates/cornerman-concept.md +28 -30
  48. package/ring/skills/referee/templates/cornerman-range.md +16 -11
  49. package/ring/skills/referee/templates/escalation.md +7 -1
  50. package/ring/skills/referee/templates/round-bell-body.md +4 -0
  51. package/ring/skills/referee/templates/wrestler-brief.md +28 -41
  52. package/ring/skills/referee/templates/wrestler-cutoff.md +4 -0
  53. package/ring/skills/referee/templates/wrestler-resume.md +4 -0
  54. package/ring/skills/sparring/SKILL.md +13 -4
  55. package/ring/skills/sparring/templates/contrato.md +29 -0
  56. package/ring/skills/sparring/templates/rudo-brief.md +7 -0
  57. package/ring/skills/title-defense/SKILL.md +2 -2
  58. package/ring/skills/weigh-in/SKILL.md +6 -0
@@ -0,0 +1,272 @@
1
+ #!/usr/bin/env node
2
+ // The briefs of the ring's agents, generated from the templates with the rules embedded in full, so
3
+ // what a wrestler or a cornerman knows does not depend on what the referee still remembers.
4
+ // Zero-dependency; the rules are brief-build.mjs.
5
+ //
6
+ // node brief.mjs wrestler <pr> --mode start|resume|cutoff --vars <file.json>
7
+ // node brief.mjs cornerman <pr> --mode range|concept|batch --vars <file.json>
8
+ // [--range <A>..<B>] [--files <f1,f2,…>]
9
+ // node brief.mjs verify <brief.md> exit 0 while it holds, 1 when it does not
10
+ //
11
+ // The referee writes only its own values (the assignment, the invariants…) to the --vars JSON; the
12
+ // script fills the rest from the bout, writes `<bout dir>/briefs/<kind>-<mode>-<n>.md`, logs it in
13
+ // `briefs/index.jsonl` and prints the path and the Agent call. The referee passes the file as it is.
14
+ import { execFileSync } from 'node:child_process';
15
+ import {
16
+ appendFileSync,
17
+ existsSync,
18
+ mkdirSync,
19
+ readFileSync,
20
+ realpathSync,
21
+ writeFileSync,
22
+ } from 'node:fs';
23
+ import { join } from 'node:path';
24
+
25
+ import {
26
+ briefStatus,
27
+ decisionsExcerpt,
28
+ fill,
29
+ readSeal,
30
+ rulesBlock,
31
+ seal,
32
+ sha256,
33
+ strayPaths,
34
+ } from './brief-build.mjs';
35
+ import {
36
+ boutDir,
37
+ isMain,
38
+ loadConfig,
39
+ projectDir,
40
+ readState,
41
+ repoSlug,
42
+ ringHome,
43
+ RING_ROOT,
44
+ } from './config.mjs';
45
+ import { loadRules } from './rule-ids.mjs';
46
+
47
+ const TEMPLATES = join(RING_ROOT, 'skills', 'referee', 'templates');
48
+
49
+ /** Per kind and mode: the templates in order, and whether the rules go in (a cut-off resumes the same agent). */
50
+ const SHAPES = {
51
+ wrestler: {
52
+ start: { templates: ['wrestler-brief.md'], rules: true },
53
+ resume: {
54
+ templates: ['wrestler-resume.md', 'wrestler-brief.md'],
55
+ rules: true,
56
+ },
57
+ cutoff: { templates: ['wrestler-cutoff.md'], rules: false },
58
+ },
59
+ cornerman: {
60
+ range: { templates: ['cornerman-range.md'], rules: true },
61
+ concept: { templates: ['cornerman-concept.md'], rules: true },
62
+ batch: { templates: ['cornerman-batch.md'], rules: true },
63
+ },
64
+ };
65
+
66
+ /** What the referee may leave out, and what stands in for it. */
67
+ const OPTIONAL = {
68
+ claims: 'none',
69
+ comments: 'none',
70
+ referencePattern: 'none yet: no round-bell approved so far',
71
+ designReference: 'not applicable: this concept draws no screen',
72
+ };
73
+
74
+ const git = (dir, args) =>
75
+ execFileSync('git', ['-C', dir, ...args], {
76
+ encoding: 'utf8',
77
+ stdio: ['ignore', 'pipe', 'pipe'],
78
+ }).trim();
79
+
80
+ function flags(argv) {
81
+ const values = {};
82
+ const rest = [];
83
+ for (let i = 0; i < argv.length; i += 1) {
84
+ if (!argv[i].startsWith('--')) {
85
+ rest.push(argv[i]);
86
+ continue;
87
+ }
88
+ values[argv[i].slice(2)] = argv[i + 1];
89
+ i += 1;
90
+ }
91
+ return { values, rest };
92
+ }
93
+
94
+ /** The bout of `pr` in this repo: its dir, its state and its venue's config. */
95
+ function boutOf(pr) {
96
+ const dir = boutDir(repoSlug(projectDir()), pr);
97
+ if (!existsSync(join(dir, 'state.json')))
98
+ throw new Error(`no bout #${pr} in this repo (${dir})`);
99
+ const state = readState(dir);
100
+ const { config } = loadConfig(projectDir(), {
101
+ contract: false,
102
+ venue: state.venue ?? 'backend',
103
+ });
104
+ return { dir, state, config };
105
+ }
106
+
107
+ /** The rules block of a bout and its hash: what a brief embeds and what a seal is checked against. */
108
+ function rulesOf(config) {
109
+ const block = rulesBlock(loadRules(config));
110
+ return { block, hash: sha256(block) };
111
+ }
112
+
113
+ /** What the script knows about the bout and its worktree. */
114
+ function boutVars({ dir, state, config }, values) {
115
+ const { worktree } = state;
116
+ const head = git(worktree, ['rev-parse', 'HEAD']);
117
+ const range =
118
+ values.range ??
119
+ (state.lastReviewed ? `${state.lastReviewed}..${head}` : null);
120
+ const commits = range
121
+ ? git(worktree, ['log', '--reverse', '--format=- `%h` %s', range]) ||
122
+ '(none)'
123
+ : '(no range)';
124
+ const files = values.files
125
+ ? values.files
126
+ .split(',')
127
+ .map((file) => file.trim())
128
+ .filter(Boolean)
129
+ : range
130
+ ? git(worktree, ['diff', '--name-only', range])
131
+ .split('\n')
132
+ .filter(Boolean)
133
+ : [];
134
+ const gate = (name) => config.gates?.[name] ?? 'none configured';
135
+ return {
136
+ files,
137
+ vars: {
138
+ pr: state.pr,
139
+ branch: state.branch,
140
+ base: `origin/${state.base}`,
141
+ worktree,
142
+ boutDir: dir,
143
+ ring: RING_ROOT,
144
+ venue: config.venue,
145
+ range: range ?? '',
146
+ commits,
147
+ headSha: head.slice(0, 12),
148
+ lastSubject: git(worktree, ['log', '-1', '--format=%s']),
149
+ gateTypecheck: gate('typecheck'),
150
+ gateTest: gate('test'),
151
+ gateLint: gate('lint'),
152
+ forbidAiTrailers: String(config.forbidAiTrailers),
153
+ diskFloor: String(config.diskFloorGb ?? 5),
154
+ backupBranch: String(config.backupBranch ?? 'none').replace(
155
+ '{pr}',
156
+ state.pr,
157
+ ),
158
+ lenses: (config.lenses ?? []).join('; '),
159
+ leftoverPaths: files.join(', ') || 'the files of the range',
160
+ files: files.map((file) => `- ${file}`).join('\n'),
161
+ },
162
+ };
163
+ }
164
+
165
+ const real = (path) => (existsSync(path) ? realpathSync(path) : path);
166
+
167
+ function generate(kind, [pr, ...argv]) {
168
+ const { values } = flags(argv);
169
+ const shape = SHAPES[kind]?.[values.mode];
170
+ if (!pr || !shape)
171
+ throw new Error(
172
+ `usage: ${kind} <pr> --mode ${Object.keys(SHAPES[kind]).join('|')} --vars <file.json>`,
173
+ );
174
+ const bout = boutOf(pr);
175
+ const own = values.vars ? JSON.parse(readFileSync(values.vars, 'utf8')) : {};
176
+ const roots = [
177
+ projectDir(),
178
+ bout.state.worktree,
179
+ bout.dir,
180
+ RING_ROOT,
181
+ ringHome(),
182
+ ].map(real);
183
+ const stray = strayPaths(own, roots);
184
+ if (stray.length)
185
+ throw new Error(
186
+ `paths outside the repo, the worktree, the bout dir and the ring (a session scratchpad is wiped on restart; what the human asked for goes to decisions.md or the technical plan):\n${stray.map(({ name, path }) => ` ${name}: ${path}`).join('\n')}`,
187
+ );
188
+ const { vars, files } = boutVars(bout, values);
189
+ const text = fill(
190
+ shape.templates
191
+ .map((name) => readFileSync(join(TEMPLATES, name), 'utf8'))
192
+ .join('\n'),
193
+ { ...OPTIONAL, ...vars, ...own },
194
+ );
195
+ const decisionsPath = join(bout.dir, 'decisions.md');
196
+ const decisions = decisionsExcerpt(
197
+ existsSync(decisionsPath) ? readFileSync(decisionsPath, 'utf8') : '',
198
+ files,
199
+ );
200
+ const rules = rulesOf(bout.config);
201
+ // The task goes first and the rules last, as the reference: a brief that opens with the rules
202
+ // reads as a checklist, and a 2026-11 lab on #1212 found fewer real problems that way.
203
+ const body = [
204
+ text.trim(),
205
+ '## Decisions already taken (excerpt: every foul, the ones on these files, the last 20)',
206
+ decisions,
207
+ `The whole ledger: \`${decisionsPath}\`.`,
208
+ ...(shape.rules ? ['---', rules.block] : []),
209
+ ].join('\n\n');
210
+ const briefs = join(bout.dir, 'briefs');
211
+ mkdirSync(briefs, { recursive: true });
212
+ const index = join(briefs, 'index.jsonl');
213
+ const n = existsSync(index)
214
+ ? readFileSync(index, 'utf8').split('\n').filter(Boolean).length + 1
215
+ : 1;
216
+ const meta = {
217
+ kind,
218
+ mode: values.mode,
219
+ pr,
220
+ n,
221
+ rules: shape.rules ? rules.hash : 'none',
222
+ };
223
+ const file = join(briefs, `${kind}-${values.mode}-${n}.md`);
224
+ writeFileSync(file, seal(body, meta));
225
+ appendFileSync(
226
+ index,
227
+ `${JSON.stringify({ ...meta, range: vars.range || null, at: new Date().toISOString(), vars: Object.keys(own) })}\n`,
228
+ );
229
+ const launch = bout.config.agents?.[kind] ?? {};
230
+ console.log(file);
231
+ console.log(
232
+ `Agent{subagent_type: "ring:${kind}", model: "${launch.model}", effort: "${launch.effort}", prompt: "ring-brief: ${file}"}`,
233
+ );
234
+ }
235
+
236
+ /**
237
+ * Whether a brief's text still holds: sealed by brief.mjs, unedited, and on the rules in force
238
+ * today. Returns the seal's meta too (kind, mode, pr) so a caller can check it is the right brief.
239
+ */
240
+ export function checkBrief(text) {
241
+ const sealed = readSeal(text);
242
+ const rulesNow = (meta) =>
243
+ meta.rules === 'none' ? 'none' : rulesOf(boutOf(meta.pr).config).hash;
244
+ return {
245
+ ...briefStatus(text, sealed ? rulesNow(sealed.meta) : null),
246
+ meta: sealed?.meta ?? null,
247
+ };
248
+ }
249
+
250
+ function verify([path]) {
251
+ if (!path) throw new Error('usage: verify <brief.md>');
252
+ const status = checkBrief(readFileSync(path, 'utf8'));
253
+ console.log(
254
+ status.ok ? 'brief holds' : `brief does not hold: ${status.reason}`,
255
+ );
256
+ return status.ok ? 0 : 1;
257
+ }
258
+
259
+ if (isMain(import.meta.url)) {
260
+ const [command, ...rest] = process.argv.slice(2);
261
+ try {
262
+ if (command === 'verify') process.exit(verify(rest));
263
+ if (!SHAPES[command])
264
+ throw new Error(
265
+ 'usage: brief.mjs wrestler|cornerman <pr> --mode … | verify <brief.md>',
266
+ );
267
+ generate(command, rest);
268
+ } catch (error) {
269
+ console.error(`brief: ${error.message}`);
270
+ process.exit(1);
271
+ }
272
+ }
@@ -0,0 +1,110 @@
1
+ #!/usr/bin/env node
2
+ // Hook on SessionStart (matcher `compact`): after a compaction the referee's context loses what it
3
+ // knew of the bout. This puts back, in under 10,000 characters (Claude Code's cap for hook output),
4
+ // what must not be lost: which bout it referees and where its card and ledger live, the
5
+ // architecture checklist, the bout's fouls (what must not happen again), the card's latest «how to
6
+ // resume», and the rule that every wrestler and cornerman is launched with a brief from brief.mjs.
7
+ // Zero-dependency. Only a session registered in a bout gets it; any error ends in exit 0, silent.
8
+ import { existsSync, readFileSync } from 'node:fs';
9
+ import { join } from 'node:path';
10
+
11
+ import {
12
+ isMain,
13
+ loadConfig,
14
+ projectDir,
15
+ readState,
16
+ RING_ROOT,
17
+ } from './config.mjs';
18
+ import { findBout, sessionRegistered } from './live.mjs';
19
+ import { checklistLines, loadRules } from './rule-ids.mjs';
20
+
21
+ /** Claude Code's cap for SessionStart output, with room to spare. */
22
+ export const BUDGET = 9500;
23
+ const RESUME = /^##\s+(How to resume|Cómo reanudar)\b/i;
24
+
25
+ /** The card's first «how to resume» section (the newest is on top), cut to `max` characters. PURE. */
26
+ export function resumeSection(card, max = 2500) {
27
+ const lines = String(card ?? '').split('\n');
28
+ const start = lines.findIndex((line) => RESUME.test(line));
29
+ if (start < 0) return '';
30
+ const end = lines.findIndex((line, i) => i > start && /^##\s/.test(line));
31
+ const text = lines
32
+ .slice(start + 1, end < 0 ? undefined : end)
33
+ .join('\n')
34
+ .trim();
35
+ return text.length > max ? `${text.slice(0, max)}…` : text;
36
+ }
37
+
38
+ /** The ledger's fouls, newest last, cut to `max` characters from the oldest. PURE. */
39
+ export function foulLines(decisions, max = 3000) {
40
+ const fouls = String(decisions ?? '')
41
+ .split('\n')
42
+ .filter((line) => /\bFOUL\b/.test(line));
43
+ const kept = [];
44
+ let size = 0;
45
+ for (const line of fouls.reverse()) {
46
+ if (size + line.length + 1 > max) break;
47
+ kept.unshift(line);
48
+ size += line.length + 1;
49
+ }
50
+ const dropped = fouls.length - kept.length;
51
+ return [
52
+ ...(dropped ? [`(${dropped} older fouls in decisions.md)`] : []),
53
+ ...kept,
54
+ ].join('\n');
55
+ }
56
+
57
+ /** The text a compacted referee gets back. PURE. */
58
+ export function compactContext({ pr, dir, checklist, fouls, resume }) {
59
+ const text = [
60
+ `# Ring: you referee bout #${pr} (context restored after a compaction)`,
61
+ '',
62
+ `Reread before acting: the judges card \`${join(dir, 'judges-card.md')}\` and the ledger \`${join(dir, 'decisions.md')}\`. Every wrestler and cornerman is launched with a brief from \`node ${RING_ROOT}/scripts/brief.mjs\` and \`prompt: "ring-brief: <path>"\`; never a brief written by hand. Every finding cites its rule id (or X0).`,
63
+ '',
64
+ '## Architecture checklist (the full rules go in every brief)',
65
+ checklist || '(none for this venue)',
66
+ '',
67
+ '## Fouls of this bout (never again)',
68
+ fouls || '(none)',
69
+ ...(resume ? ['', '## How to resume (from the card)', resume] : []),
70
+ ].join('\n');
71
+ return text.length > BUDGET ? `${text.slice(0, BUDGET)}…` : text;
72
+ }
73
+
74
+ const read = (path) => (existsSync(path) ? readFileSync(path, 'utf8') : '');
75
+
76
+ /** The hook on one input: the text to print, or null. */
77
+ export function restore(input) {
78
+ if (input?.hook_event_name !== 'SessionStart') return null;
79
+ if (input.source && input.source !== 'compact') return null;
80
+ const dir = findBout({ cwd: input.cwd });
81
+ if (!dir || !sessionRegistered(dir, input.session_id)) return null;
82
+ const state = readState(dir);
83
+ const { config } = loadConfig(projectDir(process.env, input.cwd), {
84
+ contract: false,
85
+ venue: state.venue ?? 'backend',
86
+ });
87
+ return compactContext({
88
+ pr: state.pr,
89
+ dir,
90
+ checklist: checklistLines(loadRules(config).entries).join('\n'),
91
+ fouls: foulLines(read(join(dir, 'decisions.md'))),
92
+ resume: resumeSection(read(join(dir, 'judges-card.md'))),
93
+ });
94
+ }
95
+
96
+ if (isMain(import.meta.url)) {
97
+ let raw = '';
98
+ process.stdin.setEncoding('utf8');
99
+ process.stdin.on('data', (chunk) => (raw += chunk));
100
+ process.stdin.on('end', () => {
101
+ let out = null;
102
+ try {
103
+ out = restore(JSON.parse(raw));
104
+ } catch {
105
+ out = null;
106
+ }
107
+ if (out) process.stdout.write(out);
108
+ process.exit(0);
109
+ });
110
+ }
@@ -106,6 +106,9 @@ export const DEFAULT_HEAVY_COMMANDS = [
106
106
  'make test',
107
107
  ];
108
108
 
109
+ /** What brief-guard does with a launch whose brief brief.mjs did not generate. */
110
+ export const BRIEF_GUARD_MODES = ['warn', 'block', 'off'];
111
+
109
112
  export const DEFAULTS = Object.freeze({
110
113
  gates: { typecheck: null, test: null, lint: null },
111
114
  validateCommand: null,
@@ -136,6 +139,17 @@ export const DEFAULTS = Object.freeze({
136
139
  previewTimeoutMin: 20,
137
140
  staleDays: 7,
138
141
  }),
142
+ // brief-guard on every Agent launch of a wrestler or cornerman in a bout: 'warn' lets a brief
143
+ // that brief.mjs did not generate through and says so; 'block' refuses it; 'off' does nothing.
144
+ briefGuard: 'warn',
145
+ // The model and effort of each ring agent, passed on every launch. Who writes runs on the
146
+ // cheaper model at high effort; who reviews keeps the strongest one (what slipped in bout #1223
147
+ // slipped in review). A host overrides a role in `.claude/ring.json` (`agents.wrestler.model`).
148
+ agents: Object.freeze({
149
+ wrestler: Object.freeze({ model: 'sonnet', effort: 'high' }),
150
+ rudo: Object.freeze({ model: 'sonnet', effort: 'high' }),
151
+ cornerman: Object.freeze({ model: 'opus', effort: 'high' }),
152
+ }),
139
153
  });
140
154
 
141
155
  const GATES = ['typecheck', 'test', 'lint'];
@@ -331,6 +345,10 @@ export function mergeConfig(detected, host) {
331
345
  throw new Error(
332
346
  `.claude/ring.json: "delegation" must be one of ${DELEGATION_LEVELS.join(', ')}, not ${JSON.stringify(own.delegation)}`,
333
347
  );
348
+ if ('briefGuard' in own && !BRIEF_GUARD_MODES.includes(own.briefGuard))
349
+ throw new Error(
350
+ `.claude/ring.json: "briefGuard" must be one of ${BRIEF_GUARD_MODES.join(', ')}, not ${JSON.stringify(own.briefGuard)}`,
351
+ );
334
352
  validateVenues(own.venues, VENUE_NAMES);
335
353
  return {
336
354
  ...DEFAULTS,
@@ -349,6 +367,12 @@ export function mergeConfig(detected, host) {
349
367
  ? null
350
368
  : { ...DEFAULTS.findingsGate, ...(own.findingsGate ?? {}) },
351
369
  sparring: { ...DEFAULTS.sparring, ...(own.sparring ?? {}) },
370
+ agents: Object.fromEntries(
371
+ Object.entries(DEFAULTS.agents).map(([role, launch]) => [
372
+ role,
373
+ { ...launch, ...(own.agents?.[role] ?? {}) },
374
+ ]),
375
+ ),
352
376
  };
353
377
  }
354
378
 
@@ -712,7 +736,7 @@ function listDirs(dir) {
712
736
 
713
737
  /** The bout records the chronicle narrates; play-by-play.html and state.json are not records. */
714
738
  const CHRONICLED =
715
- /^(decisions|rebounds|title-defense|judges-card)\.md$|^round-.*\.md$/;
739
+ /^(decisions|rebounds|title-defense|judges-card)\.md$|^round-.*\.md$|^(interview|verdicts|plan)\.jsonl$|^plan-tecnico\.md$/;
716
740
 
717
741
  /**
718
742
  * 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
+ }