hivecode-mcp 0.8.0 → 0.11.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/lang-ruby.js ADDED
@@ -0,0 +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
+ }
package/lang-toml.js ADDED
@@ -0,0 +1,136 @@
1
+ // TOML language provider for ICR — section/key structural splitter.
2
+ // Cargo.toml / pyproject.toml are constantly edited concurrently (two people add
3
+ // dependencies). This provider merges by STRUCTURE: every `[section]` (and
4
+ // `[[array-of-tables]]`) is a unit keyed by its name; keys in the top-level
5
+ // preamble are units of their own; splitUnit descends into a section so two
6
+ // edits to DIFFERENT keys of the same table merge cleanly.
7
+ //
8
+ // Honest heuristic: tracks #-comments and single/double/triple-quoted strings;
9
+ // parses() = balanced delimiters + well-formed section headers.
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
+ for (const q of ['"""', "'''"]) if (src.startsWith(q, i)) { let j = i + 3; while (j < n && !src.startsWith(q, j)) { if (src[j] === '\\') j++; j++ } return Math.min(n, j + 3) }
16
+ if (src[i] === '"' || src[i] === "'") {
17
+ const q = src[i]; let j = i + 1
18
+ while (j < n) { if (q === '"' && src[j] === '\\') { j += 2; continue } if (src[j] === q) { j++; break } if (src[j] === '\n') break; j++ }
19
+ return j
20
+ }
21
+ return -1
22
+ }
23
+ }
24
+
25
+ function lines(src) {
26
+ const out = []
27
+ let i = 0
28
+ while (i < src.length) {
29
+ let e = src.indexOf('\n', i); e = e < 0 ? src.length : e + 1
30
+ const body = src.slice(i, e).replace(/\r?\n$/, '')
31
+ const trimmed = body.trim()
32
+ out.push({ start: i, end: e, blank: trimmed === '' || trimmed.startsWith('#'), text: body })
33
+ i = e
34
+ }
35
+ return out
36
+ }
37
+
38
+ const SECTION = /^\s*(\[\[?)([^\]]+)\]\]?\s*(#.*)?$/
39
+ const KEYLINE = /^\s*([A-Za-z0-9_."'-]+)\s*=/
40
+
41
+ // Bracket delta for one line, string/comment aware (multi-line arrays continue a key).
42
+ function bracketDelta(src, at, s, e) {
43
+ let d = 0, k = s
44
+ while (k < e) {
45
+ const j = at(k); if (j > k) { k = Math.min(e, j); continue }
46
+ const c = src[k]
47
+ if (c === '[' || c === '{') d++
48
+ else if (c === ']' || c === '}') d--
49
+ k++
50
+ }
51
+ return d
52
+ }
53
+
54
+ // Top-level units: a [section] header owns everything until the next header;
55
+ // preamble `key = value` lines are individual units (multi-line values included).
56
+ function blocks(src) {
57
+ const at = scannerAt(src)
58
+ const L = lines(src)
59
+ const units = []
60
+ let i = 0
61
+ while (i < L.length) {
62
+ while (i < L.length && L[i].text.trim() === '') i++
63
+ if (i >= L.length) break
64
+ const start = L[i].start
65
+ while (i < L.length && L[i].blank && L[i].text.trim().startsWith('#')) i++ // leading comments attach
66
+ if (i >= L.length) { units.push({ start, end: L[L.length - 1].end }); break }
67
+ if (SECTION.test(L[i].text)) {
68
+ i++ // header
69
+ while (i < L.length && !SECTION.test(L[i].text)) i++
70
+ let last = i - 1
71
+ while (last >= 0 && L[last].text.trim() === '') last--
72
+ units.push({ start, end: L[last].end })
73
+ } else {
74
+ // one key (or stray statement); consume continuation lines while brackets open
75
+ let depth = bracketDelta(src, at, L[i].start, L[i].end)
76
+ let e = L[i].end; i++
77
+ while (i < L.length && depth > 0) { depth += bracketDelta(src, at, L[i].start, L[i].end); e = L[i].end; i++ }
78
+ units.push({ start, end: e })
79
+ }
80
+ }
81
+ return units
82
+ }
83
+
84
+ function keyOf(text) {
85
+ const head = text.split('\n').find((l) => l.trim() && !l.trim().startsWith('#')) || text
86
+ let m = head.match(SECTION); if (m) return (m[1] === '[[' ? 'tables:' : 'table:') + m[2].trim()
87
+ m = head.match(KEYLINE); if (m) return 'key:' + m[1].replace(/["']/g, '')
88
+ return 'stmt:' + head.trim().replace(/\s+/g, ' ')
89
+ }
90
+
91
+ function keyed(raw, src) {
92
+ const seen = new Map(), out = []
93
+ for (const u of raw) {
94
+ const text = src.slice(u.start, u.end)
95
+ let k = keyOf(text)
96
+ const c = (seen.get(k) || 0) + 1; seen.set(k, c)
97
+ if (c > 1) k += '#' + c
98
+ out.push({ key: k, text, start: u.start, end: u.end })
99
+ }
100
+ return out
101
+ }
102
+
103
+ function balanced(src) {
104
+ const at = scannerAt(src)
105
+ const stack = []
106
+ const pair = { ']': '[', '}': '{' }
107
+ let i = 0
108
+ while (i < src.length) {
109
+ const j = at(i); if (j > i) { if (j > src.length) return false; i = j; continue }
110
+ const c = src[i]
111
+ if (c === '[' || c === '{') stack.push(c)
112
+ else if (pair[c]) { if (stack.pop() !== pair[c]) return false }
113
+ i++
114
+ }
115
+ return stack.length === 0
116
+ }
117
+
118
+ export const toml = {
119
+ id: 'toml',
120
+ exts: ['.toml'],
121
+ parses: (src) => balanced(String(src == null ? '' : src)),
122
+ parsesUnit: (src) => balanced(String(src)),
123
+ units: (src) => keyed(blocks(String(src)), String(src)),
124
+ declaredNames: () => new Set(),
125
+ // Descend into a section: each `key = value` inside merges independently.
126
+ splitUnit: (text) => {
127
+ const src = String(text)
128
+ const L = lines(src)
129
+ let h = 0; while (h < L.length && L[h].blank) h++
130
+ if (h >= L.length || !SECTION.test(L[h].text)) return null
131
+ const sig = src.slice(0, L[h].end)
132
+ const body = src.slice(L[h].end)
133
+ const inner = keyed(blocks(body), body)
134
+ return { sig, open: '', close: '', body, units: inner, join: '', spliceable: true }
135
+ },
136
+ }
package/lang-yaml.js ADDED
@@ -0,0 +1,151 @@
1
+ // YAML language provider for ICR — indent-mapping structural splitter.
2
+ // YAML configs (CI pipelines, k8s manifests, docker-compose) are merged constantly
3
+ // and line merges garble them. This provider merges by STRUCTURE: every top-level
4
+ // `key:` block is a unit keyed by its key name, and splitUnit descends into nested
5
+ // mapping levels — so two people editing DIFFERENT jobs/services/sections merge
6
+ // cleanly, and the SAME key edited two ways is a named conflict.
7
+ //
8
+ // Honest heuristic (no parser dependency): tracks #-comments, quoted strings, and
9
+ // flow brackets; parses() checks bracket balance + indentation coherence (a dedent
10
+ // must land on an indent level that exists — the failure mode of a bad splice).
11
+ // Block scalars (|, >) are tolerated: their content is deeper-indented free text.
12
+
13
+ function scannerAt(src) {
14
+ const n = src.length
15
+ return function at(i) {
16
+ if (src[i] === '#') { let j = i + 1; while (j < n && src[j] !== '\n') j++; return j }
17
+ if (src[i] === '"' || src[i] === "'") {
18
+ const q = src[i]; let j = i + 1
19
+ while (j < n) { if (q === '"' && src[j] === '\\') { j += 2; continue } if (src[j] === q) { j++; break } if (src[j] === '\n') break; j++ }
20
+ return j
21
+ }
22
+ return -1
23
+ }
24
+ }
25
+
26
+ // Line records with indent; comment-only and blank lines are marked.
27
+ function lines(src) {
28
+ const out = []
29
+ let i = 0
30
+ while (i < src.length) {
31
+ let e = src.indexOf('\n', i); e = e < 0 ? src.length : e + 1
32
+ const raw = src.slice(i, e)
33
+ const body = raw.replace(/\r?\n$/, '')
34
+ let indent = 0; while (indent < body.length && body[indent] === ' ') indent++
35
+ const trimmed = body.trim()
36
+ out.push({ start: i, end: e, indent, blank: trimmed === '' || trimmed.startsWith('#'), text: body })
37
+ i = e
38
+ }
39
+ return out
40
+ }
41
+
42
+ const KEY = /^([^:#\s][^:#]*?):(\s|$)/ // `key:` or `key: value`
43
+ const DASH = /^-(\s|$)/ // list item
44
+
45
+ function keyOf(text) {
46
+ const t = text.trim()
47
+ const m = t.match(KEY)
48
+ if (m) return 'key:' + m[1].trim()
49
+ if (t === '---' || t === '...') return 'doc:' + t
50
+ return 'stmt:' + t.replace(/\s+/g, ' ')
51
+ }
52
+
53
+ // Units whose heads sit at exactly `baseIndent`: a `key:`/`- ` head owns all
54
+ // following blank or deeper-indented lines (its block).
55
+ function blocks(src, baseIndent = 0) {
56
+ const L = lines(src)
57
+ const units = []
58
+ let i = 0
59
+ while (i < L.length) {
60
+ while (i < L.length && L[i].text.trim() === '') i++ // pure blanks are gap text
61
+ if (i >= L.length) break
62
+ const start = L[i].start
63
+ // leading comment lines attach to the unit that follows
64
+ while (i < L.length && L[i].blank && L[i].text.trim().startsWith('#')) i++
65
+ if (i >= L.length) { units.push({ start, end: L[L.length - 1].end }); break }
66
+ if (L[i].indent !== baseIndent) { units.push({ start, end: L[i].end }); i++; continue }
67
+ i++ // the head line
68
+ while (i < L.length && (L[i].blank || L[i].indent > baseIndent)) i++
69
+ let last = i - 1
70
+ while (last >= 0 && L[last].text.trim() === '') last-- // trailing blanks are gap text
71
+ units.push({ start, end: L[last].end })
72
+ }
73
+ return units
74
+ }
75
+
76
+ function keyed(raw, src) {
77
+ const seen = new Map(), out = []
78
+ for (const u of raw) {
79
+ const text = src.slice(u.start, u.end)
80
+ // key by the HEAD line (skipping attached leading comments)
81
+ const head = text.split('\n').find((l) => l.trim() && !l.trim().startsWith('#')) || text
82
+ let k = keyOf(head)
83
+ const c = (seen.get(k) || 0) + 1; seen.set(k, c)
84
+ if (c > 1) k += '#' + c
85
+ out.push({ key: k, text, start: u.start, end: u.end })
86
+ }
87
+ return out
88
+ }
89
+
90
+ // Bracket balance (flow style) + unterminated-string detection.
91
+ function balanced(src) {
92
+ const at = scannerAt(src)
93
+ const stack = []
94
+ const pair = { ')': '(', ']': '[', '}': '{' }
95
+ let i = 0
96
+ while (i < src.length) {
97
+ const j = at(i); if (j > i) { if (j > src.length) return false; i = j; continue }
98
+ const c = src[i]
99
+ if (c === '(' || c === '[' || c === '{') stack.push(c)
100
+ else if (pair[c]) { if (stack.pop() !== pair[c]) return false }
101
+ i++
102
+ }
103
+ return stack.length === 0
104
+ }
105
+
106
+ // Indentation coherence: an indent INCREASE opens a new level (always legal in YAML —
107
+ // nested mapping, list, or block-scalar content); a DECREASE must land on a level
108
+ // that is already on the stack. This is exactly what a bad merge splice violates.
109
+ function indentsOk(src) {
110
+ const L = lines(src)
111
+ let stack = null
112
+ for (const l of L) {
113
+ if (l.blank) continue
114
+ if (stack == null) { stack = [l.indent]; continue }
115
+ const top = stack[stack.length - 1]
116
+ if (l.indent > top) { stack.push(l.indent); continue }
117
+ if (l.indent < top) {
118
+ while (stack.length > 1 && stack[stack.length - 1] > l.indent) stack.pop()
119
+ if (stack[stack.length - 1] !== l.indent) return false
120
+ }
121
+ }
122
+ return true
123
+ }
124
+
125
+ export const yaml = {
126
+ id: 'yaml',
127
+ exts: ['.yml', '.yaml'],
128
+ parses: (src) => { const s = String(src == null ? '' : src); return balanced(s) && indentsOk(s) },
129
+ parsesUnit: (src) => { const s = String(src); return balanced(s) && indentsOk(s) },
130
+ units: (src) => keyed(blocks(String(src), 0), String(src)),
131
+ declaredNames: () => new Set(), // keys are structure, not code declarations
132
+ // Descend one mapping level: `jobs:` -> each job merges independently.
133
+ splitUnit: (text) => {
134
+ const src = String(text)
135
+ const L = lines(src)
136
+ let h = 0; while (h < L.length && L[h].blank) h++
137
+ if (h >= L.length) return null
138
+ const head = L[h]
139
+ if (!KEY.test(head.text.trim())) return null
140
+ if (head.text.trim().match(/:\s*[|>][+-]?\s*(#.*)?$/)) return null // block scalar: free text, do not descend
141
+ if (!/:\s*(#.*)?$/.test(head.text)) return null // inline value (`key: v`): nothing beneath
142
+ let b = h + 1; while (b < L.length && L[b].blank) b++
143
+ if (b >= L.length || L[b].indent <= head.indent) return null // empty block
144
+ const bodyIndent = L[b].indent
145
+ const sig = src.slice(0, L[h].end)
146
+ const body = src.slice(L[h].end)
147
+ const inner = keyed(blocks(body, bodyIndent), body)
148
+ if (inner.some((u) => u.key.startsWith('stmt:-') || DASH.test(u.text.trim()))) return null // list items: order matters, stay whole
149
+ return { sig, open: '', close: '', body, units: inner, join: '', spliceable: true }
150
+ },
151
+ }