@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,96 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// hooks/feynman-lint.js — feynman Stop-hook variant
|
|
3
|
+
// Reads Claude's response from stdin JSON {response, session_id, ...}
|
|
4
|
+
// If lint fails: emit additionalContext with corrections
|
|
5
|
+
// If lint passes: exit 0 silently
|
|
6
|
+
// ALWAYS exits 0 — never block Claude (best practice)
|
|
7
|
+
// Zero deps. CJS only.
|
|
8
|
+
'use strict';
|
|
9
|
+
|
|
10
|
+
const { lint } = require('../lib/lint');
|
|
11
|
+
|
|
12
|
+
// Rule descriptions for actionable feedback
|
|
13
|
+
const RULE_DESCRIPTIONS = {
|
|
14
|
+
L01: 'Box closure: every ┌─...─┐ opening must have a matching └─...─┘ at the same column',
|
|
15
|
+
L02: 'Tree chars: last child must use └── not ├──',
|
|
16
|
+
L03: 'Arrow style: use only one arrow style per diagram (-->, →, ─→, or ──>)',
|
|
17
|
+
L04: 'Column widths: all table rows must have the same number of columns',
|
|
18
|
+
L05: 'Flow integrity: two [Box] tokens on the same line must have an arrow between them',
|
|
19
|
+
L06: 'Priority scale: if ▲ appears, ▼ must also appear (and vice versa)',
|
|
20
|
+
L07: 'Mermaid+ASCII mix: use either Mermaid or ASCII diagrams, not both in the same response',
|
|
21
|
+
L08: 'Frame width: all rows inside a ┌─ frame must have the same display width',
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Build a concise additionalContext message from lint issues
|
|
26
|
+
* @param {object[]} issues
|
|
27
|
+
* @returns {string}
|
|
28
|
+
*/
|
|
29
|
+
function buildCorrectionMessage(issues) {
|
|
30
|
+
if (!issues || issues.length === 0) return '';
|
|
31
|
+
|
|
32
|
+
// Group by rule
|
|
33
|
+
const byRule = new Map();
|
|
34
|
+
for (const iss of issues) {
|
|
35
|
+
if (!byRule.has(iss.rule)) byRule.set(iss.rule, []);
|
|
36
|
+
byRule.get(iss.rule).push(iss);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const lines = [
|
|
40
|
+
'DIAGRAM LINT CORRECTIONS NEEDED:',
|
|
41
|
+
'',
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
for (const [rule, ruleIssues] of byRule) {
|
|
45
|
+
const desc = RULE_DESCRIPTIONS[rule] || rule;
|
|
46
|
+
lines.push(`[${rule}] ${desc}`);
|
|
47
|
+
for (const iss of ruleIssues) {
|
|
48
|
+
lines.push(` - Line ${iss.line}: ${iss.message}`);
|
|
49
|
+
if (iss.suggestion) lines.push(` Fix: ${iss.suggestion}`);
|
|
50
|
+
}
|
|
51
|
+
lines.push('');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
lines.push('Please correct the ASCII diagram(s) above before responding further.');
|
|
55
|
+
|
|
56
|
+
return lines.join('\n');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Accumulate stdin
|
|
60
|
+
let input = '';
|
|
61
|
+
process.stdin.setEncoding('utf8');
|
|
62
|
+
process.stdin.on('data', chunk => { input += chunk; });
|
|
63
|
+
process.stdin.on('end', () => {
|
|
64
|
+
try {
|
|
65
|
+
const data = JSON.parse(input);
|
|
66
|
+
const response = data.response || data.message || '';
|
|
67
|
+
|
|
68
|
+
if (!response || typeof response !== 'string') {
|
|
69
|
+
process.exit(0);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const result = lint(response);
|
|
73
|
+
|
|
74
|
+
if (result.passed) {
|
|
75
|
+
// No errors — exit 0 silently
|
|
76
|
+
process.exit(0);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Build correction message
|
|
80
|
+
const correctionText = buildCorrectionMessage(result.issues);
|
|
81
|
+
|
|
82
|
+
// Emit Stop-hook output
|
|
83
|
+
process.stdout.write(JSON.stringify({
|
|
84
|
+
hookSpecificOutput: {
|
|
85
|
+
hookEventName: 'Stop',
|
|
86
|
+
additionalContext: correctionText
|
|
87
|
+
}
|
|
88
|
+
}));
|
|
89
|
+
|
|
90
|
+
// Always exit 0 — never block Claude
|
|
91
|
+
process.exit(0);
|
|
92
|
+
} catch (e) {
|
|
93
|
+
// Silent fail — never surface hook errors
|
|
94
|
+
process.exit(0);
|
|
95
|
+
}
|
|
96
|
+
});
|
package/hooks/hooks.json
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"description": "Inject feynman ASCII diagram rules before each Claude Code prompt.",
|
|
3
|
+
"hooks": {
|
|
4
|
+
"UserPromptSubmit": [
|
|
5
|
+
{
|
|
6
|
+
"hooks": [
|
|
7
|
+
{
|
|
8
|
+
"type": "command",
|
|
9
|
+
"command": "FEYNMAN_HOME=\"$HOME/.claude\" node \"${CLAUDE_PLUGIN_ROOT}/hooks/feynman-activate.js\"",
|
|
10
|
+
"timeout": 5,
|
|
11
|
+
"statusMessage": "Injecting diagram rules..."
|
|
12
|
+
}
|
|
13
|
+
]
|
|
14
|
+
}
|
|
15
|
+
]
|
|
16
|
+
}
|
|
17
|
+
}
|
package/hooks.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"hooks": {
|
|
3
|
+
"UserPromptSubmit": [
|
|
4
|
+
{
|
|
5
|
+
"hooks": [
|
|
6
|
+
{
|
|
7
|
+
"type": "command",
|
|
8
|
+
"command": "PLUGIN_ROOT=\"${PLUGIN_ROOT:-$CLAUDE_PLUGIN_ROOT}\"; FEYNMAN_HOME=\"$HOME/.codex\" node \"$PLUGIN_ROOT/hooks/feynman-activate.js\"",
|
|
9
|
+
"timeout": 5,
|
|
10
|
+
"statusMessage": "Injecting diagram rules..."
|
|
11
|
+
}
|
|
12
|
+
]
|
|
13
|
+
}
|
|
14
|
+
]
|
|
15
|
+
}
|
|
16
|
+
}
|
package/install.sh
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# feynman install — thin wrapper around bin/feynman.js
|
|
3
|
+
# Usage: bash install.sh [--force]
|
|
4
|
+
set -e
|
|
5
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
6
|
+
|
|
7
|
+
# Node >= 18 check
|
|
8
|
+
if ! command -v node >/dev/null 2>&1; then
|
|
9
|
+
echo "Error: node not found. Install Node.js >=18: https://nodejs.org" >&2
|
|
10
|
+
exit 1
|
|
11
|
+
fi
|
|
12
|
+
NODE_VER=$(node -v 2>/dev/null | sed 's/v//' | cut -d. -f1)
|
|
13
|
+
if [ -z "$NODE_VER" ] || [ "$NODE_VER" -lt 18 ] 2>/dev/null; then
|
|
14
|
+
echo "Error: Node.js >=18 required (found $(node -v 2>/dev/null || echo 'unknown'))" >&2
|
|
15
|
+
exit 1
|
|
16
|
+
fi
|
|
17
|
+
|
|
18
|
+
exec node "$SCRIPT_DIR/bin/feynman.js" install "$@"
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// lib/lint/index.js — diagram linter orchestrator
|
|
2
|
+
// lint(markdown, options) => {issues, passed}
|
|
3
|
+
// format(issues, mode) => string
|
|
4
|
+
// Zero deps. CJS only.
|
|
5
|
+
'use strict';
|
|
6
|
+
|
|
7
|
+
const { parse } = require('./parser');
|
|
8
|
+
const rules = require('./rules');
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Lint a markdown string for ASCII diagram issues.
|
|
12
|
+
* @param {string} markdown
|
|
13
|
+
* @param {{rules?: string[]}} [options] - optional rule filter
|
|
14
|
+
* @returns {{issues: object[], passed: boolean}}
|
|
15
|
+
*/
|
|
16
|
+
function lint(markdown, options) {
|
|
17
|
+
if (typeof markdown !== 'string') {
|
|
18
|
+
return { issues: [], passed: true };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const enabledRules = (options && options.rules) || null;
|
|
22
|
+
|
|
23
|
+
function isEnabled(ruleId) {
|
|
24
|
+
if (!enabledRules) return true;
|
|
25
|
+
return enabledRules.includes(ruleId);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const ast = parse(markdown);
|
|
29
|
+
const allIssues = [];
|
|
30
|
+
|
|
31
|
+
// Per-node rules
|
|
32
|
+
const perNodeRules = [
|
|
33
|
+
{ id: 'L01', fn: rules.L01_box_closure },
|
|
34
|
+
{ id: 'L02', fn: rules.L02_tree_chars },
|
|
35
|
+
{ id: 'L03', fn: rules.L03_arrow_style },
|
|
36
|
+
{ id: 'L04', fn: rules.L04_column_widths },
|
|
37
|
+
{ id: 'L05', fn: rules.L05_flow_integrity },
|
|
38
|
+
{ id: 'L06', fn: rules.L06_priority_scale },
|
|
39
|
+
{ id: 'L08', fn: rules.L08_frame_width },
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
for (const node of ast) {
|
|
43
|
+
for (const { id, fn } of perNodeRules) {
|
|
44
|
+
if (!isEnabled(id)) continue;
|
|
45
|
+
try {
|
|
46
|
+
const nodeIssues = fn(node, markdown);
|
|
47
|
+
if (Array.isArray(nodeIssues)) allIssues.push(...nodeIssues);
|
|
48
|
+
} catch (e) {
|
|
49
|
+
// Rule threw — skip silently (never crash linter)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Full-text rules (L07 operates once on full markdown, not per-node)
|
|
55
|
+
if (isEnabled('L07')) {
|
|
56
|
+
try {
|
|
57
|
+
const l07Issues = rules.L07_no_mermaid_mix(null, markdown);
|
|
58
|
+
if (Array.isArray(l07Issues)) allIssues.push(...l07Issues);
|
|
59
|
+
} catch (e) {
|
|
60
|
+
// Skip silently
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const errorCount = allIssues.filter(i => i.severity === 'error').length;
|
|
65
|
+
const passed = errorCount === 0;
|
|
66
|
+
|
|
67
|
+
return { issues: allIssues, passed };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Format issues for output.
|
|
72
|
+
* @param {object[]} issues
|
|
73
|
+
* @param {'gcc'|'json'} mode
|
|
74
|
+
* @param {string} [filename] - for gcc mode
|
|
75
|
+
* @param {boolean} [useColor] - ANSI color for TTY
|
|
76
|
+
* @returns {string}
|
|
77
|
+
*/
|
|
78
|
+
function format(issues, mode, filename, useColor) {
|
|
79
|
+
if (mode === 'json') {
|
|
80
|
+
return JSON.stringify(issues, null, 2);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// gcc mode: <file>:<line>:<col>: L0X severity message
|
|
84
|
+
if (!issues || issues.length === 0) return '';
|
|
85
|
+
|
|
86
|
+
const RESET = useColor ? '\x1b[0m' : '';
|
|
87
|
+
const RED = useColor ? '\x1b[31m' : '';
|
|
88
|
+
const YELLOW = useColor ? '\x1b[33m' : '';
|
|
89
|
+
const BOLD = useColor ? '\x1b[1m' : '';
|
|
90
|
+
|
|
91
|
+
const file = filename || '<input>';
|
|
92
|
+
|
|
93
|
+
return issues.map(iss => {
|
|
94
|
+
const color = iss.severity === 'error' ? RED : YELLOW;
|
|
95
|
+
const sev = iss.severity === 'error' ? 'error' : 'warn';
|
|
96
|
+
return `${file}:${iss.line}:${iss.column}: ${color}${BOLD}${iss.rule} ${sev}${RESET} ${iss.message}`;
|
|
97
|
+
}).join('\n');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
module.exports = { lint, format };
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// lib/lint/parser.js — ASCII diagram block parser
|
|
3
|
+
// Returns AST: [{type:'diagram', content, startLine, endLine, indent}]
|
|
4
|
+
// Detection: ``` fences (generic only) OR standalone blocks ≥30% diagram chars, ≥3 lines
|
|
5
|
+
// Zero deps. CJS only.
|
|
6
|
+
'use strict';
|
|
7
|
+
|
|
8
|
+
// Characters that indicate ASCII diagram content
|
|
9
|
+
// Box-drawing: ┌┐└┘─│├┤┬┴┼ Tree: ├── └── Arrows: →←↑↓▲▼
|
|
10
|
+
// Also count: + - | < > brackets used in ASCII art context
|
|
11
|
+
const DIAGRAM_CHARS = new Set([
|
|
12
|
+
'┌', '┐', '└', '┘', '─', '│', '├', '┤', '┬', '┴', '┼',
|
|
13
|
+
'→', '←', '↑', '↓', '▲', '▼',
|
|
14
|
+
'+', '-', '|'
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
// Box-drawing Unicode specifically (high-confidence indicators)
|
|
18
|
+
const BOX_DRAWING_CHARS = new Set([
|
|
19
|
+
'┌', '┐', '└', '┘', '─', '│', '├', '┤', '┬', '┴', '┼',
|
|
20
|
+
'→', '←', '↑', '↓', '▲', '▼'
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Count diagram chars in a string (non-space chars only for ratio)
|
|
25
|
+
* @param {string} text
|
|
26
|
+
* @returns {{diagramCount: number, nonSpaceCount: number, hasBoxDrawing: boolean}}
|
|
27
|
+
*/
|
|
28
|
+
function countDiagramChars(text) {
|
|
29
|
+
let diagramCount = 0;
|
|
30
|
+
let nonSpaceCount = 0;
|
|
31
|
+
let hasBoxDrawing = false;
|
|
32
|
+
|
|
33
|
+
for (const ch of text) {
|
|
34
|
+
if (ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r') continue;
|
|
35
|
+
nonSpaceCount++;
|
|
36
|
+
if (DIAGRAM_CHARS.has(ch)) diagramCount++;
|
|
37
|
+
if (BOX_DRAWING_CHARS.has(ch)) hasBoxDrawing = true;
|
|
38
|
+
}
|
|
39
|
+
return { diagramCount, nonSpaceCount, hasBoxDrawing };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Check if a block of text looks like a diagram.
|
|
44
|
+
* Heuristic: box-drawing Unicode dominant OR structural pattern (flow/tree/table/priority)
|
|
45
|
+
* Explicitly excludes prose lines where box-chars appear incidentally in sentences.
|
|
46
|
+
* @param {string[]} lines
|
|
47
|
+
* @returns {boolean}
|
|
48
|
+
*/
|
|
49
|
+
function looksLikeDiagram(lines) {
|
|
50
|
+
const text = lines.join('\n');
|
|
51
|
+
const { nonSpaceCount, hasBoxDrawing } = countDiagramChars(text);
|
|
52
|
+
|
|
53
|
+
// Must have minimum content
|
|
54
|
+
if (nonSpaceCount < 3) return false;
|
|
55
|
+
|
|
56
|
+
// If any line looks like natural prose (has word chars AND box chars but ratio is low)
|
|
57
|
+
// then exclude it from standalone detection.
|
|
58
|
+
// Prose check: line has alphabetic words AND box chars but < 20% diagram chars overall
|
|
59
|
+
const isProse = lines.every(line => {
|
|
60
|
+
const trimmed = line.trim();
|
|
61
|
+
if (!trimmed) return true;
|
|
62
|
+
// If the line has natural language (multiple word chars) and any box chars
|
|
63
|
+
// but the box chars are embedded in prose (not structural)
|
|
64
|
+
const wordChars = (trimmed.match(/[a-zA-Z]/g) || []).length;
|
|
65
|
+
const boxChars = [...trimmed].filter(ch => BOX_DRAWING_CHARS.has(ch)).length;
|
|
66
|
+
// Prose: more letters than box chars by a significant margin
|
|
67
|
+
if (wordChars > 5 && boxChars > 0 && wordChars > boxChars * 3) return true;
|
|
68
|
+
return !trimmed; // blank lines are neutral
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// For standalone blocks (not fenced), require structural indicators
|
|
72
|
+
// Box-drawing Unicode dominant (≥40% of non-space chars are box-drawing)
|
|
73
|
+
const boxDrawingCount = [...text].filter(ch => BOX_DRAWING_CHARS.has(ch)).length;
|
|
74
|
+
const boxRatio = nonSpaceCount > 0 ? boxDrawingCount / nonSpaceCount : 0;
|
|
75
|
+
|
|
76
|
+
if (hasBoxDrawing && boxRatio >= 0.15 && !isProse) return true;
|
|
77
|
+
|
|
78
|
+
// Check for structural patterns (these override prose check)
|
|
79
|
+
// Tree structure: lines starting with ├── or └──
|
|
80
|
+
if (/^[\s│]*[├└]──/m.test(text)) return true;
|
|
81
|
+
|
|
82
|
+
// Flow diagram: [Box] connected with arrows
|
|
83
|
+
if (/\[[^\]]+\]/.test(text) && /-->|→|─→|──>/.test(text)) return true;
|
|
84
|
+
|
|
85
|
+
// Priority scale: ▲ or ▼ on their own
|
|
86
|
+
if (/^[\s]*[▲▼]/m.test(text)) return true;
|
|
87
|
+
|
|
88
|
+
// Table: multiple lines starting with |
|
|
89
|
+
const tableRows = lines.filter(l => /^\s*\|.*\|/.test(l));
|
|
90
|
+
if (tableRows.length >= 2) return true;
|
|
91
|
+
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Get leading indent (number of spaces) of a line
|
|
97
|
+
* @param {string} line
|
|
98
|
+
* @returns {number}
|
|
99
|
+
*/
|
|
100
|
+
function getIndent(line) {
|
|
101
|
+
let i = 0;
|
|
102
|
+
while (i < line.length && line[i] === ' ') i++;
|
|
103
|
+
return i;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Parse markdown string into AST of diagram nodes.
|
|
108
|
+
* @param {string} markdown
|
|
109
|
+
* @returns {Array<{type: 'diagram', content: string, startLine: number, endLine: number, indent: number}>}
|
|
110
|
+
*/
|
|
111
|
+
function parse(markdown) {
|
|
112
|
+
const lines = markdown.split('\n');
|
|
113
|
+
const nodes = [];
|
|
114
|
+
|
|
115
|
+
let i = 0;
|
|
116
|
+
while (i < lines.length) {
|
|
117
|
+
const line = lines[i];
|
|
118
|
+
const trimmed = line.trim();
|
|
119
|
+
|
|
120
|
+
// Check for fenced code block: ```
|
|
121
|
+
const fenceMatch = trimmed.match(/^```(\w*)$/);
|
|
122
|
+
if (fenceMatch) {
|
|
123
|
+
const lang = fenceMatch[1].toLowerCase();
|
|
124
|
+
|
|
125
|
+
// Skip named language blocks (js, bash, python, mermaid, etc.)
|
|
126
|
+
// Only process generic ``` fences (empty lang tag)
|
|
127
|
+
if (lang !== '') {
|
|
128
|
+
// Advance to closing fence, skip this block
|
|
129
|
+
i++;
|
|
130
|
+
while (i < lines.length && lines[i].trim() !== '```') i++;
|
|
131
|
+
i++; // skip closing ```
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Generic fence — collect content
|
|
136
|
+
const indent = getIndent(line);
|
|
137
|
+
const blockLines = [];
|
|
138
|
+
i++; // move past opening fence
|
|
139
|
+
const startLine = i + 1; // 1-based line number of first content line
|
|
140
|
+
while (i < lines.length && lines[i].trim() !== '```') {
|
|
141
|
+
blockLines.push(lines[i]);
|
|
142
|
+
i++;
|
|
143
|
+
}
|
|
144
|
+
const endLine = i + 1; // 1-based (points to closing ```)
|
|
145
|
+
i++; // skip closing ```
|
|
146
|
+
|
|
147
|
+
// Check if it looks like a diagram
|
|
148
|
+
// Fenced blocks: also accept multiple [Box] tokens (even without arrows)
|
|
149
|
+
// since those are clearly intended as flow diagrams (L05 will catch missing arrows)
|
|
150
|
+
const hasFencedBoxPattern = blockLines.some(line => {
|
|
151
|
+
const boxes = [...line.matchAll(/\[[^\]]+\]/g)];
|
|
152
|
+
return boxes.length >= 2;
|
|
153
|
+
});
|
|
154
|
+
if (blockLines.length >= 1 && (looksLikeDiagram(blockLines) || hasFencedBoxPattern)) {
|
|
155
|
+
nodes.push({
|
|
156
|
+
type: 'diagram',
|
|
157
|
+
content: blockLines.join('\n'),
|
|
158
|
+
startLine,
|
|
159
|
+
endLine,
|
|
160
|
+
indent
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Standalone block detection: accumulate consecutive non-empty lines
|
|
167
|
+
// that collectively look like a diagram
|
|
168
|
+
if (trimmed.length > 0 && !trimmed.startsWith('#') && !trimmed.startsWith('<!--')) {
|
|
169
|
+
const startLine = i + 1; // 1-based
|
|
170
|
+
const indent = getIndent(line);
|
|
171
|
+
const blockLines = [line];
|
|
172
|
+
let j = i + 1;
|
|
173
|
+
|
|
174
|
+
// Extend block: include lines until we hit blank or markdown heading/fence
|
|
175
|
+
while (j < lines.length) {
|
|
176
|
+
const nextLine = lines[j];
|
|
177
|
+
const nextTrimmed = nextLine.trim();
|
|
178
|
+
|
|
179
|
+
// Stop at blank lines, headings, or fences
|
|
180
|
+
if (nextTrimmed === '' || nextTrimmed.startsWith('#') || nextTrimmed.startsWith('```')) break;
|
|
181
|
+
blockLines.push(nextLine);
|
|
182
|
+
j++;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Check for structural diagram indicators (order matters — strongest first)
|
|
186
|
+
const text = blockLines.join('\n');
|
|
187
|
+
const { hasBoxDrawing } = countDiagramChars(text);
|
|
188
|
+
const hasBoxToken = /\[[^\]]+\]/.test(text) && /-->|→|─>|──>/.test(text);
|
|
189
|
+
// Multiple [Box] tokens on one line without natural language between them
|
|
190
|
+
// (even without arrows — L05 will catch missing arrows)
|
|
191
|
+
// "Natural language between" means: word chars NOT in brackets between the boxes
|
|
192
|
+
const hasMultiBox = blockLines.some(l => {
|
|
193
|
+
const matches = [...l.matchAll(/\[[^\]]+\]/g)];
|
|
194
|
+
if (matches.length < 2) return false;
|
|
195
|
+
// Check what's between the first and last box
|
|
196
|
+
const firstEnd = matches[0].index + matches[0][0].length;
|
|
197
|
+
const lastStart = matches[matches.length - 1].index;
|
|
198
|
+
const between = l.slice(firstEnd, lastStart);
|
|
199
|
+
// If "between" contains regular English words (sequences of alpha), it's prose
|
|
200
|
+
// Allow: spaces, arrows, punctuation, special chars
|
|
201
|
+
if (/[a-zA-Z]{3,}/.test(between)) return false; // prose words between boxes
|
|
202
|
+
return true;
|
|
203
|
+
});
|
|
204
|
+
const hasTree = /[├└]──/.test(text);
|
|
205
|
+
const hasPriority = /^[\s]*[▲▼]/m.test(text);
|
|
206
|
+
const hasTable = blockLines.filter(l => /^\s*\|.*\|/.test(l)).length >= 2;
|
|
207
|
+
|
|
208
|
+
const isDiagram = hasBoxDrawing || hasBoxToken || hasMultiBox || hasTree || hasPriority || hasTable;
|
|
209
|
+
|
|
210
|
+
if (isDiagram && (looksLikeDiagram(blockLines) || hasMultiBox || hasTree || hasPriority || hasTable)) {
|
|
211
|
+
nodes.push({
|
|
212
|
+
type: 'diagram',
|
|
213
|
+
content: text,
|
|
214
|
+
startLine,
|
|
215
|
+
endLine: j, // 1-based (last line index + 1)
|
|
216
|
+
indent
|
|
217
|
+
});
|
|
218
|
+
i = j;
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
i++;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return nodes;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
module.exports = { parse };
|