77import {
88 fileGetSymbols ,
99 fileApplyEdit ,
10+ fileReadContent ,
1011 fileGetDiagnostics ,
1112 fileExecuteRename ,
1213 fileFindReferences ,
1314 fileGetCodeActions ,
1415 fileApplyCodeAction ,
16+ fileShowEditDiff ,
1517 type NativeDocumentSymbol ,
1618} from '../../client-pipe.js' ;
17- import { diffSymbols , extractNewName } from './symbol-diff.js' ;
19+ import { diffSymbols , type EditInfo } from './symbol-diff.js' ;
1820import type {
1921 DetectedIntent ,
2022 PropagatedChange ,
@@ -27,11 +29,63 @@ const DIAGNOSTIC_SETTLE_DELAY_MS = 800;
2729const MAX_AUTO_FIX_ATTEMPTS = 5 ;
2830
2931/**
30- * Execute a file edit with the full safety layer:
32+ * Spelling-related Code Action titles that can corrupt semantics after deletions.
33+ * These "fix" missing references by renaming to similarly-named symbols,
34+ * which changes program behavior rather than fixing an actual typo.
35+ */
36+ const HARMFUL_FIX_PATTERNS = [
37+ / c h a n g e s p e l l i n g / i,
38+ / d i d y o u m e a n / i,
39+ ] ;
40+
41+ /**
42+ * Check whether a Code Action is potentially harmful given the edit context.
43+ * Spelling corrections after deliberate deletions can silently corrupt semantics —
44+ * e.g. renaming `level15()` to `level5()` after the user deleted `level15`.
45+ */
46+ function isHarmfulAutoFix ( title : string , hasDeleteIntents : boolean ) : boolean {
47+ if ( ! hasDeleteIntents ) return false ;
48+ return HARMFUL_FIX_PATTERNS . some ( pattern => pattern . test ( title ) ) ;
49+ }
50+
51+ /**
52+ * Find a symbol by qualified name (e.g. `ParentName.childName`).
53+ * For top-level symbols, the name is matched directly.
54+ * For child symbols, splits on `.` and walks the hierarchy.
55+ */
56+ function findSymbolByQualifiedName (
57+ symbols : NativeDocumentSymbol [ ] ,
58+ qualifiedName : string ,
59+ ) : NativeDocumentSymbol | undefined {
60+ const parts = qualifiedName . split ( '.' ) ;
61+ if ( parts . length === 1 ) {
62+ return symbols . find ( s => s . name === qualifiedName ) ;
63+ }
64+ const parent = symbols . find ( s => s . name === parts [ 0 ] ) ;
65+ if ( ! parent ?. children ) return undefined ;
66+ const childName = parts . slice ( 1 ) . join ( '.' ) ;
67+ return findSymbolByQualifiedName ( parent . children , childName ) ;
68+ }
69+
70+ /**
71+ * Normalize file paths for comparison (forward slashes, lowercase on Windows).
72+ */
73+ function normalizePath ( p : string ) : string {
74+ return p . replace ( / \\ / g, '/' ) . toLowerCase ( ) ;
75+ }
76+
77+ /**
78+ * Execute a file edit with the full safety layer.
3179 *
32- * Phase 1: Virtual pre-check — compare old vs new DocumentSymbols to detect intents
33- * Phase 2: Atomic apply — apply the edit + propagate renames
34- * Phase 3: Auto-fix — apply Code Actions for new errors
80+ * All symbol detection uses VS Code's DocumentSymbol provider — zero regex.
81+ *
82+ * Flow:
83+ * Phase 0: Snapshot old symbols + old content
84+ * Phase 1: Tentatively apply the edit
85+ * Phase 2: Semantic intent detection via diffSymbols (DocumentSymbol diff)
86+ * Phase 3: If deletes with external refs → revert and block
87+ * Phase 4: If renames → revert, execute VS Code rename provider, re-apply body
88+ * Phase 5: Auto-fix via Code Actions, final diagnostics
3589 */
3690export async function executeEditWithSafetyLayer (
3791 filePath : string ,
@@ -43,25 +97,25 @@ export async function executeEditWithSafetyLayer(
4397 const propagated : PropagatedChange [ ] = [ ] ;
4498 const autoFixed : AutoFix [ ] = [ ] ;
4599
46- // ── Phase 1: Pre-check — Get symbols before edit ────────────────
100+ // ── Phase 0: Snapshot ─────────────────────────── ────────────────
47101 let oldSymbols : NativeDocumentSymbol [ ] = [ ] ;
48102 try {
49103 const beforeResult = await fileGetSymbols ( filePath ) ;
50104 oldSymbols = beforeResult . symbols ;
51105 } catch {
52- // No symbol provider available — proceed without intent detection
106+ // No symbol provider — proceed without safety checks
53107 }
54108
55- // Get pre-existing diagnostics to compare after edit
56- let preExistingErrors = 0 ;
109+ // Read old content of the edit range for potential revert
110+ let oldContent : string | undefined ;
57111 try {
58- const preDiags = await fileGetDiagnostics ( filePath ) ;
59- preExistingErrors = preDiags . diagnostics . filter ( d => d . severity . toLowerCase ( ) === 'error' ) . length ;
112+ const contentResult = await fileReadContent ( filePath , startLine , endLine ) ;
113+ oldContent = contentResult . content ;
60114 } catch {
61- // Best-effort
115+ // Best-effort — revert won't be possible
62116 }
63117
64- // ── Phase 2: Apply the edit ──────────── ─────────────────────────
118+ // ── Phase 1: Tentatively apply the edit ─────────────────────────
65119 try {
66120 await fileApplyEdit ( filePath , startLine , endLine , newContent ) ;
67121 } catch ( err ) {
@@ -77,38 +131,100 @@ export async function executeEditWithSafetyLayer(
77131 } ;
78132 }
79133
80- // ── Phase 1b: Post-apply intent detection ───────────────────────
81- // Get symbols after apply for comparison
82- let newSymbols : NativeDocumentSymbol [ ] = [ ] ;
134+ // ── Phase 2: Semantic intent detection ──────────────────────────
135+ // Get new symbols via VS Code's DocumentSymbol provider — the only source of truth.
136+ let allIntents : DetectedIntent [ ] = [ ] ;
137+
83138 if ( oldSymbols . length > 0 ) {
84139 try {
85140 const afterResult = await fileGetSymbols ( filePath ) ;
86- newSymbols = afterResult . symbols ;
87- const intents = diffSymbols ( oldSymbols , newSymbols ) ;
88- detectedIntents . push ( ...intents ) ;
141+ const newLineCount = newContent . split ( '\n' ) . length ;
142+ const oldLineCount = endLine - startLine + 1 ;
143+ const editInfoForDiff : EditInfo = {
144+ newContentEndLine : startLine + newLineCount - 1 ,
145+ linesDelta : newLineCount - oldLineCount ,
146+ } ;
147+
148+ allIntents = diffSymbols ( oldSymbols , afterResult . symbols , editInfoForDiff ) ;
89149 } catch {
90150 // Proceed without intent detection
91151 }
92152 }
93153
94- // ── Phase 2b: Rename propagation ───────────────────────────────
95- const renames = detectedIntents . filter ( i => i . type === 'rename' ) ;
96- for ( const renameIntent of renames ) {
97- const newName = extractNewName ( renameIntent ) ;
98- if ( ! newName ) continue ;
154+ const renames = allIntents . filter ( i => i . type === 'rename' && i . newName ) ;
155+ const deletes = allIntents . filter ( i => i . type === 'delete' ) ;
99156
157+ // ── Phase 3: Revert + delete protection + rename propagation ────
158+ if ( ( renames . length > 0 || deletes . length > 0 ) && oldContent !== undefined ) {
159+ // Revert to original state so we can check refs / execute renames
160+ const tentativeEndLine = startLine + newContent . split ( '\n' ) . length - 1 ;
100161 try {
101- // Find a reference to the OLD name in another file to trigger rename
102- const refs = await fileFindReferences ( filePath , 0 , 0 ) ;
103- // Search for a reference in a DIFFERENT file to propagate cross-file
104- const externalRef = refs . references . find ( r => ! r . file . endsWith ( filePath . replace ( / \\ / g, '/' ) ) ) ;
162+ await fileApplyEdit ( filePath , startLine , tentativeEndLine , oldContent ) ;
163+ } catch {
164+ // Can't revert — skip protection and propagation, edit stays applied
165+ detectedIntents . push ( ...allIntents ) ;
166+ return finalize ( filePath , detectedIntents , propagated , autoFixed , startLine ) ;
167+ }
168+
169+ // Check deletes for external references
170+ for ( const del of deletes ) {
171+ const oldSym = oldSymbols . find ( s => s . name === del . symbol ) ;
172+ if ( ! oldSym ) continue ;
173+
174+ try {
175+ const refs = await fileFindReferences (
176+ filePath ,
177+ oldSym . selectionRange . startLine ,
178+ oldSym . selectionRange . startChar ,
179+ ) ;
180+ const normalizedFilePath = normalizePath ( filePath ) ;
181+ const externalRefs = refs . references . filter ( r => {
182+ const refPath = normalizePath ( r . file ) ;
183+ return (
184+ refPath !== normalizedFilePath &&
185+ ! refPath . endsWith ( normalizedFilePath ) &&
186+ ! normalizedFilePath . endsWith ( refPath )
187+ ) ;
188+ } ) ;
189+
190+ if ( externalRefs . length > 0 ) {
191+ const refFiles = [ ...new Set ( externalRefs . map ( r => r . file ) ) ] ;
192+ return {
193+ success : false ,
194+ file : filePath ,
195+ detectedIntents : [ { type : 'delete' , symbol : del . symbol } ] ,
196+ propagated : [ ] ,
197+ autoFixed : [ ] ,
198+ remainingErrors : [ ] ,
199+ summary :
200+ `Blocked: Cannot delete '${ del . symbol } ' — it has ${ externalRefs . length } ` +
201+ `reference(s) in ${ refFiles . length } other file(s): ` +
202+ `${ refFiles . slice ( 0 , 5 ) . join ( ', ' ) } ` +
203+ `${ refFiles . length > 5 ? ` and ${ refFiles . length - 5 } more` : '' } . ` +
204+ `Resolve or remove these references first.` ,
205+ } ;
206+ }
207+ } catch {
208+ // Can't check references — allow
209+ }
210+ }
105211
106- if ( externalRef ) {
212+ // Execute VS Code rename provider for each rename.
213+ // File is in ORIGINAL state — old names exist, so the provider can resolve all refs.
214+ for ( const renameIntent of renames ) {
215+ if ( ! renameIntent . newName ) continue ;
216+
217+ // Find the old symbol — supports both top-level and child renames.
218+ // Child renames use `ParentName.childName` notation.
219+ const oldSym = findSymbolByQualifiedName ( oldSymbols , renameIntent . symbol ) ;
220+ if ( ! oldSym ) continue ;
221+
222+ try {
107223 const renameResult = await fileExecuteRename (
108- externalRef . file ,
109- externalRef . line - 1 ,
110- externalRef . character ,
111- newName ,
224+ filePath ,
225+ oldSym . selectionRange . startLine ,
226+ oldSym . selectionRange . startChar ,
227+ renameIntent . newName ,
112228 ) ;
113229 if ( renameResult . success ) {
114230 propagated . push ( {
@@ -117,15 +233,84 @@ export async function executeEditWithSafetyLayer(
117233 totalEdits : renameResult . totalEdits ,
118234 } ) ;
119235 }
236+ } catch {
237+ // Rename provider failed — body edit will still be applied
120238 }
121- } catch {
122- // Rename propagation is best-effort
239+
240+ detectedIntents . push ( renameIntent ) ;
241+ }
242+
243+ // After renames, re-resolve the edit range (rename may have changed positions)
244+ let editStartLine = startLine ;
245+ let editEndLine = endLine ;
246+
247+ if ( propagated . length > 0 ) {
248+ try {
249+ const refreshed = await fileGetSymbols ( filePath ) ;
250+ for ( const renameIntent of renames ) {
251+ if ( ! renameIntent . newName ) continue ;
252+ const renamedSym = refreshed . symbols . find ( s => s . name === renameIntent . newName ) ;
253+ if ( renamedSym ) {
254+ editStartLine = renamedSym . range . startLine ;
255+ editEndLine = renamedSym . range . endLine ;
256+ }
257+ }
258+ // Update oldSymbols so post-hoc diff sees the renamed state
259+ oldSymbols = refreshed . symbols ;
260+ } catch {
261+ // Fall back to original range
262+ }
263+ }
264+
265+ // Re-apply the body edit (newContent already contains the new name + new body)
266+ try {
267+ await fileApplyEdit ( filePath , editStartLine , editEndLine , newContent ) ;
268+ } catch ( err ) {
269+ const msg = err instanceof Error ? err . message : String ( err ) ;
270+ return {
271+ success : false ,
272+ file : filePath ,
273+ detectedIntents,
274+ propagated,
275+ autoFixed : [ ] ,
276+ remainingErrors : [ ] ,
277+ summary : `Edit failed on re-apply after rename: ${ msg } ` ,
278+ } ;
279+ }
280+ }
281+
282+ // Add non-rename/non-delete intents (body_change, add)
283+ for ( const intent of allIntents ) {
284+ if ( intent . type !== 'rename' && intent . type !== 'delete' ) {
285+ detectedIntents . push ( intent ) ;
123286 }
124287 }
288+ // Add delete intents that weren't blocked (no external refs)
289+ for ( const del of deletes ) {
290+ detectedIntents . push ( del ) ;
291+ }
292+
293+ return finalize ( filePath , detectedIntents , propagated , autoFixed , startLine ) ;
294+ }
125295
126- // ── Phase 3: Auto-fix via Code Actions ─────────────────────────
296+ /**
297+ * Post-edit finalization: show diff, run auto-fix, collect diagnostics.
298+ */
299+ async function finalize (
300+ filePath : string ,
301+ detectedIntents : DetectedIntent [ ] ,
302+ propagated : PropagatedChange [ ] ,
303+ autoFixed : AutoFix [ ] ,
304+ editStartLine : number ,
305+ ) : Promise < FileEditResult > {
306+ // Show inline diff editor (fire-and-forget)
307+ fileShowEditDiff ( filePath , editStartLine ) ;
308+
309+ // ── Auto-fix via Code Actions ──────────────────────────────────
127310 await delay ( DIAGNOSTIC_SETTLE_DELAY_MS ) ;
128311
312+ const hasDeletes = detectedIntents . some ( i => i . type === 'delete' ) ;
313+
129314 try {
130315 const postDiags = await fileGetDiagnostics ( filePath ) ;
131316 const newErrors = postDiags . diagnostics . filter ( d => d . severity . toLowerCase ( ) === 'error' ) ;
@@ -137,7 +322,7 @@ export async function executeEditWithSafetyLayer(
137322 try {
138323 const actions = await fileGetCodeActions ( filePath , error . line - 1 , error . endLine - 1 ) ;
139324 const preferred = actions . actions . find ( a => a . isPreferred ) ;
140- if ( preferred ) {
325+ if ( preferred && ! isHarmfulAutoFix ( preferred . title , hasDeletes ) ) {
141326 const applyResult = await fileApplyCodeAction (
142327 filePath ,
143328 error . line - 1 ,
@@ -174,7 +359,6 @@ export async function executeEditWithSafetyLayer(
174359 // Best-effort
175360 }
176361
177- // ── Build summary ──────────────────────────────────────────────
178362 const summary = buildSummary ( detectedIntents , propagated , autoFixed , remainingErrors ) ;
179363
180364 return {
0 commit comments