@albinocrabs/feynman 0.2.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.
@@ -0,0 +1,550 @@
1
+ // lib/lint/rules.js — 8 lint rules for ASCII diagrams
2
+ // Each rule: (ast: ASTNode, fullText: string) => Issue[]
3
+ // Issue: {rule, severity, line, column, message, suggestion?}
4
+ // Zero deps. CJS only.
5
+ 'use strict';
6
+
7
+ /**
8
+ * Create an issue object
9
+ * @param {string} rule - e.g. 'L01'
10
+ * @param {'error'|'warn'} severity
11
+ * @param {number} line - 1-based
12
+ * @param {number} column - 1-based
13
+ * @param {string} message
14
+ * @param {string} [suggestion]
15
+ * @returns {object}
16
+ */
17
+ function issue(rule, severity, line, column, message, suggestion) {
18
+ const obj = { rule, severity, line, column, message };
19
+ if (suggestion) obj.suggestion = suggestion;
20
+ return obj;
21
+ }
22
+
23
+ /**
24
+ * Get display width of a string (handles multibyte box-drawing chars as width 1)
25
+ * Box-drawing Unicode chars are double-byte but render as 1 column in most terminals.
26
+ * @param {string} str
27
+ * @returns {number}
28
+ */
29
+ function displayWidth(str) {
30
+ let width = 0;
31
+ for (const ch of str) {
32
+ const code = ch.codePointAt(0);
33
+ // CJK wide characters (East Asian width = 2)
34
+ if (
35
+ (code >= 0x1100 && code <= 0x115F) ||
36
+ (code >= 0x2E80 && code <= 0x303E) ||
37
+ (code >= 0x3040 && code <= 0x33FF) ||
38
+ (code >= 0x3400 && code <= 0x4DBF) ||
39
+ (code >= 0x4E00 && code <= 0xA4CF) ||
40
+ (code >= 0xA960 && code <= 0xA97F) ||
41
+ (code >= 0xAC00 && code <= 0xD7FF) ||
42
+ (code >= 0xF900 && code <= 0xFAFF) ||
43
+ (code >= 0xFE10 && code <= 0xFE1F) ||
44
+ (code >= 0xFE30 && code <= 0xFE4F) ||
45
+ (code >= 0xFF00 && code <= 0xFF60) ||
46
+ (code >= 0xFFE0 && code <= 0xFFE6) ||
47
+ (code >= 0x1F300 && code <= 0x1F64F) ||
48
+ (code >= 0x1F900 && code <= 0x1F9FF) ||
49
+ (code >= 0x20000 && code <= 0x2FFFD) ||
50
+ (code >= 0x30000 && code <= 0x3FFFD)
51
+ ) {
52
+ width += 2;
53
+ } else {
54
+ width += 1;
55
+ }
56
+ }
57
+ return width;
58
+ }
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // L01 — Box closure
62
+ // Every ┌ must have a matching └ at the same column.
63
+ // Every ─┐ must have a matching ─┘ at the same column.
64
+ // Vertical │ chars must align between top and bottom.
65
+ // ---------------------------------------------------------------------------
66
+ function L01_box_closure(ast) {
67
+ if (!ast || !ast.content) return [];
68
+
69
+ const lines = ast.content.split('\n');
70
+ // Don't run on diagrams with no box chars
71
+ if (!ast.content.includes('┌') && !ast.content.includes('─┐') && !ast.content.includes('┐')) {
72
+ return [];
73
+ }
74
+
75
+ const issues = [];
76
+ const baseLineNum = ast.startLine;
77
+
78
+ // Find all top-left corners (┌) and their columns
79
+ // Also find top-right corners (┐) and bottom corners (└, ┘)
80
+ // We track "open" top corners and look for matching bottoms
81
+
82
+ // Collect positions of each corner type
83
+ const topLefts = []; // {line, col}
84
+ const topRights = []; // {line, col}
85
+ const botLefts = []; // {line, col}
86
+ const botRights = []; // {line, col}
87
+
88
+ for (let li = 0; li < lines.length; li++) {
89
+ const ln = lines[li];
90
+ for (let ci = 0; ci < ln.length; ci++) {
91
+ const ch = ln[ci];
92
+ if (ch === '┌') topLefts.push({ line: baseLineNum + li, col: ci + 1, charIdx: ci });
93
+ else if (ch === '┐') topRights.push({ line: baseLineNum + li, col: ci + 1, charIdx: ci });
94
+ else if (ch === '└') botLefts.push({ line: baseLineNum + li, col: ci + 1, charIdx: ci });
95
+ else if (ch === '┘') botRights.push({ line: baseLineNum + li, col: ci + 1, charIdx: ci });
96
+ }
97
+ }
98
+
99
+ // Check: every ┌ must have a └ at the same column
100
+ for (const tl of topLefts) {
101
+ const match = botLefts.find(bl => bl.col === tl.col && bl.line > tl.line);
102
+ if (!match) {
103
+ issues.push(issue(
104
+ 'L01', 'error', tl.line, tl.col,
105
+ `Unclosed box: '┌' at line ${tl.line}, col ${tl.col} has no matching '└' at same column`,
106
+ 'Add a closing └ at the same column position'
107
+ ));
108
+ }
109
+ }
110
+
111
+ // Check: every └ must have a ┌ at the same column
112
+ for (const bl of botLefts) {
113
+ const match = topLefts.find(tl => tl.col === bl.col && tl.line < bl.line);
114
+ if (!match) {
115
+ issues.push(issue(
116
+ 'L01', 'error', bl.line, bl.col,
117
+ `Orphan closing '└' at line ${bl.line}, col ${bl.col} has no matching '┌' at same column`,
118
+ 'Add an opening ┌ at the same column position'
119
+ ));
120
+ }
121
+ }
122
+
123
+ // Check: every ┐ must have a ┘ at the same column
124
+ for (const tr of topRights) {
125
+ const match = botRights.find(br => br.col === tr.col && br.line > tr.line);
126
+ if (!match) {
127
+ issues.push(issue(
128
+ 'L01', 'error', tr.line, tr.col,
129
+ `Unclosed box: '┐' at line ${tr.line}, col ${tr.col} has no matching '┘' at same column`,
130
+ 'Add a closing ┘ at the same column position'
131
+ ));
132
+ }
133
+ }
134
+
135
+ // Check: every ┘ must have a ┐ at the same column
136
+ for (const br of botRights) {
137
+ const match = topRights.find(tr => tr.col === br.col && tr.line < br.line);
138
+ if (!match) {
139
+ issues.push(issue(
140
+ 'L01', 'error', br.line, br.col,
141
+ `Orphan closing '┘' at line ${br.line}, col ${br.col} has no matching '┐' at same column`,
142
+ 'Add an opening ┐ at the same column position'
143
+ ));
144
+ }
145
+ }
146
+
147
+ return issues;
148
+ }
149
+
150
+ // ---------------------------------------------------------------------------
151
+ // L02 — Tree chars
152
+ // Last child must use └── not ├──
153
+ // Detect: line with ├── where the NEXT tree-level sibling doesn't exist
154
+ // (i.e. ├── is used as the last item in its group)
155
+ // ---------------------------------------------------------------------------
156
+ function L02_tree_chars(ast) {
157
+ if (!ast || !ast.content) return [];
158
+
159
+ const content = ast.content;
160
+ // Skip if no tree chars at all
161
+ if (!content.includes('├') && !content.includes('└')) return [];
162
+
163
+ const lines = content.split('\n');
164
+ const issues = [];
165
+ const baseLineNum = ast.startLine;
166
+
167
+ for (let li = 0; li < lines.length; li++) {
168
+ const line = lines[li];
169
+
170
+ // Check for ├── lines
171
+ const miteeMatch = line.match(/^(\s*(?:│\s*)*)├──/);
172
+ if (!miteeMatch) continue;
173
+
174
+ // This line uses ├──. Now check if it should be └──.
175
+ // The rule: if this is the LAST sibling at its indent level, it's wrong.
176
+ // Determine the indent prefix (everything before ├)
177
+ const prefixLen = miteeMatch[0].length - 3; // length without '├──'
178
+ const prefix = line.slice(0, prefixLen); // e.g. " │ "
179
+
180
+ // Look for subsequent sibling lines: same prefix + (├── or └──)
181
+ let hasNextSibling = false;
182
+ for (let lj = li + 1; lj < lines.length; lj++) {
183
+ const next = lines[lj];
184
+ if (next.trim() === '') break; // blank line ends the block
185
+
186
+ // A sibling would start with same prefix then ├── or └──
187
+ if (next.startsWith(prefix + '├──') || next.startsWith(prefix + '└──')) {
188
+ hasNextSibling = true;
189
+ break;
190
+ }
191
+
192
+ // A line that is "shallower" (shorter prefix) means we've gone up
193
+ // Check: if line doesn't start with prefix at all (excluding │ continuation)
194
+ if (!next.startsWith(prefix) || next.startsWith(prefix.replace(/\s*$/, '').slice(0, -4))) {
195
+ // could be end of parent block
196
+ if (next.trim().startsWith('└') || next.trim().startsWith('├')) {
197
+ // same or shallower level — no more siblings
198
+ break;
199
+ }
200
+ }
201
+ }
202
+
203
+ if (!hasNextSibling) {
204
+ issues.push(issue(
205
+ 'L02', 'error', baseLineNum + li, prefixLen + 1,
206
+ `Last tree child uses '├──' but should use '└──'`,
207
+ `Replace '├──' with '└──' for the last child in each group`
208
+ ));
209
+ }
210
+ }
211
+
212
+ return issues;
213
+ }
214
+
215
+ // ---------------------------------------------------------------------------
216
+ // L03 — Arrow style
217
+ // Only ONE arrow style allowed per diagram.
218
+ // Allowed styles: -->, →, ─→, ──>
219
+ // ---------------------------------------------------------------------------
220
+ const ARROW_PATTERNS = [
221
+ { name: '→', re: /(?<![─-])→/ },
222
+ { name: '-->', re: /-->/ },
223
+ { name: '─→', re: /─→/ },
224
+ { name: '──>', re: /──>/ },
225
+ ];
226
+
227
+ function L03_arrow_style(ast) {
228
+ if (!ast || !ast.content) return [];
229
+
230
+ const content = ast.content;
231
+ const lines = content.split('\n');
232
+ const baseLineNum = ast.startLine;
233
+
234
+ // Collect which arrow styles appear and on which lines
235
+ const found = new Map(); // style name -> first line number (1-based relative to doc)
236
+
237
+ for (let li = 0; li < lines.length; li++) {
238
+ const line = lines[li];
239
+ const docLine = baseLineNum + li;
240
+
241
+ for (const { name, re } of ARROW_PATTERNS) {
242
+ if (re.test(line) && !found.has(name)) {
243
+ found.set(name, docLine);
244
+ }
245
+ }
246
+ }
247
+
248
+ if (found.size <= 1) return []; // 0 or 1 style — OK
249
+
250
+ // Multiple styles found — report on the second+ style
251
+ const styles = [...found.entries()];
252
+ const firstStyle = styles[0][0];
253
+ const issues = [];
254
+
255
+ for (let si = 1; si < styles.length; si++) {
256
+ const [name, lineNum] = styles[si];
257
+ issues.push(issue(
258
+ 'L03', 'error', lineNum, 1,
259
+ `Mixed arrow styles: diagram uses '${firstStyle}' and '${name}' — pick one style`,
260
+ `Use a single arrow style throughout the diagram (e.g. '${firstStyle}' only)`
261
+ ));
262
+ }
263
+
264
+ return issues;
265
+ }
266
+
267
+ // ---------------------------------------------------------------------------
268
+ // L04 — Column widths (markdown table consistency)
269
+ // Rows | col | col | must have same column count
270
+ // Separator |---|---| must match column count
271
+ // ---------------------------------------------------------------------------
272
+ function L04_column_widths(ast) {
273
+ if (!ast || !ast.content) return [];
274
+
275
+ const content = ast.content;
276
+ if (!content.includes('|')) return [];
277
+
278
+ const lines = content.split('\n');
279
+ const baseLineNum = ast.startLine;
280
+
281
+ // Find table-like rows: lines starting with | AND that look like markdown table rows
282
+ // Exclude lines that are just diagram connectors (| as vertical bar in flow diagrams)
283
+ // A proper table row: starts with |, has at least one cell with a word char, multiple pipes
284
+ const tableLines = [];
285
+ for (let li = 0; li < lines.length; li++) {
286
+ const line = lines[li];
287
+ // Must start with optional whitespace then |
288
+ if (!/^\s*\|/.test(line)) continue;
289
+ // Must have at least 2 pipe chars (i.e. at least one cell between them)
290
+ const pipeCount = (line.match(/\|/g) || []).length;
291
+ if (pipeCount < 2) continue;
292
+ // Must contain at least one word char (not just dashes/spaces) OR be a separator row
293
+ if (/\w/.test(line) || /^\s*\|[\s\-|:]+\|\s*$/.test(line)) {
294
+ tableLines.push({ li, line });
295
+ }
296
+ }
297
+
298
+ if (tableLines.length < 2) return [];
299
+
300
+ // Count columns per row by splitting on |
301
+ function countCols(line) {
302
+ // Split by |, trim outer empty strings
303
+ const parts = line.split('|');
304
+ // Remove leading/trailing empty strings from the outer pipes
305
+ let start = 0;
306
+ let end = parts.length;
307
+ if (parts[0].trim() === '') start = 1;
308
+ if (parts[parts.length - 1].trim() === '') end = parts.length - 1;
309
+ return end - start;
310
+ }
311
+
312
+ // Determine reference column count (from first non-separator row)
313
+ function isSeparatorRow(line) {
314
+ return /^\s*\|[\s\-|:]+\|?\s*$/.test(line);
315
+ }
316
+
317
+ let refCols = null;
318
+ const issues = [];
319
+
320
+ for (const { li, line } of tableLines) {
321
+ const docLine = baseLineNum + li;
322
+ const cols = countCols(line);
323
+
324
+ if (refCols === null && !isSeparatorRow(line)) {
325
+ refCols = cols;
326
+ continue;
327
+ }
328
+
329
+ if (refCols !== null && cols !== refCols) {
330
+ const what = isSeparatorRow(line) ? 'separator' : 'row';
331
+ issues.push(issue(
332
+ 'L04', 'error', docLine, 1,
333
+ `Table ${what} has ${cols} columns but header has ${refCols} columns`,
334
+ `Ensure all table rows and separators have ${refCols} columns`
335
+ ));
336
+ }
337
+ }
338
+
339
+ return issues;
340
+ }
341
+
342
+ // ---------------------------------------------------------------------------
343
+ // L05 — Flow integrity
344
+ // Two [Box] tokens on same line require an arrow between them
345
+ // ---------------------------------------------------------------------------
346
+ const BOX_RE = /\[[^\]]+\]/g;
347
+ const ARROW_RE = /-->|→|─→|──>/;
348
+
349
+ function L05_flow_integrity(ast) {
350
+ if (!ast || !ast.content) return [];
351
+
352
+ const content = ast.content;
353
+ if (!content.includes('[')) return [];
354
+
355
+ const lines = content.split('\n');
356
+ const baseLineNum = ast.startLine;
357
+ const issues = [];
358
+
359
+ for (let li = 0; li < lines.length; li++) {
360
+ const line = lines[li];
361
+ const boxes = [...line.matchAll(BOX_RE)];
362
+
363
+ if (boxes.length < 2) continue;
364
+
365
+ // Check between each consecutive pair of boxes
366
+ let hasViolation = false;
367
+ for (let bi = 0; bi < boxes.length - 1; bi++) {
368
+ const curEnd = boxes[bi].index + boxes[bi][0].length;
369
+ const nextStart = boxes[bi + 1].index;
370
+ const between = line.slice(curEnd, nextStart);
371
+
372
+ // If between region is pure whitespace (≥3 spaces), treat as parallel layout (not connected)
373
+ // Parallel layout means boxes are in separate columns, not sequentially connected
374
+ if (/^\s{3,}$/.test(between)) continue;
375
+
376
+ // Between region has content but no arrow — violation
377
+ if (!ARROW_RE.test(between)) {
378
+ hasViolation = true;
379
+ break;
380
+ }
381
+ }
382
+
383
+ if (hasViolation) {
384
+ issues.push(issue(
385
+ 'L05', 'error', baseLineNum + li, boxes[0].index + 1,
386
+ `${boxes.length} boxes on same line with no arrow between them: ${boxes.map(m => m[0]).join(', ')}`,
387
+ `Add an arrow (-->, →) between consecutive boxes`
388
+ ));
389
+ }
390
+ }
391
+
392
+ return issues;
393
+ }
394
+
395
+ // ---------------------------------------------------------------------------
396
+ // L06 — Priority scale
397
+ // If ▲ appears, ▼ must also appear (and vice versa)
398
+ // ---------------------------------------------------------------------------
399
+ function L06_priority_scale(ast) {
400
+ if (!ast || !ast.content) return [];
401
+
402
+ const content = ast.content;
403
+ const hasUp = /^[\s]*▲\s+\S/m.test(content);
404
+ const hasDown = /^[\s]*▼\s+\S/m.test(content);
405
+
406
+ if (hasUp === hasDown) return []; // both present or both absent — OK
407
+
408
+ const lines = content.split('\n');
409
+ const baseLineNum = ast.startLine;
410
+ const issues = [];
411
+
412
+ // Find the line with the existing marker
413
+ for (let li = 0; li < lines.length; li++) {
414
+ const line = lines[li];
415
+ const upMatch = line.match(/^(\s*)▲\s+\S/);
416
+ if (hasUp && upMatch) {
417
+ issues.push(issue(
418
+ 'L06', 'warn', baseLineNum + li, upMatch[1].length + 1,
419
+ `Priority scale has '▲' but missing '▼' — scales require both ends`,
420
+ `Add a '▼' marker to indicate the low end of the priority scale`
421
+ ));
422
+ break;
423
+ }
424
+ const downMatch = line.match(/^(\s*)▼\s+\S/);
425
+ if (hasDown && downMatch) {
426
+ issues.push(issue(
427
+ 'L06', 'warn', baseLineNum + li, downMatch[1].length + 1,
428
+ `Priority scale has '▼' but missing '▲' — scales require both ends`,
429
+ `Add a '▲' marker to indicate the high end of the priority scale`
430
+ ));
431
+ break;
432
+ }
433
+ }
434
+
435
+ return issues;
436
+ }
437
+
438
+ // ---------------------------------------------------------------------------
439
+ // L07 — No mermaid + ASCII mix
440
+ // If ``` mermaid ``` block exists alongside ASCII diagram, flag.
441
+ // This rule operates on fullText, not a single AST node.
442
+ // ---------------------------------------------------------------------------
443
+ function L07_no_mermaid_mix(_ast, fullText) {
444
+ // _ast may be null when called for full-text check
445
+ if (!fullText) return [];
446
+
447
+ const hasMermaid = /```mermaid/i.test(fullText);
448
+ if (!hasMermaid) return [];
449
+
450
+ // Check for ASCII diagram indicators
451
+ const hasAsciiBoxDrawing = /[┌┐└┘─│├┤┬┴┼→←↑↓▲▼]/.test(fullText);
452
+ const hasAsciiFlow = /\[[^\]]+\].*(?:-->|→).*\[[^\]]+\]/.test(fullText);
453
+ const hasTree = /[├└]──/.test(fullText);
454
+
455
+ if (!hasAsciiBoxDrawing && !hasAsciiFlow && !hasTree) return [];
456
+
457
+ // Find the mermaid block line
458
+ const lines = fullText.split('\n');
459
+ let mermaidLine = 1;
460
+ for (let li = 0; li < lines.length; li++) {
461
+ if (/```mermaid/i.test(lines[li])) {
462
+ mermaidLine = li + 1;
463
+ break;
464
+ }
465
+ }
466
+
467
+ return [issue(
468
+ 'L07', 'warn', mermaidLine, 1,
469
+ `Response mixes Mermaid (line ${mermaidLine}) and ASCII diagrams — use one format only`,
470
+ `Remove the \`\`\`mermaid block and use ASCII diagrams throughout`
471
+ )];
472
+ }
473
+
474
+ // ---------------------------------------------------------------------------
475
+ // L08 — Frame width discipline
476
+ // All rows inside ┌─...─┐ frame have consistent display width
477
+ // ---------------------------------------------------------------------------
478
+ function L08_frame_width(ast) {
479
+ if (!ast || !ast.content) return [];
480
+
481
+ const content = ast.content;
482
+ if (!content.includes('┌')) return [];
483
+
484
+ const lines = content.split('\n');
485
+ const baseLineNum = ast.startLine;
486
+ const issues = [];
487
+
488
+ let inFrame = false;
489
+ let frameStartLine = 0;
490
+ let frameWidth = null;
491
+
492
+ for (let li = 0; li < lines.length; li++) {
493
+ const line = lines[li];
494
+ const docLine = baseLineNum + li;
495
+
496
+ // Detect frame open line (contains ┌ and ┐)
497
+ if (!inFrame && line.includes('┌') && line.includes('┐')) {
498
+ inFrame = true;
499
+ frameStartLine = docLine;
500
+ frameWidth = displayWidth(line.trimEnd());
501
+ continue;
502
+ }
503
+
504
+ if (inFrame) {
505
+ // Detect frame close line (contains └ and ┘)
506
+ if (line.includes('└') && line.includes('┘')) {
507
+ const closeWidth = displayWidth(line.trimEnd());
508
+ if (closeWidth !== frameWidth) {
509
+ issues.push(issue(
510
+ 'L08', 'error', docLine, 1,
511
+ `Frame close row width ${closeWidth} differs from frame open width ${frameWidth} (line ${frameStartLine})`,
512
+ `Make all frame rows the same display width`
513
+ ));
514
+ }
515
+ inFrame = false;
516
+ frameWidth = null;
517
+ continue;
518
+ }
519
+
520
+ // Check internal row width
521
+ // Internal rows may have │ at start and end, or be content lines
522
+ if (line.includes('│')) {
523
+ const rowWidth = displayWidth(line.trimEnd());
524
+ if (frameWidth !== null && rowWidth !== frameWidth) {
525
+ issues.push(issue(
526
+ 'L08', 'error', docLine, 1,
527
+ `Frame row width ${rowWidth} differs from frame header width ${frameWidth} (line ${frameStartLine})`,
528
+ `Make all frame rows the same display width`
529
+ ));
530
+ }
531
+ }
532
+ }
533
+ }
534
+
535
+ return issues;
536
+ }
537
+
538
+ // ---------------------------------------------------------------------------
539
+ // Registry
540
+ // ---------------------------------------------------------------------------
541
+ module.exports = {
542
+ L01_box_closure,
543
+ L02_tree_chars,
544
+ L03_arrow_style,
545
+ L04_column_widths,
546
+ L05_flow_integrity,
547
+ L06_priority_scale,
548
+ L07_no_mermaid_mix,
549
+ L08_frame_width,
550
+ };
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "@albinocrabs/feynman",
3
+ "version": "0.2.0",
4
+ "description": "Claude Code and Codex plugin that auto-injects ASCII diagram rules into every AI request via UserPromptSubmit hook",
5
+ "license": "MIT",
6
+ "author": "apolenkov",
7
+ "keywords": [
8
+ "claude-code",
9
+ "codex",
10
+ "ascii",
11
+ "diagrams",
12
+ "claude",
13
+ "openai",
14
+ "plugin",
15
+ "hook"
16
+ ],
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/apolenkov/feynman.git"
20
+ },
21
+ "homepage": "https://github.com/apolenkov/feynman#readme",
22
+ "bugs": {
23
+ "url": "https://github.com/apolenkov/feynman/issues"
24
+ },
25
+ "engines": {
26
+ "node": ">=18"
27
+ },
28
+ "main": "lib/lint/index.js",
29
+ "bin": {
30
+ "feynman": "bin/feynman.js",
31
+ "feynman-lint": "bin/feynman-lint.js"
32
+ },
33
+ "files": [
34
+ "hooks/",
35
+ "lib/",
36
+ "bin/",
37
+ "rules/",
38
+ "skills/",
39
+ "hooks.json",
40
+ ".codex-plugin/",
41
+ "install.sh",
42
+ "uninstall.sh",
43
+ "README.md",
44
+ "LICENSE"
45
+ ],
46
+ "scripts": {
47
+ "test": "node --test tests/*.test.js",
48
+ "coverage": "c8 --reporter=text --reporter=lcov node --test tests/*.test.js",
49
+ "ci": "npm run coverage",
50
+ "lint": "node bin/feynman-lint.js"
51
+ },
52
+ "devDependencies": {
53
+ "c8": "^10"
54
+ }
55
+ }