beast-devtools 0.0.1

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 (47) hide show
  1. package/.claude/launch.json +11 -0
  2. package/CHANGELOG.md +37 -0
  3. package/README.md +87 -0
  4. package/devtools/CHANGELOG.md +26 -0
  5. package/devtools/LICENSE +15 -0
  6. package/devtools/README.md +164 -0
  7. package/devtools/client/BeastDevtools.btsx +185 -0
  8. package/devtools/client/CodeView.btsx +61 -0
  9. package/devtools/client/ComponentsPanel.btsx +212 -0
  10. package/devtools/client/FileList.btsx +35 -0
  11. package/devtools/client/InspectorPanel.btsx +131 -0
  12. package/devtools/client/RefactorPanel.btsx +304 -0
  13. package/devtools/client/api.ts +69 -0
  14. package/devtools/client/compile.test.ts +22 -0
  15. package/devtools/client/devtools.css +1279 -0
  16. package/devtools/client/highlight.ts +210 -0
  17. package/devtools/client/mount.ts +16 -0
  18. package/devtools/client/runtime.ts +168 -0
  19. package/devtools/client/util.ts +136 -0
  20. package/devtools/package.json +73 -0
  21. package/devtools/server/analyze.test.ts +148 -0
  22. package/devtools/server/analyze.ts +737 -0
  23. package/devtools/server/diff.ts +82 -0
  24. package/devtools/server/line-map.ts +63 -0
  25. package/devtools/server/octane-bundler.d.ts +17 -0
  26. package/devtools/server/project.ts +369 -0
  27. package/devtools/server/refactor.test.ts +224 -0
  28. package/devtools/server/refactor.ts +224 -0
  29. package/devtools/server/source-scan.ts +377 -0
  30. package/devtools/shared/types.ts +208 -0
  31. package/devtools/test/fixtures/App.btsx +193 -0
  32. package/devtools/tsconfig.build.json +17 -0
  33. package/devtools/vite.ts +159 -0
  34. package/favicon.ico +0 -0
  35. package/index.html +14 -0
  36. package/package.json +34 -0
  37. package/public/beast.svg +1 -0
  38. package/src/App.btsx +144 -0
  39. package/src/AppHeader.btsx +24 -0
  40. package/src/LeftArticle.btsx +22 -0
  41. package/src/RightArticle.btsx +19 -0
  42. package/src/env.d.ts +8 -0
  43. package/src/lib/utils.ts +6 -0
  44. package/src/main.ts +8 -0
  45. package/src/style.css +44 -0
  46. package/tsconfig.json +39 -0
  47. package/vite.config.ts +19 -0
