hivecode-mcp 0.12.2 → 0.13.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.
Files changed (62) hide show
  1. package/HIVE_RULES.md +11 -40
  2. package/LICENSE +21 -21
  3. package/README.md +421 -167
  4. package/core.js +91 -91
  5. package/docs/ALWAYS_ON.md +310 -0
  6. package/docs/GITHUB_WORKFLOW.md +334 -0
  7. package/docs/HIVE_RULES.md +41 -41
  8. package/docs/MCP.md +157 -118
  9. package/docs/QUICKSTART.md +266 -0
  10. package/docs/SELF_HOSTING.md +256 -256
  11. package/docs/SLACK_SETUP.md +450 -0
  12. package/docs/TROUBLESHOOTING.md +87 -0
  13. package/hive-activity.js +336 -310
  14. package/hive-await.js +207 -0
  15. package/hive-branch.js +686 -0
  16. package/hive-busy.js +88 -88
  17. package/hive-coord.js +110 -110
  18. package/hive-gate.js +135 -135
  19. package/hive-github.js +1904 -0
  20. package/hive-hook-install.js +179 -103
  21. package/hive-instructions.js +74 -66
  22. package/hive-listen.js +81 -0
  23. package/hive-mailbox.js +520 -386
  24. package/hive-mcp-http.js +580 -0
  25. package/hive-mcp.js +579 -137
  26. package/hive-mux-proto.js +58 -58
  27. package/hive-mux.js +350 -350
  28. package/hive-notify.js +233 -0
  29. package/hive-obligations.js +247 -247
  30. package/hive-pr-staleness.js +189 -189
  31. package/hive-pr.js +350 -0
  32. package/hive-privacy.js +327 -288
  33. package/hive-receipts.js +233 -233
  34. package/hive-relay-health.js +230 -225
  35. package/hive-room-key.js +209 -0
  36. package/hive-secrets.js +89 -0
  37. package/hive-session.js +114 -0
  38. package/hive-setup.js +558 -445
  39. package/hive-slack-agent.js +396 -0
  40. package/hive-slack.js +1809 -0
  41. package/hive-stdio.js +172 -168
  42. package/hive-stop-hook.js +293 -241
  43. package/hive-throttle.js +118 -118
  44. package/hive-time.js +71 -0
  45. package/hive-update.js +58 -58
  46. package/hive-version.js +110 -59
  47. package/hive-webhook.js +54 -0
  48. package/icr-merge.js +58 -58
  49. package/icr.js +851 -745
  50. package/lang-json.js +211 -211
  51. package/lang-ruby.js +230 -230
  52. package/lang-toml.js +136 -136
  53. package/lang-yaml.js +183 -183
  54. package/limits.js +413 -394
  55. package/package.json +189 -164
  56. package/reconcile.cjs +614 -477
  57. package/slack-app-manifest-agent.json +61 -0
  58. package/slack-app-manifest-inbound.json +36 -0
  59. package/slack-app-manifest.json +27 -0
  60. package/substrate.js +252 -252
  61. package/sync.js +1956 -177
  62. package/grm-link.cjs +0 -141
