Skip to content

Commit ed8aa1c

Browse files
committed
fix: Address 7 audit findings with bug fixes and documentation
Comprehensive fixes for all audit items ranging from critical bugs to documentation improvements. All issues have been verified with tests and follow the GOLDEN RULE: NEVER DELETE USER CONTENT. Critical bug fixes: - Fixed duplicate imports when removeTrailingIndex=true and mergeImportsFromSameModule=false by adding selective deduplication that only affects imports created by /index removal - Fixed GOLDEN RULE violation where commented-out imports containing 'import' keyword were filtered out and deleted. Now ALL comments are preserved unconditionally Documentation improvements: - Added clear warnings to package.json and README.md about legacy mode overriding blankLinesAfterImports, organizeSortsByFirstSpecifier, and disableImportsSorting settings - Documented default indentation behavior: modern mode uses 2 spaces (TypeScript/JavaScript convention), legacy mode uses 4 spaces - Documented that ignoredFromRemoval uses exact string matching with no support for wildcards or sub-paths Test coverage additions: - Test 91: Verifies /index + no-merge edge case is handled correctly - Test 92: Ensures commented-out imports are never deleted - Test 93: Tests custom ignoredFromRemoval library names - Test 94: Tests exact matching behavior for ignoredFromRemoval - Test 95: Tests invalid grouping config fallback to defaults All 331 tests passing (100% success rate). The extension now handles all identified edge cases gracefully and provides clear documentation for configuration behavior.
1 parent e13d6cd commit ed8aa1c

4 files changed

Lines changed: 363 additions & 5 deletions

File tree

README.md

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,15 @@ Once your settings are migrated, you have two options:
180180

181181
If the old TypeScript Hero extension is still active, you'll see a reminder in the migration notification suggesting you can disable it.
182182

183-
**Legacy Mode:** For migrated users, `legacyMode` is automatically set to `true` to match the original TypeScript Hero behavior. This preserves blank line preservation, within-group sorting behavior, and removeTrailingIndex timing. New users get `legacyMode: false` by default for modern best practices. You can change this setting anytime in your configuration.
183+
**Legacy Mode:** For migrated users, `legacyMode` is automatically set to `true` to match the original TypeScript Hero behavior exactly. When enabled, legacy mode overrides certain settings to ensure 100% backward compatibility:
184+
185+
- **`blankLinesAfterImports`** — Always preserves existing blank lines (ignores configured value)
186+
- **`organizeSortsByFirstSpecifier`** — Disabled (always sorts by library name)
187+
- **`disableImportsSorting`** — Disabled (always sorts within groups)
188+
- **Merge timing** — Merges BEFORE removeTrailingIndex (replicates old bug)
189+
- **Type-only imports** — Strips `import type` keywords (old TS <3.8 behavior)
190+
191+
New users get `legacyMode: false` by default for modern best practices. You can toggle this setting anytime via the command palette or your configuration.
184192

185193
### No Old Settings?
186194

@@ -278,6 +286,39 @@ Control spacing after imports with `blankLinesAfterImports`:
278286

279287
📖 **Detailed documentation:** [README-how-we-handle-blank-lines.md](README-how-we-handle-blank-lines.md)
280288

289+
### Multiline Import Indentation
290+
291+
Mini TypeScript Hero respects your editor's indentation settings for multiline imports:
292+
293+
```json
294+
{
295+
// Modern mode (default): Respects VS Code editor.tabSize and editor.insertSpaces
296+
// Default: 2 spaces (if no explicit tabSize configured)
297+
"miniTypescriptHero.imports.tabSize": 2,
298+
"miniTypescriptHero.imports.insertSpaces": true,
299+
300+
// Legacy mode: Always uses spaces, default 4 (matches old TypeScript Hero)
301+
// Reads editor.tabSize automatically
302+
"miniTypescriptHero.imports.legacyMode": false
303+
}
304+
```
305+
306+
**Default Indentation Behavior:**
307+
308+
- **Modern mode** (`legacyMode: false`):
309+
- Default: **2 spaces** (common TypeScript/JavaScript convention)
310+
- Supports both spaces and tabs via `editor.insertSpaces`
311+
- Respects `editor.tabSize` if explicitly configured
312+
- Example: `import {\n Foo,\n Bar\n} from './lib';`
313+
314+
- **Legacy mode** (`legacyMode: true`):
315+
- Default: **4 spaces** (VS Code default)
316+
- Always uses spaces (never tabs)
317+
- Respects `editor.tabSize` automatically
318+
- Matches old TypeScript Hero behavior exactly
319+
320+
**Note:** VS Code automatically applies `.editorconfig` settings to `editor.tabSize` and `editor.insertSpaces`. The extension reads these resolved values, so EditorConfig integration works automatically.
321+
281322
### Advanced Settings
282323

