@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.
- package/.codex-plugin/plugin.json +43 -0
- package/LICENSE +21 -0
- package/README.md +292 -0
- package/bin/feynman-lint.js +123 -0
- package/bin/feynman.js +559 -0
- package/hooks/feynman-activate.js +103 -0
- package/hooks/feynman-lint.js +96 -0
- package/hooks/hooks.json +17 -0
- package/hooks.json +16 -0
- package/install.sh +18 -0
- package/lib/lint/index.js +100 -0
- package/lib/lint/parser.js +229 -0
- package/lib/lint/rules.js +550 -0
- package/package.json +55 -0
- package/rules/feynman-activate.md +168 -0
- package/skills/feynman/SKILL.md +63 -0
- package/uninstall.sh +18 -0
|
@@ -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
|
+
}
|