@thebattlearena/corner 0.9.1-beta.70 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/ring/agents/cornerman.md +20 -3
- package/ring/agents/wrestler.md +11 -0
- package/ring/hooks/hooks.json +20 -0
- package/ring/scripts/bout-stats-render.mjs +366 -0
- package/ring/scripts/bout-stats.mjs +360 -0
- package/ring/scripts/config.mjs +1 -1
- package/ring/scripts/contract-endpoints.mjs +163 -0
- package/ring/scripts/contract-preview.mjs +34 -14
- package/ring/scripts/fight.mjs +27 -0
- package/ring/scripts/findings.mjs +3 -1
- package/ring/scripts/format-gate.mjs +723 -0
- package/ring/scripts/format-worker.mjs +120 -0
- package/ring/scripts/front-routes.mjs +345 -0
- package/ring/scripts/judges-card.mjs +324 -3
- package/ring/scripts/lookout.mjs +22 -4
- package/ring/scripts/mask-guard.mjs +34 -1
- package/ring/scripts/page-embed.mjs +112 -0
- package/ring/scripts/play-by-play.mjs +155 -7
- package/ring/scripts/preparation-collect.mjs +177 -0
- package/ring/scripts/preparation.mjs +547 -0
- package/ring/scripts/round-bell.mjs +9 -2
- package/ring/scripts/tech-plan-log.mjs +81 -0
- package/ring/scripts/tech-plan.mjs +191 -0
- package/ring/scripts/verdicts-log.mjs +75 -0
- package/ring/scripts/verdicts.mjs +205 -0
- package/ring/skills/play-by-play/SKILL.md +235 -33
- package/ring/skills/play-by-play/templates/blocks.css +196 -0
- package/ring/skills/play-by-play/templates/chronicle.html +358 -55
- package/ring/skills/play-by-play/templates/endpoints-view.js +1153 -0
- package/ring/skills/play-by-play/templates/interview-form.js +516 -0
- package/ring/skills/play-by-play/templates/now-panel.js +719 -0
- package/ring/skills/referee/SKILL.md +136 -42
- package/ring/skills/referee/references/api-diagram.md +56 -0
- package/ring/skills/referee/references/precedents.md +11 -0
- package/ring/skills/referee/references/rulebook.md +65 -4
- package/ring/skills/referee/templates/api-diagram.html +115 -0
- package/ring/skills/referee/templates/escalation.md +7 -1
- package/ring/skills/referee/templates/round-bell-body.md +4 -0
- package/ring/skills/referee/templates/wrestler-brief.md +3 -0
- package/ring/skills/sparring/SKILL.md +10 -2
- package/ring/skills/sparring/templates/contrato.md +29 -0
- package/ring/skills/sparring/templates/rudo-brief.md +7 -0
- package/ring/skills/weigh-in/SKILL.md +6 -0
|
@@ -0,0 +1,723 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The host's format, kept by ring while a bout is active: sort-imports (@getsupervisor/js-sort-imports,
|
|
3
|
+
// with the repo's own tsconfig, so an alias sorts the same as with the repo's `sort` script) and then
|
|
4
|
+
// prettier, using the binaries the repo installed. Never downloads anything: a repo without them is
|
|
5
|
+
// left as it is, and `check` says so.
|
|
6
|
+
//
|
|
7
|
+
// edit-hook PostToolUse on Edit|Write|MultiEdit: formats the edited file.
|
|
8
|
+
// shell-hook PostToolUse on Bash: formats what the shell wrote (cat >, sed -i, a script), which
|
|
9
|
+
// the edit hook never sees. Only the files that changed since its last pass, kept in
|
|
10
|
+
// `git rev-parse --git-path ring-format.json`, so it works in a worktree and costs
|
|
11
|
+
// nothing when the command wrote nothing. It looks at the toplevel of the session's
|
|
12
|
+
// cwd and at the worktree of an active bout of that repo the command names.
|
|
13
|
+
// fix [<dir>] [--range <a>..<b>]
|
|
14
|
+
// The wrestler's gate before each commit: formats the changed files (diff, staged and
|
|
15
|
+
// untracked); the repo's own `fix:changed` script wins when it has one. With --range,
|
|
16
|
+
// the files a range touched, to fix what is already committed.
|
|
17
|
+
// check [<dir>] [--range <a>..<b>]
|
|
18
|
+
// The cornerman's check, read-only: formats a copy of each changed file (of the range,
|
|
19
|
+
// as it is at <b>) and lists the ones that come out different. Not `sort-imports
|
|
20
|
+
// --check`: it reports a one-specifier import past printWidth that sort-imports wraps
|
|
21
|
+
// and prettier joins again, a false positive. Exit 0 clean, 1 unclean, 2 NOT VERIFIED
|
|
22
|
+
// (sort-imports or prettier missing: never read as clean), 64 an error.
|
|
23
|
+
//
|
|
24
|
+
// Both hooks act only on a repo with an active ring bout, never block (exit 0) and stand aside when
|
|
25
|
+
// the repo's `.claude/settings.json` already runs its own format hook for that event.
|
|
26
|
+
import { execFileSync } from 'node:child_process';
|
|
27
|
+
import {
|
|
28
|
+
existsSync,
|
|
29
|
+
mkdirSync,
|
|
30
|
+
mkdtempSync,
|
|
31
|
+
readFileSync,
|
|
32
|
+
rmSync,
|
|
33
|
+
statSync,
|
|
34
|
+
writeFileSync,
|
|
35
|
+
} from 'node:fs';
|
|
36
|
+
import { tmpdir } from 'node:os';
|
|
37
|
+
import { fileURLToPath } from 'node:url';
|
|
38
|
+
import {
|
|
39
|
+
basename,
|
|
40
|
+
dirname,
|
|
41
|
+
extname,
|
|
42
|
+
isAbsolute,
|
|
43
|
+
join,
|
|
44
|
+
relative,
|
|
45
|
+
resolve,
|
|
46
|
+
} from 'node:path';
|
|
47
|
+
import { activeBouts, HOST_CONFIG, isMain, repoSlug } from './config.mjs';
|
|
48
|
+
|
|
49
|
+
export const FORMATTABLE = [
|
|
50
|
+
'.ts',
|
|
51
|
+
'.tsx',
|
|
52
|
+
'.js',
|
|
53
|
+
'.jsx',
|
|
54
|
+
'.mjs',
|
|
55
|
+
'.cjs',
|
|
56
|
+
'.json',
|
|
57
|
+
'.md',
|
|
58
|
+
'.css',
|
|
59
|
+
'.scss',
|
|
60
|
+
'.yml',
|
|
61
|
+
'.yaml',
|
|
62
|
+
];
|
|
63
|
+
export const SORTABLE = ['.ts', '.tsx'];
|
|
64
|
+
const STATE = 'ring-format.json';
|
|
65
|
+
|
|
66
|
+
const parse = (text) => {
|
|
67
|
+
try {
|
|
68
|
+
return JSON.parse(text);
|
|
69
|
+
} catch {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The tsconfig sort-imports reads the aliases from: `.claude/ring.json` `format.tsconfig`, else the
|
|
76
|
+
* one the repo's own sort-imports script passes, else `tsconfig.json` when the repo has it. PURE.
|
|
77
|
+
*/
|
|
78
|
+
export function formatTsconfig({ ringJson, packageJson, exists }) {
|
|
79
|
+
const configured = parse(ringJson ?? '')?.format?.tsconfig;
|
|
80
|
+
if (configured) return configured;
|
|
81
|
+
// `scripts.sort` first (what `yarn sort` does), then `sort:check`, then any other script: a
|
|
82
|
+
// build or test command that happens to call sort-imports must not win over the repo's sort.
|
|
83
|
+
const all = parse(packageJson ?? '')?.scripts ?? {};
|
|
84
|
+
const scripts = [
|
|
85
|
+
all.sort,
|
|
86
|
+
all['sort:check'],
|
|
87
|
+
...Object.entries(all)
|
|
88
|
+
.filter(([name]) => !['sort', 'sort:check'].includes(name))
|
|
89
|
+
.map(([, script]) => script),
|
|
90
|
+
].filter(Boolean);
|
|
91
|
+
const fromScript = scripts
|
|
92
|
+
.map(
|
|
93
|
+
(script) =>
|
|
94
|
+
/sort-imports\b.*?--tsconfig[ =](\S+)/.exec(String(script))?.[1],
|
|
95
|
+
)
|
|
96
|
+
.find(Boolean);
|
|
97
|
+
if (fromScript) return fromScript.replace(/^["']|["']$/g, '');
|
|
98
|
+
return exists('tsconfig.json') ? 'tsconfig.json' : null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const FORMAT_COMMAND =
|
|
102
|
+
/format-changed|after-shell-write|prettier|sort-imports|fix-changed/;
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Does the repo already format on this event (`edit` or `shell`) with its own hook? PURE: `settings`
|
|
106
|
+
* are the texts of its `.claude/settings.json` and `.claude/settings.local.json`.
|
|
107
|
+
*/
|
|
108
|
+
export function ownFormatHook(settings, event) {
|
|
109
|
+
const wanted = event === 'shell' ? /\bBash\b/ : /\b(Edit|Write|MultiEdit)\b/;
|
|
110
|
+
return settings
|
|
111
|
+
.map(parse)
|
|
112
|
+
.filter(Boolean)
|
|
113
|
+
.flatMap((config) => config.hooks?.PostToolUse ?? [])
|
|
114
|
+
.some(
|
|
115
|
+
(entry) =>
|
|
116
|
+
wanted.test(entry.matcher ?? '') &&
|
|
117
|
+
(entry.hooks ?? []).some((hook) =>
|
|
118
|
+
FORMAT_COMMAND.test(hook.command ?? ''),
|
|
119
|
+
),
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The worktree paths of active bouts a shell command names (`cd <wt>`, `git -C <wt>`…). PURE. */
|
|
124
|
+
export function namedWorktrees(command, worktrees) {
|
|
125
|
+
const text = String(command ?? '');
|
|
126
|
+
return worktrees.filter(
|
|
127
|
+
(worktree) =>
|
|
128
|
+
worktree &&
|
|
129
|
+
new RegExp(
|
|
130
|
+
`${worktree.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?=$|[/\\s'";&|)])`,
|
|
131
|
+
).test(text),
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const git = (dir, args) =>
|
|
136
|
+
execFileSync('git', ['-C', dir, ...args], {
|
|
137
|
+
encoding: 'utf8',
|
|
138
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
139
|
+
});
|
|
140
|
+
const gitOr = (dir, args, fallback = null) => {
|
|
141
|
+
try {
|
|
142
|
+
return git(dir, args);
|
|
143
|
+
} catch {
|
|
144
|
+
return fallback;
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
/** The git toplevel holding `path` (a file or a directory), or null outside a repo. */
|
|
149
|
+
export function toplevel(path) {
|
|
150
|
+
const dir =
|
|
151
|
+
existsSync(path) && statSync(path).isDirectory() ? path : dirname(path);
|
|
152
|
+
return gitOr(dir, ['rev-parse', '--show-toplevel'])?.trim() || null;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The repo's binaries: its own node_modules/.bin, else the main checkout's (a fresh worktree of the
|
|
157
|
+
* wrestler may not have installed its dependencies). Null when absent: nothing is downloaded.
|
|
158
|
+
*/
|
|
159
|
+
export function binaries(root) {
|
|
160
|
+
const common = gitOr(root, [
|
|
161
|
+
'rev-parse',
|
|
162
|
+
'--path-format=absolute',
|
|
163
|
+
'--git-common-dir',
|
|
164
|
+
])?.trim();
|
|
165
|
+
const homes = [root, common ? dirname(common) : null].filter(Boolean);
|
|
166
|
+
const find = (name) =>
|
|
167
|
+
homes
|
|
168
|
+
.map((home) => join(home, 'node_modules', '.bin', name))
|
|
169
|
+
.find((bin) => existsSync(bin)) ?? null;
|
|
170
|
+
return { sort: find('sort-imports'), prettier: find('prettier') };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const readOr = (path) => (existsSync(path) ? readFileSync(path, 'utf8') : null);
|
|
174
|
+
|
|
175
|
+
/** What formatting `root` takes: binaries, tsconfig (absolute) and whether it formats on its own. */
|
|
176
|
+
export function toolchain(root) {
|
|
177
|
+
const tsconfig = formatTsconfig({
|
|
178
|
+
ringJson: readOr(join(root, HOST_CONFIG)),
|
|
179
|
+
packageJson: readOr(join(root, 'package.json')),
|
|
180
|
+
exists: (name) => existsSync(join(root, name)),
|
|
181
|
+
});
|
|
182
|
+
const settings = ['settings.json', 'settings.local.json'].map((name) =>
|
|
183
|
+
readOr(join(root, '.claude', name)),
|
|
184
|
+
);
|
|
185
|
+
return {
|
|
186
|
+
root,
|
|
187
|
+
...binaries(root),
|
|
188
|
+
tsconfig: tsconfig
|
|
189
|
+
? isAbsolute(tsconfig)
|
|
190
|
+
? tsconfig
|
|
191
|
+
: join(root, tsconfig)
|
|
192
|
+
: null,
|
|
193
|
+
ownHook: {
|
|
194
|
+
edit: ownFormatHook(settings, 'edit'),
|
|
195
|
+
shell: ownFormatHook(settings, 'shell'),
|
|
196
|
+
},
|
|
197
|
+
ownFix: Boolean(
|
|
198
|
+
parse(readOr(join(root, 'package.json')) ?? '')?.scripts?.['fix:changed'],
|
|
199
|
+
),
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const formattable = (file) => FORMATTABLE.includes(extname(file).toLowerCase());
|
|
204
|
+
const sortable = (file) => SORTABLE.includes(extname(file).toLowerCase());
|
|
205
|
+
|
|
206
|
+
/** sort-imports, then prettier, on files of `tools.root` (absolute paths). Throws what fails. */
|
|
207
|
+
export function formatFiles(tools, files) {
|
|
208
|
+
const run = (bin, args) =>
|
|
209
|
+
execFileSync(bin, args, { cwd: tools.root, stdio: 'pipe' });
|
|
210
|
+
const sorted = files.filter(sortable);
|
|
211
|
+
const tsconfig = tools.tsconfig ? ['--tsconfig', tools.tsconfig] : [];
|
|
212
|
+
if (tools.sort && sorted.length)
|
|
213
|
+
run(tools.sort, [...tsconfig, '--write', ...sorted]);
|
|
214
|
+
const pretty = files.filter(formattable);
|
|
215
|
+
if (tools.prettier && pretty.length)
|
|
216
|
+
run(tools.prettier, [
|
|
217
|
+
'--write',
|
|
218
|
+
'--ignore-unknown',
|
|
219
|
+
'--log-level',
|
|
220
|
+
'warn',
|
|
221
|
+
...pretty,
|
|
222
|
+
]);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* `text` (the content of `file`) as the repo's format would leave it: sort-imports on a copy outside
|
|
227
|
+
* the repo, then prettier from stdin with the file's own path, so its config and ignore file apply.
|
|
228
|
+
*/
|
|
229
|
+
export function formattedText(tools, file, text) {
|
|
230
|
+
let out = text;
|
|
231
|
+
if (tools.sort && sortable(file)) {
|
|
232
|
+
const dir = mkdtempSync(join(tmpdir(), 'ring-format-'));
|
|
233
|
+
try {
|
|
234
|
+
const copy = join(dir, basename(file));
|
|
235
|
+
writeFileSync(copy, out);
|
|
236
|
+
const tsconfig = tools.tsconfig ? ['--tsconfig', tools.tsconfig] : [];
|
|
237
|
+
execFileSync(tools.sort, [...tsconfig, '--write', copy], {
|
|
238
|
+
cwd: tools.root,
|
|
239
|
+
stdio: 'pipe',
|
|
240
|
+
});
|
|
241
|
+
out = readFileSync(copy, 'utf8');
|
|
242
|
+
} finally {
|
|
243
|
+
rmSync(dir, { recursive: true, force: true });
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
if (tools.prettier && formattable(file))
|
|
247
|
+
out = execFileSync(tools.prettier, ['--stdin-filepath', file], {
|
|
248
|
+
cwd: tools.root,
|
|
249
|
+
input: out,
|
|
250
|
+
encoding: 'utf8',
|
|
251
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
252
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
253
|
+
});
|
|
254
|
+
return out;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const lines = (text) =>
|
|
258
|
+
String(text ?? '')
|
|
259
|
+
.split('\n')
|
|
260
|
+
.map((line) => line.trim())
|
|
261
|
+
.filter(Boolean);
|
|
262
|
+
|
|
263
|
+
/** The changed files of `root` (diff, staged, untracked), relative, that still exist. */
|
|
264
|
+
export function changedFiles(root) {
|
|
265
|
+
const listed = [
|
|
266
|
+
gitOr(root, ['diff', '--name-only', '--diff-filter=ACMR']),
|
|
267
|
+
gitOr(root, ['diff', '--cached', '--name-only', '--diff-filter=ACMR']),
|
|
268
|
+
gitOr(root, ['ls-files', '--others', '--exclude-standard']),
|
|
269
|
+
];
|
|
270
|
+
if (listed.includes(null))
|
|
271
|
+
throw new Error(`git cannot list the changes of ${root}`);
|
|
272
|
+
return [...new Set(listed.flatMap(lines))].filter(
|
|
273
|
+
(file) => formattable(file) && existsSync(join(root, file)),
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Is a bout of the repo at `root` active? A ring home that cannot be read counts as no. */
|
|
278
|
+
function boutActive(root) {
|
|
279
|
+
try {
|
|
280
|
+
return activeBouts(repoSlug(root)).length > 0;
|
|
281
|
+
} catch {
|
|
282
|
+
return false;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const readStdin = () => {
|
|
287
|
+
try {
|
|
288
|
+
return parse(readFileSync(0, 'utf8')) ?? {};
|
|
289
|
+
} catch {
|
|
290
|
+
return {};
|
|
291
|
+
}
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
/** edit-hook: the file an Edit/Write/MultiEdit just wrote. */
|
|
295
|
+
export function editHook(input) {
|
|
296
|
+
const file = input?.tool_input?.file_path;
|
|
297
|
+
if (!file || !existsSync(file) || !formattable(file))
|
|
298
|
+
return 'skip: not a formattable file';
|
|
299
|
+
const root = toplevel(file);
|
|
300
|
+
if (!root) return 'skip: outside a repo';
|
|
301
|
+
if (!boutActive(root)) return 'skip: no active bout';
|
|
302
|
+
const tools = toolchain(root);
|
|
303
|
+
if (tools.ownHook.edit) return 'skip: the repo formats on its own';
|
|
304
|
+
if (!tools.sort && !tools.prettier)
|
|
305
|
+
return 'skip: no sort-imports nor prettier installed';
|
|
306
|
+
formatFiles(tools, [resolve(file)]);
|
|
307
|
+
return 'formatted';
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const fingerprint = (path) => {
|
|
311
|
+
try {
|
|
312
|
+
const stats = statSync(path);
|
|
313
|
+
return `${stats.mtimeMs}:${stats.size}`;
|
|
314
|
+
} catch {
|
|
315
|
+
return null;
|
|
316
|
+
}
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
/** The roots a shell command may have written in: the cwd's repo and the bout worktrees it names. */
|
|
320
|
+
function shellRoots(input) {
|
|
321
|
+
const cwdRoot = toplevel(input?.cwd || process.cwd());
|
|
322
|
+
const roots = new Set(cwdRoot ? [cwdRoot] : []);
|
|
323
|
+
let bouts = [];
|
|
324
|
+
try {
|
|
325
|
+
bouts = activeBouts(null);
|
|
326
|
+
} catch {
|
|
327
|
+
bouts = [];
|
|
328
|
+
}
|
|
329
|
+
namedWorktrees(
|
|
330
|
+
input?.tool_input?.command,
|
|
331
|
+
bouts
|
|
332
|
+
.map(({ state }) => state.worktree)
|
|
333
|
+
.filter((worktree) => worktree && existsSync(worktree)),
|
|
334
|
+
).forEach((worktree) => roots.add(toplevel(worktree)));
|
|
335
|
+
return [...roots].filter(Boolean);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** shell-hook: what changed since the last pass in each root the command may have written in. */
|
|
339
|
+
export function shellHook(input) {
|
|
340
|
+
return shellRoots(input).map((root) => {
|
|
341
|
+
if (!boutActive(root)) return `${root}: no active bout`;
|
|
342
|
+
const tools = toolchain(root);
|
|
343
|
+
if (tools.ownHook.shell) return `${root}: the repo formats on its own`;
|
|
344
|
+
if (!tools.sort && !tools.prettier)
|
|
345
|
+
return `${root}: no sort-imports nor prettier`;
|
|
346
|
+
const statePath = gitOr(root, ['rev-parse', '--git-path', STATE])?.trim();
|
|
347
|
+
const stateFile = statePath ? resolve(root, statePath) : null;
|
|
348
|
+
const previous = (stateFile && parse(readOr(stateFile) ?? '')) || {};
|
|
349
|
+
const files = changedFiles(root);
|
|
350
|
+
const touched = files.filter(
|
|
351
|
+
(file) => fingerprint(join(root, file)) !== previous[file],
|
|
352
|
+
);
|
|
353
|
+
// One file at a time: a file prettier cannot parse must not stop the others, and it stays out
|
|
354
|
+
// of the state, so the next pass tries it again (the gate reports it if it never formats).
|
|
355
|
+
const failed = new Set(
|
|
356
|
+
touched.filter((file) => {
|
|
357
|
+
try {
|
|
358
|
+
formatFiles(tools, [join(root, file)]);
|
|
359
|
+
return false;
|
|
360
|
+
} catch {
|
|
361
|
+
return true;
|
|
362
|
+
}
|
|
363
|
+
}),
|
|
364
|
+
);
|
|
365
|
+
if (stateFile) {
|
|
366
|
+
const now = Object.fromEntries(
|
|
367
|
+
files
|
|
368
|
+
.filter((file) => !failed.has(file))
|
|
369
|
+
.map((file) => [file, fingerprint(join(root, file))]),
|
|
370
|
+
);
|
|
371
|
+
try {
|
|
372
|
+
mkdirSync(dirname(stateFile), { recursive: true });
|
|
373
|
+
writeFileSync(stateFile, JSON.stringify(now));
|
|
374
|
+
} catch {
|
|
375
|
+
// The state only saves work: without it the next pass looks at more, never at less.
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
return `${root}: ${touched.length} formatted`;
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** The formattable files a commit range `<a>..<b>` adds or changes, relative, that still exist. */
|
|
383
|
+
const rangeFiles = (root, range) =>
|
|
384
|
+
lines(git(root, ['diff', '--name-only', '--diff-filter=ACMR', range])).filter(
|
|
385
|
+
formattable,
|
|
386
|
+
);
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* fix: the changed files of `dir` formatted (the repo's own `fix:changed` when it has one), or,
|
|
390
|
+
* with `range`, the files the range touched as they are in the worktree now, by ring itself: the
|
|
391
|
+
* way to fix what is already committed (the wrestler commits the result).
|
|
392
|
+
*/
|
|
393
|
+
export function fix(dir, range = null) {
|
|
394
|
+
const root = toplevel(dir);
|
|
395
|
+
if (!root) throw new Error(`${dir} is not inside a git repo`);
|
|
396
|
+
const tools = toolchain(root);
|
|
397
|
+
// What was staged must be committed formatted: a fully staged file is staged again after
|
|
398
|
+
// formatting; one staged in part (with other edits unstaged) is left for the wrestler, who
|
|
399
|
+
// decides what goes in, and is reported.
|
|
400
|
+
const staged = lines(
|
|
401
|
+
gitOr(root, ['diff', '--cached', '--name-only', '--diff-filter=ACMR']),
|
|
402
|
+
);
|
|
403
|
+
const unstaged = new Set(
|
|
404
|
+
lines(gitOr(root, ['diff', '--name-only', '--diff-filter=ACMR'])),
|
|
405
|
+
);
|
|
406
|
+
const restage = () => {
|
|
407
|
+
const whole = staged.filter(
|
|
408
|
+
(file) => !unstaged.has(file) && existsSync(join(root, file)),
|
|
409
|
+
);
|
|
410
|
+
if (whole.length) git(root, ['add', '--', ...whole]);
|
|
411
|
+
return {
|
|
412
|
+
restaged: whole,
|
|
413
|
+
partial: staged.filter((file) => unstaged.has(file)),
|
|
414
|
+
};
|
|
415
|
+
};
|
|
416
|
+
if (tools.ownFix && !range) {
|
|
417
|
+
execFileSync(
|
|
418
|
+
existsSync(join(root, 'yarn.lock')) ? 'yarn' : 'npm',
|
|
419
|
+
['run', 'fix:changed'],
|
|
420
|
+
{
|
|
421
|
+
cwd: root,
|
|
422
|
+
stdio: 'inherit',
|
|
423
|
+
},
|
|
424
|
+
);
|
|
425
|
+
return { root, files: null, by: 'the repo (fix:changed)', ...restage() };
|
|
426
|
+
}
|
|
427
|
+
if (!tools.sort && !tools.prettier)
|
|
428
|
+
throw new Error(
|
|
429
|
+
`${root} has neither sort-imports nor prettier in node_modules/.bin: install the dependencies (or report it to the referee)`,
|
|
430
|
+
);
|
|
431
|
+
const files = (range ? rangeFiles(root, range) : changedFiles(root)).filter(
|
|
432
|
+
(file) => existsSync(join(root, file)),
|
|
433
|
+
);
|
|
434
|
+
if (files.length)
|
|
435
|
+
formatFiles(
|
|
436
|
+
tools,
|
|
437
|
+
files.map((file) => join(root, file)),
|
|
438
|
+
);
|
|
439
|
+
return {
|
|
440
|
+
root,
|
|
441
|
+
files,
|
|
442
|
+
by: 'ring',
|
|
443
|
+
missing: missingTools(tools),
|
|
444
|
+
...restage(),
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const missingTools = (tools) =>
|
|
449
|
+
[!tools.sort && 'sort-imports', !tools.prettier && 'prettier'].filter(
|
|
450
|
+
Boolean,
|
|
451
|
+
);
|
|
452
|
+
|
|
453
|
+
const WORKER = join(
|
|
454
|
+
dirname(fileURLToPath(import.meta.url)),
|
|
455
|
+
'format-worker.mjs',
|
|
456
|
+
);
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* The files (relative) whose `text` the repo's format would change, judged in one batch by
|
|
460
|
+
* format-worker.mjs; a file it could not judge counts as unclean.
|
|
461
|
+
*/
|
|
462
|
+
function batchUnclean(tools, entries) {
|
|
463
|
+
const byAbs = new Map(
|
|
464
|
+
entries.map(({ file }) => [join(tools.root, file), file]),
|
|
465
|
+
);
|
|
466
|
+
const output = execFileSync(process.execPath, [WORKER], {
|
|
467
|
+
cwd: tools.root,
|
|
468
|
+
input: JSON.stringify({
|
|
469
|
+
root: tools.root,
|
|
470
|
+
tsconfig: tools.tsconfig,
|
|
471
|
+
sort: tools.sort,
|
|
472
|
+
prettier: tools.prettier,
|
|
473
|
+
files: entries.map(({ file, text }) => ({
|
|
474
|
+
file: join(tools.root, file),
|
|
475
|
+
text,
|
|
476
|
+
})),
|
|
477
|
+
}),
|
|
478
|
+
encoding: 'utf8',
|
|
479
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
480
|
+
maxBuffer: 256 * 1024 * 1024,
|
|
481
|
+
});
|
|
482
|
+
const { changed, failed } = JSON.parse(output);
|
|
483
|
+
return [...new Set([...changed, ...failed])].map((file) => byAbs.get(file));
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* check: the files that would change, read-only. `range` is `<a>..<b>` (files as they are at b).
|
|
488
|
+
* `verified` is false when sort-imports or prettier is not installed: then the files listed in
|
|
489
|
+
* `files` were not (fully) checked, and nothing may read that as clean.
|
|
490
|
+
*/
|
|
491
|
+
export function check(dir, range = null, only = null) {
|
|
492
|
+
const root = toplevel(dir);
|
|
493
|
+
if (!root) throw new Error(`${dir} is not inside a git repo`);
|
|
494
|
+
const tools = toolchain(root);
|
|
495
|
+
const missing = missingTools(tools);
|
|
496
|
+
const [, head] = range ? range.split('..') : [];
|
|
497
|
+
const files = only ?? (range ? rangeFiles(root, range) : changedFiles(root));
|
|
498
|
+
// Nothing to format is verified clean, whatever is installed.
|
|
499
|
+
if (missing.length === 2 && files.length)
|
|
500
|
+
return {
|
|
501
|
+
root,
|
|
502
|
+
verified: false,
|
|
503
|
+
checked: 0,
|
|
504
|
+
files,
|
|
505
|
+
unclean: [],
|
|
506
|
+
missing,
|
|
507
|
+
tsconfig: tools.tsconfig,
|
|
508
|
+
};
|
|
509
|
+
const unclean = files.length
|
|
510
|
+
? batchUnclean(
|
|
511
|
+
tools,
|
|
512
|
+
files.map((file) => ({
|
|
513
|
+
file,
|
|
514
|
+
text: range
|
|
515
|
+
? git(root, ['show', `${head || 'HEAD'}:${file}`])
|
|
516
|
+
: readFileSync(join(root, file), 'utf8'),
|
|
517
|
+
})),
|
|
518
|
+
)
|
|
519
|
+
: [];
|
|
520
|
+
return {
|
|
521
|
+
root,
|
|
522
|
+
verified: missing.length === 0 || files.length === 0,
|
|
523
|
+
checked: files.length,
|
|
524
|
+
files,
|
|
525
|
+
unclean,
|
|
526
|
+
missing,
|
|
527
|
+
tsconfig: tools.tsconfig,
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// ---------------------------------------------------------------- the gate of a bout
|
|
532
|
+
|
|
533
|
+
const CLEAN_BLOBS = 'format-clean.json';
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* Why a bout's range is refused for its format, or null. PURE over the check's result:
|
|
537
|
+
* unclean files always refuse; a check that could not run (NOT VERIFIED) refuses unless the
|
|
538
|
+
* referee recorded `formatUnverified` for the bout (a repo without the tooling).
|
|
539
|
+
*/
|
|
540
|
+
export function formatRefusal(result, state) {
|
|
541
|
+
if (result.unclean.length)
|
|
542
|
+
return `files not as sort-imports + prettier leave them: ${result.unclean.join(', ')}. The wrestler runs format-gate.mjs fix <worktree> --range <base>..HEAD, commits, and it is checked again`;
|
|
543
|
+
if (!result.verified && !state.formatUnverified)
|
|
544
|
+
return `the format could NOT be verified (${result.missing.join(' and ')} not installed in the worktree nor in the main checkout): install the dependencies in the worktree, or, if the repo really has no such tooling, approve with --format-unverified "<why>" (it goes to decisions.md)`;
|
|
545
|
+
return null;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* The format gate of a bout up to `sha`: the range from the merge base with origin/<base> to
|
|
550
|
+
* `sha`, checked as committed in the bout's worktree. Blobs that already came out clean (with the
|
|
551
|
+
* same tsconfig) are kept in `<bout dir>/format-clean.json` and not checked again, so approve,
|
|
552
|
+
* push, round-bell and close only pay for what changed. Returns { refusal, result }.
|
|
553
|
+
*/
|
|
554
|
+
export function boutFormatGate(dir, state, sha) {
|
|
555
|
+
const root = state.worktree;
|
|
556
|
+
if (!root || !existsSync(root))
|
|
557
|
+
throw new Error(
|
|
558
|
+
`the bout's worktree ${root} does not exist: its format cannot be checked`,
|
|
559
|
+
);
|
|
560
|
+
const baseRef = [`origin/${state.base}`, state.base].find(
|
|
561
|
+
(ref) =>
|
|
562
|
+
gitOr(root, ['rev-parse', '--verify', '-q', `${ref}^{commit}`]) !== null,
|
|
563
|
+
);
|
|
564
|
+
if (!baseRef)
|
|
565
|
+
throw new Error(
|
|
566
|
+
`neither origin/${state.base} nor ${state.base} exists in ${root}: fetch the base before checking the format`,
|
|
567
|
+
);
|
|
568
|
+
const base = git(root, ['merge-base', baseRef, sha]).trim();
|
|
569
|
+
const range = `${base}..${sha}`;
|
|
570
|
+
const tools = toolchain(root);
|
|
571
|
+
const cachePath = join(dir, CLEAN_BLOBS);
|
|
572
|
+
const cache = parse(readOr(cachePath) ?? '') ?? {};
|
|
573
|
+
const key = tools.tsconfig ?? '-';
|
|
574
|
+
const clean = new Set(cache[key] ?? []);
|
|
575
|
+
const blobOf = (file) => git(root, ['rev-parse', `${sha}:${file}`]).trim();
|
|
576
|
+
const files = rangeFiles(root, range);
|
|
577
|
+
const pending = files.filter((file) => !clean.has(blobOf(file)));
|
|
578
|
+
const result =
|
|
579
|
+
pending.length || !files.length
|
|
580
|
+
? check(root, range, pending)
|
|
581
|
+
: {
|
|
582
|
+
root,
|
|
583
|
+
verified: true,
|
|
584
|
+
checked: 0,
|
|
585
|
+
files,
|
|
586
|
+
unclean: [],
|
|
587
|
+
missing: [],
|
|
588
|
+
tsconfig: tools.tsconfig,
|
|
589
|
+
};
|
|
590
|
+
if (result.verified) {
|
|
591
|
+
pending
|
|
592
|
+
.filter((file) => !result.unclean.includes(file))
|
|
593
|
+
.forEach((file) => clean.add(blobOf(file)));
|
|
594
|
+
try {
|
|
595
|
+
writeFileSync(cachePath, JSON.stringify({ ...cache, [key]: [...clean] }));
|
|
596
|
+
} catch {
|
|
597
|
+
// The cache only saves work.
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
return {
|
|
601
|
+
refusal: formatRefusal(result, state),
|
|
602
|
+
result: { ...result, range },
|
|
603
|
+
};
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* Records that the bout's repo really has no format tooling: the referee's decision, with its why,
|
|
608
|
+
* in decisions.md and in state.json (`formatUnverified`). From then on a NOT VERIFIED check does
|
|
609
|
+
* not refuse; unclean files still do.
|
|
610
|
+
*/
|
|
611
|
+
export function acceptUnverified(dir, reason, at = new Date().toISOString()) {
|
|
612
|
+
const why = String(reason ?? '').trim();
|
|
613
|
+
if (!why)
|
|
614
|
+
throw new Error(
|
|
615
|
+
'accepting an unverified format needs its why: --format-unverified "<why>"',
|
|
616
|
+
);
|
|
617
|
+
const statePath = join(dir, 'state.json');
|
|
618
|
+
const state = JSON.parse(readFileSync(statePath, 'utf8'));
|
|
619
|
+
writeFileSync(
|
|
620
|
+
statePath,
|
|
621
|
+
`${JSON.stringify({ ...state, formatUnverified: { reason: why, at } }, null, 2)}\n`,
|
|
622
|
+
);
|
|
623
|
+
writeFileSync(
|
|
624
|
+
join(dir, 'decisions.md'),
|
|
625
|
+
`${readOr(join(dir, 'decisions.md')) ?? ''}- ${at} · format · referee · FORMAT NOT VERIFIED accepted for the bout (no sort-imports/prettier in the repo): ${why}\n`,
|
|
626
|
+
);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/**
|
|
630
|
+
* Exit codes of `check`: 0 clean, 1 unclean files, 2 NOT VERIFIED (a binary is missing: never read
|
|
631
|
+
* it as clean), 64 a usage or setup error.
|
|
632
|
+
*/
|
|
633
|
+
export const CHECK_EXIT = { clean: 0, unclean: 1, unverified: 2, error: 64 };
|
|
634
|
+
|
|
635
|
+
export const checkExit = (result) =>
|
|
636
|
+
result.unclean.length
|
|
637
|
+
? CHECK_EXIT.unclean
|
|
638
|
+
: result.verified
|
|
639
|
+
? CHECK_EXIT.clean
|
|
640
|
+
: CHECK_EXIT.unverified;
|
|
641
|
+
|
|
642
|
+
function main(argv) {
|
|
643
|
+
const [command, ...rest] = argv;
|
|
644
|
+
const valueOf = (flag) => rest[rest.indexOf(flag) + 1];
|
|
645
|
+
const dir =
|
|
646
|
+
rest.find(
|
|
647
|
+
(arg, index) => !arg.startsWith('--') && rest[index - 1] !== '--range',
|
|
648
|
+
) ?? process.cwd();
|
|
649
|
+
if (command === 'edit-hook') {
|
|
650
|
+
try {
|
|
651
|
+
editHook(readStdin());
|
|
652
|
+
} catch {
|
|
653
|
+
// A hook that formats never blocks the edit it follows.
|
|
654
|
+
}
|
|
655
|
+
return 0;
|
|
656
|
+
}
|
|
657
|
+
if (command === 'shell-hook') {
|
|
658
|
+
try {
|
|
659
|
+
shellHook(readStdin());
|
|
660
|
+
} catch {
|
|
661
|
+
// Same: formatting is a courtesy of the hook; the gate is `fix` and `check`.
|
|
662
|
+
}
|
|
663
|
+
return 0;
|
|
664
|
+
}
|
|
665
|
+
if (command === 'fix') {
|
|
666
|
+
const result = fix(
|
|
667
|
+
dir,
|
|
668
|
+
rest.includes('--range') ? valueOf('--range') : null,
|
|
669
|
+
);
|
|
670
|
+
const files =
|
|
671
|
+
result.files === null
|
|
672
|
+
? 'its changed files'
|
|
673
|
+
: `${result.files.length} file(s)`;
|
|
674
|
+
console.log(
|
|
675
|
+
`[ring format] ${relative(process.cwd(), result.root) || '.'}: ${files} formatted by ${result.by}`,
|
|
676
|
+
);
|
|
677
|
+
if (result.restaged?.length)
|
|
678
|
+
console.log(
|
|
679
|
+
`[ring format] staged again, formatted: ${result.restaged.join(', ')}`,
|
|
680
|
+
);
|
|
681
|
+
if (result.partial?.length)
|
|
682
|
+
console.log(
|
|
683
|
+
`[ring format] staged in part, NOT staged again (stage what you mean to commit): ${result.partial.join(', ')}`,
|
|
684
|
+
);
|
|
685
|
+
if (result.missing?.length)
|
|
686
|
+
console.log(
|
|
687
|
+
`[ring format] not installed, skipped: ${result.missing.join(', ')}`,
|
|
688
|
+
);
|
|
689
|
+
return 0;
|
|
690
|
+
}
|
|
691
|
+
if (command === 'accept-unverified') {
|
|
692
|
+
acceptUnverified(rest[0], rest[1]);
|
|
693
|
+
console.log(
|
|
694
|
+
`[ring format] NOT VERIFIED accepted for ${rest[0]} (decisions.md)`,
|
|
695
|
+
);
|
|
696
|
+
return 0;
|
|
697
|
+
}
|
|
698
|
+
if (command === 'check') {
|
|
699
|
+
const result = check(
|
|
700
|
+
dir,
|
|
701
|
+
rest.includes('--range') ? valueOf('--range') : null,
|
|
702
|
+
);
|
|
703
|
+
console.log(JSON.stringify(result, null, 2));
|
|
704
|
+
if (!result.verified)
|
|
705
|
+
console.error(
|
|
706
|
+
`[ring format] NOT VERIFIED: ${result.missing.join(' and ')} not installed in ${result.root} nor in its main checkout`,
|
|
707
|
+
);
|
|
708
|
+
return checkExit(result);
|
|
709
|
+
}
|
|
710
|
+
console.error(
|
|
711
|
+
'usage: format-gate.mjs edit-hook | shell-hook | fix [<dir>] [--range <a>..<b>] | check [<dir>] [--range <a>..<b>] | accept-unverified <bout dir> "<why>"',
|
|
712
|
+
);
|
|
713
|
+
return CHECK_EXIT.error;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
if (isMain(import.meta.url)) {
|
|
717
|
+
try {
|
|
718
|
+
process.exitCode = main(process.argv.slice(2));
|
|
719
|
+
} catch (error) {
|
|
720
|
+
console.error(`[ring format] ${error.message}`);
|
|
721
|
+
process.exitCode = CHECK_EXIT.error;
|
|
722
|
+
}
|
|
723
|
+
}
|