@@ -0,0 +1,224 @@
1
+ import { afterEach, describe, expect, test } from 'bun:test'
2
+ import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync, existsSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { compileBeastResult, componentNameFromPath } from 'beast-tsrx'
6
+ import { createOctaneCompiler } from 'octane/compiler/bundler'
7
+ import type { AnalyzerSettings, RefactorSuggestion, RefactorTarget } from '../shared/types.ts'
8
+ import { analyzeDocument } from './analyze.ts'
9
+ import { BeastProject } from './project.ts'
10
+ import { planRefactor, RefactorError, type RefactorPlan } from './refactor.ts'
11
+
12
+ const APP = readFileSync(new URL('../test/fixtures/App.btsx', import.meta.url), 'utf8')
13
+ const SETTINGS: AnalyzerSettings = { depthLimit: 5, minLines: 8, fileLines: 30 }
14
+ const octane = createOctaneCompiler({ root: process.cwd(), environment: 'client', hmr: false, dev: true })
15
+
16
+ function suggestionsFor(source: string, name: string, settings: Partial<AnalyzerSettings> = {}) {
17
+ const absolutePath = `/project/src/${name}.btsx`
18
+ const { ast } = compileBeastResult(source, { filename: absolutePath, componentName: name })
19
+ const analysis = analyzeDocument(ast, source, name, { ...SETTINGS, ...settings })
20
+ return { absolutePath, ast, suggestions: analysis.suggestions }
21
+ }
22
+
23
+ function plan(
24
+ source: string,
25
+ host: string,
26
+ pick: (suggestion: RefactorSuggestion) => boolean,
27
+ target: RefactorTarget,
28
+ options: { settings?: Partial<AnalyzerSettings>; exists?: (path: string) => boolean } = {},
29
+ ): RefactorPlan {
30
+ const { absolutePath, ast, suggestions } = suggestionsFor(source, host, options.settings)
31
+ const suggestion = suggestions.find(pick)
32
+ if (suggestion === undefined) throw new Error(`No matching suggestion among ${suggestions.map((s) => s.name)}`)
33
+ return planRefactor({ absolutePath, source, document: ast, suggestion, target, exists: options.exists ?? (() => false) })
34
+ }
35
+
36
+ function expectCompiles(path: string, source: string): void {
37
+ const { code } = compileBeastResult(source, { filename: path, componentName: componentNameFromPath(path) })
38
+ expect(octane.transform(code, path.replace(/\.btsx$/, '.tsrx'), { environment: 'client', dev: true })).not.toBeNull()
39
+ }
40
+
41
+ describe('hoisting into the same file', () => {
42
+ test('inserts a local component above props and replaces the section with a call', () => {
43
+ const result = plan(APP, 'App', (s) => s.name === 'AppHeader', 'inline')
44
+ expect(result.changes).toHaveLength(1)
45
+ const after = result.changes[0]!.after
46
+ expect(after.indexOf('component AppHeader')).toBeLessThan(after.indexOf('props { docsUrl }: Props'))
47
+ expect(after).toContain(' AppHeader(activeId={activeId} setActiveId={setActiveId} setCopiedSide={setCopiedSide})')
48
+ expect(after.split('\n').filter((line) => line.includes("aria-controls=\"workflow-panel\""))).toHaveLength(1)
49
+ expectCompiles('/project/src/App.btsx', after)
50
+ })
51
+
52
+ test('replaces every copy of an identical block', () => {
53
+ const block = [' .card', ' h2 Title', ' p Body', ' footer', ' small Fine print']
54
+ const source = ['section', ' .left', ...block, ' .right', ...block, ''].join('\n')
55
+ const result = plan(source, 'Cards', (s) => s.kind === 'duplicate' && s.autoApply.blocked === null, 'inline', { settings: { minLines: 4 } })
56
+ const after = result.changes[0]!.after
57
+ expect(after.match(/^ {4}Card$/gm)).toHaveLength(2)
58
+ expect(after.startsWith('component Card\n .card\n')).toBe(true)
59
+ expectCompiles('/project/src/Cards.btsx', after)
60
+ })
61
+
62
+ test('refuses copies that differ, since one call would change behavior', () => {
63
+ expect(() => plan(APP, 'App', (s) => s.kind === 'duplicate', 'inline')).toThrow(RefactorError)
64
+ })
65
+
66
+ test('refuses components with scoped styles', () => {
67
+ const source = [
68
+ 'section',
69
+ ' .a',
70
+ ' .b',
71
+ ' .c',
72
+ ' p One',
73
+ ' p Two',
74
+ ' p Three',
75
+ ' style',
76
+ ' .c { color: red; }',
77
+ '',
78
+ ].join('\n')
79
+ expect(() => plan(source, 'Styled', (s) => s.kind === 'extract', 'inline', { settings: { depthLimit: 2, minLines: 3 } })).toThrow(
80
+ /scoped style/,
81
+ )
82
+ })
83
+ })
84
+
85
+ describe('moving to a new file', () => {
86
+ const settings = { fileLines: 18 }
87
+
88
+ test('creates a sibling file that imports the types and values it uses', () => {
89
+ const result = plan(APP, 'App', (s) => s.name === 'AppHeader', 'file', { settings })
90
+ const [source, created] = result.changes as [RefactorPlan['changes'][0], RefactorPlan['changes'][0]]
91
+ expect(created.absolutePath).toBe('/project/src/AppHeader.btsx')
92
+ expect(created.before).toBeNull()
93
+ expect(created.after.split('\n').slice(0, 3)).toEqual([
94
+ "import { panels } from './App.btsx'",
95
+ "import type { PanelId, PanelSide } from './App.btsx'",
96
+ 'props { activeId, setActiveId, setCopiedSide }: { activeId: PanelId; setActiveId: (value: PanelId) => void; setCopiedSide: (value: PanelSide | null) => void }',
97
+ ])
98
+ expect(source.after).toContain("import AppHeader from './AppHeader.btsx'")
99
+ expect(source.after).toContain(" export type PanelId = 'language' | 'integration' | 'skills'")
100
+ expect(source.after).toContain(' export const panels: Panel[] = [')
101
+ expect(source.after).not.toContain('export type LeftPanel')
102
+ expectCompiles(source.absolutePath, source.after)
103
+ expectCompiles(created.absolutePath, created.after)
104
+ })
105
+
106
+ test('copies the imports the section needs and keeps module directives', () => {
107
+ const source = [
108
+ 'module "use strong";',
109
+ "import Badge from './Badge.btsx'",
110
+ "import { useState } from 'octane'",
111
+ 'props { groups }: { groups: { id: string; items: string[] }[] }',
112
+ '.list',
113
+ ' each group in groups',
114
+ ' .group(key={group.id})',
115
+ ' Badge(tone="green") OK',
116
+ ' ul',
117
+ ' each item in group.items',
118
+ ' li #{item}',
119
+ '',
120
+ ].join('\n')
121
+ const result = plan(source, 'Status', (s) => s.name === 'Group', 'file', { settings: { depthLimit: 3, minLines: 4 } })
122
+ const [edited, created] = result.changes
123
+ expect(created!.after.split('\n').slice(0, 4)).toEqual([
124
+ 'module',
125
+ ' "use strong";',
126
+ "import Badge from './Badge.btsx'",
127
+ 'props { group }: { group: any }',
128
+ ])
129
+ expect(edited!.after).toContain(' Group(key={group.id} group={group})')
130
+ expect(edited!.after.split('\n')[3]).toBe("import Group from './Group.btsx'")
131
+ expectCompiles('/project/src/Status.btsx', edited!.after)
132
+ expectCompiles('/project/src/Group.btsx', created!.after)
133
+ })
134
+
135
+ test('picks a free file name', () => {
136
+ const taken = new Set(['/project/src/AppHeader.btsx'])
137
+ const result = plan(APP, 'App', (s) => s.name === 'AppHeader', 'file', { settings, exists: (path) => taken.has(path) })
138
+ expect(result.component).toBe('AppHeader2')
139
+ expect(result.changes[1]!.absolutePath).toBe('/project/src/AppHeader2.btsx')
140
+ expect(result.changes[0]!.after).toContain('AppHeader2(activeId={activeId}')
141
+ })
142
+
143
+ test('keeps sections that use a file-local component in the file', () => {
144
+ const source = [
145
+ 'component Badge',
146
+ ' span.badge ok',
147
+ 'section',
148
+ ' .a',
149
+ ' .b',
150
+ ' .c',
151
+ ' Badge',
152
+ ' p Two',
153
+ ' p Three',
154
+ '',
155
+ ].join('\n')
156
+ const [suggestion] = suggestionsFor(source, 'Local', { depthLimit: 2, minLines: 3 }).suggestions
157
+ expect(suggestion?.autoApply.fileBlocked).toContain('Badge')
158
+ expect(() => plan(source, 'Local', (s) => s.kind === 'extract', 'file', { settings: { depthLimit: 2, minLines: 3 } })).toThrow(
159
+ /Badge/,
160
+ )
161
+ })
162
+
163
+ test('defaults large sections to their own file', () => {
164
+ const { suggestions } = suggestionsFor(APP, 'App', { fileLines: 20 })
165
+ expect(suggestions.find((s) => s.name === 'AppHeader')?.autoApply.target).toBe('file')
166
+ expect(suggestions.find((s) => s.name === 'RightArticle')?.autoApply.target).toBe('inline')
167
+ })
168
+ })
169
+
170
+ describe('applying through the project', () => {
171
+ let root: string | null = null
172
+ afterEach(() => {
173
+ if (root !== null) rmSync(root, { recursive: true, force: true })
174
+ root = null
175
+ })
176
+
177
+ function project() {
178
+ root = mkdtempSync(join(tmpdir(), 'beast-devtools-'))
179
+ mkdirSync(join(root, 'src'))
180
+ writeFileSync(join(root, 'src/App.btsx'), APP)
181
+ return new BeastProject({ root, include: ['src'], exclude: [] })
182
+ }
183
+
184
+ test('previews, writes, and undoes a move to a new file', () => {
185
+ const beast = project()
186
+ const settings = { ...SETTINGS, fileLines: 18 }
187
+ const report = beast.file('src/App.btsx', settings)!
188
+ const suggestion = report.analysis!.suggestions.find((s) => s.name === 'AppHeader')!
189
+ const request = { path: 'src/App.btsx', hash: report.hash, settings, suggestionId: suggestion.id, target: 'file' as const }
190
+
191
+ const preview = beast.apply({ ...request, dryRun: true })
192
+ expect(preview.undoId).toBeNull()
193
+ expect(preview.files.map((f) => [f.path, f.action])).toEqual([
194
+ ['src/App.btsx', 'edit'],
195
+ ['src/AppHeader.btsx', 'create'],
196
+ ])
197
+ expect(existsSync(join(root!, 'src/AppHeader.btsx'))).toBe(false)
198
+
199
+ const applied = beast.apply({ ...request, dryRun: false })
200
+ expect(existsSync(join(root!, 'src/AppHeader.btsx'))).toBe(true)
201
+ expect(readFileSync(join(root!, 'src/App.btsx'), 'utf8')).toContain('AppHeader(activeId={activeId}')
202
+ expect(() => beast.apply({ ...request, dryRun: true })).toThrow(/changed since it was analyzed/)
203
+
204
+ beast.undo(applied.undoId!)
205
+ expect(readFileSync(join(root!, 'src/App.btsx'), 'utf8')).toBe(APP)
206
+ expect(existsSync(join(root!, 'src/AppHeader.btsx'))).toBe(false)
207
+ })
208
+
209
+ test('refuses to undo over later edits', () => {
210
+ const beast = project()
211
+ const report = beast.file('src/App.btsx', SETTINGS)!
212
+ const suggestion = report.analysis!.suggestions.find((s) => s.name === 'RightArticle')!
213
+ const applied = beast.apply({
214
+ path: 'src/App.btsx',
215
+ hash: report.hash,
216
+ settings: SETTINGS,
217
+ suggestionId: suggestion.id,
218
+ target: 'inline',
219
+ dryRun: false,
220
+ })
221
+ writeFileSync(join(root!, 'src/App.btsx'), `${readFileSync(join(root!, 'src/App.btsx'), 'utf8')}\n// edited`)
222
+ expect(() => beast.undo(applied.undoId!)).toThrow(/edited after the refactor/)
223
+ })
224
+ })
@@ -0,0 +1,224 @@
1
+ import { basename, dirname, join } from 'node:path'
2
+ import type { BeastDocument, ModuleDeclaration } from 'beast-tsrx'
3
+ import type { LineRange, RefactorSuggestion, RefactorTarget } from '../shared/types.js'
4
+ import { parseImport, renderImport, topLevelDeclarations, type Declaration, type ImportSpecifier } from './source-scan.js'
5
+
6
+ /** A refusal with an HTTP status: 409 for stale input, 422 for refactors that cannot be done safely. */
7
+ export class RefactorError extends Error {
8
+ constructor(
9
+ message: string,
10
+ readonly status: 409 | 422,
11
+ ) {
12
+ super(message)
13
+ }
14
+ }
15
+
16
+ export interface FileChange {
17
+ absolutePath: string
18
+ /** Previous content, or null when the file is created. */
19
+ before: string | null
20
+ after: string
21
+ }
22
+
23
+ export interface RefactorPlan {
24
+ component: string
25
+ summary: string
26
+ changes: FileChange[]
27
+ }
28
+
29
+ export interface PlanInput {
30
+ absolutePath: string
31
+ source: string
32
+ document: BeastDocument
33
+ suggestion: RefactorSuggestion
34
+ target: RefactorTarget
35
+ exists: (absolutePath: string) => boolean
36
+ }
37
+
38
+ interface LineEdit {
39
+ /** 1-based line where the edit starts; insertions go before it. */
40
+ start: number
41
+ deleteCount: number
42
+ insert: string[]
43
+ }
44
+
45
+ /**
46
+ * Turn a refactor suggestion into concrete file contents, either hoisting the
47
+ * section into a local `component` of the same file or moving it to a sibling
48
+ * `.btsx` file. The caller validates and writes the result.
49
+ */
50
+ export function planRefactor(input: PlanInput): RefactorPlan {
51
+ const { suggestion, target } = input
52
+ if (suggestion.autoApply.blocked !== null) throw new RefactorError(suggestion.autoApply.blocked, 422)
53
+ if (target === 'file' && suggestion.autoApply.fileBlocked !== null) {
54
+ throw new RefactorError(suggestion.autoApply.fileBlocked, 422)
55
+ }
56
+ return target === 'inline' ? planInline(input) : planFile(input)
57
+ }
58
+
59
+ function planInline({ absolutePath, source, suggestion }: PlanInput): RefactorPlan {
60
+ const lines = source.split('\n')
61
+ const edits: LineEdit[] = [
62
+ ...replaceOccurrences(lines, suggestion, suggestion.name),
63
+ { start: suggestion.insertBeforeLine, deleteCount: 0, insert: [...suggestion.snippet.split('\n'), ''] },
64
+ ]
65
+ return {
66
+ component: suggestion.name,
67
+ summary: `Hoisted ${suggestion.name} into ${basename(absolutePath)}`,
68
+ changes: [{ absolutePath, before: source, after: applyEdits(lines, edits).join('\n') }],
69
+ }
70
+ }
71
+
72
+ function planFile({ absolutePath, source, document, suggestion, exists }: PlanInput): RefactorPlan {
73
+ const lines = source.split('\n')
74
+ const dir = dirname(absolutePath)
75
+ const name = availableName(suggestion.name, source, (candidate) => exists(join(dir, `${candidate}.btsx`)))
76
+ const newPath = join(dir, `${name}.btsx`)
77
+ const references = new Set(suggestion.references)
78
+ suggestion.props.forEach((prop) => references.delete(prop.name))
79
+
80
+ // Imports the section relies on are copied; both files sit in one directory,
81
+ // so relative specifiers stay valid.
82
+ const header: string[] = []
83
+ const imports = document.declarations.filter((d) => d.kind === 'import')
84
+ for (const declaration of imports) {
85
+ const parsed = parseImport(declaration.code)
86
+ if (parsed === null) continue
87
+ const used = parsed.specifiers.filter((specifier) => references.has(specifier.local))
88
+ if (used.length > 0) header.push(...renderImport(parsed, used))
89
+ }
90
+
91
+ // Module-level types and values the section uses are exported from the
92
+ // source and imported back. Type imports are erased, so only values create
93
+ // a (render-time, cycle-safe) runtime import.
94
+ const exported = new Map<string, { declaration: Declaration; module: ModuleDeclaration }>()
95
+ const directives: string[] = []
96
+ for (const module of document.declarations) {
97
+ if (module.kind !== 'module') continue
98
+ directives.push(...leadingDirectives(module.code))
99
+ for (const declaration of topLevelDeclarations(module.code)) {
100
+ for (const binding of declaration.names) {
101
+ if (references.has(binding) && !exported.has(binding)) exported.set(binding, { declaration, module })
102
+ }
103
+ }
104
+ }
105
+ const quote = imports.length > 0 ? (parseImport(imports[0]!.code)?.quote ?? "'") : "'"
106
+ const sourceSpecifier = `./${basename(absolutePath)}`
107
+ const specifiers: ImportSpecifier[] = [...exported].map(([binding, { declaration }]) => ({
108
+ local: binding,
109
+ imported: binding,
110
+ typeOnly: declaration.kind === 'type',
111
+ }))
112
+ if (specifiers.length > 0) {
113
+ header.push(...renderImport({ source: sourceSpecifier, quote, typeOnly: false, specifiers }, specifiers))
114
+ }
115
+
116
+ const [, ...rest] = suggestion.snippet.split('\n')
117
+ const body = rest.map((line) => line.replace(/^ {2}/, ''))
118
+ const content = [
119
+ ...(directives.length > 0 ? ['module', ...directives.map((directive) => ` ${directive}`)] : []),
120
+ ...header,
121
+ ...(body[0]?.startsWith('props ') ? [body.shift()!] : []),
122
+ ...(header.length > 0 || directives.length > 0 ? [''] : []),
123
+ ...body,
124
+ '',
125
+ ]
126
+
127
+ const edits: LineEdit[] = [
128
+ ...replaceOccurrences(lines, suggestion, name),
129
+ ...exportEdits(lines, [...exported.values()]),
130
+ {
131
+ start: importInsertLine(document),
132
+ deleteCount: 0,
133
+ insert: [`import ${name} from ${quote}./${name}.btsx${quote}`],
134
+ },
135
+ ]
136
+
137
+ return {
138
+ component: name,
139
+ summary: `Moved ${name} to ${name}.btsx`,
140
+ changes: [
141
+ { absolutePath, before: source, after: applyEdits(lines, edits).join('\n') },
142
+ { absolutePath: newPath, before: null, after: content.join('\n') },
143
+ ],
144
+ }
145
+ }
146
+
147
+ /** Replace every occurrence with a call, keeping each occurrence's indentation. */
148
+ function replaceOccurrences(lines: readonly string[], suggestion: RefactorSuggestion, name: string): LineEdit[] {
149
+ const call = suggestion.usage.trimStart().replace(suggestion.name, name)
150
+ return suggestion.occurrences.map((range: LineRange) => {
151
+ const indent = /^ */.exec(lines[range.startLine - 1] ?? '')![0]
152
+ return { start: range.startLine, deleteCount: range.endLine - range.startLine + 1, insert: [indent + call] }
153
+ })
154
+ }
155
+
156
+ /** Add `export` to each module declaration another file now imports. */
157
+ function exportEdits(
158
+ lines: readonly string[],
159
+ targets: ReadonlyArray<{ declaration: Declaration; module: ModuleDeclaration }>,
160
+ ): LineEdit[] {
161
+ const byLine = new Map<number, Declaration[]>()
162
+ const seen = new Set<Declaration>()
163
+ for (const { declaration, module } of targets) {
164
+ // One `const { a, b } = …` can supply several imported bindings.
165
+ if (declaration.exported || seen.has(declaration)) continue
166
+ seen.add(declaration)
167
+ const line = module.codeStart.line + module.code.slice(0, declaration.offset).split('\n').length - 1
168
+ byLine.set(line, [...(byLine.get(line) ?? []), declaration])
169
+ }
170
+ return [...byLine].map(([line, declarations]) => {
171
+ let text = lines[line - 1] ?? ''
172
+ for (const declaration of declarations) {
173
+ const keyword = declaration.keyword.replace(/\s+/g, '\\s+')
174
+ const pattern = new RegExp(`(^|[^\\w$.])(${keyword}\\s+)`)
175
+ if (!pattern.test(text)) throw new RefactorError(`Could not export ${declaration.names.join(', ')} on line ${line}.`, 422)
176
+ text = text.replace(pattern, '$1export $2')
177
+ }
178
+ return { start: line, deleteCount: 1, insert: [text] }
179
+ })
180
+ }
181
+
182
+ /** After the last import, or after a leading directive-only `module`, or at the top. */
183
+ function importInsertLine(document: BeastDocument): number {
184
+ const imports = document.declarations.filter((d) => d.kind === 'import')
185
+ if (imports.length > 0) return Math.max(...imports.map((d) => d.span.end.line)) + 1
186
+ const first = document.declarations[0]
187
+ if (first?.kind === 'module' && leadingDirectives(first.code).length > 0) {
188
+ return first.codeStart.line + first.code.trimEnd().split('\n').length
189
+ }
190
+ return 1
191
+ }
192
+
193
+ /** `"use strong";`-style directives at the start of a module block. */
194
+ function leadingDirectives(code: string): string[] {
195
+ const directives: string[] = []
196
+ for (const line of code.split('\n')) {
197
+ const text = line.trim()
198
+ if (text === '') continue
199
+ if (!/^(['"])use [\w ]+\1;?$/.test(text)) break
200
+ directives.push(text)
201
+ }
202
+ return directives
203
+ }
204
+
205
+ /** The suggested name, suffixed until it is free as a file and as an identifier. */
206
+ function availableName(base: string, source: string, taken: (name: string) => boolean): string {
207
+ let name = base
208
+ for (let n = 2; taken(name) || (name !== base && new RegExp(`\\b${name}\\b`).test(source)); n++) name = `${base}${n}`
209
+ return name
210
+ }
211
+
212
+ function applyEdits(lines: readonly string[], edits: readonly LineEdit[]): string[] {
213
+ // Bottom-up keeps earlier line numbers valid; at one line, rewrites go
214
+ // before insertions so an insertion never lands on a rewritten line.
215
+ const ordered = [...edits].sort((a, b) => b.start - a.start || b.deleteCount - a.deleteCount)
216
+ const next = [...lines]
217
+ let floor = Number.POSITIVE_INFINITY
218
+ for (const edit of ordered) {
219
+ if (edit.start + edit.deleteCount > floor) throw new RefactorError('Refactor edits overlap.', 422)
220
+ next.splice(edit.start - 1, edit.deleteCount, ...edit.insert)
221
+ floor = edit.start
222
+ }
223
+ return next
224
+ }