283324
```json
@@ -295,6 +336,8 @@ Control spacing after imports with `blankLinesAfterImports`:
295336
"miniTypescriptHero.imports.organizeSortsByFirstSpecifier": false,
296337

297338
// Libraries that should never be removed (even if unused)
339+
// Uses exact string matching - no wildcards or sub-paths
340+
// Example: "react" matches "react" but NOT "react-dom" or "react/jsx-runtime"
298341
"miniTypescriptHero.imports.ignoredFromRemoval": ["react"],
299342

300343
// Character threshold for multiline imports

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -121,13 +121,13 @@
121121
"miniTypescriptHero.imports.organizeSortsByFirstSpecifier": {
122122
"type": "boolean",
123123
"default": false,
124-
"description": "Defines if the imports are organized by first specifier/alias instead of module path.",
124+
"description": "Defines if the imports are organized by first specifier/alias instead of module path. Note: Ignored when legacyMode is enabled (legacy mode always sorts by library name).",
125125
"scope": "resource"
126126
},
127127
"miniTypescriptHero.imports.disableImportsSorting": {
128128
"type": "boolean",
129129
"default": false,
130-
"description": "Defines if sorting is disable during organize imports.",
130+
"description": "Defines if sorting is disable during organize imports. Note: Ignored when legacyMode is enabled (legacy mode always sorts within groups).",
131131
"scope": "resource"
132132
},
133133
"miniTypescriptHero.imports.disableImportRemovalOnOrganize": {

src/imports/import-manager.ts

Lines changed: 124 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -428,6 +428,103 @@ export class ImportManager {
428428
});
429429
}
430430

431+
/**
432+
* Helper method to deduplicate imports from specific modules only.
433+
*
434+
* FIX: When removeTrailingIndex=true and mergeImportsFromSameModule=false,
435+
* imports like './lib' and './lib/index' both become './lib' after /index removal.
436+
* This creates duplicate imports. We need to merge them to avoid loading the same module twice.
437+
*
438+
* Strategy: Only merge imports for libraries in the affectedLibraries set.
439+
* This preserves the user's intent to keep other imports separate.
440+
*
441+
* @param imports List of imports to deduplicate
442+
* @param affectedLibraries Set of library names that need deduplication
443+
*/
444+
private deduplicateImportsSelective(imports: Import[], affectedLibraries: Set<string>): Import[] {
445+
const byLibrary = new Map<string, Import[]>();
446+
447+
for (const imp of imports) {
448+
// Group by libraryName and isTypeOnly (type-only and value imports should NOT merge)
449+
const typePrefix = (imp instanceof NamedImport && imp.isTypeOnly) ? 'type:' : '';
450+
const groupKey = typePrefix + imp.libraryName;
451+
452+
if (!byLibrary.has(groupKey)) {
453+
byLibrary.set(groupKey, []);
454+
}
455+
byLibrary.get(groupKey)!.push(imp);
456+
}
457+
458+
const deduplicated: Import[] = [];
459+
460+
for (const [, duplicates] of byLibrary) {
461+
if (duplicates.length === 1) {
462+
// No duplicates, keep as-is
463+
deduplicated.push(duplicates[0]);
464+
continue;
465+
}
466+
467+
// Check if this library is affected by /index removal
468+
const libraryName = duplicates[0].libraryName;
469+
const isAffected = affectedLibraries.has(libraryName);
470+
471+
if (!isAffected) {
472+
// NOT affected by /index removal - keep all imports separate (respect mergeImportsFromSameModule=false)
473+
deduplicated.push(...duplicates);
474+
continue;
475+
}
476+
477+
// Multiple imports from same module - merge only NamedImports
478+
const namedImports = duplicates.filter(i => i instanceof NamedImport) as NamedImport[];
479+
480+
if (namedImports.length > 0) {
481+
// Merge all named imports into one
482+
const allSpecifiers: SymbolSpecifier[] = [];
483+
let mergedDefault: string | undefined;
484+
485+
for (const namedImp of namedImports) {
486+
allSpecifiers.push(...namedImp.specifiers);
487+
if (namedImp.defaultAlias) {
488+
mergedDefault = namedImp.defaultAlias; // Keep LAST default (matches old extension)
489+
}
490+
}
491+
492+
// Deduplicate specifiers by name and sort
493+
const uniqueSpecifiers = Array.from(
494+
new Map(allSpecifiers.map(s => [s.specifier, s])).values()
495+
).sort(specifierSort);
496+
497+
const mergedImport = new NamedImport(
498+
namedImports[0].libraryName,
499+
uniqueSpecifiers,
500+
mergedDefault,
501+
namedImports[0].isTypeOnly,
502+
namedImports[0].attributes,
503+
);
504+
505+
// Add merged import at position of first occurrence
506+
let namedAdded = false;
507+
for (const imp of duplicates) {
508+
if (imp instanceof NamedImport) {
509+
if (!namedAdded) {
510+
deduplicated.push(mergedImport);
511+
namedAdded = true;
512+
}
513+
// Skip other NamedImports (already merged)
514+
} else {
515+
// String or Namespace - keep as-is
516+
deduplicated.push(imp);
517+
}
518+
}
519+
} else {
520+
// No NamedImports to merge, keep all (shouldn't happen but handle gracefully)
521+
deduplicated.push(...duplicates);
522+
}
523+
}
524+
525+
return deduplicated;
526+
}
527+
431528
/**
432529
* Organize imports: remove unused, sort, and group.
433530
* Returns TextEdits to apply the changes.
@@ -528,7 +625,28 @@ export class ImportManager {
528625
// In modern mode, /index removal happens first so imports like './lib/index' and './lib' can merge
529626
// In legacy mode, we do it after merging to replicate the old extension's bug
530627
if (this.config.removeTrailingIndex(this.document.uri) && !this.config.legacyMode(this.document.uri)) {
628+
// Track which imports will be affected by /index removal BEFORE changing them
629+
// This allows us to deduplicate ONLY the imports that became duplicates due to /index removal
630+
const affectedLibraries = new Set<string>();
631+
for (const imp of keep) {
632+
if (imp.libraryName.endsWith('/index')) {
633+
const withoutIndex = imp.libraryName.replace(/\/index$/, '');
634+
// Check if another import exists with the same name (without /index)
635+
const hasDuplicate = keep.some(other =>
636+
other !== imp && other.libraryName === withoutIndex
637+
);
638+
if (hasDuplicate) {
639+
affectedLibraries.add(withoutIndex);
640+
}
641+
}
642+
}
643+
531644
keep = this.removeTrailingIndexFromImports(keep);
645+
646+
// If merging is DISABLED but /index removal created duplicates, deduplicate those specific imports
647+
if (!this.config.mergeImportsFromSameModule(this.document.uri) && affectedLibraries.size > 0) {
648+
keep = this.deduplicateImportsSelective(keep, affectedLibraries);
649+
}
532650
}
533651

534652
// Merge imports from same module (configurable)
@@ -761,8 +879,12 @@ export class ImportManager {
761879
if (!isWithinImport) {
762880
const lineText = this.document.lineAt(i).text;
763881
const trimmedText = lineText.trim();
764-
const isStandaloneComment = (trimmedText.startsWith('//') || trimmedText.startsWith('/*') || trimmedText.startsWith('*')) &&
765-
!trimmedText.includes('import ');
882+
// GOLDEN RULE - NEVER DELETE USER COMMENTS!
883+
// We MUST preserve ALL comments, including those with "import" keyword.
884+
// This includes commented-out imports like "// import { Foo } from './bar';"
885+
const isStandaloneComment = trimmedText.startsWith('//') ||
886+
trimmedText.startsWith('/*') ||
887+
trimmedText.startsWith('*');
766888

767889
if (isStandaloneComment) {
768890
commentsBetweenImports.push(lineText);

0 commit comments

Comments
 (0)