package/lang-ruby.js CHANGED
@@ -1,230 +1,230 @@
1
- // Ruby language provider for ICR — keyword-nesting structural splitter (def/class/
2
- // module … end). Same honest-heuristic contract as the brace/python providers: not a
3
- // full parser, but conservative — anything it can't confidently structure falls back
4
- // to the engine's safe line tier, so output is never MORE broken than the inputs.
5
- //
6
- // Structure model: a line-oriented scan tracking strings, comments (# and =begin/=end),
7
- // bracket depth, and KEYWORD depth (def/class/module/if/while/… open; `end` closes).
8
- // Top-level def/class/module blocks become keyed units; the intent layer (dangling
9
- // refs, rename + call-site rewrite, token merge) rides on a lexer like Python's.
10
-
11
- function scannerAt(src) {
12
- const n = src.length
13
- return function at(i) {
14
- if (src[i] === '#') { let j = i + 1; while (j < n && src[j] !== '\n') j++; return j }
15
- if (src[i] === '"' || src[i] === "'") {
16
- const q = src[i]; let j = i + 1
17
- while (j < n) { if (src[j] === '\\') { j += 2; continue } if (src[j] === q) { j++; break } j++ }
18
- return j
19
- }
20
- return -1
21
- }
22
- }
23
-
24
- // Per physical line: the line's CODE text (strings/comments blanked), its bracket
25
- // delta, and byte range. =begin/=end block comments are whole-line constructs.
26
- function codeLines(src) {
27
- const at = scannerAt(src)
28
- const out = []
29
- let i = 0, inBlockComment = false
30
- const n = src.length
31
- while (i < n) {
32
- let e = src.indexOf('\n', i); e = e < 0 ? n : e + 1
33
- const raw = src.slice(i, e)
34
- if (inBlockComment) {
35
- if (/^=end\b/.test(raw)) inBlockComment = false
36
- out.push({ start: i, end: e, code: '', brackets: 0 })
37
- i = e; continue
38
- }
39
- if (/^=begin\b/.test(raw)) { inBlockComment = true; out.push({ start: i, end: e, code: '', brackets: 0 }); i = e; continue }
40
- let code = '', k = i, brackets = 0
41
- while (k < e) {
42
- const j = at(k)
43
- if (j > k) { if (src[k] !== '#') code += ' '; k = Math.min(e, j); continue }
44
- const c = src[k]
45
- if (c === '(' || c === '[' || c === '{') brackets++
46
- else if (c === ')' || c === ']' || c === '}') brackets--
47
- code += c; k++
48
- }
49
- out.push({ start: i, end: e, code, brackets })
50
- i = e
51
- }
52
- return out
53
- }
54
-
55
- // Keyword depth delta for one line of CODE. Conservative rules for the ambiguous
56
- // keywords (modifier `x if y` does not open; `while … do` opens once, not twice;
57
- // endless `def foo = expr` does not open).
58
- const OPENERS = new Set(['if', 'unless', 'while', 'until', 'case', 'begin', 'for'])
59
- function keywordDelta(code) {
60
- const words = code.match(/[A-Za-z_]\w*[?!]?/g) || []
61
- let d = 0
62
- const trimmed = code.trim()
63
- const endless = /\bdef\s+[A-Za-z_][\w.?!]*(\([^)]*\))?\s*=(?!=)/.test(code)
64
- const hasLoop = /\b(while|until|for)\b/.test(code)
65
- for (let w = 0; w < words.length; w++) {
66
- const word = words[w]
67
- if (word === 'def') { if (!endless) d++ }
68
- else if (word === 'class' || word === 'module') d++
69
- else if (OPENERS.has(word)) {
70
- // statement position only: first word of the line, or right after an assignment
71
- const isFirst = trimmed.startsWith(word)
72
- const afterAssign = new RegExp('=\\s*' + word + '\\b').test(code)
73
- if (word === 'while' || word === 'until' || word === 'for') { if (isFirst) d++ }
74
- else if (isFirst || afterAssign) d++
75
- }
76
- else if (word === 'do') { if (!hasLoop) d++ }
77
- else if (word === 'end') d--
78
- }
79
- return d
80
- }
81
-
82
- const HEAD = /^\s*(def)\s+(self\.)?([A-Za-z_][\w.?!]*)/
83
- const CONT = /^\s*(class|module)\s+(?:<<\s*self|([A-Z]\w*(?:::[A-Z]\w*)*))/
84
-
85
- function keyOf(text) {
86
- let m = text.match(HEAD); if (m) return 'def:' + (m[2] || '') + m[3]
87
- m = text.match(CONT); if (m) return m[1] + ':' + (m[2] || 'self')
88
- return 'stmt:' + text.split('\n')[0].trim().replace(/\s+/g, ' ')
89
- }
90
-
91
- // Units over a region: a def/class/module head at keyword-depth 0 owns everything
92
- // until its matching `end`; other depth-0 lines are statements (joined while
93
- // brackets stay open).
94
- function blocks(src) {
95
- const L = codeLines(src)
96
- const units = []
97
- let i = 0
98
- while (i < L.length) {
99
- while (i < L.length && L[i].code.trim() === '') i++
100
- if (i >= L.length) break
101
- const start = L[i].start
102
- const head = L[i].code
103
- if (/^\s*(def|class|module)\b/.test(head)) {
104
- let depth = 0, brackets = 0
105
- do {
106
- depth += keywordDelta(L[i].code)
107
- brackets += L[i].brackets
108
- i++
109
- } while (i < L.length && (depth > 0 || brackets > 0))
110
- units.push({ start, end: L[i - 1].end })
111
- } else {
112
- let brackets = L[i].brackets, depth = keywordDelta(L[i].code)
113
- i++
114
- while (i < L.length && (brackets > 0 || depth > 0)) { brackets += L[i].brackets; depth += keywordDelta(L[i].code); i++ }
115
- units.push({ start, end: L[i - 1].end })
116
- }
117
- }
118
- return units
119
- }
120
-
121
- function keyed(raw, src) {
122
- const seen = new Map(), out = []
123
- for (const u of raw) {
124
- const text = src.slice(u.start, u.end)
125
- let k = keyOf(text)
126
- const c = (seen.get(k) || 0) + 1; seen.set(k, c)
127
- if (c > 1) k += '#' + c
128
- out.push({ key: k, text, start: u.start, end: u.end })
129
- }
130
- return out
131
- }
132
-
133
- // Approximate parse: brackets balanced AND keyword nesting closes exactly
134
- // (never negative, ends at zero) AND no unterminated block comment.
135
- function wellNested(src) {
136
- const L = codeLines(src)
137
- let depth = 0, brackets = 0
138
- for (const l of L) {
139
- depth += keywordDelta(l.code)
140
- brackets += l.brackets
141
- if (depth < 0 || brackets < 0) return false
142
- }
143
- return depth === 0 && brackets === 0
144
- }
145
-
146
- // Lexer for the intent layer + token merge (comments in gaps, strings one token).
147
- function lexTokens(src) {
148
- const at = scannerAt(src)
149
- const out = []
150
- const n = src.length
151
- let i = 0
152
- while (i < n) {
153
- const c = src[i]
154
- if (c === ' ' || c === '\t' || c === '\r' || c === '\n') { i++; continue }
155
- const j = at(i)
156
- if (j > i) {
157
- if (c === '#') { i = j; continue }
158
- out.push({ start: i, end: j, k: src.slice(i, j) }); i = j; continue
159
- }
160
- if (/[A-Za-z_]/.test(c)) { let e = i + 1; while (e < n && /\w/.test(src[e])) e++; if (src[e] === '?' || src[e] === '!') e++; out.push({ start: i, end: e, k: src.slice(i, e) }); i = e; continue }
161
- if (/[0-9]/.test(c)) { let e = i + 1; while (e < n && /[\w.]/.test(src[e])) e++; out.push({ start: i, end: e, k: src.slice(i, e) }); i = e; continue }
162
- out.push({ start: i, end: i + 1, k: c }); i++
163
- }
164
- return out
165
- }
166
-
167
- // Reference position: not `.name` (method call on receiver) — `:name` symbols DO
168
- // count (send(:helper) is a real reference; over-inclusion is the cautious direction).
169
- function isRefToken(src, t) {
170
- if (!/^[A-Za-z_]\w*[?!]?$/.test(t.k)) return false
171
- let p = t.start - 1
172
- while (p >= 0 && (src[p] === ' ' || src[p] === '\t')) p--
173
- return !(p >= 0 && src[p] === '.')
174
- }
175
-
176
- export const ruby = {
177
- id: 'ruby',
178
- exts: ['.rb', '.rake'],
179
- parses: (src) => wellNested(String(src == null ? '' : src)),
180
- parsesUnit: (src) => wellNested(String(src)),
181
- units: (src) => keyed(blocks(String(src)), String(src)),
182
- declaredNames: (src) => {
183
- const names = new Set()
184
- for (const u of keyed(blocks(String(src)), String(src))) {
185
- const m = u.key.match(/^(?:def|class|module):(?:self\.)?([A-Za-z_][\w.?!]*)/)
186
- if (m) names.add(m[1])
187
- }
188
- return names
189
- },
190
- usedIdentifiers: (src) => {
191
- src = String(src)
192
- const used = new Set()
193
- for (const t of lexTokens(src)) if (isRefToken(src, t)) used.add(t.k)
194
- return used
195
- },
196
- declBody: (src, name) => {
197
- src = String(src)
198
- for (const u of keyed(blocks(src), src)) {
199
- const m = u.key.match(/^(?:def|class|module):(?:self\.)?([A-Za-z_][\w.?!]*)/)
200
- if (!m || m[1] !== name) continue
201
- return u.text.replace(name, '').trimEnd()
202
- }
203
- return null
204
- },
205
- renameRefs: (src, oldName, newName) => {
206
- src = String(src)
207
- const spots = []
208
- for (const t of lexTokens(src)) if (t.k === oldName && isRefToken(src, t)) spots.push(t)
209
- let out = src
210
- for (const t of spots.reverse()) out = out.slice(0, t.start) + newName + out.slice(t.end)
211
- return out
212
- },
213
- tokenize: (src) => lexTokens(String(src)),
214
- // Descend into a class/module so two agents editing DIFFERENT methods merge.
215
- splitUnit: (text) => {
216
- const src = String(text)
217
- if (!/^\s*(class|module)\b/.test(src)) return null
218
- const L = codeLines(src)
219
- if (!L.length) return null
220
- const sigEnd = L[0].end
221
- // body = everything between the header line and the final matching `end` line
222
- let last = L.length - 1
223
- while (last > 0 && L[last].code.trim() === '') last--
224
- if (!/^\s*end\b/.test(L[last].code)) return null
225
- const bodyStart = sigEnd, bodyEnd = L[last].start
226
- const body = src.slice(bodyStart, bodyEnd)
227
- const inner = keyed(blocks(body), body)
228
- return { sig: src.slice(0, sigEnd), open: '', close: src.slice(bodyEnd), body, units: inner, join: '', spliceable: true }
229
- },
230
- }
1
+ // Ruby language provider for ICR — keyword-nesting structural splitter (def/class/
2
+ // module … end). Same honest-heuristic contract as the brace/python providers: not a
3
+ // full parser, but conservative — anything it can't confidently structure falls back
4
+ // to the engine's safe line tier, so output is never MORE broken than the inputs.
5
+ //
6
+ // Structure model: a line-oriented scan tracking strings, comments (# and =begin/=end),
7
+ // bracket depth, and KEYWORD depth (def/class/module/if/while/… open; `end` closes).
8
+ // Top-level def/class/module blocks become keyed units; the intent layer (dangling
9
+ // refs, rename + call-site rewrite, token merge) rides on a lexer like Python's.
10
+
11
+ function scannerAt(src) {
12
+ const n = src.length
13
+ return function at(i) {
14
+ if (src[i] === '#') { let j = i + 1; while (j < n && src[j] !== '\n') j++; return j }
15
+ if (src[i] === '"' || src[i] === "'") {
16
+ const q = src[i]; let j = i + 1
17
+ while (j < n) { if (src[j] === '\\') { j += 2; continue } if (src[j] === q) { j++; break } j++ }
18
+ return j
19
+ }
20
+ return -1
21
+ }
22
+ }
23
+
24
+ // Per physical line: the line's CODE text (strings/comments blanked), its bracket
25
+ // delta, and byte range. =begin/=end block comments are whole-line constructs.
26
+ function codeLines(src) {
27
+ const at = scannerAt(src)
28
+ const out = []
29
+ let i = 0, inBlockComment = false
30
+ const n = src.length
31
+ while (i < n) {
32
+ let e = src.indexOf('\n', i); e = e < 0 ? n : e + 1
33
+ const raw = src.slice(i, e)
34
+ if (inBlockComment) {
35
+ if (/^=end\b/.test(raw)) inBlockComment = false
36
+ out.push({ start: i, end: e, code: '', brackets: 0 })
37
+ i = e; continue
38
+ }
39
+ if (/^=begin\b/.test(raw)) { inBlockComment = true; out.push({ start: i, end: e, code: '', brackets: 0 }); i = e; continue }
40
+ let code = '', k = i, brackets = 0
41
+ while (k < e) {
42
+ const j = at(k)
43
+ if (j > k) { if (src[k] !== '#') code += ' '; k = Math.min(e, j); continue }
44
+ const c = src[k]
45
+ if (c === '(' || c === '[' || c === '{') brackets++
46
+ else if (c === ')' || c === ']' || c === '}') brackets--
47
+ code += c; k++
48
+ }
49
+ out.push({ start: i, end: e, code, brackets })
50
+ i = e
51
+ }
52
+ return out
53
+ }
54
+
55
+ // Keyword depth delta for one line of CODE. Conservative rules for the ambiguous
56
+ // keywords (modifier `x if y` does not open; `while … do` opens once, not twice;
57
+ // endless `def foo = expr` does not open).
58
+ const OPENERS = new Set(['if', 'unless', 'while', 'until', 'case', 'begin', 'for'])
59
+ function keywordDelta(code) {
60
+ const words = code.match(/[A-Za-z_]\w*[?!]?/g) || []
61
+ let d = 0
62
+ const trimmed = code.trim()
63
+ const endless = /\bdef\s+[A-Za-z_][\w.?!]*(\([^)]*\))?\s*=(?!=)/.test(code)
64
+ const hasLoop = /\b(while|until|for)\b/.test(code)
65
+ for (let w = 0; w < words.length; w++) {
66
+ const word = words[w]
67
+ if (word === 'def') { if (!endless) d++ }
68
+ else if (word === 'class' || word === 'module') d++
69
+ else if (OPENERS.has(word)) {
70
+ // statement position only: first word of the line, or right after an assignment
71
+ const isFirst = trimmed.startsWith(word)
72
+ const afterAssign = new RegExp('=\\s*' + word + '\\b').test(code)
73
+ if (word === 'while' || word === 'until' || word === 'for') { if (isFirst) d++ }
74
+ else if (isFirst || afterAssign) d++
75
+ }
76
+ else if (word === 'do') { if (!hasLoop) d++ }
77
+ else if (word === 'end') d--
78
+ }
79
+ return d
80
+ }
81
+
82
+ const HEAD = /^\s*(def)\s+(self\.)?([A-Za-z_][\w.?!]*)/
83
+ const CONT = /^\s*(class|module)\s+(?:<<\s*self|([A-Z]\w*(?:::[A-Z]\w*)*))/
84
+
85
+ function keyOf(text) {
86
+ let m = text.match(HEAD); if (m) return 'def:' + (m[2] || '') + m[3]
87
+ m = text.match(CONT); if (m) return m[1] + ':' + (m[2] || 'self')
88
+ return 'stmt:' + text.split('\n')[0].trim().replace(/\s+/g, ' ')
89
+ }
90
+
91
+ // Units over a region: a def/class/module head at keyword-depth 0 owns everything
92
+ // until its matching `end`; other depth-0 lines are statements (joined while
93
+ // brackets stay open).
94
+ function blocks(src) {
95
+ const L = codeLines(src)
96
+ const units = []
97
+ let i = 0
98
+ while (i < L.length) {
99
+ while (i < L.length && L[i].code.trim() === '') i++
100
+ if (i >= L.length) break
101
+ const start = L[i].start
102
+ const head = L[i].code
103
+ if (/^\s*(def|class|module)\b/.test(head)) {
104
+ let depth = 0, brackets = 0
105
+ do {
106
+ depth += keywordDelta(L[i].code)
107
+ brackets += L[i].brackets
108
+ i++
109
+ } while (i < L.length && (depth > 0 || brackets > 0))
110
+ units.push({ start, end: L[i - 1].end })
111
+ } else {
112
+ let brackets = L[i].brackets, depth = keywordDelta(L[i].code)
113
+ i++
114
+ while (i < L.length && (brackets > 0 || depth > 0)) { brackets += L[i].brackets; depth += keywordDelta(L[i].code); i++ }
115
+ units.push({ start, end: L[i - 1].end })
116
+ }
117
+ }
118
+ return units
119
+ }
120
+
121
+ function keyed(raw, src) {
122
+ const seen = new Map(), out = []
123
+ for (const u of raw) {
124
+ const text = src.slice(u.start, u.end)
125
+ let k = keyOf(text)
126
+ const c = (seen.get(k) || 0) + 1; seen.set(k, c)
127
+ if (c > 1) k += '#' + c
128
+ out.push({ key: k, text, start: u.start, end: u.end })
129
+ }
130
+ return out
131
+ }
132
+
133
+ // Approximate parse: brackets balanced AND keyword nesting closes exactly
134
+ // (never negative, ends at zero) AND no unterminated block comment.
135
+ function wellNested(src) {
136
+ const L = codeLines(src)
137
+ let depth = 0, brackets = 0
138
+ for (const l of L) {
139
+ depth += keywordDelta(l.code)
140
+ brackets += l.brackets
141
+ if (depth < 0 || brackets < 0) return false
142
+ }
143
+ return depth === 0 && brackets === 0
144
+ }
145
+
146
+ // Lexer for the intent layer + token merge (comments in gaps, strings one token).
147
+ function lexTokens(src) {
148
+ const at = scannerAt(src)
149
+ const out = []
150
+ const n = src.length
151
+ let i = 0
152
+ while (i < n) {
153
+ const c = src[i]
154
+ if (c === ' ' || c === '\t' || c === '\r' || c === '\n') { i++; continue }
155
+ const j = at(i)
156
+ if (j > i) {
157
+ if (c === '#') { i = j; continue }
158
+ out.push({ start: i, end: j, k: src.slice(i, j) }); i = j; continue
159
+ }
160
+ if (/[A-Za-z_]/.test(c)) { let e = i + 1; while (e < n && /\w/.test(src[e])) e++; if (src[e] === '?' || src[e] === '!') e++; out.push({ start: i, end: e, k: src.slice(i, e) }); i = e; continue }
161
+ if (/[0-9]/.test(c)) { let e = i + 1; while (e < n && /[\w.]/.test(src[e])) e++; out.push({ start: i, end: e, k: src.slice(i, e) }); i = e; continue }
162
+ out.push({ start: i, end: i + 1, k: c }); i++
163
+ }
164
+ return out
165
+ }
166
+
167
+ // Reference position: not `.name` (method call on receiver) — `:name` symbols DO
168
+ // count (send(:helper) is a real reference; over-inclusion is the cautious direction).
169
+ function isRefToken(src, t) {
170
+ if (!/^[A-Za-z_]\w*[?!]?$/.test(t.k)) return false
171
+ let p = t.start - 1
172
+ while (p >= 0 && (src[p] === ' ' || src[p] === '\t')) p--
173
+ return !(p >= 0 && src[p] === '.')
174
+ }
175
+
176
+ export const ruby = {
177
+ id: 'ruby',
178
+ exts: ['.rb', '.rake'],
179
+ parses: (src) => wellNested(String(src == null ? '' : src)),
180
+ parsesUnit: (src) => wellNested(String(src)),
181
+ units: (src) => keyed(blocks(String(src)), String(src)),
182
+ declaredNames: (src) => {
183
+ const names = new Set()
184
+ for (const u of keyed(blocks(String(src)), String(src))) {
185
+ const m = u.key.match(/^(?:def|class|module):(?:self\.)?([A-Za-z_][\w.?!]*)/)
186
+ if (m) names.add(m[1])
187
+ }
188
+ return names
189
+ },
190
+ usedIdentifiers: (src) => {
191
+ src = String(src)
192
+ const used = new Set()
193
+ for (const t of lexTokens(src)) if (isRefToken(src, t)) used.add(t.k)
194
+ return used
195
+ },
196
+ declBody: (src, name) => {
197
+ src = String(src)
198
+ for (const u of keyed(blocks(src), src)) {
199
+ const m = u.key.match(/^(?:def|class|module):(?:self\.)?([A-Za-z_][\w.?!]*)/)
200
+ if (!m || m[1] !== name) continue
201
+ return u.text.replace(name, '').trimEnd()
202
+ }
203
+ return null
204
+ },
205
+ renameRefs: (src, oldName, newName) => {
206
+ src = String(src)
207
+ const spots = []
208
+ for (const t of lexTokens(src)) if (t.k === oldName && isRefToken(src, t)) spots.push(t)
209
+ let out = src
210
+ for (const t of spots.reverse()) out = out.slice(0, t.start) + newName + out.slice(t.end)
211
+ return out
212
+ },
213
+ tokenize: (src) => lexTokens(String(src)),
214
+ // Descend into a class/module so two agents editing DIFFERENT methods merge.
215
+ splitUnit: (text) => {
216
+ const src = String(text)
217
+ if (!/^\s*(class|module)\b/.test(src)) return null
218
+ const L = codeLines(src)
219
+ if (!L.length) return null
220
+ const sigEnd = L[0].end
221
+ // body = everything between the header line and the final matching `end` line
222
+ let last = L.length - 1
223
+ while (last > 0 && L[last].code.trim() === '') last--
224
+ if (!/^\s*end\b/.test(L[last].code)) return null
225
+ const bodyStart = sigEnd, bodyEnd = L[last].start
226
+ const body = src.slice(bodyStart, bodyEnd)
227
+ const inner = keyed(blocks(body), body)
228
+ return { sig: src.slice(0, sigEnd), open: '', close: src.slice(bodyEnd), body, units: inner, join: '', spliceable: true }
229
+ },
230
+ }