Skip to content

Commit 4791df9

Browse files
committed
fix: preserve user content in all modes + address audit 4 bugs
Golden rule: never silently delete user code or comments. Code preservation: - Preserve non-comment code between imports (moved after organized block) - Both modes now keep code that was between import statements Comment preservation (planned difference from old extension): - Stop stripping specifier comments in legacy mode - Old extension lost comments because its parser couldn't parse them - Our parser (ts-morph) can, so stripping is unnecessary content deletion - Comments now trigger multiline wrapping in all modes Audit 4 bug fixes: - B1: Fix greedy regex in extractSpecifierComments (.+ → .+?) - B2: Only write legacyMode to workspace folders that had old settings - B3: findFiles now respects files.exclude (undefined instead of null) - B4: Multiline wrap threshold measures full import line, not just braces - B5+B6: Support .mts/.cts/.mjs/.cjs file extensions Updated comparison tests to reflect planned differences. Documented golden rule exceptions in CLAUDE.md.
1 parent fc4d9ab commit 4791df9

7 files changed

Lines changed: 252 additions & 98 deletions

File tree

CLAUDE.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -501,6 +501,24 @@ All commands are prefixed with `miniTypescriptHero`:
501501
**New Users**: Get `legacyMode: false` by default for modern best practices (1 blank line, correct sorting, proper merge timing)
502502
**Note**: See README for specific behaviors replicated and exceptions. Both old and new extensions merge imports by default; legacy mode preserves the old merge-before-removeTrailingIndex timing that can create duplicates.
503503

