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,377 @@
1
+ /**
2
+ * Lightweight lexical helpers for the TypeScript slices Beast keeps verbatim
3
+ * (setup code, props parameters, attribute expressions). They deliberately
4
+ * avoid a full parser: devtools only needs identifier names, top-level
5
+ * declarations, and a few hook shapes.
6
+ */
7
+
8
+ const RESERVED = new Set([
9
+ 'as', 'async', 'await', 'break', 'case', 'catch', 'class', 'const', 'continue', 'debugger', 'default',
10
+ 'delete', 'do', 'else', 'export', 'extends', 'false', 'finally', 'for', 'function', 'if', 'import', 'in',
11
+ 'instanceof', 'let', 'new', 'null', 'of', 'return', 'satisfies', 'super', 'switch', 'this', 'throw',
12
+ 'true', 'try', 'typeof', 'undefined', 'var', 'void', 'while', 'with', 'yield',
13
+ ])
14
+
15
+ /**
16
+ * Replace string, template, and comment contents with spaces so structural
17
+ * scanning cannot be confused by quoted brackets. Offsets are preserved.
18
+ * With `keepTemplateExpressions`, `${...}` bodies inside template literals are
19
+ * kept because they reference identifiers.
20
+ */
21
+ export function maskLiterals(code: string, keepTemplateExpressions = false): string {
22
+ const out = [...code]
23
+ let i = 0
24
+ const blank = (from: number, to: number) => {
25
+ for (let k = from; k < to && k < out.length; k++) if (out[k] !== '\n') out[k] = ' '
26
+ }
27
+
28
+ const skipTemplate = (start: number): number => {
29
+ let j = start + 1
30
+ let literalStart = j
31
+ while (j < code.length) {
32
+ const char = code[j]
33
+ if (char === '\\') {
34
+ j += 2
35
+ continue
36
+ }
37
+ if (char === '`') {
38
+ blank(literalStart, j)
39
+ return j + 1
40
+ }
41
+ if (char === '$' && code[j + 1] === '{') {
42
+ blank(literalStart, j)
43
+ const end = skipBalanced(j + 1)
44
+ // `$` is an identifier character, so it is always masked.
45
+ blank(j, keepTemplateExpressions ? j + 1 : end)
46
+ j = end
47
+ literalStart = j
48
+ continue
49
+ }
50
+ j++
51
+ }
52
+ blank(literalStart, j)
53
+ return j
54
+ }
55
+
56
+ // Scan from an opening `{` to just past its matching `}`, honoring nested literals.
57
+ const skipBalanced = (open: number): number => {
58
+ let depth = 0
59
+ let j = open
60
+ while (j < code.length) {
61
+ const char = code[j]
62
+ if (char === '"' || char === "'") {
63
+ j = skipQuoted(j)
64
+ continue
65
+ }
66
+ if (char === '`') {
67
+ j = skipTemplate(j)
68
+ continue
69
+ }
70
+ if (char === '{') depth++
71
+ else if (char === '}') {
72
+ depth--
73
+ if (depth === 0) return j + 1
74
+ }
75
+ j++
76
+ }
77
+ return j
78
+ }
79
+
80
+ const skipQuoted = (start: number): number => {
81
+ const quote = code[start]
82
+ let j = start + 1
83
+ while (j < code.length && code[j] !== quote && code[j] !== '\n') {
84
+ j += code[j] === '\\' ? 2 : 1
85
+ }
86
+ blank(start + 1, j)
87
+ return j + 1
88
+ }
89
+
90
+ while (i < code.length) {
91
+ const char = code[i]
92
+ const next = code[i + 1]
93
+ if (char === '/' && next === '/') {
94
+ const end = code.indexOf('\n', i)
95
+ const stop = end === -1 ? code.length : end
96
+ blank(i, stop)
97
+ i = stop
98
+ } else if (char === '/' && next === '*') {
99
+ const end = code.indexOf('*/', i + 2)
100
+ const stop = end === -1 ? code.length : end + 2
101
+ blank(i, stop)
102
+ i = stop
103
+ } else if (char === '"' || char === "'") {
104
+ i = skipQuoted(i)
105
+ } else if (char === '`') {
106
+ i = skipTemplate(i)
107
+ } else {
108
+ i++
109
+ }
110
+ }
111
+ return out.join('')
112
+ }
113
+
114
+ /** Every identifier referenced by an expression, excluding property names and keywords. */
115
+ export function identifiersIn(code: string): Set<string> {
116
+ const masked = maskLiterals(code, true)
117
+ const names = new Set<string>()
118
+ const pattern = /[A-Za-z_$][\w$]*/g
119
+ for (const match of masked.matchAll(pattern)) {
120
+ const name = match[0]
121
+ if (RESERVED.has(name)) continue
122
+ const before = masked.slice(0, match.index).trimEnd()
123
+ // `a.b` and `a?.b` are property reads of `b`; `...b` is a spread of `b`.
124
+ if (before.endsWith('.') && !before.endsWith('...')) continue
125
+ names.add(name)
126
+ }
127
+ return names
128
+ }
129
+
130
+ /** Names bound by a destructuring pattern or a plain identifier. */
131
+ export function patternNames(pattern: string): string[] {
132
+ const text = pattern.trim()
133
+ if (text.startsWith('{') || text.startsWith('[')) {
134
+ const inner = text.slice(1, matchingClose(text, 0))
135
+ return splitTopLevel(inner, ',').flatMap((part) => {
136
+ let entry = part.trim()
137
+ if (entry === '') return []
138
+ if (entry.startsWith('...')) entry = entry.slice(3)
139
+ const eq = indexOfTopLevel(entry, '=')
140
+ if (eq !== -1) entry = entry.slice(0, eq)
141
+ if (text.startsWith('{')) {
142
+ const colon = indexOfTopLevel(entry, ':')
143
+ if (colon !== -1) return patternNames(entry.slice(colon + 1))
144
+ }
145
+ return patternNames(entry)
146
+ })
147
+ }
148
+ const identifier = /^[A-Za-z_$][\w$]*/.exec(text)
149
+ return identifier ? [identifier[0]] : []
150
+ }
151
+
152
+ export interface Declaration {
153
+ names: string[]
154
+ /** Source text of the initializer (up to the end of the statement). */
155
+ init: string
156
+ /** Offset of the declaration keyword inside the scanned code. */
157
+ offset: number
158
+ keyword: string
159
+ /** `type` and `interface` declare types; everything else declares values. */
160
+ kind: 'value' | 'type'
161
+ exported: boolean
162
+ }
163
+
164
+ /** Top-level `const`/`let`/`var`/`function`/`class`/`enum`/`type`/`interface` declarations. */
165
+ export function topLevelDeclarations(code: string): Declaration[] {
166
+ const masked = maskLiterals(code)
167
+ const declarations: Declaration[] = []
168
+ let depth = 0
169
+ for (let i = 0; i < masked.length; i++) {
170
+ const char = masked[i]!
171
+ if (char === '{' || char === '(' || char === '[') depth++
172
+ else if (char === '}' || char === ')' || char === ']') depth--
173
+ if (depth !== 0 || !/[a-z]/.test(char) || /[\w$]/.test(masked[i - 1] ?? '')) continue
174
+
175
+ const keyword = /^(const|let|var|function|class|enum|type|interface|async\s+function)\s+/.exec(masked.slice(i))
176
+ if (keyword === null) continue
177
+ const rest = i + keyword[0].length
178
+ const exported = /\bexport\s*$/.test(masked.slice(Math.max(0, i - 16), i))
179
+ const base = { offset: i, keyword: keyword[1]!, exported }
180
+ if (keyword[1] === 'const' || keyword[1] === 'let' || keyword[1] === 'var') {
181
+ const opener = masked[rest]
182
+ const patternEnd = opener === '{' || opener === '['
183
+ ? matchingClose(masked, rest) + 1
184
+ : rest + (/^[A-Za-z_$][\w$]*/.exec(masked.slice(rest))?.[0].length ?? 0)
185
+ const eq = masked.indexOf('=', patternEnd)
186
+ const end = statementEnd(masked, eq === -1 ? patternEnd : eq + 1)
187
+ declarations.push({
188
+ ...base,
189
+ names: patternNames(code.slice(rest, patternEnd)),
190
+ init: eq === -1 ? '' : code.slice(eq + 1, end).trim(),
191
+ kind: 'value',
192
+ })
193
+ i = end - 1
194
+ } else if (keyword[1] === 'type' || keyword[1] === 'interface') {
195
+ // `type` is only a declaration when a name and `=` or type parameters follow.
196
+ const name = /^([A-Za-z_$][\w$]*)\s*(?:[=<]|extends\b|\{)/.exec(masked.slice(rest))?.[1]
197
+ if (name !== undefined) declarations.push({ ...base, names: [name], init: '', kind: 'type' })
198
+ i = rest
199
+ } else {
200
+ const name = /^\*?\s*([A-Za-z_$][\w$]*)/.exec(masked.slice(rest))?.[1]
201
+ if (name !== undefined) declarations.push({ ...base, names: [name], init: '', kind: 'value' })
202
+ i = rest
203
+ }
204
+ }
205
+ return declarations
206
+ }
207
+
208
+ export interface ImportSpecifier {
209
+ /** Binding name in the importing module. */
210
+ local: string
211
+ /** `default`, `*`, or the exported name. */
212
+ imported: string
213
+ typeOnly: boolean
214
+ }
215
+
216
+ export interface ParsedImport {
217
+ source: string
218
+ /** Quote character used for the module specifier. */
219
+ quote: string
220
+ typeOnly: boolean
221
+ specifiers: ImportSpecifier[]
222
+ }
223
+
224
+ /** Parse one `import` statement; side-effect imports return no specifiers. */
225
+ export function parseImport(code: string): ParsedImport | null {
226
+ const text = code.trim().replace(/;$/, '').trim()
227
+ const bare = /^import\s+(['"])([^'"]+)\1$/.exec(text)
228
+ if (bare !== null) return { source: bare[2]!, quote: bare[1]!, typeOnly: false, specifiers: [] }
229
+ const match = /^import\s+(type\s+)?([\s\S]+?)\s+from\s+(['"])([^'"]+)\3$/.exec(text)
230
+ if (match === null) return null
231
+ const typeOnly = match[1] !== undefined
232
+ const specifiers: ImportSpecifier[] = []
233
+ let clause = match[2]!.trim()
234
+ const named = /\{([\s\S]*)\}/.exec(clause)
235
+ if (named !== null) {
236
+ for (const part of named[1]!.split(',')) {
237
+ const entry = part.trim()
238
+ if (entry === '') continue
239
+ const inlineType = /^type\s+/.test(entry)
240
+ const [imported, local = imported] = entry.replace(/^type\s+/, '').split(/\s+as\s+/).map((name) => name.trim())
241
+ specifiers.push({ local: local!, imported: imported!, typeOnly: typeOnly || inlineType })
242
+ }
243
+ clause = clause.replace(named[0], '')
244
+ }
245
+ for (const part of clause.split(',')) {
246
+ const entry = part.trim()
247
+ if (entry === '') continue
248
+ const namespace = /^\*\s+as\s+([A-Za-z_$][\w$]*)$/.exec(entry)
249
+ if (namespace !== null) specifiers.push({ local: namespace[1]!, imported: '*', typeOnly })
250
+ else if (/^[A-Za-z_$][\w$]*$/.test(entry)) specifiers.push({ local: entry, imported: 'default', typeOnly })
251
+ }
252
+ return { source: match[4]!, quote: match[3]!, typeOnly, specifiers }
253
+ }
254
+
255
+ /** Render import statements for a subset of specifiers, one line per statement. */
256
+ export function renderImport(parsed: ParsedImport, specifiers: readonly ImportSpecifier[]): string[] {
257
+ const q = parsed.quote
258
+ const from = `from ${q}${parsed.source}${q}`
259
+ const lines: string[] = []
260
+ const namespace = specifiers.find((s) => s.imported === '*')
261
+ if (namespace !== undefined) lines.push(`import ${namespace.typeOnly ? 'type ' : ''}* as ${namespace.local} ${from}`)
262
+ const rest = specifiers.filter((s) => s.imported !== '*')
263
+ const types = rest.filter((s) => s.typeOnly)
264
+ const values = rest.filter((s) => !s.typeOnly)
265
+ const clause = (list: readonly ImportSpecifier[]) => {
266
+ const fallback = list.find((s) => s.imported === 'default')
267
+ const named = list
268
+ .filter((s) => s.imported !== 'default')
269
+ .map((s) => (s.imported === s.local ? s.local : `${s.imported} as ${s.local}`))
270
+ return [fallback?.local, named.length > 0 ? `{ ${named.join(', ')} }` : undefined].filter(Boolean).join(', ')
271
+ }
272
+ if (values.length > 0) lines.push(`import ${clause(values)} ${from}`)
273
+ // A type-only import may name a default or named bindings, not both.
274
+ const typeDefault = types.filter((s) => s.imported === 'default')
275
+ const typeNamed = types.filter((s) => s.imported !== 'default')
276
+ if (typeDefault.length > 0) lines.push(`import type ${clause(typeDefault)} ${from}`)
277
+ if (typeNamed.length > 0) lines.push(`import type ${clause(typeNamed)} ${from}`)
278
+ return lines
279
+ }
280
+
281
+ /** Split a `props` parameter such as `{ a, b = 1 }: Props` into names and type text. */
282
+ export function parsePropsParameter(parameter: string): { names: string[]; type: string | null } {
283
+ const text = parameter.trim().replace(/;$/, '')
284
+ const masked = maskLiterals(text)
285
+ const patternEnd = text.startsWith('{') || text.startsWith('[')
286
+ ? matchingClose(masked, 0) + 1
287
+ : (/^[A-Za-z_$][\w$]*/.exec(text)?.[0].length ?? 0)
288
+ const names = patternNames(text.slice(0, patternEnd))
289
+ const rest = text.slice(patternEnd).trim()
290
+ return { names, type: rest.startsWith(':') ? rest.slice(1).trim() || null : null }
291
+ }
292
+
293
+ /** Return the balanced type-argument text after `callee<`, if the initializer starts with it. */
294
+ export function hookCall(init: string): { hook: string; typeArgument: string | null; argument: string } | null {
295
+ const match = /^(?:await\s+)?(use[A-Z]\w*)\s*/.exec(init)
296
+ if (match === null) return null
297
+ let i = match[0].length
298
+ let typeArgument: string | null = null
299
+ if (init[i] === '<') {
300
+ let depth = 0
301
+ const start = i
302
+ for (; i < init.length; i++) {
303
+ if (init[i] === '<') depth++
304
+ else if (init[i] === '>' && init[i - 1] !== '=') {
305
+ depth--
306
+ if (depth === 0) break
307
+ }
308
+ }
309
+ typeArgument = init.slice(start + 1, i).trim()
310
+ i++
311
+ }
312
+ if (init[i] !== '(') return null
313
+ const close = matchingClose(maskLiterals(init), i)
314
+ const args = splitTopLevel(init.slice(i + 1, close), ',')
315
+ return { hook: match[1]!, typeArgument, argument: (args[0] ?? '').trim() }
316
+ }
317
+
318
+ function matchingClose(text: string, open: number): number {
319
+ const pairs: Record<string, string> = { '{': '}', '[': ']', '(': ')' }
320
+ const stack: string[] = []
321
+ for (let i = open; i < text.length; i++) {
322
+ const char = text[i]!
323
+ if (char in pairs) stack.push(pairs[char]!)
324
+ else if (char === stack.at(-1)) {
325
+ stack.pop()
326
+ if (stack.length === 0) return i
327
+ }
328
+ }
329
+ return text.length
330
+ }
331
+
332
+ function splitTopLevel(text: string, separator: string): string[] {
333
+ const masked = maskLiterals(text)
334
+ const parts: string[] = []
335
+ let depth = 0
336
+ let start = 0
337
+ for (let i = 0; i < masked.length; i++) {
338
+ const char = masked[i]!
339
+ if ('{[(<'.includes(char)) depth++
340
+ else if ('}])>'.includes(char) && !(char === '>' && masked[i - 1] === '=')) depth--
341
+ else if (char === separator && depth === 0) {
342
+ parts.push(text.slice(start, i))
343
+ start = i + 1
344
+ }
345
+ }
346
+ parts.push(text.slice(start))
347
+ return parts
348
+ }
349
+
350
+ function indexOfTopLevel(text: string, char: string): number {
351
+ let depth = 0
352
+ for (let i = 0; i < text.length; i++) {
353
+ const current = text[i]!
354
+ if ('{[(<'.includes(current)) depth++
355
+ else if ('}])>'.includes(current) && !(current === '>' && text[i - 1] === '=')) depth--
356
+ else if (current === char && depth === 0) return i
357
+ }
358
+ return -1
359
+ }
360
+
361
+ function statementEnd(masked: string, from: number): number {
362
+ let depth = 0
363
+ for (let i = from; i < masked.length; i++) {
364
+ const char = masked[i]!
365
+ if (char === '{' || char === '(' || char === '[') depth++
366
+ else if (char === '}' || char === ')' || char === ']') {
367
+ if (depth === 0) return i
368
+ depth--
369
+ } else if (depth === 0 && (char === ';' || char === '\n')) {
370
+ // A newline only ends the statement when the next line does not continue it.
371
+ if (char === ';') return i
372
+ const rest = masked.slice(i + 1).trimStart()
373
+ if (!/^[.?:+\-*/%&|=,)]/.test(rest) && !/[=,(+\-*/%&|?:]\s*$/.test(masked.slice(from, i))) return i
374
+ }
375
+ }
376
+ return masked.length
377
+ }
@@ -0,0 +1,208 @@
1
+ /**
2
+ * Wire types shared by the dev-server API (`devtools/server`) and the in-page
3
+ * overlay (`devtools/client`). Everything here must stay JSON-serializable.
4
+ */
5
+
6
+ export const API_BASE = '/__beast-devtools/api'
7
+ export const SOURCE_CHANGED_EVENT = 'beast-devtools:source-changed'
8
+
9
+ export interface AnalyzerSettings {
10
+ /** Template nesting depth (0 = component root) above which a line counts as too deep. */
11
+ depthLimit: number
12
+ /** Smallest section, in source lines, worth extracting into a component. */
13
+ minLines: number
14
+ /** Sections at least this long are extracted into their own `.btsx` file by default. */
15
+ fileLines: number
16
+ }
17
+
18
+ export const DEFAULT_SETTINGS: AnalyzerSettings = { depthLimit: 5, minLines: 8, fileLines: 30 }
19
+
20
+ export type Severity = 'info' | 'warning' | 'critical'
21
+
22
+ export interface LineRange {
23
+ startLine: number
24
+ endLine: number
25
+ }
26
+
27
+ export interface SuggestedProp {
28
+ name: string
29
+ type: string
30
+ }
31
+
32
+ export interface RefactorSuggestion extends LineRange {
33
+ id: string
34
+ kind: 'extract' | 'duplicate'
35
+ severity: Severity
36
+ /** Component (default or local `component`) the section currently lives in. */
37
+ host: string
38
+ /** Proposed PascalCase name for the new local component. */
39
+ name: string
40
+ /** Selector-style label of the section root, e.g. `section#workflow-panel`. */
41
+ label: string
42
+ reason: string
43
+ lines: number
44
+ /** Nesting depth of the section root inside its host. */
45
+ depth: number
46
+ /** Deepest nesting depth reached inside the section, measured in the host. */
47
+ reach: number
48
+ props: SuggestedProp[]
49
+ /** Ready-to-paste `component` declaration. */
50
+ snippet: string
51
+ /** Call site that replaces the section. */
52
+ usage: string
53
+ /** Line before which the `component` declaration should be inserted. */
54
+ insertBeforeLine: number
55
+ /** Every structurally identical copy (duplicate suggestions only). */
56
+ occurrences: LineRange[]
57
+ /** Identifiers and component tags the section references, for moving it to another file. */
58
+ references: string[]
59
+ autoApply: AutoApply
60
+ }
61
+
62
+ export type RefactorTarget = 'inline' | 'file'
63
+
64
+ /** Whether the suggestion can be applied automatically, and where by default. */
65
+ export interface AutoApply {
66
+ /** Default target: sections of `fileLines` or more go to their own file. */
67
+ target: RefactorTarget
68
+ /** Why the suggestion cannot be applied at all, if so. */
69
+ blocked: string | null
70
+ /** Why the section cannot move to its own file, if so. */
71
+ fileBlocked: string | null
72
+ }
73
+
74
+ export interface ComponentMetrics {
75
+ name: string
76
+ line: number
77
+ templateLines: number
78
+ maxDepth: number
79
+ }
80
+
81
+ export interface FileAnalysis {
82
+ settings: AnalyzerSettings
83
+ /** Detected indentation step in spaces. */
84
+ indentUnit: number
85
+ /** Structural nesting depth per source line (index = line - 1); `null` outside templates. */
86
+ lineDepths: Array<number | null>
87
+ maxDepth: number
88
+ averageDepth: number
89
+ templateLines: number
90
+ /** Template lines deeper than `settings.depthLimit`. */
91
+ deepLines: number
92
+ /** Number of template lines at each depth (index = depth). */
93
+ histogram: number[]
94
+ components: ComponentMetrics[]
95
+ suggestions: RefactorSuggestion[]
96
+ }
97
+
98
+ export interface DiagnosticInfo {
99
+ code: string
100
+ severity: 'error' | 'warning'
101
+ message: string
102
+ line: number
103
+ column: number
104
+ endLine: number
105
+ endColumn: number
106
+ hint?: string
107
+ /** Human-readable rendering with a caret under the failing source. */
108
+ formatted: string
109
+ }
110
+
111
+ export type CompiledOutput =
112
+ | {
113
+ ok: true
114
+ tsrx: string
115
+ /** BTSX line (index = line - 1) for each generated TSRX line; `null` when unmapped. */
116
+ tsrxToBtsx: Array<number | null>
117
+ /** Generated TSRX lines for each BTSX line (index = line - 1). */
118
+ btsxToTsrx: number[][]
119
+ diagnostics: DiagnosticInfo[]
120
+ }
121
+ | { ok: false; error: DiagnosticInfo }
122
+
123
+ export interface FileSummary {
124
+ path: string
125
+ lines: number
126
+ maxDepth: number | null
127
+ deepLines: number
128
+ suggestions: number
129
+ error: string | null
130
+ }
131
+
132
+ export interface HookBinding {
133
+ /** Hook call, e.g. `useState`. */
134
+ hook: string
135
+ /** Names bound by the declaration, e.g. `['activeId', 'setActiveId']`. */
136
+ names: string[]
137
+ line: number
138
+ }
139
+
140
+ export interface ComponentLocation {
141
+ name: string
142
+ path: string
143
+ absolutePath: string
144
+ line: number
145
+ column: number
146
+ local: boolean
147
+ /** Value-bearing hooks declared in setup, in call order. */
148
+ hooks: HookBinding[]
149
+ }
150
+
151
+ export interface ProjectReport {
152
+ root: string
153
+ settings: AnalyzerSettings
154
+ files: FileSummary[]
155
+ components: ComponentLocation[]
156
+ }
157
+
158
+ export interface FileReport {
159
+ path: string
160
+ absolutePath: string
161
+ /** Content hash; refactors are refused when the file changed since it was analyzed. */
162
+ hash: string
163
+ source: string
164
+ compiled: CompiledOutput
165
+ analysis: FileAnalysis | null
166
+ }
167
+
168
+ export interface ApplyRequest {
169
+ path: string
170
+ hash: string
171
+ settings: AnalyzerSettings
172
+ suggestionId: string
173
+ target: RefactorTarget
174
+ /** Plan and validate without writing. */
175
+ dryRun: boolean
176
+ }
177
+
178
+ export interface DiffLine {
179
+ type: 'context' | 'add' | 'remove'
180
+ text: string
181
+ }
182
+
183
+ export interface DiffHunk {
184
+ oldStart: number
185
+ newStart: number
186
+ lines: DiffLine[]
187
+ }
188
+
189
+ export interface PlannedFile {
190
+ path: string
191
+ action: 'create' | 'edit'
192
+ added: number
193
+ removed: number
194
+ hunks: DiffHunk[]
195
+ }
196
+
197
+ export interface ApplyResult {
198
+ /** Undo handle; present once the change has been written. */
199
+ undoId: string | null
200
+ /** Name of the extracted component. */
201
+ component: string
202
+ summary: string
203
+ files: PlannedFile[]
204
+ }
205
+
206
+ export interface UndoResult {
207
+ summary: string
208
+ }