hivecode-mcp 0.14.1 → 0.14.3
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/HIVE_RULES.md +98 -98
- package/LICENSE +21 -21
- package/README.md +421 -421
- package/core.js +91 -91
- package/docs/ALWAYS_ON.md +310 -310
- package/docs/GITHUB_WORKFLOW.md +394 -394
- package/docs/HIVE_RULES.md +41 -41
- package/docs/MCP.md +157 -157
- package/docs/QUICKSTART.md +266 -266
- package/docs/SELF_HOSTING.md +256 -256
- package/docs/SLACK_SETUP.md +474 -474
- package/docs/TROUBLESHOOTING.md +106 -105
- package/hive-activity.js +336 -336
- package/hive-authors.js +46 -44
- package/hive-await.js +230 -230
- package/hive-branch.js +674 -607
- package/hive-busy.js +88 -88
- package/hive-coord.js +110 -110
- package/hive-gate.js +135 -135
- package/hive-github.js +3 -1
- package/hive-hook-install.js +179 -179
- package/hive-instructions.js +74 -74
- package/hive-land.js +709 -691
- package/hive-listen.js +81 -81
- package/hive-mailbox.js +520 -520
- package/hive-mcp-http.js +591 -591
- package/hive-mcp.js +92 -12
- package/hive-mux-proto.js +58 -58
- package/hive-mux.js +354 -351
- package/hive-notify.js +234 -233
- package/hive-obligations.js +247 -247
- package/hive-poll-proto.js +50 -50
- package/hive-pr-staleness.js +189 -189
- package/hive-pr.js +637 -512
- package/hive-privacy.js +327 -327
- package/hive-receipts.js +233 -233
- package/hive-relay-health.js +234 -234
- package/hive-repos.js +430 -430
- package/hive-room-key.js +210 -210
- package/hive-room-mode.js +1761 -1668
- package/hive-room-watcher.js +623 -561
- package/hive-secrets.js +89 -89
- package/hive-session.js +114 -114
- package/hive-setup.js +560 -560
- package/hive-slack-agent.js +408 -408
- package/hive-slack.js +2417 -2367
- package/hive-stdio.js +172 -172
- package/hive-stop-hook.js +293 -293
- package/hive-throttle.js +118 -118
- package/hive-time.js +71 -71
- package/hive-transport.js +617 -578
- package/hive-update.js +58 -58
- package/hive-version.js +110 -110
- package/hive-webhook.js +55 -54
- package/icr-merge.js +58 -58
- package/icr.js +856 -851
- package/lang-brace.js +305 -305
- package/lang-js.js +695 -651
- package/lang-json.js +211 -211
- package/lang-python.js +588 -588
- package/lang-ruby.js +230 -230
- package/lang-toml.js +136 -136
- package/lang-yaml.js +183 -183
- package/limits.js +413 -413
- package/package.json +11 -3
- package/reconcile.cjs +621 -621
- package/slack-app-manifest-agent.json +61 -61
- package/slack-app-manifest-inbound.json +36 -36
- package/slack-app-manifest.json +27 -27
- package/substrate.js +252 -252
- package/sync.js +434 -60
- 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
|
+
}
|