@thebattlearena/corner 0.10.0 → 0.10.1-beta.79

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.
@@ -0,0 +1,407 @@
1
+ #!/usr/bin/env node
2
+ // The architecture gate: which layer may import which, checked on what a range adds. Zero-dependency.
3
+ //
4
+ // node arch-gate.mjs check <worktree> --range <A>..<B> [--json] exit 0 clean, 1 violations,
5
+ // 2 nothing configured
6
+ // node arch-gate.mjs status <worktree> what the repo has, and how to
7
+ // add what it lacks
8
+ //
9
+ // Two sources, both when the repo has them:
10
+ // - ring's own scanner, from `layers` in `.claude/ring.json`: each layer has `paths` (globs) and
11
+ // `mayImport` (the layers it may depend on; its own is always allowed), and may `forbid` packages
12
+ // by regex (`"forbid": ["^@nestjs/", "^typeorm$"]` for a domain layer). Only imports on lines the
13
+ // range ADDS count: what was already there is debt, not this range's.
14
+ // - the repo's dependency-cruiser, when it has a config (`.dependency-cruiser.{js,cjs,mjs,json}`) and
15
+ // the binary: run on the files the range changed, with the repo's known-violations baseline when
16
+ // it keeps one (`--ignore-known`). Its `error` rules count; warnings are reported, not counted.
17
+ // Every violation is a finding that cites P14 (the host's rules are not the referee's decisions).
18
+ // mask-guard runs the same check on every push to the PR branch; `judges-card open` warns when a
19
+ // repo has neither source.
20
+ import { execFileSync, spawnSync } from 'node:child_process';
21
+ import { existsSync, readFileSync } from 'node:fs';
22
+ import { join, normalize, posix } from 'node:path';
23
+
24
+ import { isMain, loadConfig } from './config.mjs';
25
+ import { globToRegExp } from './contract.mjs';
26
+
27
+ const CODE = /\.(?:[cm]?[jt]sx?)$/;
28
+ const IMPORT =
29
+ /(?:\bimport\s+(?:type\s+)?(?:[\w*{}\s,$]+\s+from\s+)?|\bexport\s+(?:type\s+)?[\w*{}\s,$]+\s+from\s+|\bimport\s*\(\s*|\brequire\s*\(\s*)['"]([^'"\n]+)['"]/g;
30
+ const DEPCRUISE_CONFIGS = [
31
+ '.dependency-cruiser.js',
32
+ '.dependency-cruiser.cjs',
33
+ '.dependency-cruiser.mjs',
34
+ '.dependency-cruiser.json',
35
+ ];
36
+ const KNOWN_VIOLATIONS = '.dependency-cruiser-known-violations.json';
37
+
38
+ const git = (root, args) =>
39
+ execFileSync('git', ['-C', root, ...args], {
40
+ encoding: 'utf8',
41
+ stdio: ['ignore', 'pipe', 'pipe'],
42
+ maxBuffer: 1 << 26,
43
+ });
44
+
45
+ /** The module specifiers a file imports, with their line (1-based). PURE. */
46
+ export function importSpecs(text) {
47
+ const out = [];
48
+ const lineAt = (index) => text.slice(0, index).split('\n').length;
49
+ for (const match of String(text).matchAll(IMPORT))
50
+ out.push({ spec: match[1], line: lineAt(match.index) });
51
+ return out;
52
+ }
53
+
54
+ /** The new-side line numbers a `git diff -U0` of one file adds. PURE. */
55
+ export function addedLines(diff) {
56
+ const added = new Set();
57
+ for (const hunk of String(diff).matchAll(
58
+ /^@@ -\S+ \+(\d+)(?:,(\d+))? @@/gm,
59
+ )) {
60
+ const start = Number(hunk[1]);
61
+ const count = hunk[2] === undefined ? 1 : Number(hunk[2]);
62
+ for (let i = 0; i < count; i += 1) added.add(start + i);
63
+ }
64
+ return added;
65
+ }
66
+
67
+ const stripJsonComments = (text) =>
68
+ String(text)
69
+ .replace(/\/\*[\s\S]*?\*\//g, '')
70
+ .replace(/(^|[^:"'])\/\/.*$/gm, '$1')
71
+ .replace(/,(\s*[}\]])/g, '$1');
72
+
73
+ /** The path aliases of a tsconfig: [{prefix, targets}] with repo-relative targets. PURE. */
74
+ export function tsAliases(tsconfigText) {
75
+ try {
76
+ const { compilerOptions = {} } = JSON.parse(
77
+ stripJsonComments(tsconfigText),
78
+ );
79
+ const base = compilerOptions.baseUrl ?? '.';
80
+ return Object.entries(compilerOptions.paths ?? {}).map(
81
+ ([key, targets]) => ({
82
+ prefix: key.replace(/\*$/, ''),
83
+ exact: !key.endsWith('*'),
84
+ targets: targets.map((target) =>
85
+ posix.normalize(posix.join(base, target.replace(/\*$/, ''))),
86
+ ),
87
+ }),
88
+ );
89
+ } catch {
90
+ return [];
91
+ }
92
+ }
93
+
94
+ /** Where a specifier points inside the repo (repo-relative), or null for a package. PURE. */
95
+ export function resolveSpec(fromFile, spec, aliases = []) {
96
+ if (spec.startsWith('.'))
97
+ return posix.normalize(posix.join(posix.dirname(fromFile), spec));
98
+ const alias = aliases.find(({ prefix, exact }) =>
99
+ exact ? spec === prefix : spec.startsWith(prefix),
100
+ );
101
+ if (!alias) return null;
102
+ return posix
103
+ .normalize(
104
+ `${alias.targets[0].replace(/\/$/, '')}/${spec.slice(alias.prefix.length)}`,
105
+ )
106
+ .replace(/\/$/, '');
107
+ }
108
+
109
+ /** The first layer whose globs hold `path` (a file or a module without extension), or null. PURE. */
110
+ export function layerOf(path, layers) {
111
+ return (
112
+ layers.find(({ paths }) =>
113
+ paths.some((glob) => {
114
+ const re = globToRegExp(glob.replace(/^\.\//, ''));
115
+ return (
116
+ re.test(path) || re.test(`${path}.ts`) || re.test(`${path}/index.ts`)
117
+ );
118
+ }),
119
+ ) ?? null
120
+ );
121
+ }
122
+
123
+ /** The violations of one file's added imports. PURE. */
124
+ export function fileViolations({ file, text, added, layers, aliases }) {
125
+ const from = layerOf(file, layers);
126
+ if (!from) return [];
127
+ const out = [];
128
+ for (const { spec, line } of importSpecs(text)) {
129
+ if (!added.has(line)) continue;
130
+ const target = resolveSpec(file, spec, aliases);
131
+ if (target === null) {
132
+ const banned = (from.forbid ?? []).find((pattern) =>
133
+ new RegExp(pattern).test(spec),
134
+ );
135
+ if (banned)
136
+ out.push({
137
+ file,
138
+ line,
139
+ spec,
140
+ from: from.name,
141
+ to: spec,
142
+ why: `the ${from.name} layer may not depend on ${spec} (forbid ${banned})`,
143
+ });
144
+ continue;
145
+ }
146
+ const to = layerOf(target, layers);
147
+ if (!to || to.name === from.name) continue;
148
+ if ((from.mayImport ?? []).includes(to.name)) continue;
149
+ out.push({
150
+ file,
151
+ line,
152
+ spec,
153
+ from: from.name,
154
+ to: to.name,
155
+ why: `${from.name} imports ${to.name} (${spec}); ${from.name} may import only ${(from.mayImport ?? []).join(', ') || 'itself'}`,
156
+ });
157
+ }
158
+ return out;
159
+ }
160
+
161
+ /** A violation as a finding line. PURE. */
162
+ export const findingLine = (v) =>
163
+ `${v.file}:${v.line} — P14 — ${v.why} — ${
164
+ v.spec === null
165
+ ? 'move it into the layer its job belongs to (domain, application or infrastructure)'
166
+ : 'depend on a port the inner layer owns, or move the code to the layer that may import it'
167
+ }`;
168
+
169
+ const depcruiseConfig = (root) =>
170
+ DEPCRUISE_CONFIGS.find((name) => existsSync(join(root, name))) ?? null;
171
+ const depcruiseBin = (root) =>
172
+ [join(root, 'node_modules', '.bin', 'depcruise')].find(existsSync) ?? null;
173
+
174
+ /** What the repo has for the gate, and what to add when it has nothing. */
175
+ export function archStatus(root, config) {
176
+ const layers = config.layers ?? [];
177
+ const cruiser = depcruiseConfig(root);
178
+ const bin = depcruiseBin(root);
179
+ return {
180
+ layers: layers.length,
181
+ dependencyCruiser: Boolean(cruiser && bin),
182
+ dependencyCruiserConfig: cruiser,
183
+ dependencyCruiserBinary: Boolean(bin),
184
+ configured: layers.length > 0 || Boolean(cruiser && bin),
185
+ };
186
+ }
187
+
188
+ export const SETUP_HINT =
189
+ 'add "layers" to .claude/ring.json (each with "name", "paths" and "mayImport"; see the ring README, «Gate de arquitectura») or a dependency-cruiser config (.dependency-cruiser.cjs) with the package installed';
190
+
191
+ /** Files the layer check leaves out: tests by default (they may wire adapters on purpose). */
192
+ export const DEFAULT_LAYERS_IGNORE = [
193
+ '**/*.spec.*',
194
+ '**/*.test.*',
195
+ '**/__tests__/**',
196
+ '**/test/**',
197
+ ];
198
+
199
+ const ignored = (file, globs) =>
200
+ globs.some((glob) => globToRegExp(glob).test(file));
201
+
202
+ function ownScan(root, range, config) {
203
+ const layers = config.layers ?? [];
204
+ if (!layers.length) return [];
205
+ const ignore = config.layersIgnore ?? DEFAULT_LAYERS_IGNORE;
206
+ const aliases = existsSync(join(root, 'tsconfig.json'))
207
+ ? tsAliases(readFileSync(join(root, 'tsconfig.json'), 'utf8'))
208
+ : [];
209
+ const head = range.split('..')[1] || 'HEAD';
210
+ const files = git(root, ['diff', '--name-only', '--diff-filter=ACMR', range])
211
+ .split('\n')
212
+ .filter(
213
+ (file) =>
214
+ CODE.test(file) && !ignored(file, ignore) && layerOf(file, layers),
215
+ );
216
+ const imports = files.flatMap((file) =>
217
+ fileViolations({
218
+ file,
219
+ text: git(root, ['show', `${head}:${file}`]),
220
+ added: addedLines(git(root, ['diff', '-U0', range, '--', file])),
221
+ layers,
222
+ aliases,
223
+ }),
224
+ );
225
+ const created = git(root, ['diff', '--name-only', '--diff-filter=A', range])
226
+ .split('\n')
227
+ .filter(Boolean);
228
+ return [...imports, ...placementViolations(created, config)];
229
+ }
230
+
231
+ /**
232
+ * The new files that fall in no layer although a `layersRequired` glob covers them: code that
233
+ * lives outside the architecture is invisible to the rest of the gate. Tests and the
234
+ * `layersIgnore` files are left out. PURE.
235
+ */
236
+ export function placementViolations(created, config) {
237
+ const required = config.layersRequired ?? [];
238
+ if (!required.length) return [];
239
+ const layers = config.layers ?? [];
240
+ const ignore = config.layersIgnore ?? DEFAULT_LAYERS_IGNORE;
241
+ return created
242
+ .filter(
243
+ (file) =>
244
+ CODE.test(file) &&
245
+ !ignored(file, ignore) &&
246
+ required.some((glob) => globToRegExp(glob).test(file)) &&
247
+ !layerOf(file, layers),
248
+ )
249
+ .map((file) => ({
250
+ file,
251
+ line: 1,
252
+ spec: null,
253
+ from: null,
254
+ to: null,
255
+ why: `a new file outside the layers (${layers.map((layer) => layer.name).join(', ')}), so no dependency rule reaches it`,
256
+ }));
257
+ }
258
+
259
+ function cruise(root, range) {
260
+ const cruiser = depcruiseConfig(root);
261
+ const bin = depcruiseBin(root);
262
+ if (!cruiser || !bin) return { ran: false, errors: [], warnings: [] };
263
+ const files = git(root, ['diff', '--name-only', '--diff-filter=ACMR', range])
264
+ .split('\n')
265
+ .filter((file) => CODE.test(file) && existsSync(join(root, file)));
266
+ if (!files.length) return { ran: true, errors: [], warnings: [] };
267
+ const known = existsSync(join(root, KNOWN_VIOLATIONS))
268
+ ? ['--ignore-known', KNOWN_VIOLATIONS]
269
+ : [];
270
+ const run = spawnSync(
271
+ bin,
272
+ ['--config', cruiser, ...known, '--output-type', 'json', ...files],
273
+ { cwd: root, encoding: 'utf8', maxBuffer: 1 << 26 },
274
+ );
275
+ let violations = [];
276
+ try {
277
+ violations = JSON.parse(run.stdout).summary?.violations ?? [];
278
+ } catch {
279
+ return { ran: false, errors: [], warnings: [], failure: run.stderr.trim() };
280
+ }
281
+ const toFinding = (v) => ({
282
+ file: v.from,
283
+ line: 1,
284
+ spec: v.to,
285
+ from: v.rule?.name ?? 'dependency-cruiser',
286
+ to: v.to,
287
+ why: `dependency-cruiser ${v.rule?.name}: ${v.from} → ${v.to}`,
288
+ });
289
+ return {
290
+ ran: true,
291
+ errors: violations
292
+ .filter((v) => v.rule?.severity === 'error')
293
+ .map(toFinding),
294
+ warnings: violations
295
+ .filter((v) => v.rule?.severity !== 'error')
296
+ .map(toFinding),
297
+ };
298
+ }
299
+
300
+ /** The gate on a range: { status, violations, warnings, notes }. */
301
+ export function archCheck(root, range, config) {
302
+ const status = archStatus(root, config);
303
+ if (!status.configured)
304
+ return { status, violations: [], warnings: [], notes: [SETUP_HINT] };
305
+ const own = ownScan(root, range, config);
306
+ const repo = cruise(root, range);
307
+ return {
308
+ status,
309
+ violations: [...own, ...repo.errors],
310
+ warnings: repo.warnings,
311
+ notes: repo.failure
312
+ ? [`dependency-cruiser did not run: ${repo.failure}`]
313
+ : [],
314
+ };
315
+ }
316
+
317
+ /**
318
+ * The config the gate reads for a checkout: its own `.claude/ring.json`, or, when it has none (a
319
+ * bout's worktree of a repo that keeps the file untracked), the main checkout's of the same repo.
320
+ */
321
+ export function archConfig(root, venue = 'backend') {
322
+ const read = (dir) => loadConfig(dir, { contract: false, venue }).config;
323
+ if (existsSync(join(root, '.claude', 'ring.json'))) return read(root);
324
+ try {
325
+ const common = git(root, [
326
+ 'rev-parse',
327
+ '--path-format=absolute',
328
+ '--git-common-dir',
329
+ ]).trim();
330
+ const main = posix.dirname(common);
331
+ if (main !== root && existsSync(join(main, '.claude', 'ring.json')))
332
+ return read(main);
333
+ } catch {
334
+ // Not a git checkout: its own config, defaults included.
335
+ }
336
+ return read(root);
337
+ }
338
+
339
+ /** The gate for a push to a bout's PR branch: { refusal } (null when it passes or is not configured). */
340
+ export function boutArchGate(state, sha) {
341
+ const root = state.worktree;
342
+ const config = archConfig(root, state.venue ?? 'backend');
343
+ if (!archStatus(root, config).configured) return { refusal: null };
344
+ const baseRef = [`origin/${state.base}`, state.base].find((ref) => {
345
+ try {
346
+ git(root, ['rev-parse', '--verify', '-q', `${ref}^{commit}`]);
347
+ return true;
348
+ } catch {
349
+ return false;
350
+ }
351
+ });
352
+ if (!baseRef) return { refusal: null };
353
+ const base = git(root, ['merge-base', baseRef, sha]).trim();
354
+ const { violations } = archCheck(root, `${base}..${sha}`, config);
355
+ return {
356
+ refusal: violations.length
357
+ ? `${violations.length} architecture violation(s) the range adds (P14):\n${violations.map(findingLine).join('\n')}\nFix them, or take the human's decision to the hard list.`
358
+ : null,
359
+ };
360
+ }
361
+
362
+ function flags(argv) {
363
+ const values = {};
364
+ const rest = [];
365
+ for (let i = 0; i < argv.length; i += 1) {
366
+ if (argv[i] === '--json') values.json = true;
367
+ else if (argv[i].startsWith('--')) values[argv[i].slice(2)] = argv[++i];
368
+ else rest.push(argv[i]);
369
+ }
370
+ return { values, rest };
371
+ }
372
+
373
+ if (isMain(import.meta.url)) {
374
+ const [command, ...argv] = process.argv.slice(2);
375
+ const { values, rest } = flags(argv);
376
+ const root = rest[0] ? normalize(rest[0]) : process.cwd();
377
+ try {
378
+ const config = archConfig(root);
379
+ if (command === 'status') {
380
+ const status = archStatus(root, config);
381
+ console.log(JSON.stringify(status, null, 2));
382
+ if (!status.configured)
383
+ console.error(`arch-gate: nothing configured; ${SETUP_HINT}`);
384
+ process.exit(status.configured ? 0 : 2);
385
+ }
386
+ if (command !== 'check' || !values.range)
387
+ throw new Error(
388
+ 'usage: arch-gate.mjs check <worktree> --range <A>..<B> [--json] | status <worktree>',
389
+ );
390
+ const result = archCheck(root, values.range, config);
391
+ if (values.json) console.log(JSON.stringify(result, null, 2));
392
+ else if (!result.status.configured)
393
+ console.log(`arch — NO VERIFICADO — ${SETUP_HINT}`);
394
+ else {
395
+ for (const v of result.violations) console.log(findingLine(v));
396
+ for (const w of result.warnings) console.log(`(warning) ${w.why}`);
397
+ for (const note of result.notes) console.log(`(note) ${note}`);
398
+ if (!result.violations.length) console.log('arch: clean');
399
+ }
400
+ process.exit(
401
+ !result.status.configured ? 2 : result.violations.length ? 1 : 0,
402
+ );
403
+ } catch (error) {
404
+ console.error(`arch-gate: ${error.message}`);
405
+ process.exit(3);
406
+ }
407
+ }
@@ -0,0 +1,130 @@
1
+ // The pure rules of a generated brief (brief.mjs is its CLI): the rules block, the excerpt of
2
+ // decisions, the filling of a template with no placeholder left, the seal that proves the brief
3
+ // came from the script with the rules in force, and the paths a brief may point to. Zero-dependency.
4
+ import { createHash } from 'node:crypto';
5
+ import { basename, isAbsolute, sep } from 'node:path';
6
+
7
+ import { parseDecisionLine } from './chronicle.mjs';
8
+ import { checklistLines } from './rule-ids.mjs';
9
+
10
+ export const BRIEF_VERSION = 'v1';
11
+ const SEAL = /^<!-- ring-brief (v\d+) (.*) -->$/;
12
+
13
+ export const sha256 = (text) =>
14
+ `sha256:${createHash('sha256').update(text).digest('hex')}`;
15
+
16
+ /**
17
+ * The rules every brief carries, in full and before anything else (the same text for every brief
18
+ * of a venue, so a model can reuse it): the architecture checklist, then each rule file. PURE.
19
+ */
20
+ export function rulesBlock({ sources, entries }) {
21
+ const files = sources.map(
22
+ ({ path, text }) =>
23
+ `<rules file="${basename(path)}">\n${text.trim()}\n</rules>`,
24
+ );
25
+ return [
26
+ '# The rules in force for this bout',
27
+ '',
28
+ 'Embedded in full by `brief.mjs`: these are the rules you apply and cite by id. Nothing you',
29
+ 'remember from another bout replaces them.',
30
+ '',
31
+ '## Architecture checklist (what slips past a line-by-line review; check it first)',
32
+ '',
33
+ ...checklistLines(entries),
34
+ '',
35
+ ...files,
36
+ ].join('\n');
37
+ }
38
+
39
+ /**
40
+ * The decisions a brief carries: every foul (what must not be repeated), the ones whose text names
41
+ * a file of the range, and the last `last`; in ledger order, each once. PURE.
42
+ */
43
+ export function decisionsExcerpt(
44
+ decisionsText,
45
+ files = [],
46
+ { last = 20 } = {},
47
+ ) {
48
+ const lines = String(decisionsText ?? '')
49
+ .split('\n')
50
+ .filter((line) => parseDecisionLine(line)?.id);
51
+ const names = files.map((file) => basename(file)).filter(Boolean);
52
+ const tail = new Set(lines.slice(-last));
53
+ const kept = lines.filter(
54
+ (line) =>
55
+ tail.has(line) ||
56
+ /\bFOUL\b/.test(line) ||
57
+ names.some((name) => line.includes(name)),
58
+ );
59
+ return kept.length ? kept.join('\n') : '(no decisions yet)';
60
+ }
61
+
62
+ /** The template with every `{{name}}` filled; throws naming each one left empty. PURE. */
63
+ export function fill(template, vars) {
64
+ const missing = new Set();
65
+ const text = template.replace(/\{\{(\w+)\}\}/g, (whole, name) => {
66
+ const value = vars[name];
67
+ if (value === undefined || value === null || String(value).trim() === '') {
68
+ missing.add(name);
69
+ return whole;
70
+ }
71
+ return String(value);
72
+ });
73
+ if (missing.size)
74
+ throw new Error(`the brief is missing: ${[...missing].join(', ')}`);
75
+ return text;
76
+ }
77
+
78
+ /** The last line of a brief: what it is and the hashes that prove it. PURE. */
79
+ export function seal(body, meta) {
80
+ const fields = Object.entries({ ...meta, body: sha256(body) })
81
+ .map(([key, value]) => `${key}=${value}`)
82
+ .join(' ');
83
+ return `${body}\n<!-- ring-brief ${BRIEF_VERSION} ${fields} -->\n`;
84
+ }
85
+
86
+ /** The seal of a brief, its body and whether the body is the one sealed; null without a seal. PURE. */
87
+ export function readSeal(text) {
88
+ const lines = String(text).replace(/\n$/, '').split('\n');
89
+ const match = SEAL.exec(lines.at(-1) ?? '');
90
+ if (!match) return null;
91
+ const meta = Object.fromEntries(
92
+ match[2].split(' ').map((pair) => pair.split('=')),
93
+ );
94
+ const body = lines.slice(0, -1).join('\n');
95
+ return { version: match[1], meta, body, intact: sha256(body) === meta.body };
96
+ }
97
+
98
+ /**
99
+ * Whether a sealed brief still holds: its body untouched and its rules the ones in force now
100
+ * (`rulesHash`). The reason when it does not. PURE.
101
+ */
102
+ export function briefStatus(text, rulesHash) {
103
+ const sealed = readSeal(text);
104
+ if (!sealed)
105
+ return { ok: false, reason: 'not generated by brief.mjs (no seal)' };
106
+ if (!sealed.intact)
107
+ return { ok: false, reason: 'edited by hand after it was generated' };
108
+ if (sealed.meta.rules && sealed.meta.rules !== rulesHash)
109
+ return { ok: false, reason: 'the rules changed since; generate it again' };
110
+ return { ok: true, meta: sealed.meta };
111
+ }
112
+
113
+ const ABSOLUTE_PATH = /(?:^|[\s`'"(])(\/[^\s`'"()]+)/g;
114
+
115
+ /**
116
+ * The absolute paths in the referee's values that point outside the places a brief may send an
117
+ * agent (the repo, the worktree, the bout dir, the ring): a session scratchpad is wiped on restart,
118
+ * so nothing the human asked for may live there. PURE.
119
+ */
120
+ export function strayPaths(vars, roots) {
121
+ const inside = (path) =>
122
+ roots.some((root) => path === root || path.startsWith(root + sep));
123
+ return Object.entries(vars).flatMap(([name, value]) =>
124
+ [...String(value ?? '').matchAll(ABSOLUTE_PATH)]
125
+ .map((match) => match[1].replace(/[.,;:]+$/, ''))
126
+ .filter((path) => isAbsolute(path))
127
+ .filter((path) => !inside(path))
128
+ .map((path) => ({ name, path })),
129
+ );
130
+ }