504+
### 5. Planned Differences from Old Extension (Golden Rule: Never Delete User Content)
505+
506+
Legacy mode replicates old formatting behaviors, but we **intentionally diverge** where the old behavior is harmful. The golden rule is: **never silently delete user code or comments**.
507+
508+
| Old Extension Behavior | Our Behavior (ALL modes) | Reason |
509+
|---|---|---|
510+
| Specifier comments lost (`// comment`, `/* comment */` next to specifiers) | **Preserved** — comments stay with their specifiers, trigger multiline wrapping | Old parser (typescript-parser) couldn't parse specifier comments; our parser (ts-morph) can, so stripping is unnecessary deletion of user content |
511+
| Non-comment code between import statements silently deleted during reorganization | **Preserved** — code is moved after the organized import block | Old extension deletes imports individually (preserving gaps), our whole-range replacement is a regression without this fix |
512+
| Crashes on certain edge cases (shebangs, directives, empty files) | **Gracefully handled** | Already documented — crash prevention is always correct |
513+
514+
**Legacy mode DOES replicate these formatting-only behaviors:**
515+
- Strip `import type` keywords (matches old output format, TypeScript 3.8+ wasn't supported)
516+
- Strip specifier-level `type` modifiers (matches old output format)
517+
- Within-group sorting always by library name (never by first specifier)
518+
- Blank line preservation mode (special handling for headers and leading blanks)
519+
- Merge timing: merge before removeTrailingIndex (matches old quirk)
520+
- 4-space indentation from VS Code editor.tabSize default
521+
504522
---
505523

506524
## 💡 Important Development Lessons

src/commands/batch-organizer.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -133,12 +133,12 @@ export class BatchOrganizer {
133133
* Find all TypeScript/JavaScript files in the workspace.
134134
*/
135135
private async findTargetFiles(): Promise<Uri[]> {
136-
const include = '**/*.{ts,tsx,js,jsx}';
136+
const include = '**/*.{ts,tsx,js,jsx,mts,cts,mjs,cjs}';
137137

138138
this.logger.appendLine(`[BatchOrganizer] Searching workspace: ${include}`);
139139

140-
// Find all files (VS Code respects files.exclude by default)
141-
const allFiles = await workspace.findFiles(include, null);
140+
// Find all files (undefined = VS Code respects files.exclude; null would bypass it)
141+
const allFiles = await workspace.findFiles(include, undefined);
142142

143143
// Manually filter files using exclude patterns
144144
// IMPORTANT: Get excludePatterns per file based on its workspace folder
@@ -167,8 +167,8 @@ export class BatchOrganizer {
167167
.replace(/\\/g, '/');
168168
// Handle case where folder IS the workspace root (relative path is empty)
169169
const includeGlob = relativePath
170-
? `${relativePath}/**/*.{ts,tsx,js,jsx}`
171-
: '**/*.{ts,tsx,js,jsx}';
170+
? `${relativePath}/**/*.{ts,tsx,js,jsx,mts,cts,mjs,cjs}`
171+
: '**/*.{ts,tsx,js,jsx,mts,cts,mjs,cjs}';
172172
const excludePatterns = this.getExcludePatterns(workspaceFolder.uri);
173173

174174
this.logger.appendLine(`[BatchOrganizer] Searching folder: ${includeGlob}`);
@@ -177,7 +177,7 @@ export class BatchOrganizer {
177177
// Use RelativePattern to scope findFiles to the specific workspace folder
178178
// (plain string patterns search across ALL workspace roots in multi-root workspaces)
179179
const include = new RelativePattern(workspaceFolder, includeGlob);
180-
const allFiles = await workspace.findFiles(include, null);
180+
const allFiles = await workspace.findFiles(include, undefined);
181181

182182
// Manually filter files using exclude patterns
183183
const files = allFiles.filter(fileUri => !this.isFileExcluded(fileUri, excludePatterns));

src/configuration/settings-migration.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { ExtensionContext, workspace, ConfigurationTarget, window, extensions } from 'vscode';
1+
import { ExtensionContext, workspace, ConfigurationTarget, window, extensions, Uri } from 'vscode';
22

33
const OLD_SECTION = 'typescriptHero.imports';
44
const NEW_SECTION = 'miniTypescriptHero.imports';
@@ -108,6 +108,7 @@ async function performMigration(): Promise<number> {
108108
let migratedGlobalCount = 0;
109109
let migratedWorkspaceCount = 0;
110110
let migratedWorkspaceFolderCount = 0;
111+
const migratedFolderUris: Uri[] = [];
111112

112113
// Step 1: Migrate global and workspace-level settings
113114
for (const setting of SETTINGS_TO_MIGRATE) {
@@ -149,6 +150,9 @@ async function performMigration(): Promise<number> {
149150
await newFolderConfig.update(setting, inspect.workspaceFolderValue, ConfigurationTarget.WorkspaceFolder);
150151
migratedCount++;
151152
migratedWorkspaceFolderCount++;
153+
if (!migratedFolderUris.some(u => u.toString() === folder.uri.toString())) {
154+
migratedFolderUris.push(folder.uri);
155+
}
152156
}
153157
}
154158
}
@@ -174,9 +178,9 @@ async function performMigration(): Promise<number> {
174178
await newConfig.update('legacyMode', true, ConfigurationTarget.Workspace);
175179
}
176180
if (migratedWorkspaceFolderCount > 0) {
177-
// WorkspaceFolder scope requires a URI-scoped config — iterate each folder
178-
for (const folder of workspace.workspaceFolders ?? []) {
179-
const scopedConfig = workspace.getConfiguration(NEW_SECTION, folder.uri);
181+
// Only write legacyMode to folders that actually had old settings
182+
for (const folderUri of migratedFolderUris) {
183+
const scopedConfig = workspace.getConfiguration(NEW_SECTION, folderUri);
180184
await scopedConfig.update('legacyMode', true, ConfigurationTarget.WorkspaceFolder);
181185
}
182186
}

src/imports/import-manager.ts

Lines changed: 59 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ export class ImportManager {
5959
// For untitled documents (no file extension), ts-morph needs a file extension
6060
// to determine the script kind. Map languageId to the correct extension.
6161
let fileName = this.document.fileName;
62-
if (!/\.(tsx?|jsx?)$/i.test(fileName)) {
62+
if (!/\.(m?tsx?|[cm]?jsx?|cts)$/i.test(fileName)) {
6363
const extMap: Record<string, string> = {
6464
'typescript': '.ts',
6565
'typescriptreact': '.tsx',
@@ -372,8 +372,8 @@ export class ImportManager {
372372
): Map<string, { leading?: string; trailing?: string }> {
373373
const result = new Map<string, { leading?: string; trailing?: string }>();
374374

375-
// Extract the part between first { and last } (greedy to handle } in comments)
376-
const match = fullImportText.match(/\{(.+)\}/s);
375+
// Extract the part between first { and first } (non-greedy to avoid matching import attributes)
376+
const match = fullImportText.match(/\{(.+?)\}/s);
377377
if (!match) {
378378
return result;
379379
}
@@ -637,22 +637,13 @@ export class ImportManager {
637637
// BUT: In legacy mode, strip type-only flag from NamedImports
638638
const imp = imports[0];
639639
if (isLegacy && imp instanceof NamedImport && imp.isTypeOnly) {
640-
// Strip isTypeOnly flag, individual specifier flags, and comments (legacy mode)
640+
// Strip isTypeOnly flag and individual specifier type flags (legacy mode)
641+
// BUT: Preserve comments — GOLDEN RULE: never delete user content
641642
const specs = imp.specifiers.map(s => ({
642643
...s,
643644
isTypeOnly: false,
644-
leadingComment: undefined,
645-
trailingComment: undefined,
646645
}));
647646
merged.push(new NamedImport(imp.libraryName, specs, imp.defaultAlias, false, imp.attributes));
648-
} else if (isLegacy && imp instanceof NamedImport) {
649-
// In legacy mode, strip comments from all named imports
650-
const specs = imp.specifiers.map(s => ({
651-
...s,
652-
leadingComment: undefined,
653-
trailingComment: undefined,
654-
}));
655-
merged.push(new NamedImport(imp.libraryName, specs, imp.defaultAlias, imp.isTypeOnly, imp.attributes));
656647
} else {
657648
merged.push(imp);
658649
}
@@ -669,13 +660,12 @@ export class ImportManager {
669660
let mergedDefault: string | undefined;
670661

671662
for (const namedImp of namedImports) {
672-
// In legacy mode, strip isTypeOnly and comments from individual specifiers
663+
// In legacy mode, strip isTypeOnly from individual specifiers
664+
// BUT: Preserve comments — GOLDEN RULE: never delete user content
673665
const specs = isLegacy
674666
? namedImp.specifiers.map(s => ({
675667
...s,
676668
isTypeOnly: false,
677-
leadingComment: undefined,
678-
trailingComment: undefined,
679669
}))
680670
: namedImp.specifiers;
681671
allSpecifiers.push(...specs);
@@ -852,14 +842,17 @@ export class ImportManager {
852842
}
853843
}
854844

855-
// Extract comments between imports (old TypeScript Hero moves them after imports)
856-
// Only extract standalone comment lines that are BETWEEN import declarations,
857-
// not lines that are WITHIN a multi-line import declaration
845+
// Extract comments and non-import code between imports.
846+
// Old TypeScript Hero deletes imports individually (preserving everything else).
847+
// We replace the entire range, so we MUST extract and preserve all non-import content.
848+
//
849+
// GOLDEN RULE: NEVER delete user code or comments!
858850
//
859851
// KNOWN LIMITATION: Comments INSIDE multiline import braces are not preserved.
860852
// Example: `import { Foo, /* comment */ Bar } from 'lib'` - the comment is lost.
861853
// This is complex to fix and is an edge case. Standalone comments between imports ARE preserved.
862854
const commentsBetweenImports: string[] = [];
855+
const codeBetweenImports: string[] = [];
863856
let insideBlockComment = false;
864857
for (let i = importSectionStartLine; i <= importSectionEndLine; i++) {
865858
const lineNumber = i + 1; // Convert to 1-indexed for comparison with ts-morph
@@ -871,7 +864,7 @@ export class ImportManager {
871864
return lineNumber >= start && lineNumber <= end;
872865
});
873866

874-
// Only check for standalone comments if the line is NOT within an import
867+
// Only check for standalone comments/code if the line is NOT within an import
875868
if (!isWithinImport) {
876869
const lineText = this.document.lineAt(i).text;
877870
const trimmedText = lineText.trim();
@@ -885,9 +878,7 @@ export class ImportManager {
885878
continue;
886879
}
887880

888-
// GOLDEN RULE - NEVER DELETE USER COMMENTS!
889-
// We MUST preserve ALL comments, including those with "import" keyword.
890-
// This includes commented-out imports like "// import { Foo } from './bar';"
881+
// Check for standalone comments
891882
const isStandaloneComment = trimmedText.startsWith('//') ||
892883
trimmedText.startsWith('/*') ||
893884
trimmedText.startsWith('*');
@@ -897,6 +888,11 @@ export class ImportManager {
897888
if (trimmedText.startsWith('/*') && !trimmedText.includes('*/')) {
898889
insideBlockComment = true;
899890
}
891+
} else if (trimmedText.length > 0) {
892+
// GOLDEN RULE: Preserve non-import code between imports!
893+
// This handles side-effect calls, variable declarations, etc.
894+
// The old TypeScript Hero also preserves these (it deletes imports individually).
895+
codeBetweenImports.push(lineText);
900896
}
901897
}
902898
}
@@ -1007,6 +1003,13 @@ export class ImportManager {
10071003
importText += this.eol;
10081004
}
10091005

1006+
// GOLDEN RULE: Preserve non-import code that was between imports
1007+
// (e.g., side-effect calls like `config.init()`, variable declarations)
1008+
if (codeBetweenImports.length > 0) {
1009+
importText += codeBetweenImports.join(this.eol);
1010+
importText += this.eol;
1011+
}
1012+
10101013
// Add re-export statements after imports (preserves export { X } from './m')
10111014
if (reExportsToOutput.length > 0) {
10121015
importText += reExportsToOutput.join(this.eol);
@@ -1139,33 +1142,33 @@ export class ImportManager {
11391142
if (imp instanceof NamedImport) {
11401143
const parts: string[] = [];
11411144

1145+
// Add 'type' keyword for type-only imports (TS 3.8+)
1146+
// In legacy mode, always strip 'type' keyword (old extension doesn't support it)
1147+
const useTypeKeyword = imp.isTypeOnly && !this.config.legacyMode(this.document.uri);
1148+
const typeKeyword = useTypeKeyword ? 'type ' : '';
1149+
11421150
// Default import
11431151
if (imp.defaultAlias) {
11441152
parts.push(imp.defaultAlias);
11451153
}
11461154

11471155
// Named imports
11481156
if (imp.specifiers.length > 0) {
1149-
const isLegacy = this.config.legacyMode(this.document.uri);
1150-
1151-
// Check if any specifiers have comments (forces multiline in modern mode)
1152-
const hasComments = !isLegacy && imp.specifiers.some(s => s.leadingComment || s.trailingComment);
1157+
// Check if any specifiers have comments (forces multiline)
1158+
const hasComments = imp.specifiers.some(s => s.leadingComment || s.trailingComment);
11531159

11541160
const specifierStrings = imp.specifiers.map(spec => {
11551161
const baseSpec = spec.alias ? `${spec.specifier} as ${spec.alias}` : spec.specifier;
11561162
const typePrefix = spec.isTypeOnly ? 'type ' : '';
11571163
let result = `${typePrefix}${baseSpec}`;
11581164

1159-
// In modern mode, preserve comments
1160-
if (!isLegacy) {
1161-
if (spec.leadingComment) {
1162-
result = `${spec.leadingComment} ${result}`;
1163-
}
1164-
if (spec.trailingComment) {
1165-
result = `${result} ${spec.trailingComment}`;
1166-
}
1165+
// Preserve comments (in both modes — GOLDEN RULE: never delete user content)
1166+
if (spec.leadingComment) {
1167+
result = `${spec.leadingComment} ${result}`;
1168+
}
1169+
if (spec.trailingComment) {
1170+
result = `${result} ${spec.trailingComment}`;
11671171
}
1168-
// In legacy mode, comments are stripped (old extension behavior)
11691172

11701173
return result;
11711174
});
@@ -1175,11 +1178,15 @@ export class ImportManager {
11751178
const braceClose = spaceInBraces ? ' }' : '}';
11761179

11771180
// Check if it should be multiline
1181+
// Measure the FULL import line length (not just the brace part) against threshold
11781182
const threshold = this.config.multiLineWrapThreshold(this.document.uri);
1179-
const singleLine = `${braceOpen}${specifiersText}${braceClose}`;
1183+
const defaultPart = imp.defaultAlias ? `${imp.defaultAlias}, ` : '';
1184+
const bracePart = `${braceOpen}${specifiersText}${braceClose}`;
1185+
const fromPart = ` from ${quote}${imp.libraryName}${quote}${attrs}${semi}`;
1186+
const fullLine = `import ${typeKeyword}${defaultPart}${bracePart}${fromPart}`;
11801187

11811188
// Force multiline if there are comments
1182-
if (hasComments || (singleLine.length > threshold && imp.specifiers.length > 1)) {
1189+
if (hasComments || (fullLine.length > threshold && imp.specifiers.length > 1)) {
11831190
// Multiline
11841191
const trailingComma = this.config.multiLineTrailingComma(this.document.uri) ? ',' : '';
11851192

@@ -1190,29 +1197,20 @@ export class ImportManager {
11901197
const typePrefix = spec.isTypeOnly ? 'type ' : '';
11911198
let result = `${typePrefix}${baseSpec}`;
11921199

1193-
// In modern mode, handle comments with proper comma placement
1194-
if (!isLegacy) {
1195-
// Add leading comment before everything
1196-
if (spec.leadingComment) {
1197-
result = `${spec.leadingComment} ${result}`;
1198-
}
1200+
// Preserve comments with proper comma placement (GOLDEN RULE: never delete user content)
1201+
if (spec.leadingComment) {
1202+
result = `${spec.leadingComment} ${result}`;
1203+
}
11991204

1200-
// Add comma before trailing comment (if not the last item or if trailingComma is enabled)
1201-
const needsComma = index < imp.specifiers.length - 1 || trailingComma;
1202-
if (needsComma) {
1203-
result = `${result},`;
1204-
}
1205+
// Add comma before trailing comment
1206+
const needsComma = index < imp.specifiers.length - 1 || trailingComma;
1207+
if (needsComma) {
1208+
result = `${result},`;
1209+
}
12051210

1206-
// Add trailing comment after the comma
1207-
if (spec.trailingComment) {
1208-
result = `${result} ${spec.trailingComment}`;
1209-
}
1210-
} else {
1211-
// Legacy mode: no comments, just add comma
1212-
const needsComma = index < imp.specifiers.length - 1 || trailingComma;
1213-
if (needsComma) {
1214-
result = `${result},`;
1215-
}
1211+
// Add trailing comment after the comma
1212+
if (spec.trailingComment) {
1213+
result = `${result} ${spec.trailingComment}`;
12161214
}
12171215

12181216
return result;
@@ -1221,14 +1219,10 @@ export class ImportManager {
12211219
const namedPart = `{${this.eol}${indent}${formattedSpecifiers.join(this.eol + indent)}${this.eol}}`;
12221220
parts.push(namedPart);
12231221
} else {
1224-
parts.push(singleLine);
1222+
parts.push(bracePart);
12251223
}
12261224
}
12271225

1228-
// Add 'type' keyword for type-only imports (TS 3.8+)
1229-
// In legacy mode, always strip 'type' keyword (old extension doesn't support it)
1230-
const useTypeKeyword = imp.isTypeOnly && !this.config.legacyMode(this.document.uri);
1231-
const typeKeyword = useTypeKeyword ? 'type ' : '';
12321226
return `import ${typeKeyword}${parts.join(', ')} from ${quote}${imp.libraryName}${quote}${attrs}${semi}`;
12331227
}
12341228

0 commit comments

Comments
 (0)