hivecode-mcp 0.14.0 → 0.14.2

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 (72) hide show
  1. package/HIVE_RULES.md +98 -98
  2. package/LICENSE +21 -21
  3. package/README.md +421 -421
  4. package/core.js +91 -91
  5. package/docs/ALWAYS_ON.md +310 -310
  6. package/docs/GITHUB_WORKFLOW.md +394 -394
  7. package/docs/HIVE_RULES.md +41 -41
  8. package/docs/MCP.md +157 -157
  9. package/docs/QUICKSTART.md +266 -266
  10. package/docs/SELF_HOSTING.md +256 -256
  11. package/docs/SLACK_SETUP.md +474 -450
  12. package/docs/TROUBLESHOOTING.md +105 -105
  13. package/hive-activity.js +336 -336
  14. package/hive-authors.js +46 -44
  15. package/hive-await.js +230 -230
  16. package/hive-branch.js +608 -607
  17. package/hive-busy.js +88 -88
  18. package/hive-coord.js +110 -110
  19. package/hive-gate.js +135 -135
  20. package/hive-github.js +2004 -1944
  21. package/hive-hook-install.js +179 -179
  22. package/hive-instructions.js +74 -74
  23. package/hive-land.js +695 -691
  24. package/hive-listen.js +81 -81
  25. package/hive-mailbox.js +520 -520
  26. package/hive-mcp-http.js +591 -580
  27. package/hive-mcp.js +2156 -2093
  28. package/hive-mux-proto.js +58 -58
  29. package/hive-mux.js +351 -350
  30. package/hive-notify.js +234 -233
  31. package/hive-obligations.js +247 -247
  32. package/hive-poll-proto.js +50 -50
  33. package/hive-pr-staleness.js +189 -189
  34. package/hive-pr.js +600 -512
  35. package/hive-privacy.js +327 -327
  36. package/hive-receipts.js +233 -233
  37. package/hive-relay-health.js +234 -230
  38. package/hive-repos.js +430 -430
  39. package/hive-room-key.js +210 -209
  40. package/hive-room-mode.js +1668 -1668
  41. package/hive-room-watcher.js +562 -561
  42. package/hive-secrets.js +89 -89
  43. package/hive-session.js +114 -114
  44. package/hive-setup.js +560 -558
  45. package/hive-slack-agent.js +408 -396
  46. package/hive-slack.js +2384 -1809
  47. package/hive-stdio.js +172 -172
  48. package/hive-stop-hook.js +293 -293
  49. package/hive-throttle.js +118 -118
  50. package/hive-time.js +71 -71
  51. package/hive-transport.js +578 -353
  52. package/hive-update.js +58 -58
  53. package/hive-version.js +110 -110
  54. package/hive-webhook.js +55 -54
  55. package/icr-merge.js +58 -58
  56. package/icr.js +851 -851
  57. package/lang-brace.js +305 -305
  58. package/lang-js.js +651 -651
  59. package/lang-json.js +211 -211
  60. package/lang-python.js +588 -588
  61. package/lang-ruby.js +230 -230
  62. package/lang-toml.js +136 -136
  63. package/lang-yaml.js +183 -183
  64. package/limits.js +413 -413
  65. package/package.json +192 -192
  66. package/reconcile.cjs +621 -614
  67. package/slack-app-manifest-agent.json +61 -61
  68. package/slack-app-manifest-inbound.json +36 -36
  69. package/slack-app-manifest.json +27 -27
  70. package/substrate.js +252 -252
  71. package/sync.js +5189 -4994
  72. package/token.js +201 -201
package/lang-python.js CHANGED
@@ -1,588 +1,588 @@
1
- // Python language provider for ICR — indentation-based structural splitter.
2
- // Implements the ICR provider contract { id, exts, parses, units, declaredNames,
3
- // splitUnit, parsesUnit } so the shared merge engine merges Python by structure:
4
- // top-level defs/classes/statements are units keyed by name; editing different
5
- // methods of the same class merges via splitUnit. Heuristic (tracks #-comments,
6
- // triple-quoted and normal strings, bracket depth) — not a full parser; parses()
7
- // is an approximate balanced-delimiter check.
8
-
9
- function scannerAt(src) {
10
- const n = src.length
11
- return function at(i) {
12
- if (src[i] === '#') { let j = i + 1; while (j < n && src[j] !== '\n') j++; return j }
13
- 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) }
14
- if (src[i] === '"' || src[i] === "'") { const q = src[i]; let j = i + 1; while (j < n) { if (src[j] === '\\') { j += 2; continue } if (src[j] === q) { j++; break } if (src[j] === '\n') break; j++ } return j }
15
- return -1
16
- }
17
- }
18
-
19
- // Line records: { start, end (incl newline), indent, blank, text }
20
- function lines(src) {
21
- const out = []
22
- let i = 0
23
- while (i < src.length) {
24
- let e = src.indexOf('\n', i); if (e < 0) e = src.length; else e = e + 1
25
- const raw = src.slice(i, e)
26
- const trimmed = raw.replace(/\r?\n$/, '')
27
- let indent = 0; while (indent < trimmed.length && (trimmed[indent] === ' ' || trimmed[indent] === '\t')) indent++
28
- const blank = trimmed.trim() === '' || trimmed.trim().startsWith('#')
29
- out.push({ start: i, end: e, indent, blank, text: raw })
30
- i = e
31
- }
32
- return out
33
- }
34
-
35
- const HEAD = /^\s*(?:async\s+def|def)\s+([A-Za-z_]\w*)/
36
- const CLS = /^\s*class\s+([A-Za-z_]\w*)/
37
- // Continuation clauses of a COMPOUND statement. They are not independent statements —
38
- // an `else:` only means something relative to its `if`/`for`/`try` — so blocks() must
39
- // attach them to the preceding unit. Keying them separately let two sides' restructured
40
- // if/else halves interleave into adjacent dangling `else:` blocks (ConGra, transformers
41
- // file_utils.py: output had `else:` twice in a row — ast-invalid, heuristics blind).
42
- const COMPOUND_CONT = /^\s*(?:else\b|elif\b|except\b|finally\b)/
43
-
44
- function keyOf(text) {
45
- let m = text.match(HEAD); if (m) return 'def:' + m[1]
46
- m = text.match(CLS); if (m) return 'class:' + m[1]
47
- return 'stmt:' + text.split('\n')[0].trim().replace(/\s+/g, ' ')
48
- }
49
-
50
- // Split src into units whose heads sit at exactly `base` indentation. A def/class
51
- // head owns the following blank + more-indented lines (its body). A run of leading
52
- // @decorator lines attaches to the head that follows. Everything else is a
53
- // one-logical-line statement (continued while bracket depth > 0).
54
- function blocks(src, base = 0) {
55
- const L = lines(src)
56
- const at = scannerAt(src)
57
- const units = []
58
- let i = 0
59
- // find bracket depth over a line range (to keep continued statements together)
60
- const bracketDelta = (s, e) => {
61
- let d = 0, k = s
62
- while (k < e) { const j = at(k); if (j > k) { k = j; continue } const c = src[k]; if (c === '(' || c === '[' || c === '{') d++; else if (c === ')' || c === ']' || c === '}') d--; k++ }
63
- return d
64
- }
65
- while (i < L.length) {
66
- while (i < L.length && L[i].text.trim() === '') i++ // skip ONLY pure blank lines, NOT comments
67
- if (i >= L.length) break
68
- if (L[i].indent !== base) { // stray deeper line (shouldn't happen at top) — treat as its own stmt
69
- const s = L[i].start; let e = L[i].end; i++
70
- units.push({ start: s, end: e })
71
- continue
72
- }
73
- const start = L[i].start
74
- // consume leading decorators
75
- let isDecorator = /^\s*@/.test(L[i].text)
76
- while (i < L.length && L[i].indent === base && /^\s*@/.test(L[i].text)) i++
77
- const headIdx = i
78
- const head = i < L.length ? L[i] : null
79
- if (head && (HEAD.test(head.text) || CLS.test(head.text) || isDecorator)) {
80
- // a block: consume the header line, then its body (blank or deeper-indented lines)
81
- i++ // header line
82
- while (i < L.length && (L[i].blank || L[i].indent > base)) i++
83
- // Trailing PURE-BLANK lines are GAP text, not part of the unit — if the unit owned
84
- // them, appending a new unit after the last one would mutate the last unit's text
85
- // and raise a false same-key conflict (found by multilang fuzz). Trailing comments
86
- // stay attached (comment-attachment convention).
87
- let last = i - 1
88
- while (last > headIdx && L[last].text.trim() === '') last--
89
- units.push({ start, end: L[last].end })
90
- } else if (head) {
91
- // simple statement: consume continued lines while bracket depth remains open
92
- let e = head.end, depth = bracketDelta(head.start, head.end); i++
93
- while (i < L.length && (depth > 0 || /\\\s*$/.test(L[i - 1].text.replace(/\r?\n$/, '')))) { depth += bracketDelta(L[i].start, L[i].end); e = L[i].end; i++ }
94
- // COMPOUND statement (if/for/while/try/with/else/…): the logical line ends with ':',
95
- // so this header OWNS its indented body — one unit, like def/class. Without this the
96
- // body lines become stray per-line units and the clause structure can't survive a
97
- // structured merge coherently.
98
- const logicalEndsColon = (() => {
99
- const at = scannerAt(src)
100
- let lastC = '', k = start
101
- while (k < e) { const j = at(k); if (j > k) { if (src[k] !== '#') lastC = 's'; k = Math.min(e, j); continue } const c = src[k]; if (c !== ' ' && c !== '\t' && c !== '\r' && c !== '\n') lastC = c; k++ }
102
- return lastC === ':'
103
- })()
104
- if (logicalEndsColon) {
105
- const bodyHead = i - 1
106
- while (i < L.length && (L[i].blank || L[i].indent > base)) i++
107
- let last = i - 1
108
- while (last > bodyHead && L[last].text.trim() === '') last--
109
- e = L[last].end
110
- }
111
- // Continuation clause (else/elif/except/finally): not an independent statement —
112
- // extend the PREVIOUS unit so the whole compound statement is one mergeable unit.
113
- if (COMPOUND_CONT.test(head.text) && units.length) units[units.length - 1].end = e
114
- else units.push({ start, end: e })
115
- }
116
- }
117
- return units
118
- }
119
-
120
- function keyed(raw, src) {
121
- const seen = new Map(), out = []
122
- for (const u of raw) {
123
- const text = src.slice(u.start, u.end)
124
- let k = keyOf(text)
125
- const c = (seen.get(k) || 0) + 1; seen.set(k, c)
126
- if (c > 1) k += '#' + c
127
- out.push({ key: k, text, start: u.start, end: u.end })
128
- }
129
- return out
130
- }
131
-
132
- function balanced(src) {
133
- const at = scannerAt(src)
134
- const stack = []
135
- const pair = { ')': '(', ']': '[', '}': '{' }
136
- let i = 0
137
- while (i < src.length) {
138
- const j = at(i); if (j > i) { if (j > src.length) return false; i = j; continue }
139
- const c = src[i]
140
- if (c === '(' || c === '[' || c === '{') stack.push(c)
141
- else if (pair[c]) { if (stack.pop() !== pair[c]) return false }
142
- i++
143
- }
144
- return stack.length === 0
145
- }
146
-
147
- // INDENTATION validity — the half of Python syntax that delimiter balance cannot see.
148
- // Walks LOGICAL lines (joining bracket continuations, backslash continuations, and
149
- // multi-line strings) and enforces Python's block rules: a line ending with ':' must
150
- // open a deeper-indented block; an indent may only appear after such an opener; every
151
- // dedent must land on an indent level already on the stack. This is what lets parses()
152
- // refuse a merge that spliced statements into the wrong block — balance alone passes it.
153
- // The first line's indent is taken as the baseline so unit fragments validate too.
154
- function indentsOk(src) {
155
- const at = scannerAt(src)
156
- const n = src.length
157
- let stack = null // [indent levels]; seeded from the first logical line
158
- let i = 0, prevOpened = false
159
- while (i < n) {
160
- let indent = 0
161
- while (i < n && (src[i] === ' ' || src[i] === '\t')) { indent++; i++ }
162
- if (i >= n) break
163
- if (src[i] === '\r' || src[i] === '\n') { i++; continue } // blank line
164
- if (src[i] === '#') { i = Math.min(n, at(i)); continue } // comment-only line
165
- if (stack == null) stack = [indent] // baseline (fragment-friendly)
166
- if (prevOpened) {
167
- if (indent <= stack[stack.length - 1]) return false // ':' promised a block; none came
168
- stack.push(indent)
169
- } else if (indent > stack[stack.length - 1]) {
170
- return false // indent without an opener
171
- } else if (indent < stack[stack.length - 1]) {
172
- while (stack.length > 1 && stack[stack.length - 1] > indent) stack.pop()
173
- if (stack[stack.length - 1] !== indent) return false // dedent to a level never opened
174
- }
175
- // scan to the end of this LOGICAL line (brackets, backslash, strings span physical lines)
176
- let depth = 0, last = ''
177
- while (i < n) {
178
- const j = at(i)
179
- if (j > i) { if (src[i] !== '#') last = 's'; i = Math.min(n, j); continue } // string counts as content; comment doesn't
180
- const c = src[i]
181
- if (c === '(' || c === '[' || c === '{') depth++
182
- else if (c === ')' || c === ']' || c === '}') depth--
183
- if (c === '\n') {
184
- if (depth > 0 || last === '\\') { if (last === '\\') last = ''; i++; continue } // continuation
185
- i++; break
186
- }
187
- if (c !== ' ' && c !== '\t' && c !== '\r') last = c
188
- i++
189
- }
190
- prevOpened = last === ':'
191
- }
192
- return !prevOpened // must not end still promising a block
193
- }
194
-
195
- // Generic lexer for the token-level inner merge + the intent layer. Identifiers,
196
- // numbers, whole strings are tokens; comments live in the gaps (a comment-only edit
197
- // is a no-op at this tier, same contract as the JS provider).
198
- function lexTokens(src) {
199
- const at = scannerAt(src)
200
- const out = []
201
- const n = src.length
202
- let i = 0
203
- while (i < n) {
204
- const c = src[i]
205
- if (c === ' ' || c === '\t' || c === '\r' || c === '\n') { i++; continue }
206
- const j = at(i)
207
- if (j > i) {
208
- if (c === '#') { i = j; continue } // comment → gap
209
- out.push({ start: i, end: j, k: src.slice(i, j) }); i = j; continue // string → one token
210
- }
211
- if (/[A-Za-z_]/.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 }
212
- 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 }
213
- out.push({ start: i, end: i + 1, k: c }); i++
214
- }
215
- return out
216
- }
217
-
218
- // Token stream for the token-level MERGE tier, in an indentation language. Unlike
219
- // lexTokens (used by the intent layer, where whitespace is noise), this emits a NEWLINE
220
- // token for every real line break — a statement boundary. Without it a token splice can
221
- // weld two statements onto one physical line (`x = f(y)if z:`), which balance+indent
222
- // checks read as a single logical line and wrongly accept — a parse-guarantee violation
223
- // found by the ConGra corpus (ast.parse rejects it). With newlines as tokens, any spliced
224
- // run that crosses a line carries its break, so statements can never fuse. Newlines inside
225
- // strings/comments stay inside those tokens; a bare `\n` between brackets is still emitted
226
- // (harmless: it only ever makes the token diff finer, never coarser).
227
- function lexTokensNL(src) {
228
- const at = scannerAt(src)
229
- const out = []
230
- const n = src.length
231
- let i = 0, depth = 0, contBackslash = false
232
- while (i < n) {
233
- const c = src[i]
234
- if (c === '\n') {
235
- // Emit a NEWLINE token only at a real statement boundary: not inside brackets, not
236
- // after a line-continuation backslash. Inside brackets a newline is insignificant
237
- // (like whitespace) — emitting it there corrupts multi-line-call token interleaving.
238
- if (depth === 0 && !contBackslash) out.push({ start: i, end: i + 1, k: '\n' })
239
- contBackslash = false; i++; continue
240
- }
241
- if (c === ' ' || c === '\t' || c === '\r') { i++; continue }
242
- const j = at(i)
243
- if (j > i) {
244
- contBackslash = false
245
- if (c === '#') { i = j; continue }
246
- out.push({ start: i, end: j, k: src.slice(i, j) }); i = j; continue
247
- }
248
- if (c === '\\') { contBackslash = true; i++; continue }
249
- contBackslash = false
250
- if (c === '(' || c === '[' || c === '{') depth++
251
- else if (c === ')' || c === ']' || c === '}') { if (depth > 0) depth-- }
252
- if (/[A-Za-z_]/.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 }
253
- 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 }
254
- out.push({ start: i, end: i + 1, k: c }); i++
255
- }
256
- return out
257
- }
258
-
259
- // Reference-position identifier (not attribute access `.name`)?
260
- function isRefToken(src, t) {
261
- if (!/^[A-Za-z_]\w*$/.test(t.k)) return false
262
- let p = t.start - 1
263
- while (p >= 0 && (src[p] === ' ' || src[p] === '\t')) p--
264
- return !(p >= 0 && src[p] === '.')
265
- }
266
-
267
- // --- scope-aware reference analysis ---------------------------------------------
268
- //
269
- // WHY THIS IS EXACT AND NOT ANOTHER HEURISTIC. Python binds by assignment: a name
270
- // assigned anywhere in a function body is local to that function for the WHOLE body, and
271
- // reading it before the assignment is an UnboundLocalError rather than a global read. So
272
- // a function that assigns `helper` cannot also be referring to a module-level `helper` —
273
- // the language forbids the mixed case. That is what makes subtracting local bindings safe
274
- // here, where a plain "does this name appear anywhere" check has to stay over-cautious.
275
- //
276
- // WHAT IT FIXES. usedIdentifiers reports every name it sees, so deleting a top-level
277
- // `helper` while some unrelated function happens to have a local named `helper` was
278
- // reported as ref:helper — a merge refused that was perfectly safe, falling back to line
279
- // merge with conflict markers. Common names (config, data, result, helper) trip this
280
- // constantly. Reproduced in test/scope-repro.mjs before this existed.
281
- //
282
- // THE DIRECTION THAT MATTERS. Missing a REAL reference would let ICR delete something
283
- // still in use — the one failure this library promises never to have — so every rule
284
- // below fails towards "still referenced":
285
- // • module scope subtracts nothing, so top-level rebinding never hides a reference;
286
- // • a nested def is its own scope, never folded into its parent's bindings;
287
- // • a class body contributes no bindings at all (its names are attributes, invisible
288
- // to methods), so they stay free;
289
- // • `global`/`nonlocal` remove a name from the local set;
290
- // • subscript and attribute targets (`a[i] = `, `a.b = `) bind nothing — `a` must
291
- // already exist, so it stays a reference;
292
- // • anything unrecognised is left free.
293
- const PY_KW = new Set(['False', 'None', 'True', 'and', 'as', 'assert', 'async', 'await',
294
- 'break', 'class', 'continue', 'def', 'del', 'elif', 'else', 'except', 'finally', 'for',
295
- 'from', 'global', 'if', 'import', 'in', 'is', 'lambda', 'nonlocal', 'not', 'or', 'pass',
296
- 'raise', 'return', 'try', 'while', 'with', 'yield'])
297
-
298
- const isName = (k) => /^[A-Za-z_]\w*$/.test(k) && !PY_KW.has(k)
299
- const OPEN = new Set(['(', '[', '{'])
300
- const CLOSE = new Set([')', ']', '}'])
301
-
302
- // A target position binds only a bare name. `a[i] = x` and `a.b = x` require `a` to exist
303
- // already, so they are reads, not bindings.
304
- function bareTargets(toks, from, to, out) {
305
- for (let i = from; i < to; i++) {
306
- const t = toks[i]
307
- if (!isName(t.k)) continue
308
- const prev = toks[i - 1] && toks[i - 1].k
309
- const next = toks[i + 1] && toks[i + 1].k
310
- if (prev === '.' || next === '.' || next === '[' || next === '(') continue
311
- out.add(t.k)
312
- }
313
- }
314
-
315
- // Names this statement binds in its own scope, and names it explicitly disowns.
316
- function stmtBindings(text) {
317
- const bind = new Set(), disown = new Set()
318
- const toks = lexTokens(text).filter((t) => t.k.trim() !== '')
319
- const kw = toks.length ? toks[0].k : ''
320
-
321
- if (kw === 'global' || kw === 'nonlocal') {
322
- for (const t of toks.slice(1)) if (isName(t.k)) disown.add(t.k)
323
- return { bind, disown }
324
- }
325
-
326
- if (kw === 'import' || kw === 'from') {
327
- // `import a.b as c` binds c; `import a.b` binds a; `from m import x, y as z`.
328
- const start = kw === 'from' ? toks.findIndex((t) => t.k === 'import') : 0
329
- if (start >= 0) {
330
- for (let i = start + 1; i < toks.length; i++) {
331
- if (toks[i].k === 'as' && isName((toks[i + 1] || {}).k)) { bind.add(toks[i + 1].k); i++; continue }
332
- // first segment of a dotted path, and only when not renamed by a later `as`
333
- if (isName(toks[i].k) && (toks[i - 1] || {}).k !== '.' &&
334
- (toks[i + 1] || {}).k !== '.' && (toks[i + 1] || {}).k !== 'as') bind.add(toks[i].k)
335
- else if (isName(toks[i].k) && (toks[i + 1] || {}).k === '.' && kw === 'import') bind.add(toks[i].k)
336
- }
337
- }
338
- return { bind, disown }
339
- }
340
-
341
- let depth = 0
342
- for (let i = 0; i < toks.length; i++) {
343
- const k = toks[i].k
344
- if (OPEN.has(k)) { depth++; continue }
345
- if (CLOSE.has(k)) { depth--; continue }
346
-
347
- // `for <targets> in` — at any depth, so comprehensions bind too.
348
- if (k === 'for') {
349
- let j = i + 1
350
- while (j < toks.length && toks[j].k !== 'in') j++
351
- bareTargets(toks, i + 1, j, bind)
352
- i = j
353
- continue
354
- }
355
- // `with x as name`, `except E as name`
356
- if (k === 'as' && isName((toks[i + 1] || {}).k)) { bind.add(toks[i + 1].k); i++; continue }
357
-
358
- if (k !== '=') continue
359
- const prev = (toks[i - 1] || {}).k
360
- const next = (toks[i + 1] || {}).k
361
- if (next === '=') { i++; continue } // ==
362
- if (prev === '=' || prev === '!' || prev === '<' || prev === '>') continue
363
- if (depth > 0) {
364
- // Inside brackets `=` is a keyword argument, except for the walrus `:=`.
365
- if (prev === ':' && isName((toks[i - 2] || {}).k)) bind.add(toks[i - 2].k)
366
- continue
367
- }
368
- // Statement-level assignment: simple, augmented (`+=`, prev is an operator), or
369
- // annotated (`x: int = 1`). Everything left of it is the target list.
370
- bareTargets(toks, 0, i, bind)
371
- break
372
- }
373
- return { bind, disown }
374
- }
375
-
376
- function refsIn(text, emit) {
377
- for (const t of lexTokens(text)) if (isName(t.k) && isRefToken(text, t)) emit(t.k)
378
- }
379
-
380
- // Parameter names of a def header — used as bindings of the CHILD scope, and excluded
381
- // from the header's own references so a parameter is never mistaken for a free name.
382
- function defParams(header) {
383
- const toks = lexTokens(header).filter((t) => t.k.trim() !== '')
384
- const open = toks.findIndex((t) => t.k === '(')
385
- if (open < 0) return new Set()
386
- const params = new Set()
387
- let depth = 0
388
- for (let i = open; i < toks.length; i++) {
389
- const k = toks[i].k
390
- if (OPEN.has(k)) { depth++; continue }
391
- if (CLOSE.has(k)) { depth--; if (depth === 0) break; continue }
392
- if (depth !== 1 || !isName(k)) continue
393
- const prev = (toks[i - 1] || {}).k
394
- if (prev === '(' || prev === ',' || prev === '*' || prev === '**') params.add(k)
395
- }
396
- return params
397
- }
398
-
399
- // Split a def/class unit into the part evaluated in the ENCLOSING scope (decorators and
400
- // the header line — where default values and base classes live) and the body, which is
401
- // its own scope.
402
- function splitDeclUnit(text) {
403
- const L = lines(text)
404
- let h = 0
405
- while (h < L.length && (L[h].blank || /^\s*@/.test(L[h].text))) h++
406
- if (h >= L.length) return null
407
- const head = L[h]
408
- const isDef = HEAD.test(head.text), isCls = CLS.test(head.text)
409
- if (!isDef && !isCls) return null
410
- // A signature can span lines while its brackets are open.
411
- let e = h
412
- let depth = 0
413
- do {
414
- for (const ch of L[e].text) { if (OPEN.has(ch)) depth++; else if (CLOSE.has(ch)) depth-- }
415
- e++
416
- } while (e < L.length && depth > 0)
417
- const header = text.slice(0, L[e - 1].end)
418
- let b = e
419
- while (b < L.length && L[b].blank) b++
420
- const body = b < L.length ? text.slice(L[b].start) : ''
421
- return { isDef, header, body, bodyIndent: b < L.length ? L[b].indent : 0 }
422
- }
423
-
424
- // Walk one scope's statements, descending through compound bodies (same scope) but
425
- // stopping at def/class (new scope).
426
- function scopeParts(code, base, onStmt, onDecl) {
427
- for (const u of blocks(code, base)) {
428
- const text = code.slice(u.start, u.end)
429
- if (splitDeclUnit(text)) { onDecl(text); continue }
430
- const L = lines(text)
431
- let b = 1
432
- while (b < L.length && L[b].blank) b++
433
- if (b < L.length && L[b].indent > base) {
434
- onStmt(text.slice(0, L[b].start)) // the `if ...:` / `for ...:` head
435
- scopeParts(text.slice(L[b].start), L[b].indent, onStmt, onDecl)
436
- } else onStmt(text)
437
- }
438
- }
439
-
440
- // `subtract` is false at module scope: a module-level rebinding must never hide a
441
- // reference to the declaration being deleted.
442
- function scopeFree(code, base, enclosing, free, subtract) {
443
- const stmts = [], decls = []
444
- scopeParts(code, base, (t) => stmts.push(t), (t) => decls.push(t))
445
-
446
- const local = new Set(), disowned = new Set()
447
- for (const t of stmts) {
448
- const { bind, disown } = stmtBindings(t)
449
- for (const n of bind) local.add(n)
450
- for (const n of disown) disowned.add(n)
451
- }
452
- for (const t of decls) {
453
- const s = splitDeclUnit(t)
454
- const m = s && (s.header.match(HEAD) || s.header.match(CLS))
455
- if (m) local.add(m[1])
456
- }
457
- for (const n of disowned) local.delete(n)
458
-
459
- const bound = subtract ? new Set([...enclosing, ...local]) : new Set(enclosing)
460
- for (const t of stmts) refsIn(t, (n) => { if (!bound.has(n)) free.add(n) })
461
-
462
- for (const t of decls) {
463
- const s = splitDeclUnit(t)
464
- if (!s) continue
465
- const params = s.isDef ? defParams(s.header) : new Set()
466
- const declName = (s.header.match(HEAD) || s.header.match(CLS) || [])[1]
467
- // Decorators, annotations and default values run in THIS scope.
468
- refsIn(s.header, (n) => {
469
- if (n === declName || params.has(n) || bound.has(n)) return
470
- free.add(n)
471
- })
472
- if (!s.body) continue
473
- // A class body contributes no bindings to anything nested inside it, so its methods
474
- // are analysed against the enclosing scope only. `subtract: s.isDef` is the whole of
475
- // that rule and it is load-bearing: with subtraction left on for a class, `helper = 9`
476
- // at class level became a binding the methods inherited, and a real `helper()` call
477
- // inside a method stopped being reported — a merge shipped with a dangling reference,
478
- // caught by the safety case in test/scope-repro.mjs. Class attributes are reached as
479
- // `self.helper` or `C.helper`, never as a bare name, so they must never shadow.
480
- scopeFree(s.body, s.bodyIndent,
481
- s.isDef ? new Set([...bound, ...params]) : bound, free, s.isDef)
482
- }
483
- }
484
-
485
- // PER-STATEMENT bracket nesting — the soundness backstop the whole-file `balanced()`
486
- // (global) and `indentsOk()` (indentation) both miss. A token merge can splice a result
487
- // that is GLOBALLY balanced yet LOCALLY mis-nested: side A reformats `f(x)` to span lines
488
- // and a concurrent edit detaches the `)`, so `( ... { ... )` lands in one statement and a
489
- // stray `)` balances the count elsewhere. In VALID Python every logical statement is
490
- // independently balanced AND properly nested, so we check exactly that: segment the token
491
- // stream at depth-0 NEWLINE tokens (which lexTokensNL only emits between statements) and
492
- // require each segment's brackets to nest correctly. Sound — it never rejects valid Python
493
- // — and it closes the token-merge corruption class the ConGra corpus exposed via ast.parse.
494
- function stmtBracketsOk(src) {
495
- const pair = { ')': '(', ']': '[', '}': '{' }
496
- let stack = []
497
- for (const t of lexTokensNL(src)) {
498
- if (t.k === '\n') { if (stack.length) return false; continue } // statement boundary: must be closed
499
- if (t.k === '(' || t.k === '[' || t.k === '{') stack.push(t.k)
500
- else if (pair[t.k]) { if (stack.pop() !== pair[t.k]) return false }
501
- }
502
- return stack.length === 0
503
- }
504
-
505
- export const python = {
506
- id: 'python',
507
- exts: ['.py', '.pyi'],
508
- parses: (src) => { const s = String(src == null ? '' : src); return balanced(s) && stmtBracketsOk(s) && indentsOk(s) },
509
- parsesUnit: (src) => { const s = String(src); return balanced(s) && indentsOk(s) },
510
- units: (src) => keyed(blocks(String(src), 0), String(src)),
511
- declaredNames: (src) => {
512
- const names = new Set()
513
- for (const u of keyed(blocks(String(src), 0), String(src))) {
514
- const m = u.key.match(/^(?:def|class):([A-Za-z_]\w*)/)
515
- if (m) names.add(m[1])
516
- }
517
- return names
518
- },
519
- // --- intent layer (heuristic but honest): dangling refs, rename, token merge ---
520
- // Every identifier used in reference position (skips `.attribute` access). Approximate
521
- // and OVER-inclusive — which only makes the dangling check MORE cautious: a deleted-
522
- // but-still-referenced def/class is surfaced as a conflict, never shipped broken.
523
- usedIdentifiers: (src) => {
524
- src = String(src)
525
- const used = new Set()
526
- for (const t of lexTokens(src)) if (isRefToken(src, t)) used.add(t.k)
527
- return used
528
- },
529
- // Scope-aware upgrade of the above — the engine prefers this for the dangling-reference
530
- // check (icr.js). Names that resolve to a LOCAL binding are not reported, so a deleted
531
- // top-level `helper` is no longer held hostage by an unrelated local of the same name.
532
- // Kept alongside usedIdentifiers rather than replacing it: the approximate version is
533
- // still the honest answer for anything this cannot resolve.
534
- referencedFreeNames: (src) => {
535
- const free = new Set()
536
- scopeFree(String(src), 0, new Set(), free, false)
537
- return free
538
- },
539
- // The def/class text with its own name excluded, so renamed twins compare equal.
540
- // trimEnd(): a unit owns its trailing blank lines, and splicing can change how many
541
- // survive — trailing whitespace must not defeat the rename match.
542
- declBody: (src, name) => {
543
- src = String(src)
544
- for (const u of keyed(blocks(src, 0), src)) {
545
- const m = u.key.match(/^(?:def|class):([A-Za-z_]\w*)/)
546
- if (!m || m[1] !== name) continue
547
- return u.text.replace(name, '').trimEnd()
548
- }
549
- return null
550
- },
551
- // Rewrite reference-position uses of oldName (never `.oldName` attribute access).
552
- // The engine only accepts the rewrite if the result still passes parses().
553
- renameRefs: (src, oldName, newName) => {
554
- src = String(src)
555
- const spots = []
556
- for (const t of lexTokens(src)) if (t.k === oldName && isRefToken(src, t)) spots.push(t)
557
- let out = src
558
- for (const t of spots.reverse()) out = out.slice(0, t.start) + newName + out.slice(t.end)
559
- return out
560
- },
561
- // Finest granularity: token stream for the token-level inner merge. NEWLINE-aware
562
- // (lexTokensNL) so a token splice can never weld two statements onto one line — the
563
- // parse guarantee holds even where the indentation heuristic alone would be fooled.
564
- tokenize: (src) => lexTokensNL(String(src)),
565
- // Descend into a class/def body so two agents editing DIFFERENT methods merge.
566
- splitUnit: (text) => {
567
- const src = String(text)
568
- const L = lines(src)
569
- let h = 0; while (h < L.length && (L[h].blank || /^\s*@/.test(L[h].text))) h++
570
- if (h >= L.length) return null
571
- // Only descend into a CLASS (named methods → mergeable). A def body is bare
572
- // statements with no stable keys; descending would falsely merge two edits to the
573
- // same function, so we don't — same-def edits become a clean conflict instead.
574
- if (!CLS.test(L[h].text)) return null
575
- const headEnd = L[h].end
576
- // body = everything after the header line; its base indent is the first body line's indent
577
- let b = h + 1; while (b < L.length && L[b].blank) b++
578
- if (b >= L.length) return null
579
- const bodyIndent = L[b].indent
580
- const sig = src.slice(0, headEnd)
581
- const body = src.slice(headEnd)
582
- const inner = keyed(blocks(body, bodyIndent), body)
583
- // spliceable: inner units carry byte ranges in `body`, so the engine rebuilds the
584
- // class body by splicing merged methods back in — comments/blank lines between
585
- // methods survive verbatim instead of being reflowed by a join.
586
- return { sig, open: '', close: '', body, units: inner, join: '', spliceable: true }
587
- },
588
- }
1
+ // Python language provider for ICR — indentation-based structural splitter.
2
+ // Implements the ICR provider contract { id, exts, parses, units, declaredNames,
3
+ // splitUnit, parsesUnit } so the shared merge engine merges Python by structure:
4
+ // top-level defs/classes/statements are units keyed by name; editing different
5
+ // methods of the same class merges via splitUnit. Heuristic (tracks #-comments,
6
+ // triple-quoted and normal strings, bracket depth) — not a full parser; parses()
7
+ // is an approximate balanced-delimiter check.
8
+
9
+ function scannerAt(src) {
10
+ const n = src.length
11
+ return function at(i) {
12
+ if (src[i] === '#') { let j = i + 1; while (j < n && src[j] !== '\n') j++; return j }
13
+ 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) }
14
+ if (src[i] === '"' || src[i] === "'") { const q = src[i]; let j = i + 1; while (j < n) { if (src[j] === '\\') { j += 2; continue } if (src[j] === q) { j++; break } if (src[j] === '\n') break; j++ } return j }
15
+ return -1
16
+ }
17
+ }
18
+
19
+ // Line records: { start, end (incl newline), indent, blank, text }
20
+ function lines(src) {
21
+ const out = []
22
+ let i = 0
23
+ while (i < src.length) {
24
+ let e = src.indexOf('\n', i); if (e < 0) e = src.length; else e = e + 1
25
+ const raw = src.slice(i, e)
26
+ const trimmed = raw.replace(/\r?\n$/, '')
27
+ let indent = 0; while (indent < trimmed.length && (trimmed[indent] === ' ' || trimmed[indent] === '\t')) indent++
28
+ const blank = trimmed.trim() === '' || trimmed.trim().startsWith('#')
29
+ out.push({ start: i, end: e, indent, blank, text: raw })
30
+ i = e
31
+ }
32
+ return out
33
+ }
34
+
35
+ const HEAD = /^\s*(?:async\s+def|def)\s+([A-Za-z_]\w*)/
36
+ const CLS = /^\s*class\s+([A-Za-z_]\w*)/
37
+ // Continuation clauses of a COMPOUND statement. They are not independent statements —
38
+ // an `else:` only means something relative to its `if`/`for`/`try` — so blocks() must
39
+ // attach them to the preceding unit. Keying them separately let two sides' restructured
40
+ // if/else halves interleave into adjacent dangling `else:` blocks (ConGra, transformers
41
+ // file_utils.py: output had `else:` twice in a row — ast-invalid, heuristics blind).
42
+ const COMPOUND_CONT = /^\s*(?:else\b|elif\b|except\b|finally\b)/
43
+
44
+ function keyOf(text) {
45
+ let m = text.match(HEAD); if (m) return 'def:' + m[1]
46
+ m = text.match(CLS); if (m) return 'class:' + m[1]
47
+ return 'stmt:' + text.split('\n')[0].trim().replace(/\s+/g, ' ')
48
+ }
49
+
50
+ // Split src into units whose heads sit at exactly `base` indentation. A def/class
51
+ // head owns the following blank + more-indented lines (its body). A run of leading
52
+ // @decorator lines attaches to the head that follows. Everything else is a
53
+ // one-logical-line statement (continued while bracket depth > 0).
54
+ function blocks(src, base = 0) {
55
+ const L = lines(src)
56
+ const at = scannerAt(src)
57
+ const units = []
58
+ let i = 0
59
+ // find bracket depth over a line range (to keep continued statements together)
60
+ const bracketDelta = (s, e) => {
61
+ let d = 0, k = s
62
+ while (k < e) { const j = at(k); if (j > k) { k = j; continue } const c = src[k]; if (c === '(' || c === '[' || c === '{') d++; else if (c === ')' || c === ']' || c === '}') d--; k++ }
63
+ return d
64
+ }
65
+ while (i < L.length) {
66
+ while (i < L.length && L[i].text.trim() === '') i++ // skip ONLY pure blank lines, NOT comments
67
+ if (i >= L.length) break
68
+ if (L[i].indent !== base) { // stray deeper line (shouldn't happen at top) — treat as its own stmt
69
+ const s = L[i].start; let e = L[i].end; i++
70
+ units.push({ start: s, end: e })
71
+ continue
72
+ }
73
+ const start = L[i].start
74
+ // consume leading decorators
75
+ let isDecorator = /^\s*@/.test(L[i].text)
76
+ while (i < L.length && L[i].indent === base && /^\s*@/.test(L[i].text)) i++
77
+ const headIdx = i
78
+ const head = i < L.length ? L[i] : null
79
+ if (head && (HEAD.test(head.text) || CLS.test(head.text) || isDecorator)) {
80
+ // a block: consume the header line, then its body (blank or deeper-indented lines)
81
+ i++ // header line
82
+ while (i < L.length && (L[i].blank || L[i].indent > base)) i++
83
+ // Trailing PURE-BLANK lines are GAP text, not part of the unit — if the unit owned
84
+ // them, appending a new unit after the last one would mutate the last unit's text
85
+ // and raise a false same-key conflict (found by multilang fuzz). Trailing comments
86
+ // stay attached (comment-attachment convention).
87
+ let last = i - 1
88
+ while (last > headIdx && L[last].text.trim() === '') last--
89
+ units.push({ start, end: L[last].end })
90
+ } else if (head) {
91
+ // simple statement: consume continued lines while bracket depth remains open
92
+ let e = head.end, depth = bracketDelta(head.start, head.end); i++
93
+ while (i < L.length && (depth > 0 || /\\\s*$/.test(L[i - 1].text.replace(/\r?\n$/, '')))) { depth += bracketDelta(L[i].start, L[i].end); e = L[i].end; i++ }
94
+ // COMPOUND statement (if/for/while/try/with/else/…): the logical line ends with ':',
95
+ // so this header OWNS its indented body — one unit, like def/class. Without this the
96
+ // body lines become stray per-line units and the clause structure can't survive a
97
+ // structured merge coherently.
98
+ const logicalEndsColon = (() => {
99
+ const at = scannerAt(src)
100
+ let lastC = '', k = start
101
+ while (k < e) { const j = at(k); if (j > k) { if (src[k] !== '#') lastC = 's'; k = Math.min(e, j); continue } const c = src[k]; if (c !== ' ' && c !== '\t' && c !== '\r' && c !== '\n') lastC = c; k++ }
102
+ return lastC === ':'
103
+ })()
104
+ if (logicalEndsColon) {
105
+ const bodyHead = i - 1
106
+ while (i < L.length && (L[i].blank || L[i].indent > base)) i++
107
+ let last = i - 1
108
+ while (last > bodyHead && L[last].text.trim() === '') last--
109
+ e = L[last].end
110
+ }
111
+ // Continuation clause (else/elif/except/finally): not an independent statement —
112
+ // extend the PREVIOUS unit so the whole compound statement is one mergeable unit.
113
+ if (COMPOUND_CONT.test(head.text) && units.length) units[units.length - 1].end = e
114
+ else units.push({ start, end: e })
115
+ }
116
+ }
117
+ return units
118
+ }
119
+
120
+ function keyed(raw, src) {
121
+ const seen = new Map(), out = []
122
+ for (const u of raw) {
123
+ const text = src.slice(u.start, u.end)
124
+ let k = keyOf(text)
125
+ const c = (seen.get(k) || 0) + 1; seen.set(k, c)
126
+ if (c > 1) k += '#' + c
127
+ out.push({ key: k, text, start: u.start, end: u.end })
128
+ }
129
+ return out
130
+ }
131
+
132
+ function balanced(src) {
133
+ const at = scannerAt(src)
134
+ const stack = []
135
+ const pair = { ')': '(', ']': '[', '}': '{' }
136
+ let i = 0
137
+ while (i < src.length) {
138
+ const j = at(i); if (j > i) { if (j > src.length) return false; i = j; continue }
139
+ const c = src[i]
140
+ if (c === '(' || c === '[' || c === '{') stack.push(c)
141
+ else if (pair[c]) { if (stack.pop() !== pair[c]) return false }
142
+ i++
143
+ }
144
+ return stack.length === 0
145
+ }
146
+
147
+ // INDENTATION validity — the half of Python syntax that delimiter balance cannot see.
148
+ // Walks LOGICAL lines (joining bracket continuations, backslash continuations, and
149
+ // multi-line strings) and enforces Python's block rules: a line ending with ':' must
150
+ // open a deeper-indented block; an indent may only appear after such an opener; every
151
+ // dedent must land on an indent level already on the stack. This is what lets parses()
152
+ // refuse a merge that spliced statements into the wrong block — balance alone passes it.
153
+ // The first line's indent is taken as the baseline so unit fragments validate too.
154
+ function indentsOk(src) {
155
+ const at = scannerAt(src)
156
+ const n = src.length
157
+ let stack = null // [indent levels]; seeded from the first logical line
158
+ let i = 0, prevOpened = false
159
+ while (i < n) {
160
+ let indent = 0
161
+ while (i < n && (src[i] === ' ' || src[i] === '\t')) { indent++; i++ }
162
+ if (i >= n) break
163
+ if (src[i] === '\r' || src[i] === '\n') { i++; continue } // blank line
164
+ if (src[i] === '#') { i = Math.min(n, at(i)); continue } // comment-only line
165
+ if (stack == null) stack = [indent] // baseline (fragment-friendly)
166
+ if (prevOpened) {
167
+ if (indent <= stack[stack.length - 1]) return false // ':' promised a block; none came
168
+ stack.push(indent)
169
+ } else if (indent > stack[stack.length - 1]) {
170
+ return false // indent without an opener
171
+ } else if (indent < stack[stack.length - 1]) {
172
+ while (stack.length > 1 && stack[stack.length - 1] > indent) stack.pop()
173
+ if (stack[stack.length - 1] !== indent) return false // dedent to a level never opened
174
+ }
175
+ // scan to the end of this LOGICAL line (brackets, backslash, strings span physical lines)
176
+ let depth = 0, last = ''
177
+ while (i < n) {
178
+ const j = at(i)
179
+ if (j > i) { if (src[i] !== '#') last = 's'; i = Math.min(n, j); continue } // string counts as content; comment doesn't
180
+ const c = src[i]
181
+ if (c === '(' || c === '[' || c === '{') depth++
182
+ else if (c === ')' || c === ']' || c === '}') depth--
183
+ if (c === '\n') {
184
+ if (depth > 0 || last === '\\') { if (last === '\\') last = ''; i++; continue } // continuation
185
+ i++; break
186
+ }
187
+ if (c !== ' ' && c !== '\t' && c !== '\r') last = c
188
+ i++
189
+ }
190
+ prevOpened = last === ':'
191
+ }
192
+ return !prevOpened // must not end still promising a block
193
+ }
194
+
195
+ // Generic lexer for the token-level inner merge + the intent layer. Identifiers,
196
+ // numbers, whole strings are tokens; comments live in the gaps (a comment-only edit
197
+ // is a no-op at this tier, same contract as the JS provider).
198
+ function lexTokens(src) {
199
+ const at = scannerAt(src)
200
+ const out = []
201
+ const n = src.length
202
+ let i = 0
203
+ while (i < n) {
204
+ const c = src[i]
205
+ if (c === ' ' || c === '\t' || c === '\r' || c === '\n') { i++; continue }
206
+ const j = at(i)
207
+ if (j > i) {
208
+ if (c === '#') { i = j; continue } // comment → gap
209
+ out.push({ start: i, end: j, k: src.slice(i, j) }); i = j; continue // string → one token
210
+ }
211
+ if (/[A-Za-z_]/.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 }
212
+ 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 }
213
+ out.push({ start: i, end: i + 1, k: c }); i++
214
+ }
215
+ return out
216
+ }
217
+
218
+ // Token stream for the token-level MERGE tier, in an indentation language. Unlike
219
+ // lexTokens (used by the intent layer, where whitespace is noise), this emits a NEWLINE
220
+ // token for every real line break — a statement boundary. Without it a token splice can
221
+ // weld two statements onto one physical line (`x = f(y)if z:`), which balance+indent
222
+ // checks read as a single logical line and wrongly accept — a parse-guarantee violation
223
+ // found by the ConGra corpus (ast.parse rejects it). With newlines as tokens, any spliced
224
+ // run that crosses a line carries its break, so statements can never fuse. Newlines inside
225
+ // strings/comments stay inside those tokens; a bare `\n` between brackets is still emitted
226
+ // (harmless: it only ever makes the token diff finer, never coarser).
227
+ function lexTokensNL(src) {
228
+ const at = scannerAt(src)
229
+ const out = []
230
+ const n = src.length
231
+ let i = 0, depth = 0, contBackslash = false
232
+ while (i < n) {
233
+ const c = src[i]
234
+ if (c === '\n') {
235
+ // Emit a NEWLINE token only at a real statement boundary: not inside brackets, not
236
+ // after a line-continuation backslash. Inside brackets a newline is insignificant
237
+ // (like whitespace) — emitting it there corrupts multi-line-call token interleaving.
238
+ if (depth === 0 && !contBackslash) out.push({ start: i, end: i + 1, k: '\n' })
239
+ contBackslash = false; i++; continue
240
+ }
241
+ if (c === ' ' || c === '\t' || c === '\r') { i++; continue }
242
+ const j = at(i)
243
+ if (j > i) {
244
+ contBackslash = false
245
+ if (c === '#') { i = j; continue }
246
+ out.push({ start: i, end: j, k: src.slice(i, j) }); i = j; continue
247
+ }
248
+ if (c === '\\') { contBackslash = true; i++; continue }
249
+ contBackslash = false
250
+ if (c === '(' || c === '[' || c === '{') depth++
251
+ else if (c === ')' || c === ']' || c === '}') { if (depth > 0) depth-- }
252
+ if (/[A-Za-z_]/.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 }
253
+ 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 }
254
+ out.push({ start: i, end: i + 1, k: c }); i++
255
+ }
256
+ return out
257
+ }
258
+
259
+ // Reference-position identifier (not attribute access `.name`)?
260
+ function isRefToken(src, t) {
261
+ if (!/^[A-Za-z_]\w*$/.test(t.k)) return false
262
+ let p = t.start - 1
263
+ while (p >= 0 && (src[p] === ' ' || src[p] === '\t')) p--
264
+ return !(p >= 0 && src[p] === '.')
265
+ }
266
+
267
+ // --- scope-aware reference analysis ---------------------------------------------
268
+ //
269
+ // WHY THIS IS EXACT AND NOT ANOTHER HEURISTIC. Python binds by assignment: a name
270
+ // assigned anywhere in a function body is local to that function for the WHOLE body, and
271
+ // reading it before the assignment is an UnboundLocalError rather than a global read. So
272
+ // a function that assigns `helper` cannot also be referring to a module-level `helper` —
273
+ // the language forbids the mixed case. That is what makes subtracting local bindings safe
274
+ // here, where a plain "does this name appear anywhere" check has to stay over-cautious.
275
+ //
276
+ // WHAT IT FIXES. usedIdentifiers reports every name it sees, so deleting a top-level
277
+ // `helper` while some unrelated function happens to have a local named `helper` was
278
+ // reported as ref:helper — a merge refused that was perfectly safe, falling back to line
279
+ // merge with conflict markers. Common names (config, data, result, helper) trip this
280
+ // constantly. Reproduced in test/scope-repro.mjs before this existed.
281
+ //
282
+ // THE DIRECTION THAT MATTERS. Missing a REAL reference would let ICR delete something
283
+ // still in use — the one failure this library promises never to have — so every rule
284
+ // below fails towards "still referenced":
285
+ // • module scope subtracts nothing, so top-level rebinding never hides a reference;
286
+ // • a nested def is its own scope, never folded into its parent's bindings;
287
+ // • a class body contributes no bindings at all (its names are attributes, invisible
288
+ // to methods), so they stay free;
289
+ // • `global`/`nonlocal` remove a name from the local set;
290
+ // • subscript and attribute targets (`a[i] = `, `a.b = `) bind nothing — `a` must
291
+ // already exist, so it stays a reference;
292
+ // • anything unrecognised is left free.
293
+ const PY_KW = new Set(['False', 'None', 'True', 'and', 'as', 'assert', 'async', 'await',
294
+ 'break', 'class', 'continue', 'def', 'del', 'elif', 'else', 'except', 'finally', 'for',
295
+ 'from', 'global', 'if', 'import', 'in', 'is', 'lambda', 'nonlocal', 'not', 'or', 'pass',
296
+ 'raise', 'return', 'try', 'while', 'with', 'yield'])
297
+
298
+ const isName = (k) => /^[A-Za-z_]\w*$/.test(k) && !PY_KW.has(k)
299
+ const OPEN = new Set(['(', '[', '{'])
300
+ const CLOSE = new Set([')', ']', '}'])
301
+
302
+ // A target position binds only a bare name. `a[i] = x` and `a.b = x` require `a` to exist
303
+ // already, so they are reads, not bindings.
304
+ function bareTargets(toks, from, to, out) {
305
+ for (let i = from; i < to; i++) {
306
+ const t = toks[i]
307
+ if (!isName(t.k)) continue
308
+ const prev = toks[i - 1] && toks[i - 1].k
309
+ const next = toks[i + 1] && toks[i + 1].k
310
+ if (prev === '.' || next === '.' || next === '[' || next === '(') continue
311
+ out.add(t.k)
312
+ }
313
+ }
314
+
315
+ // Names this statement binds in its own scope, and names it explicitly disowns.
316
+ function stmtBindings(text) {
317
+ const bind = new Set(), disown = new Set()
318
+ const toks = lexTokens(text).filter((t) => t.k.trim() !== '')
319
+ const kw = toks.length ? toks[0].k : ''
320
+
321
+ if (kw === 'global' || kw === 'nonlocal') {
322
+ for (const t of toks.slice(1)) if (isName(t.k)) disown.add(t.k)
323
+ return { bind, disown }
324
+ }
325
+
326
+ if (kw === 'import' || kw === 'from') {
327
+ // `import a.b as c` binds c; `import a.b` binds a; `from m import x, y as z`.
328
+ const start = kw === 'from' ? toks.findIndex((t) => t.k === 'import') : 0
329
+ if (start >= 0) {
330
+ for (let i = start + 1; i < toks.length; i++) {
331
+ if (toks[i].k === 'as' && isName((toks[i + 1] || {}).k)) { bind.add(toks[i + 1].k); i++; continue }
332
+ // first segment of a dotted path, and only when not renamed by a later `as`
333
+ if (isName(toks[i].k) && (toks[i - 1] || {}).k !== '.' &&
334
+ (toks[i + 1] || {}).k !== '.' && (toks[i + 1] || {}).k !== 'as') bind.add(toks[i].k)
335
+ else if (isName(toks[i].k) && (toks[i + 1] || {}).k === '.' && kw === 'import') bind.add(toks[i].k)
336
+ }
337
+ }
338
+ return { bind, disown }
339
+ }
340
+
341
+ let depth = 0
342
+ for (let i = 0; i < toks.length; i++) {
343
+ const k = toks[i].k
344
+ if (OPEN.has(k)) { depth++; continue }
345
+ if (CLOSE.has(k)) { depth--; continue }
346
+
347
+ // `for <targets> in` — at any depth, so comprehensions bind too.
348
+ if (k === 'for') {
349
+ let j = i + 1
350
+ while (j < toks.length && toks[j].k !== 'in') j++
351
+ bareTargets(toks, i + 1, j, bind)
352
+ i = j
353
+ continue
354
+ }
355
+ // `with x as name`, `except E as name`
356
+ if (k === 'as' && isName((toks[i + 1] || {}).k)) { bind.add(toks[i + 1].k); i++; continue }
357
+
358
+ if (k !== '=') continue
359
+ const prev = (toks[i - 1] || {}).k
360
+ const next = (toks[i + 1] || {}).k
361
+ if (next === '=') { i++; continue } // ==
362
+ if (prev === '=' || prev === '!' || prev === '<' || prev === '>') continue
363
+ if (depth > 0) {
364
+ // Inside brackets `=` is a keyword argument, except for the walrus `:=`.
365
+ if (prev === ':' && isName((toks[i - 2] || {}).k)) bind.add(toks[i - 2].k)
366
+ continue
367
+ }
368
+ // Statement-level assignment: simple, augmented (`+=`, prev is an operator), or
369
+ // annotated (`x: int = 1`). Everything left of it is the target list.
370
+ bareTargets(toks, 0, i, bind)
371
+ break
372
+ }
373
+ return { bind, disown }
374
+ }
375
+
376
+ function refsIn(text, emit) {
377
+ for (const t of lexTokens(text)) if (isName(t.k) && isRefToken(text, t)) emit(t.k)
378
+ }
379
+
380
+ // Parameter names of a def header — used as bindings of the CHILD scope, and excluded
381
+ // from the header's own references so a parameter is never mistaken for a free name.
382
+ function defParams(header) {
383
+ const toks = lexTokens(header).filter((t) => t.k.trim() !== '')
384
+ const open = toks.findIndex((t) => t.k === '(')
385
+ if (open < 0) return new Set()
386
+ const params = new Set()
387
+ let depth = 0
388
+ for (let i = open; i < toks.length; i++) {
389
+ const k = toks[i].k
390
+ if (OPEN.has(k)) { depth++; continue }
391
+ if (CLOSE.has(k)) { depth--; if (depth === 0) break; continue }
392
+ if (depth !== 1 || !isName(k)) continue
393
+ const prev = (toks[i - 1] || {}).k
394
+ if (prev === '(' || prev === ',' || prev === '*' || prev === '**') params.add(k)
395
+ }
396
+ return params
397
+ }
398
+
399
+ // Split a def/class unit into the part evaluated in the ENCLOSING scope (decorators and
400
+ // the header line — where default values and base classes live) and the body, which is
401
+ // its own scope.
402
+ function splitDeclUnit(text) {
403
+ const L = lines(text)
404
+ let h = 0
405
+ while (h < L.length && (L[h].blank || /^\s*@/.test(L[h].text))) h++
406
+ if (h >= L.length) return null
407
+ const head = L[h]
408
+ const isDef = HEAD.test(head.text), isCls = CLS.test(head.text)
409
+ if (!isDef && !isCls) return null
410
+ // A signature can span lines while its brackets are open.
411
+ let e = h
412
+ let depth = 0
413
+ do {
414
+ for (const ch of L[e].text) { if (OPEN.has(ch)) depth++; else if (CLOSE.has(ch)) depth-- }
415
+ e++
416
+ } while (e < L.length && depth > 0)
417
+ const header = text.slice(0, L[e - 1].end)
418
+ let b = e
419
+ while (b < L.length && L[b].blank) b++
420
+ const body = b < L.length ? text.slice(L[b].start) : ''
421
+ return { isDef, header, body, bodyIndent: b < L.length ? L[b].indent : 0 }
422
+ }
423
+
424
+ // Walk one scope's statements, descending through compound bodies (same scope) but
425
+ // stopping at def/class (new scope).
426
+ function scopeParts(code, base, onStmt, onDecl) {
427
+ for (const u of blocks(code, base)) {
428
+ const text = code.slice(u.start, u.end)
429
+ if (splitDeclUnit(text)) { onDecl(text); continue }
430
+ const L = lines(text)
431
+ let b = 1
432
+ while (b < L.length && L[b].blank) b++
433
+ if (b < L.length && L[b].indent > base) {
434
+ onStmt(text.slice(0, L[b].start)) // the `if ...:` / `for ...:` head
435
+ scopeParts(text.slice(L[b].start), L[b].indent, onStmt, onDecl)
436
+ } else onStmt(text)
437
+ }
438
+ }
439
+
440
+ // `subtract` is false at module scope: a module-level rebinding must never hide a
441
+ // reference to the declaration being deleted.
442
+ function scopeFree(code, base, enclosing, free, subtract) {
443
+ const stmts = [], decls = []
444
+ scopeParts(code, base, (t) => stmts.push(t), (t) => decls.push(t))
445
+
446
+ const local = new Set(), disowned = new Set()
447
+ for (const t of stmts) {
448
+ const { bind, disown } = stmtBindings(t)
449
+ for (const n of bind) local.add(n)
450
+ for (const n of disown) disowned.add(n)
451
+ }
452
+ for (const t of decls) {
453
+ const s = splitDeclUnit(t)
454
+ const m = s && (s.header.match(HEAD) || s.header.match(CLS))
455
+ if (m) local.add(m[1])
456
+ }
457
+ for (const n of disowned) local.delete(n)
458
+
459
+ const bound = subtract ? new Set([...enclosing, ...local]) : new Set(enclosing)
460
+ for (const t of stmts) refsIn(t, (n) => { if (!bound.has(n)) free.add(n) })
461
+
462
+ for (const t of decls) {
463
+ const s = splitDeclUnit(t)
464
+ if (!s) continue
465
+ const params = s.isDef ? defParams(s.header) : new Set()
466
+ const declName = (s.header.match(HEAD) || s.header.match(CLS) || [])[1]
467
+ // Decorators, annotations and default values run in THIS scope.
468
+ refsIn(s.header, (n) => {
469
+ if (n === declName || params.has(n) || bound.has(n)) return
470
+ free.add(n)
471
+ })
472
+ if (!s.body) continue
473
+ // A class body contributes no bindings to anything nested inside it, so its methods
474
+ // are analysed against the enclosing scope only. `subtract: s.isDef` is the whole of
475
+ // that rule and it is load-bearing: with subtraction left on for a class, `helper = 9`
476
+ // at class level became a binding the methods inherited, and a real `helper()` call
477
+ // inside a method stopped being reported — a merge shipped with a dangling reference,
478
+ // caught by the safety case in test/scope-repro.mjs. Class attributes are reached as
479
+ // `self.helper` or `C.helper`, never as a bare name, so they must never shadow.
480
+ scopeFree(s.body, s.bodyIndent,
481
+ s.isDef ? new Set([...bound, ...params]) : bound, free, s.isDef)
482
+ }
483
+ }
484
+
485
+ // PER-STATEMENT bracket nesting — the soundness backstop the whole-file `balanced()`
486
+ // (global) and `indentsOk()` (indentation) both miss. A token merge can splice a result
487
+ // that is GLOBALLY balanced yet LOCALLY mis-nested: side A reformats `f(x)` to span lines
488
+ // and a concurrent edit detaches the `)`, so `( ... { ... )` lands in one statement and a
489
+ // stray `)` balances the count elsewhere. In VALID Python every logical statement is
490
+ // independently balanced AND properly nested, so we check exactly that: segment the token
491
+ // stream at depth-0 NEWLINE tokens (which lexTokensNL only emits between statements) and
492
+ // require each segment's brackets to nest correctly. Sound — it never rejects valid Python
493
+ // — and it closes the token-merge corruption class the ConGra corpus exposed via ast.parse.
494
+ function stmtBracketsOk(src) {
495
+ const pair = { ')': '(', ']': '[', '}': '{' }
496
+ let stack = []
497
+ for (const t of lexTokensNL(src)) {
498
+ if (t.k === '\n') { if (stack.length) return false; continue } // statement boundary: must be closed
499
+ if (t.k === '(' || t.k === '[' || t.k === '{') stack.push(t.k)
500
+ else if (pair[t.k]) { if (stack.pop() !== pair[t.k]) return false }
501
+ }
502
+ return stack.length === 0
503
+ }
504
+
505
+ export const python = {
506
+ id: 'python',
507
+ exts: ['.py', '.pyi'],
508
+ parses: (src) => { const s = String(src == null ? '' : src); return balanced(s) && stmtBracketsOk(s) && indentsOk(s) },
509
+ parsesUnit: (src) => { const s = String(src); return balanced(s) && indentsOk(s) },
510
+ units: (src) => keyed(blocks(String(src), 0), String(src)),
511
+ declaredNames: (src) => {
512
+ const names = new Set()
513
+ for (const u of keyed(blocks(String(src), 0), String(src))) {
514
+ const m = u.key.match(/^(?:def|class):([A-Za-z_]\w*)/)
515
+ if (m) names.add(m[1])
516
+ }
517
+ return names
518
+ },
519
+ // --- intent layer (heuristic but honest): dangling refs, rename, token merge ---
520
+ // Every identifier used in reference position (skips `.attribute` access). Approximate
521
+ // and OVER-inclusive — which only makes the dangling check MORE cautious: a deleted-
522
+ // but-still-referenced def/class is surfaced as a conflict, never shipped broken.
523
+ usedIdentifiers: (src) => {
524
+ src = String(src)
525
+ const used = new Set()
526
+ for (const t of lexTokens(src)) if (isRefToken(src, t)) used.add(t.k)
527
+ return used
528
+ },
529
+ // Scope-aware upgrade of the above — the engine prefers this for the dangling-reference
530
+ // check (icr.js). Names that resolve to a LOCAL binding are not reported, so a deleted
531
+ // top-level `helper` is no longer held hostage by an unrelated local of the same name.
532
+ // Kept alongside usedIdentifiers rather than replacing it: the approximate version is
533
+ // still the honest answer for anything this cannot resolve.
534
+ referencedFreeNames: (src) => {
535
+ const free = new Set()
536
+ scopeFree(String(src), 0, new Set(), free, false)
537
+ return free
538
+ },
539
+ // The def/class text with its own name excluded, so renamed twins compare equal.
540
+ // trimEnd(): a unit owns its trailing blank lines, and splicing can change how many
541
+ // survive — trailing whitespace must not defeat the rename match.
542
+ declBody: (src, name) => {
543
+ src = String(src)
544
+ for (const u of keyed(blocks(src, 0), src)) {
545
+ const m = u.key.match(/^(?:def|class):([A-Za-z_]\w*)/)
546
+ if (!m || m[1] !== name) continue
547
+ return u.text.replace(name, '').trimEnd()
548
+ }
549
+ return null
550
+ },
551
+ // Rewrite reference-position uses of oldName (never `.oldName` attribute access).
552
+ // The engine only accepts the rewrite if the result still passes parses().
553
+ renameRefs: (src, oldName, newName) => {
554
+ src = String(src)
555
+ const spots = []
556
+ for (const t of lexTokens(src)) if (t.k === oldName && isRefToken(src, t)) spots.push(t)
557
+ let out = src
558
+ for (const t of spots.reverse()) out = out.slice(0, t.start) + newName + out.slice(t.end)
559
+ return out
560
+ },
561
+ // Finest granularity: token stream for the token-level inner merge. NEWLINE-aware
562
+ // (lexTokensNL) so a token splice can never weld two statements onto one line — the
563
+ // parse guarantee holds even where the indentation heuristic alone would be fooled.
564
+ tokenize: (src) => lexTokensNL(String(src)),
565
+ // Descend into a class/def body so two agents editing DIFFERENT methods merge.
566
+ splitUnit: (text) => {
567
+ const src = String(text)
568
+ const L = lines(src)
569
+ let h = 0; while (h < L.length && (L[h].blank || /^\s*@/.test(L[h].text))) h++
570
+ if (h >= L.length) return null
571
+ // Only descend into a CLASS (named methods → mergeable). A def body is bare
572
+ // statements with no stable keys; descending would falsely merge two edits to the
573
+ // same function, so we don't — same-def edits become a clean conflict instead.
574
+ if (!CLS.test(L[h].text)) return null
575
+ const headEnd = L[h].end
576
+ // body = everything after the header line; its base indent is the first body line's indent
577
+ let b = h + 1; while (b < L.length && L[b].blank) b++
578
+ if (b >= L.length) return null
579
+ const bodyIndent = L[b].indent
580
+ const sig = src.slice(0, headEnd)
581
+ const body = src.slice(headEnd)
582
+ const inner = keyed(blocks(body, bodyIndent), body)
583
+ // spliceable: inner units carry byte ranges in `body`, so the engine rebuilds the
584
+ // class body by splicing merged methods back in — comments/blank lines between
585
+ // methods survive verbatim instead of being reflowed by a join.
586
+ return { sig, open: '', close: '', body, units: inner, join: '', spliceable: true }
587
+ },
588
+ }