diff --git a/eslint.config.mjs b/eslint.config.mjs index d0515e6..d903cc9 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -7,6 +7,7 @@ import { fileURLToPath } from 'node:url'; import js from '@eslint/js'; import typescriptEslint from '@typescript-eslint/eslint-plugin'; import tsParser from '@typescript-eslint/parser'; +import zeroTolerance from '@coderrob/eslint-plugin-zero-tolerance'; import _import from 'eslint-plugin-import'; import jsdoc from 'eslint-plugin-jsdoc'; import prettier from 'eslint-plugin-prettier'; @@ -69,6 +70,7 @@ export default [ jsdoc, 'simple-import-sort': simpleImportSort, sonarjs, + 'zero-tolerance': zeroTolerance, local: localPlugin, }, settings: { @@ -176,6 +178,9 @@ export default [ 'no-shadow': 'off', 'space-in-parens': ['error', 'never'], 'spaced-comment': ['error', 'always'], + + // Zero-tolerance rules + ...zeroTolerance.configs.recommended.rules, }, }, @@ -202,17 +207,72 @@ export default [ 'sonarjs/publicly-writable-directories': 'off', 'simple-import-sort/imports': 'off', 'simple-import-sort/exports': 'off', + // Zero-tolerance rules relaxed for test callbacks and test data + 'zero-tolerance/max-function-lines': 'off', // describe/it callbacks are inherently long + 'zero-tolerance/no-magic-numbers': 'off', // test data values are inline by design + 'zero-tolerance/no-magic-strings': 'off', // test data strings are inline by design + 'zero-tolerance/no-type-assertion': 'off', // test mocks need type assertions for VS Code types + 'zero-tolerance/require-jsdoc-functions': 'off', // test callbacks don't need JSDoc + 'zero-tolerance/no-array-mutation': 'off', // test setup legitimately mutates arrays + 'zero-tolerance/no-object-mutation': 'off', // test setup legitimately mutates objects + 'zero-tolerance/no-date-now': 'off', // test fixtures legitimately use Date + 'zero-tolerance/prefer-readonly-parameters': 'off', // test callbacks and mock fns use mutable sigs + }, + }, + + // Test infrastructure files (helpers, runners, shared types) + { + files: ['**/src/test/*.ts'], + rules: { + 'zero-tolerance/max-function-lines': 'off', + 'zero-tolerance/no-magic-numbers': 'off', + 'zero-tolerance/no-magic-strings': 'off', + 'zero-tolerance/no-type-assertion': 'off', + 'zero-tolerance/require-jsdoc-functions': 'off', + 'zero-tolerance/require-interface-prefix': 'off', // test helper interfaces don't need I prefix + 'zero-tolerance/no-re-export': 'off', // test helpers may re-export types for convenience + 'zero-tolerance/no-banned-types': 'off', // test helpers may use indexed access types }, }, // Allow 'instanceof Error' only within guards helper to implement the guard itself { - files: ['src/utils/guards.ts'], + files: ['src/utils/guards.ts', 'src/utils/assert.ts'], rules: { 'no-restricted-syntax': 'off', }, }, + // Inherently stateful implementations that legitimately mutate internal state + { + files: [ + 'src/utils/semaphore.ts', // Semaphore requires mutable permit/queue state + 'src/core/barrel/content-sanitizer.ts', // Multiline state machine requires mutable buffer + 'src/extension.ts', // VS Code subscriptions.push() and queue class are externally constrained + 'src/logging/output-channel.logger.ts', // Logger bindings and timestamp are implementation details + ], + rules: { + 'zero-tolerance/no-array-mutation': 'off', + 'zero-tolerance/no-object-mutation': 'off', + 'zero-tolerance/no-date-now': 'off', + 'zero-tolerance/no-await-in-loop': 'off', // BarrelCommandQueue.processQueue is intentionally sequential + }, + }, + + // Files where prefer-readonly-parameters cannot be correctly applied + { + files: [ + 'src/utils/assert.ts', // generic T params - Readonly breaks null/undefined inference + 'src/core/barrel/barrel-content.builder.ts', // optional class-instance DI constructor params + 'src/core/barrel/barrel-file.generator.ts', // optional class-instance DI constructor params + 'src/core/barrel/content-sanitizer.ts', // mutable state machine IMultilineState param + 'src/core/parser/export.parser.ts', // ts-morph Statement nodes - Readonly != Statement + ], + rules: { + 'zero-tolerance/prefer-readonly-parameters': 'off', + }, + }, + // Source files - relax strict return type rules since methods already have explicit return types { files: ['src/**/*.ts'], diff --git a/package-lock.json b/package-lock.json index 5089eed..d0e78b7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.1.1", "license": "Apache-2.0", "devDependencies": { + "@coderrob/eslint-plugin-zero-tolerance": "^1.2.2", "@types/glob": "^8.1.0", "@types/node": "^22.x", "@types/vscode": "^1.80.0", @@ -680,6 +681,216 @@ "node": ">=18" } }, + "node_modules/@coderrob/eslint-plugin-zero-tolerance": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@coderrob/eslint-plugin-zero-tolerance/-/eslint-plugin-zero-tolerance-1.2.2.tgz", + "integrity": "sha512-sBronWtOvoFKybc/Y+Deq2OXOkDoKytrYegdscC7AGZCG75Ea0MWlnLYKRh+blz459ruK7q/WAgF5b8DeRX+ug==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@typescript-eslint/utils": "^8.57.2" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.0.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/@coderrob/eslint-plugin-zero-tolerance/node_modules/@typescript-eslint/project-service": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.58.0.tgz", + "integrity": "sha512-8Q/wBPWLQP1j16NxoPNIKpDZFMaxl7yWIoqXWYeWO+Bbd2mjgvoF0dxP2jKZg5+x49rgKdf7Ck473M8PC3V9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.58.0", + "@typescript-eslint/types": "^8.58.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@coderrob/eslint-plugin-zero-tolerance/node_modules/@typescript-eslint/scope-manager": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.58.0.tgz", + "integrity": "sha512-W1Lur1oF50FxSnNdGp3Vs6P+yBRSmZiw4IIjEeYxd8UQJwhUF0gDgDD/W/Tgmh73mxgEU3qX0Bzdl/NGuSPEpQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@coderrob/eslint-plugin-zero-tolerance/node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.0.tgz", + "integrity": "sha512-doNSZEVJsWEu4htiVC+PR6NpM+pa+a4ClH9INRWOWCUzMst/VA9c4gXq92F8GUD1rwhNvRLkgjfYtFXegXQF7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@coderrob/eslint-plugin-zero-tolerance/node_modules/@typescript-eslint/types": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.0.tgz", + "integrity": "sha512-O9CjxypDT89fbHxRfETNoAnHj/i6IpRK0CvbVN3qibxlLdo5p5hcLmUuCCrHMpxiWSwKyI8mCP7qRNYuOJ0Uww==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@coderrob/eslint-plugin-zero-tolerance/node_modules/@typescript-eslint/typescript-estree": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.0.tgz", + "integrity": "sha512-7vv5UWbHqew/dvs+D3e1RvLv1v2eeZ9txRHPnEEBUgSNLx5ghdzjHa0sgLWYVKssH+lYmV0JaWdoubo0ncGYLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.58.0", + "@typescript-eslint/tsconfig-utils": "8.58.0", + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@coderrob/eslint-plugin-zero-tolerance/node_modules/@typescript-eslint/utils": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.58.0.tgz", + "integrity": "sha512-RfeSqcFeHMHlAWzt4TBjWOAtoW9lnsAGiP3GbaX9uVgTYYrMbVnGONEfUCiSss+xMHFl+eHZiipmA8WkQ7FuNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.58.0", + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/typescript-estree": "8.58.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@coderrob/eslint-plugin-zero-tolerance/node_modules/@typescript-eslint/visitor-keys": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.0.tgz", + "integrity": "sha512-XJ9UD9+bbDo4a4epraTwG3TsNPeiB9aShrUneAVXy8q4LuwowN+qu89/6ByLMINqvIMeI9H9hOHQtg/ijrYXzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@coderrob/eslint-plugin-zero-tolerance/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@coderrob/eslint-plugin-zero-tolerance/node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@coderrob/eslint-plugin-zero-tolerance/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@coderrob/eslint-plugin-zero-tolerance/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/@colors/colors": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", @@ -10152,9 +10363,9 @@ "license": "MIT" }, "node_modules/ts-api-utils": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", - "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", "dev": true, "license": "MIT", "engines": { diff --git a/package.json b/package.json index 39504e6..4898916 100644 --- a/package.json +++ b/package.json @@ -88,6 +88,7 @@ }, "description": "A Visual Studio Code extension to automatically export types, functions, constants, and classes through barrel files", "devDependencies": { + "@coderrob/eslint-plugin-zero-tolerance": "^1.2.2", "@types/glob": "^8.1.0", "@types/node": "^22.x", "@types/vscode": "^1.80.0", diff --git a/src/core/barrel/barrel-content.builder.ts b/src/core/barrel/barrel-content.builder.ts index 6b6b0a0..d7f14f4 100644 --- a/src/core/barrel/barrel-content.builder.ts +++ b/src/core/barrel/barrel-content.builder.ts @@ -29,6 +29,18 @@ import { import { sortAlphabetically } from '../../utils/string.js'; import { FileSystemService } from '../io/file-system.service.js'; +/** + * Converts a legacy export name string to a BarrelExport object. + * @param name The export name to convert. + * @returns The corresponding BarrelExport object. + */ +function legacyExportFromName(name: string): BarrelExport { + if (name === DEFAULT_EXPORT_NAME) { + return { kind: BarrelExportKind.Default }; + } + return { kind: BarrelExportKind.Value, name }; +} + /** * Service to build the content of a barrel file from exports. */ @@ -74,37 +86,39 @@ export class BarrelContentBuilder { * @returns The barrel file content as a string. */ async buildContent( - entries: Map, + entries: Readonly>, directoryPath: string, exportExtension = '', ): Promise { - const lines: string[] = []; const normalizedEntries = this.normalizeEntries(entries); + const lines = await this.collectAllExportLines( + normalizedEntries, + exportExtension, + directoryPath, + ); + return lines.join(NEWLINE) + NEWLINE; + } - // Sort files alphabetically for consistent output + /** + * Collects all export lines from the normalized entry map using parallel resolution. + * @param normalizedEntries Map of relative paths to barrel entries. + * @param exportExtension The file extension to use for exports. + * @param directoryPath The directory path for relative imports. + * @returns Flattened array of all export lines. + */ + private async collectAllExportLines( + normalizedEntries: Readonly>, + exportExtension: string, + directoryPath: string, + ): Promise { const sortedPaths = sortAlphabetically(normalizedEntries.keys()); - - for (const relativePath of sortedPaths) { + const promises = sortedPaths.flatMap((relativePath) => { const entry = normalizedEntries.get(relativePath); - if (!entry) { - continue; - } - - const exportLines = await this.createLinesForEntry( - relativePath, - entry, - exportExtension, - directoryPath, - ); - if (exportLines.length === 0) { - continue; - } - - lines.push(...exportLines); - } - - // Add newline at end of file - return lines.join(NEWLINE) + NEWLINE; + if (!entry) return []; + return [this.createLinesForEntry(relativePath, entry, exportExtension, directoryPath)]; + }); + const lineGroups = await Promise.all(promises); + return lineGroups.flat(); } /** @@ -112,14 +126,16 @@ export class BarrelContentBuilder { * @param entries Source entries (string[] or BarrelEntry) * @returns Map of BarrelEntry */ - private normalizeEntries(entries: Map): Map { + private normalizeEntries( + entries: Readonly>, + ): Map { const normalized = new Map(); for (const [relativePath, entry] of entries) { if (Array.isArray(entry)) { normalized.set(relativePath, { kind: BarrelEntryKind.File, - exports: entry.map((name) => this.toLegacyExport(name)), + exports: entry.map(legacyExportFromName), }); } else { normalized.set(relativePath, entry); @@ -129,19 +145,6 @@ export class BarrelContentBuilder { return normalized; } - /** - * Converts a legacy export name to a BarrelExport object. - * @param name The export name. - * @returns The corresponding BarrelExport object. - */ - private toLegacyExport(name: string): BarrelExport { - if (name === DEFAULT_EXPORT_NAME) { - return { kind: BarrelExportKind.Default }; - } - - return { kind: BarrelExportKind.Value, name }; - } - /** * Creates export lines for a given entry. * @param relativePath The entry path @@ -152,7 +155,7 @@ export class BarrelContentBuilder { */ private async createLinesForEntry( relativePath: string, - entry: BarrelEntry, + entry: Readonly, exportExtension: string, directoryPath: string, ): Promise { @@ -182,6 +185,17 @@ export class BarrelContentBuilder { return [`export * from './${modulePath}';`]; } + /** + * Determines whether an export should be retained (not a parent-directory reference). + * @param exp The barrel export to check. + * @returns True if the export should be kept. + */ + private isRelevantExport(exp: Readonly): boolean { + return exp.kind === BarrelExportKind.Default + ? true + : !exp.name.includes(PARENT_DIRECTORY_SEGMENT); + } + /** * Builds export statement(s) for a file and its exports. * @param filePath The file path @@ -192,13 +206,11 @@ export class BarrelContentBuilder { */ private async buildFileExportLines( filePath: string, - exports: BarrelExport[], + exports: readonly BarrelExport[], exportExtension: string, directoryPath: string, ): Promise { - const cleanedExports = exports.filter((exp) => - exp.kind === BarrelExportKind.Default ? true : !exp.name.includes(PARENT_DIRECTORY_SEGMENT), - ); + const cleanedExports = exports.filter(this.isRelevantExport.bind(this)); // Skip files with no exports if (cleanedExports.length === 0) { @@ -224,45 +236,86 @@ export class BarrelContentBuilder { * @param exports The exports * @returns The export statement(s) */ - // eslint-disable-next-line complexity -- Acceptable complexity for export combination logic - private generateExportStatements(modulePath: string, exports: BarrelExport[]): string[] { - const lines: string[] = []; - + private generateExportStatements(modulePath: string, exports: readonly BarrelExport[]): string[] { const valueNames = this.getExportNames(exports, BarrelExportKind.Value); const typeNames = this.getExportNames(exports, BarrelExportKind.Type); - const hasDefault = exports.some((exp) => exp.kind === BarrelExportKind.Default); + const namedLine = this.buildNamedExportLine(modulePath, valueNames, typeNames); + const defaultLine = exports.some(this.isDefaultKindExport.bind(this)) + ? [`export { default } from './${modulePath}';`] + : []; + return [...(namedLine ? [namedLine] : []), ...defaultLine]; + } - // If we have both values and types, combine them using TypeScript 4.5+ syntax + /** + * Checks whether a barrel export is a default export. + * @param exp The barrel export to check. + * @returns True if the export kind is Default. + */ + private isDefaultKindExport(exp: Readonly): boolean { + return exp.kind === BarrelExportKind.Default; + } + + /** + * Prefixes an export name with 'type ' for mixed export syntax. + * @param name The export name to prefix. + * @returns The name with a 'type ' prefix. + */ + private toTypeExportName(name: string): string { + return `type ${name}`; + } + + /** + * Builds a combined or single named export line for the given module. + * Returns null when there are no value or type names to export. + * @param modulePath The module path for the export statement. + * @param valueNames The value export names. + * @param typeNames The type export names. + * @returns An export line string, or null if nothing to export. + */ + private buildNamedExportLine( + modulePath: string, + valueNames: readonly string[], + typeNames: readonly string[], + ): string | null { if (valueNames.length > 0 && typeNames.length > 0) { - const combinedExports = [...valueNames, ...typeNames.map((name) => `type ${name}`)].join( + const combined = [...valueNames, ...typeNames.map(this.toTypeExportName.bind(this))].join( ', ', ); - lines.push(`export { ${combinedExports} } from './${modulePath}';`); - } else if (valueNames.length > 0) { - lines.push(`export { ${valueNames.join(', ')} } from './${modulePath}';`); - } else if (typeNames.length > 0) { - lines.push(`export type { ${typeNames.join(', ')} } from './${modulePath}';`); + return `export { ${combined} } from './${modulePath}';`; } - - if (hasDefault) { - lines.push(`export { default } from './${modulePath}';`); + if (valueNames.length > 0) { + return `export { ${valueNames.join(', ')} } from './${modulePath}';`; } - - return lines; + if (typeNames.length > 0) { + return `export type { ${typeNames.join(', ')} } from './${modulePath}';`; + } + return null; } /** * Extracts and sorts export names of a specific kind. + * @param exports TODO: describe parameter + * @param kind TODO: describe parameter + * @returns TODO: describe return value */ private getExportNames( - exports: BarrelExport[], + exports: readonly BarrelExport[], kind: BarrelExportKind.Value | BarrelExportKind.Type, ): string[] { - return sortAlphabetically( - exports - .filter((exp): exp is BarrelExport & { name: string } => exp.kind === kind && 'name' in exp) - .map((exp) => exp.name), - ); + /** + * Checks whether an export has the given kind and a name property. + * @param exp - The barrel export to check. + * @returns True if the export matches the kind and has a name. + */ + const matchesKind = (exp: Readonly): exp is BarrelExport & { name: string } => + exp.kind === kind && 'name' in exp; + /** + * Extracts the name from a named barrel export. + * @param exp - The named barrel export. + * @returns The export name string. + */ + const getName = (exp: BarrelExport & { name: string }): string => exp.name; + return sortAlphabetically(exports.filter(matchesKind).map(getName)); } /** @@ -286,7 +339,7 @@ export class BarrelContentBuilder { // For files, remove .ts/.tsx extension and replace with the desired export extension const modulePath = filePath.replace(/\.tsx?$/, '') + exportExtension; // Normalize path separators for cross-platform compatibility - return modulePath.replaceAll('\\', '/'); + return modulePath.replaceAll(/\\/g, '/'); } /** diff --git a/src/core/barrel/barrel-file.generator.ts b/src/core/barrel/barrel-file.generator.ts index 298065d..220a4af 100644 --- a/src/core/barrel/barrel-file.generator.ts +++ b/src/core/barrel/barrel-file.generator.ts @@ -27,9 +27,9 @@ import { BarrelGenerationMode, DEFAULT_EXPORT_NAME, type IBarrelGenerationOptions, + type ILoggerInstance, INDEX_FILENAME, type IParsedExport, - type LoggerInstance, type NormalizedBarrelGenerationOptions, } from '../../types/index.js'; import { processConcurrently } from '../../utils/semaphore.js'; @@ -45,11 +45,27 @@ type NormalizedGenerationOptions = NormalizedBarrelGenerationOptions; /** * Information about TypeScript files and subdirectories in a directory. */ -interface DirectoryInfo { +interface IDirectoryInfo { tsFiles: string[]; subdirectories: string[]; } +/** + * Options for building barrel file content. + */ +interface IBarrelBuildOptions { + hasExistingIndex: boolean; +} + +/** + * Checks whether a string line contains any non-whitespace content. + * @param line - The line to check. + * @returns True if the line is non-empty after trimming. + */ +function isNonEmptyTrimmedLine(line: string): boolean { + return line.trim().length > 0; +} + /** * Service to generate or update a barrel (index.ts) file in a directory. */ @@ -70,7 +86,7 @@ export class BarrelFileGenerator { fileSystemService?: FileSystemService, exportParser?: ExportParser, barrelContentBuilder?: BarrelContentBuilder, - logger?: LoggerInstance, + logger?: Readonly, ) { this.barrelContentBuilder = barrelContentBuilder || new BarrelContentBuilder(); this.fileSystemService = fileSystemService || new FileSystemService(); @@ -84,7 +100,10 @@ export class BarrelFileGenerator { * @param options Behavioral options for generation. * @returns Promise that resolves when barrel files have been created/updated. */ - async generateBarrelFile(directoryUri: Uri, options?: IBarrelGenerationOptions): Promise { + async generateBarrelFile( + directoryUri: Readonly, + options?: Readonly, + ): Promise { const normalizedOptions = this.normalizeOptions(options); await this.generateBarrelFileFromPath(directoryUri.fsPath, normalizedOptions); } @@ -98,28 +117,40 @@ export class BarrelFileGenerator { */ private async generateBarrelFileFromPath( directoryPath: string, - options: NormalizedGenerationOptions, + options: Readonly, depth = 0, ): Promise { const barrelFilePath = path.join(directoryPath, INDEX_FILENAME); const { tsFiles, subdirectories } = await this.readDirectoryInfo(directoryPath); - if (options.recursive) { await this.processChildDirectories(subdirectories, options, depth); } - const entries = await this.collectEntries(directoryPath, tsFiles, subdirectories); + await this.writeBarrelIfNeeded(directoryPath, entries, barrelFilePath, options); + } - const hasExistingIndex = await this.fileSystemService.fileExists(barrelFilePath); - if (!this.shouldWriteBarrel(entries, options, hasExistingIndex)) { - return; - } - + /** + * Writes the barrel file if content generation conditions are met. + * @param directoryPath The directory path. + * @param entries The collected entries. + * @param barrelFilePath The barrel file path. + * @param options Normalized generation options. + * @returns Promise that resolves when done. + */ + private async writeBarrelIfNeeded( + directoryPath: string, + entries: Readonly>, + barrelFilePath: string, + options: Readonly, + ): Promise { + const hasExistingIndex = await this.fileSystemService.hasFile(barrelFilePath); + const buildOptions: IBarrelBuildOptions = { hasExistingIndex }; + if (!this.shouldWriteBarrel(entries, options, buildOptions)) return; const barrelContent = await this.buildBarrelContent( directoryPath, entries, barrelFilePath, - hasExistingIndex, + buildOptions, ); await this.fileSystemService.writeFile(barrelFilePath, barrelContent); } @@ -134,11 +165,11 @@ export class BarrelFileGenerator { */ private async buildBarrelContent( directoryPath: string, - entries: Map, + entries: Readonly>, barrelFilePath: string, - hasExistingIndex: boolean, + buildOptions: Readonly, ): Promise { - const exportExtension = await this.determineExportExtension(barrelFilePath, hasExistingIndex); + const exportExtension = await this.determineExportExtension(barrelFilePath, buildOptions); const newContent = await this.barrelContentBuilder.buildContent( entries, @@ -146,7 +177,7 @@ export class BarrelFileGenerator { exportExtension, ); - if (!hasExistingIndex) { + if (!buildOptions.hasExistingIndex) { return newContent; } @@ -174,7 +205,7 @@ export class BarrelFileGenerator { const newContentLines = newContent.trim() ? newContent.trim().split('\n') : []; const allLines = [...preservedLines, ...newContentLines]; - const filteredLines = allLines.filter((line) => line.trim().length > 0); + const filteredLines = allLines.filter(isNonEmptyTrimmedLine); return filteredLines.length > 0 ? filteredLines.join('\n') + '\n' : '\n'; } @@ -187,9 +218,9 @@ export class BarrelFileGenerator { */ private async determineExportExtension( barrelFilePath: string, - hasExistingIndex: boolean, + buildOptions: Readonly, ): Promise { - if (!hasExistingIndex) { + if (!buildOptions.hasExistingIndex) { return '.js'; } @@ -200,8 +231,10 @@ export class BarrelFileGenerator { /** * Reads directory info for TypeScript files and subdirectories. + * @param directoryPath TODO: describe parameter + * @returns TODO: describe return value */ - private async readDirectoryInfo(directoryPath: string): Promise { + private async readDirectoryInfo(directoryPath: string): Promise { const [tsFiles, subdirectories] = await Promise.all([ this.fileSystemService.getTypeScriptFiles(directoryPath), this.fileSystemService.getSubdirectories(directoryPath), @@ -217,34 +250,51 @@ export class BarrelFileGenerator { * @returns Promise that resolves when all child directories have been processed. */ private async processChildDirectories( - subdirectories: string[], - options: NormalizedGenerationOptions, + subdirectories: readonly string[], + options: Readonly, depth: number, ): Promise { const maxDepth = 20; - if (depth >= maxDepth) { console.warn( `Maximum recursion depth (${maxDepth}) reached at depth ${depth}. Skipping deeper directories.`, ); return; } - - for (const subdirectoryPath of subdirectories) { - if (options.mode !== BarrelGenerationMode.UpdateExisting) { - await this.generateBarrelFileFromPath(subdirectoryPath, options, depth + 1); - continue; - } - - const hasIndex = await this.fileSystemService.fileExists( - path.join(subdirectoryPath, INDEX_FILENAME), + if (options.mode !== BarrelGenerationMode.UpdateExisting) { + await Promise.all( + subdirectories.map((p) => this.generateBarrelFileFromPath(p, options, depth + 1)), ); - if (!hasIndex) { - continue; - } - - await this.generateBarrelFileFromPath(subdirectoryPath, options, depth + 1); + return; } + await this.processUpdateExistingDirectories(subdirectories, options, depth); + } + + /** + * Processes subdirectories in UpdateExisting mode, only recurse into those with an index file. + * @param subdirectories Array of subdirectory paths. + * @param options Normalized generation options. + * @param depth Current recursion depth. + * @returns Promise that resolves when processing is complete. + */ + private async processUpdateExistingDirectories( + subdirectories: readonly string[], + options: Readonly, + depth: number, + ): Promise { + const filtered = await Promise.all( + subdirectories.map(async (subdirectoryPath) => { + const hasIndex = await this.fileSystemService.hasFile( + path.join(subdirectoryPath, INDEX_FILENAME), + ); + return hasIndex ? subdirectoryPath : null; + }), + ); + await Promise.all( + filtered + .filter((p): p is string => p !== null) + .map((p) => this.generateBarrelFileFromPath(p, options, depth + 1)), + ); } /** @@ -256,8 +306,8 @@ export class BarrelFileGenerator { */ private async collectEntries( directoryPath: string, - tsFiles: string[], - subdirectories: string[], + tsFiles: readonly string[], + subdirectories: readonly string[], ): Promise> { const entries = new Map(); @@ -276,38 +326,45 @@ export class BarrelFileGenerator { */ private async addFileEntries( directoryPath: string, - tsFiles: string[], - entries: Map, + tsFiles: readonly string[], + entries: Readonly>, ): Promise { const concurrencyLimit = 10; const batchSize = 50; + const batches = Array.from({ length: Math.ceil(tsFiles.length / batchSize) }, (_, i) => + tsFiles.slice(i * batchSize, (i + 1) * batchSize), + ); + const batchResults = await Promise.all( + batches.map((batch) => + processConcurrently(batch, concurrencyLimit, (filePath) => + this.resolveFileEntry(directoryPath, filePath), + ), + ), + ); + for (const result of batchResults.flat()) { + if (result) entries.set(result.relativePath, result.entry); + } + } - for (let i = 0; i < tsFiles.length; i += batchSize) { - const batch = tsFiles.slice(i, i + batchSize); - const results = await processConcurrently(batch, concurrencyLimit, async (filePath) => { - try { - const parsedExports = await this.exportCache.getExports(filePath); - const exports = this.normalizeParsedExports(parsedExports); - - if (exports.length === 0) { - return null; - } - - const relativePath = path.relative(directoryPath, filePath); - return { relativePath, entry: { kind: BarrelEntryKind.File, exports } }; - } catch (error) { - console.warn(`Failed to process file ${filePath}:`, error); - return null; - } - }); - - for (const result of results) { - if (!result) { - continue; - } - - entries.set(result.relativePath, result.entry); - } + /** + * Resolves the barrel entry for a single TypeScript file. + * @param directoryPath The directory containing the file. + * @param filePath The absolute path to the file. + * @returns Entry metadata or null if the file has no exports. + */ + private async resolveFileEntry( + directoryPath: string, + filePath: string, + ): Promise<{ relativePath: string; entry: BarrelEntry } | null> { + try { + const parsedExports = await this.exportCache.resolveExports(filePath); + const exports = this.normalizeParsedExports(parsedExports); + if (exports.length === 0) return null; + const relativePath = path.relative(directoryPath, filePath); + return { relativePath, entry: { kind: BarrelEntryKind.File, exports } }; + } catch (error) { + console.warn(`Failed to process file ${filePath}:`, error); + return null; } } @@ -320,17 +377,20 @@ export class BarrelFileGenerator { */ private async addSubdirectoryEntries( directoryPath: string, - subdirectories: string[], - entries: Map, + subdirectories: readonly string[], + entries: Readonly>, ): Promise { - for (const subdirectoryPath of subdirectories) { - const barrelPath = path.join(subdirectoryPath, INDEX_FILENAME); - if (!(await this.fileSystemService.fileExists(barrelPath))) { - continue; - } - - const relativePath = path.relative(directoryPath, subdirectoryPath); - entries.set(relativePath, { kind: BarrelEntryKind.Directory }); + const results = await Promise.all( + subdirectories.map(async (subdirectoryPath) => { + const barrelPath = path.join(subdirectoryPath, INDEX_FILENAME); + const hasBarrel = await this.fileSystemService.hasFile(barrelPath); + return hasBarrel ? subdirectoryPath : null; + }), + ); + for (const subdirectoryPath of results.filter((p): p is string => p !== null)) { + entries.set(path.relative(directoryPath, subdirectoryPath), { + kind: BarrelEntryKind.Directory, + }); } } @@ -342,9 +402,9 @@ export class BarrelFileGenerator { * @returns True if the barrel file should be written; otherwise false. */ private shouldWriteBarrel( - entries: Map, - options: NormalizedGenerationOptions, - hasExistingIndex: boolean, + entries: Readonly>, + options: Readonly, + buildOptions: Readonly, ): boolean { if (entries.size > 0) { return true; @@ -352,10 +412,10 @@ export class BarrelFileGenerator { if (options.mode !== BarrelGenerationMode.UpdateExisting) { this.throwIfNoFilesAndNotRecursive(options); - return hasExistingIndex; + return buildOptions.hasExistingIndex; } - return hasExistingIndex; + return buildOptions.hasExistingIndex; } /** @@ -363,7 +423,7 @@ export class BarrelFileGenerator { * @param options Normalized generation options. * @throws Error if no TypeScript files are found in non-recursive mode. */ - private throwIfNoFilesAndNotRecursive(options: NormalizedGenerationOptions): void { + private throwIfNoFilesAndNotRecursive(options: Readonly): void { if (!options.recursive) { throw new Error('No TypeScript files found in the selected directory'); } @@ -374,7 +434,9 @@ export class BarrelFileGenerator { * @param options Optional generation options. * @returns Normalized generation options with defaults applied. */ - private normalizeOptions(options?: IBarrelGenerationOptions): NormalizedGenerationOptions { + private normalizeOptions( + options?: Readonly, + ): NormalizedGenerationOptions { return { recursive: options?.recursive ?? false, mode: options?.mode ?? BarrelGenerationMode.CreateOrUpdate, @@ -386,7 +448,7 @@ export class BarrelFileGenerator { * @param exports Array of parsed exports. * @returns Array of normalized BarrelExport objects. */ - private normalizeParsedExports(exports: IParsedExport[]): BarrelExport[] { + private normalizeParsedExports(exports: readonly IParsedExport[]): BarrelExport[] { return exports.map((exp) => { if (exp.name === DEFAULT_EXPORT_NAME) { return { kind: BarrelExportKind.Default }; diff --git a/src/core/barrel/content-sanitizer.ts b/src/core/barrel/content-sanitizer.ts index 3902e93..6850e0d 100644 --- a/src/core/barrel/content-sanitizer.ts +++ b/src/core/barrel/content-sanitizer.ts @@ -15,7 +15,7 @@ * */ -import type { LoggerInstance } from '../../types/index.js'; +import type { ILoggerInstance } from '../../types/index.js'; import { extractExportPath, isMultilineExportEnd, @@ -26,7 +26,7 @@ import { /** * State object for tracking multiline export parsing. */ -interface MultilineState { +interface IMultilineState { buffer: string[]; inMultiline: boolean; } @@ -34,22 +34,27 @@ interface MultilineState { /** * Result of content sanitization. */ -export interface SanitizationResult { +export interface ISanitizationResult { preservedLines: string[]; } +interface IPreservationDecision { + isExternal: boolean; + willBeRegenerated: boolean; +} + /** * Service for sanitizing barrel file content during updates. * Handles both single-line and multiline export statements. */ export class BarrelContentSanitizer { - private readonly logger?: LoggerInstance; + private readonly logger?: ILoggerInstance; /** * Creates a new BarrelContentSanitizer instance. * @param logger Optional logger for debug output. */ - constructor(logger?: LoggerInstance) { + constructor(logger?: Readonly) { this.logger = logger; } @@ -62,10 +67,10 @@ export class BarrelContentSanitizer { */ preserveDefinitionsAndSanitizeExports( existingContent: string, - newContentPaths: Set, - ): SanitizationResult { + newContentPaths: Readonly>, + ): ISanitizationResult { const lines = existingContent.trim().split('\n'); - const state: MultilineState = { buffer: [], inMultiline: false }; + const state: IMultilineState = { buffer: [], inMultiline: false }; const preservedLines: string[] = []; for (const line of lines) { @@ -82,56 +87,82 @@ export class BarrelContentSanitizer { /** * Processes a single line during barrel content preservation. * Manages multiline export state and returns lines to preserve. + * @param line TODO: describe parameter + * @param state TODO: describe parameter + * @param newContentPaths TODO: describe parameter + * @returns TODO: describe return value */ private processLineForPreservation( line: string, - state: MultilineState, - newContentPaths: Set, + state: IMultilineState, + newContentPaths: Readonly>, ): string[] { const trimmedLine = line.trim(); - if (state.inMultiline) { - state.buffer.push(line); - if (isMultilineExportEnd(trimmedLine)) { - const result = this.processMultilineBlock(state.buffer, newContentPaths); - state.buffer = []; - state.inMultiline = false; - return result; - } - return []; + return this.processInMultilineLine(line, trimmedLine, state, newContentPaths); } - if (isMultilineExportStart(trimmedLine)) { state.inMultiline = true; state.buffer = [line]; return []; } - return this.processSingleLine(line, trimmedLine, newContentPaths); } + /** + * Processes a line received while in multiline export state. + * @param line - The raw line. + * @param trimmedLine - The trimmed line. + * @param state - Current multiline state. + * @param newContentPaths - Set of paths that will be regenerated. + * @returns Lines to preserve. + */ + private processInMultilineLine( + line: string, + trimmedLine: string, + state: IMultilineState, + newContentPaths: Readonly>, + ): string[] { + state.buffer.push(line); + if (isMultilineExportEnd(trimmedLine)) { + const result = this.processMultilineBlock(state.buffer, newContentPaths); + state.buffer = []; + state.inMultiline = false; + return result; + } + return []; + } + /** * Processes a completed multiline export block and determines if it should be preserved. * @returns Lines to preserve (empty array if should be stripped). + * @param buffer TODO: describe parameter + * @param newContentPaths TODO: describe parameter */ - private processMultilineBlock(buffer: string[], newContentPaths: Set): string[] { + private processMultilineBlock( + buffer: readonly string[], + newContentPaths: Readonly>, + ): string[] { const fullBlock = buffer.join('\n'); const exportPath = extractExportPath(fullBlock); if (!exportPath) { // Failed to parse as export, preserve the lines - return buffer; + return [...buffer]; } - return this.shouldPreserveReExport(exportPath, newContentPaths) ? buffer : []; + return this.shouldPreserveReExport(exportPath, newContentPaths) ? [...buffer] : []; } /** * Processes a single line for preservation in barrel content. * @returns Lines to preserve (empty array if should be stripped). + * @param line TODO: describe parameter + * @param trimmedLine TODO: describe parameter + * @param newContentPaths TODO: describe parameter */ private processSingleLine( line: string, trimmedLine: string, - newContentPaths: Set, + newContentPaths: Readonly>, ): string[] { const exportPath = extractExportPath(trimmedLine); if (exportPath) { @@ -149,45 +180,60 @@ export class BarrelContentSanitizer { * @param normalizedNewPaths Set of pre-normalized paths that will be regenerated. * @returns True if the re-export should be preserved. */ - private shouldPreserveReExport(exportPath: string, normalizedNewPaths: Set): boolean { + private shouldPreserveReExport( + exportPath: string, + normalizedNewPaths: Readonly>, + ): boolean { const isExternal = exportPath.startsWith('..'); const normalizedPath = normalizeExportPath(exportPath); const willBeRegenerated = normalizedNewPaths.has(normalizedPath); const shouldPreserve = !isExternal && !willBeRegenerated; - this.logPreservationDecision(exportPath, normalizedPath, isExternal, willBeRegenerated); + this.logPreservationDecision(exportPath, normalizedPath, { isExternal, willBeRegenerated }); return shouldPreserve; } /** * Logs debug information about re-export preservation decisions. + * @param exportPath TODO: describe parameter + * @param normalizedPath TODO: describe parameter + * @param decision TODO: describe parameter */ private logPreservationDecision( exportPath: string, normalizedPath: string, - isExternal: boolean, - willBeRegenerated: boolean, + decision: Readonly, ): void { if (!this.logger) { return; } - this.logger.debug( - `[SANITIZER] Checking: ${exportPath} → ${normalizedPath} isExternal: ${isExternal} willBeRegenerated: ${willBeRegenerated}`, + `[SANITIZER] Checking: ${exportPath} → ${normalizedPath} isExternal: ${decision.isExternal} willBeRegenerated: ${decision.willBeRegenerated}`, ); + this.logPreservationAction(exportPath, normalizedPath, decision); + } - if (isExternal) { - this.logger.debug(`Stripping external re-export: ${exportPath}`); - return; - } - - if (willBeRegenerated) { - this.logger.debug( + /** + * Logs the preservation action for a re-export. + * @param exportPath - The export path. + * @param normalizedPath - The normalized path. + * @param decision - The preservation decision. + */ + private logPreservationAction( + exportPath: string, + normalizedPath: string, + decision: Readonly, + ): void { + const { logger } = this; + if (!logger) return; + if (decision.isExternal) { + logger.debug(`Stripping external re-export: ${exportPath}`); + } else if (decision.willBeRegenerated) { + logger.debug( `Stripping re-export that will be regenerated: ${exportPath} (normalized: ${normalizedPath})`, ); - return; + } else { + logger.debug(`Preserving re-export: ${exportPath}`); } - - this.logger.debug(`Preserving re-export: ${exportPath}`); } } diff --git a/src/core/barrel/export-cache.ts b/src/core/barrel/export-cache.ts index 924d09a..8cac901 100644 --- a/src/core/barrel/export-cache.ts +++ b/src/core/barrel/export-cache.ts @@ -20,7 +20,7 @@ import type { IParsedExport } from '../../types/index.js'; /** * Minimal file system interface required by ExportCache. */ -export interface ExportCacheFileSystem { +export interface IExportCacheFileSystem { getFileStats(filePath: string): Promise<{ mtime: Date }>; readFile(filePath: string): Promise; } @@ -28,14 +28,14 @@ export interface ExportCacheFileSystem { /** * Minimal export parser interface required by ExportCache. */ -export interface ExportCacheParser { +export interface IExportCacheParser { extractExports(content: string): IParsedExport[]; } /** * Represents cached export information for a file. */ -export interface CachedExport { +export interface ICachedExport { exports: IParsedExport[]; mtime: number; } @@ -43,17 +43,19 @@ export interface CachedExport { /** * Configuration options for the export cache. */ -export interface ExportCacheOptions { +export interface IExportCacheOptions { /** Maximum number of entries to cache. Default: 1000 */ maxSize?: number; } +const DEFAULT_MAX_CACHE_SIZE = 1000; + /** * Cache for parsed exports to avoid re-parsing unchanged files. * Uses file modification time to invalidate stale entries. */ export class ExportCache { - private readonly cache = new Map(); + private readonly cache = new Map(); private readonly maxSize: number; /** @@ -63,38 +65,42 @@ export class ExportCache { * @param options Cache configuration options. */ constructor( - private readonly fileSystemService: ExportCacheFileSystem, - private readonly exportParser: ExportCacheParser, - options?: ExportCacheOptions, + private readonly fileSystemService: Readonly, + private readonly exportParser: Readonly, + options?: Readonly, ) { - this.maxSize = options?.maxSize ?? 1000; + this.maxSize = options?.maxSize ?? DEFAULT_MAX_CACHE_SIZE; } /** - * Gets exports for a file, using cache if available and valid. + * Resolves exports for a file, using cache if available and valid. * @param filePath The file path to get exports for. * @returns Promise that resolves to the parsed exports. */ - async getExports(filePath: string): Promise { + async resolveExports(filePath: string): Promise { const stats = await this.fileSystemService.getFileStats(filePath); const currentMtime = stats.mtime.getTime(); - - // Check cache first const cached = this.cache.get(filePath); if (cached?.mtime === currentMtime) { return cached.exports; } + return this.fetchAndCacheExports(filePath, currentMtime); + } - // Parse and cache the exports + /** + * Fetches, parses, and caches exports for a file that has changed. + * @param filePath - The file path to fetch exports for. + * @param currentMtime - The current modification time. + * @returns Promise resolving to the parsed exports. + */ + private async fetchAndCacheExports( + filePath: string, + currentMtime: number, + ): Promise { const content = await this.fileSystemService.readFile(filePath); const exports = this.exportParser.extractExports(content); - - // Cache with modification time this.cache.set(filePath, { exports, mtime: currentMtime }); - - // Evict oldest entry if over capacity this.evictIfNeeded(); - return exports; } @@ -107,6 +113,7 @@ export class ExportCache { /** * Returns the current number of cached entries. + * @returns TODO: describe return value */ get size(): number { return this.cache.size; diff --git a/src/core/barrel/export-patterns.ts b/src/core/barrel/export-patterns.ts index 79a4e5e..56d52e7 100644 --- a/src/core/barrel/export-patterns.ts +++ b/src/core/barrel/export-patterns.ts @@ -39,16 +39,22 @@ const MULTILINE_EXPORT_PATTERN = * @param text The text to parse (can be single line or multiline). * @returns The export path if found, otherwise null. */ -export function extractExportPath(text: string): string | null { - const normalized = text.trim(); - // Try single-line pattern first (faster for common case) - const singleLineMatch = EXPORT_PATH_PATTERN.exec(normalized); - if (singleLineMatch) { - return singleLineMatch[1]; +export function detectExtensionFromBarrelContent(content: string): string | null { + const lines = content.trim().split('\n'); + + for (const line of lines) { + const trimmedLine = line.trim(); + if (!isExportLine(trimmedLine)) { + continue; + } + + const extension = extractExtensionFromLine(trimmedLine); + if (extension !== null) { + return extension; + } } - // Try multiline pattern (handles newlines within braces) - const multilineMatch = MULTILINE_EXPORT_PATTERN.exec(normalized); - return multilineMatch ? multilineMatch[1] : null; + + return null; } /** @@ -58,18 +64,6 @@ export function extractExportPath(text: string): string | null { * @param exportPath The path to normalize. * @returns The normalized path without extension or /index suffix. */ -export function normalizeExportPath(exportPath: string): string { - return exportPath.replace(/\.(js|mjs|ts|tsx|mts|cts)$/, '').replace(/\/index$/, ''); -} - -/** - * Extracts all export paths from barrel content and returns them normalized. - * Paths are normalized by stripping extensions (e.g., ./foo.js → ./foo) and - * removing /index suffixes (e.g., ./utils/index → ./utils) for consistent - * comparison during deduplication. - * @param content The barrel file content. - * @returns Set of normalized module paths found in export statements. - */ export function extractAllExportPaths(content: string): Set { const paths = new Set(); const lines = content.trim().split('\n'); @@ -88,19 +82,29 @@ export function extractAllExportPaths(content: string): Set { } /** - * Checks if a line is an export statement using AST parsing. - * @param line The line to check. - * @returns True if the line is an export statement. + * Extracts all export paths from barrel content and returns them normalized. + * Paths are normalized by stripping extensions (e.g., ./foo.js → ./foo) and + * removing /index suffixes (e.g., ./utils/index → ./utils) for consistent + * comparison during deduplication. + * @param content The barrel file content. + * @returns Set of normalized module paths found in export statements. */ -export function isExportLine(line: string): boolean { - const path = extractExportPath(line); - return path !== null; +export function extractExportPath(text: string): string | null { + const normalized = text.trim(); + // Try single-line pattern first (faster for common case) + const singleLineMatch = EXPORT_PATH_PATTERN.exec(normalized); + if (singleLineMatch) { + return singleLineMatch[1]; + } + // Try multiline pattern (handles newlines within braces) + const multilineMatch = MULTILINE_EXPORT_PATTERN.exec(normalized); + return multilineMatch ? multilineMatch[1] : null; } /** - * Extracts the extension pattern from an export line. - * @param line The export line. - * @returns The extension pattern, or null if none found. + * Checks if a line is an export statement using AST parsing. + * @param line The line to check. + * @returns True if the line is an export statement. */ export function extractExtensionFromLine(line: string): string | null { const exportPath = extractExportPath(line); @@ -121,33 +125,19 @@ export function extractExtensionFromLine(line: string): string | null { } /** - * Detects the file extension pattern used in existing barrel content. - * @param content The barrel file content. - * @returns The extension pattern used, or null if none detected. + * Extracts the extension pattern from an export line. + * @param line The export line. + * @returns The extension pattern, or null if none found. */ -export function detectExtensionFromBarrelContent(content: string): string | null { - const lines = content.trim().split('\n'); - - for (const line of lines) { - const trimmedLine = line.trim(); - if (!isExportLine(trimmedLine)) { - continue; - } - - const extension = extractExtensionFromLine(trimmedLine); - if (extension !== null) { - return extension; - } - } - - return null; +export function isExportLine(line: string): boolean { + const path = extractExportPath(line); + return path !== null; } /** - * Checks if a line closes a multiline export statement. - * Simple heuristic check for performance since this is called per-line. - * @param line The line to check. - * @returns True if the line ends a multiline export. + * Detects the file extension pattern used in existing barrel content. + * @param content The barrel file content. + * @returns The extension pattern used, or null if none detected. */ export function isMultilineExportEnd(line: string): boolean { // Quick heuristic: line contains } followed by from and a quote @@ -155,10 +145,10 @@ export function isMultilineExportEnd(line: string): boolean { } /** - * Checks if a line starts a multiline export (opens but doesn't close on same line). + * Checks if a line closes a multiline export statement. * Simple heuristic check for performance since this is called per-line. * @param line The line to check. - * @returns True if the line starts a multiline export. + * @returns True if the line ends a multiline export. */ export function isMultilineExportStart(line: string): boolean { const trimmed = line.trim(); @@ -171,3 +161,13 @@ export function isMultilineExportStart(line: string): boolean { // If it already ends on the same line, it's not a multiline start return !isMultilineExportEnd(trimmed); } + +/** + * Checks if a line starts a multiline export (opens but doesn't close on same line). + * Simple heuristic check for performance since this is called per-line. + * @param line The line to check. + * @returns True if the line starts a multiline export. + */ +export function normalizeExportPath(exportPath: string): string { + return exportPath.replace(/\.(js|mjs|ts|tsx|mts|cts)$/, '').replace(/\/index$/, ''); +} diff --git a/src/core/barrel/index.ts b/src/core/barrel/index.ts index 3b95f3f..c06f8a2 100644 --- a/src/core/barrel/index.ts +++ b/src/core/barrel/index.ts @@ -16,13 +16,13 @@ */ export { BarrelContentBuilder } from './barrel-content.builder.js'; export { BarrelFileGenerator } from './barrel-file.generator.js'; -export { BarrelContentSanitizer, type SanitizationResult } from './content-sanitizer.js'; +export { BarrelContentSanitizer, type ISanitizationResult } from './content-sanitizer.js'; export { - type CachedExport, ExportCache, - type ExportCacheFileSystem, - type ExportCacheOptions, - type ExportCacheParser, + type ICachedExport, + type IExportCacheFileSystem, + type IExportCacheOptions, + type IExportCacheParser, } from './export-cache.js'; export { detectExtensionFromBarrelContent, diff --git a/src/core/io/file-system.service.ts b/src/core/io/file-system.service.ts index 35bce97..af3d26f 100644 --- a/src/core/io/file-system.service.ts +++ b/src/core/io/file-system.service.ts @@ -15,6 +15,7 @@ * */ +import type { Stats } from 'node:fs'; import { Dirent } from 'node:fs'; import * as fs from 'node:fs/promises'; import * as path from 'node:path'; @@ -22,6 +23,11 @@ import * as path from 'node:path'; import { INDEX_FILENAME } from '../../types/index.js'; import { getErrorMessage } from '../../utils/index.js'; +const BYTES_PER_KB = 1024; +const BYTES_PER_MB = BYTES_PER_KB * BYTES_PER_KB; +const MAX_FILE_SIZE_MB = 10; +const MB_DECIMAL_PLACES = 2; + const IGNORED_DIRECTORIES = new Set([ // Dependencies 'node_modules', @@ -98,9 +104,14 @@ export class FileSystemService { */ async getTypeScriptFiles(directoryPath: string): Promise { const entries = await this.readDirectory(directoryPath); - return entries - .filter((entry) => this.isTypeScriptFile(entry)) - .map((entry) => path.join(directoryPath, entry.name)); + /** + * Converts a directory entry to its absolute file path. + * @param entry - The directory entry to convert. + * @returns The absolute file path. + */ + const toAbsolutePath = (entry: Readonly): string => + path.join(directoryPath, entry.name); + return entries.filter(this.isTypeScriptFile.bind(this)).map(toAbsolutePath); } /** @@ -110,9 +121,14 @@ export class FileSystemService { */ async getSubdirectories(directoryPath: string): Promise { const entries = await this.readDirectory(directoryPath); - return entries - .filter((entry) => this.isTraversableDirectory(entry)) - .map((entry) => path.join(directoryPath, entry.name)); + /** + * Converts a directory entry to its absolute directory path. + * @param entry - The directory entry to convert. + * @returns The absolute directory path. + */ + const toAbsolutePath = (entry: Readonly): string => + path.join(directoryPath, entry.name); + return entries.filter(this.isTraversableDirectory.bind(this)).map(toAbsolutePath); } /** @@ -120,7 +136,7 @@ export class FileSystemService { * @param entry The directory entry * @returns True if it's a TypeScript file; otherwise, false */ - private isTypeScriptFile(entry: Dirent): boolean { + private isTypeScriptFile(entry: Readonly): boolean { if (!entry.isFile()) return false; if (this.shouldExcludeFile(entry.name)) return false; return this.isTypeScriptExtension(entry.name); @@ -170,7 +186,7 @@ export class FileSystemService { * @param entry The directory entry * @returns True if the directory should be traversed; otherwise, false */ - private isTraversableDirectory(entry: Dirent): boolean { + private isTraversableDirectory(entry: Readonly): boolean { const normalized = normalizeCase(entry.name); return ( entry.isDirectory() && !IGNORED_DIRECTORIES.has(normalized) && !normalized.startsWith('.') @@ -186,13 +202,13 @@ export class FileSystemService { async readFile(filePath: string): Promise { try { // Check file size before reading to prevent memory issues with large files - const maxFileSizeBytes = 10 * 1024 * 1024; // 10MB limit + const maxFileSizeBytes = MAX_FILE_SIZE_MB * BYTES_PER_MB; const stats = await this.fs.stat(filePath); if (stats.size > maxFileSizeBytes) { throw new Error( - `File ${filePath} is too large (${(stats.size / 1024 / 1024).toFixed(2)}MB). ` + - `Maximum allowed size is ${(maxFileSizeBytes / 1024 / 1024).toFixed(0)}MB.`, + `File ${filePath} is too large (${(stats.size / BYTES_PER_MB).toFixed(MB_DECIMAL_PLACES)}MB). ` + + `Maximum allowed size is ${(maxFileSizeBytes / BYTES_PER_MB).toFixed(0)}MB.`, ); } @@ -268,7 +284,7 @@ export class FileSystemService { * @param filePath The path to check * @returns True if the path exists; otherwise, false */ - async fileExists(filePath: string): Promise { + async hasFile(filePath: string): Promise { try { await this.fs.access(filePath); return true; @@ -297,7 +313,7 @@ export class FileSystemService { * @returns Promise that resolves to file stats * @throws Error if the stat operation fails */ - async getFileStats(filePath: string): Promise { + async getFileStats(filePath: string): Promise { try { return await this.fs.stat(filePath); } catch (error) { @@ -310,6 +326,7 @@ export class FileSystemService { * Reads the entries of a directory with error handling. * @param directoryPath The directory path * @returns Array of directory entries + * @throws {Error} TODO: describe error condition */ private async readDirectory(directoryPath: string): Promise { try { diff --git a/src/core/parser/export.parser.ts b/src/core/parser/export.parser.ts index b32d47d..cc6b621 100644 --- a/src/core/parser/export.parser.ts +++ b/src/core/parser/export.parser.ts @@ -36,6 +36,21 @@ const SCRIPT_KIND_MAP: Record = { '.cjs': ScriptKind.JS, }; +/** + * Options describing the type-only status of an export to record. + */ +interface IRecordExportOptions { + typeOnly: boolean; +} + +/** + * Options describing the specifier and type-only status of a named export declaration. + */ +interface INamedExportOptions { + hasModuleSpecifier: boolean; + isTypeOnly: boolean; +} + /** * Service responsible for parsing TypeScript exports using the TypeScript AST. * This provides accurate parsing by using the TypeScript compiler itself, @@ -45,14 +60,12 @@ const SCRIPT_KIND_MAP: Record = { export class ExportParser { /** * Extracts all export statements from TypeScript code using AST parsing. + * @param content TODO: describe parameter + * @param fileName TODO: describe parameter + * @returns TODO: describe return value */ extractExports(content: string, fileName = 'temp.ts'): IParsedExport[] { - // Create a new project instance for each parsing operation to avoid memory accumulation - const project = new Project({ - useInMemoryFileSystem: true, - compilerOptions: { allowJs: true, noEmit: true, skipLibCheck: true }, - }); - + const project = this.createProject(); const exportMap = new Map(); const sourceFile = project.createSourceFile(fileName, content, { overwrite: true, @@ -68,34 +81,64 @@ export class ExportParser { } } + /** + * Creates a new in-memory TypeScript project for parsing. + * @returns A new Project instance configured for in-memory use. + */ + private createProject(): Project { + return new Project({ + useInMemoryFileSystem: true, + compilerOptions: { allowJs: true, noEmit: true, skipLibCheck: true }, + }); + } + /** * Determines the script kind for a file based on its extension. + * @param fileName TODO: describe parameter + * @returns TODO: describe return value */ private getScriptKind(fileName: string): ScriptKind { - const ext = Object.keys(SCRIPT_KIND_MAP).find((e) => fileName.endsWith(e)); + /** + * Checks whether the filename ends with the given extension. + * @param ext - The file extension to match. + * @returns True if the filename ends with the extension. + */ + const isMatchingExtension = (ext: string): boolean => fileName.endsWith(ext); + const ext = Object.keys(SCRIPT_KIND_MAP).find(isMatchingExtension); return ext ? SCRIPT_KIND_MAP[ext] : ScriptKind.TS; } /** * Builds the final export list and ensures default exports are included. + * @param sourceFile TODO: describe parameter + * @param exportMap TODO: describe parameter + * @returns TODO: describe return value */ private buildResult( - sourceFile: SourceFile, - exportMap: Map, + sourceFile: Readonly, + exportMap: Readonly>, ): IParsedExport[] { const result = Array.from(exportMap.values()); - if (this.hasDefaultExport(sourceFile) && !result.some((e) => e.name === DEFAULT_EXPORT_NAME)) { - result.push({ name: DEFAULT_EXPORT_NAME, typeOnly: false }); + /** + * Checks whether a parsed export is the default export. + * @param e - The parsed export to check. + * @returns True if the export name matches the default export name. + */ + const isDefaultExport = (e: Readonly): boolean => e.name === DEFAULT_EXPORT_NAME; + if (this.hasDefaultExport(sourceFile) && !result.some(isDefaultExport)) { + return [...result, { name: DEFAULT_EXPORT_NAME, typeOnly: false }]; } return result; } /** * Collects export declarations (export { ... } from ...) from the source file. + * @param sourceFile TODO: describe parameter + * @param exportMap TODO: describe parameter */ private collectExportDeclarations( - sourceFile: SourceFile, - exportMap: Map, + sourceFile: Readonly, + exportMap: Readonly>, ): void { for (const exportDecl of sourceFile.getExportDeclarations()) { this.processExportDeclaration(exportDecl, exportMap); @@ -104,53 +147,65 @@ export class ExportParser { /** * Processes a single export declaration and records its named exports. + * @param exportDecl TODO: describe parameter + * @param exportMap TODO: describe parameter */ private processExportDeclaration( - exportDecl: ExportDeclaration, - exportMap: Map, + exportDecl: Readonly, + exportMap: Readonly>, ): void { const hasModuleSpecifier = Boolean(exportDecl.getModuleSpecifier()); const isTypeOnly = exportDecl.isTypeOnly(); for (const namedExport of exportDecl.getNamedExports()) { - this.processNamedExport(namedExport, hasModuleSpecifier, isTypeOnly, exportMap); + this.processNamedExport(namedExport, { hasModuleSpecifier, isTypeOnly }, exportMap); } } /** * Records an individual named export, accounting for aliasing and type-only flags. + * @param namedExport TODO: describe parameter + * @param options TODO: describe parameter + * @param exportMap TODO: describe parameter */ private processNamedExport( - namedExport: ExportSpecifier, - hasModuleSpecifier: boolean, - isTypeOnly: boolean, - exportMap: Map, + namedExport: Readonly, + options: Readonly, + exportMap: Readonly>, ): void { const alias = namedExport.getAliasNode()?.getText(); // Skip re-exports without aliases (export { foo } from './module') - if (this.isUnaliasedReExport(hasModuleSpecifier, alias)) { + if (this.isUnaliasedReExport(options, alias)) { return; } const name = alias ?? namedExport.getName(); - const typeOnly = isTypeOnly || namedExport.isTypeOnly(); - this.recordExport(exportMap, name, typeOnly); + const typeOnly = options.isTypeOnly || namedExport.isTypeOnly(); + this.recordExport(exportMap, name, { typeOnly }); } /** * Determines whether a named export is an unaliased re-export (export { foo } from ...). + * @param options TODO: describe parameter + * @param alias TODO: describe parameter + * @returns TODO: describe return value */ - private isUnaliasedReExport(hasModuleSpecifier: boolean, alias: string | undefined): boolean { - return hasModuleSpecifier && !alias; + private isUnaliasedReExport( + options: Readonly, + alias: string | undefined, + ): boolean { + return options.hasModuleSpecifier && !alias; } /** * Collects exported statements such as types, classes, functions, enums, and variables. + * @param sourceFile TODO: describe parameter + * @param exportMap TODO: describe parameter */ private collectExportedStatements( - sourceFile: SourceFile, - exportMap: Map, + sourceFile: Readonly, + exportMap: Readonly>, ): void { for (const statement of sourceFile.getStatements()) { this.processTypeDeclaration(statement, exportMap); @@ -163,67 +218,88 @@ export class ExportParser { /** * Records exported interfaces and type aliases. + * @param stmt TODO: describe parameter + * @param map TODO: describe parameter */ - private processTypeDeclaration(stmt: Statement, map: Map): void { + private processTypeDeclaration(stmt: Statement, map: Readonly>): void { if (Node.isInterfaceDeclaration(stmt) && stmt.isExported()) { - this.recordExport(map, stmt.getName(), true); + this.recordExport(map, stmt.getName(), { typeOnly: true }); } if (Node.isTypeAliasDeclaration(stmt) && stmt.isExported()) { - this.recordExport(map, stmt.getName(), true); + this.recordExport(map, stmt.getName(), { typeOnly: true }); } } /** * Records exported class declarations (excluding default exports). + * @param stmt TODO: describe parameter + * @param map TODO: describe parameter */ - private processClassDeclaration(stmt: Statement, map: Map): void { + private processClassDeclaration( + stmt: Statement, + map: Readonly>, + ): void { if (!Node.isClassDeclaration(stmt) || !stmt.isExported() || stmt.isDefaultExport()) { return; } const name = stmt.getName(); if (name) { - this.recordExport(map, name, false); + this.recordExport(map, name, { typeOnly: false }); } } /** * Records exported function declarations (excluding default exports). + * @param stmt TODO: describe parameter + * @param map TODO: describe parameter */ - private processFunctionDeclaration(stmt: Statement, map: Map): void { + private processFunctionDeclaration( + stmt: Statement, + map: Readonly>, + ): void { if (!Node.isFunctionDeclaration(stmt) || !stmt.isExported() || stmt.isDefaultExport()) { return; } const name = stmt.getName(); if (name) { - this.recordExport(map, name, false); + this.recordExport(map, name, { typeOnly: false }); } } /** * Records exported enum declarations. + * @param stmt TODO: describe parameter + * @param map TODO: describe parameter */ - private processEnumDeclaration(stmt: Statement, map: Map): void { + private processEnumDeclaration(stmt: Statement, map: Readonly>): void { if (Node.isEnumDeclaration(stmt) && stmt.isExported()) { - this.recordExport(map, stmt.getName(), false); + this.recordExport(map, stmt.getName(), { typeOnly: false }); } } /** * Records exported variable declarations. + * @param stmt TODO: describe parameter + * @param map TODO: describe parameter */ - private processVariableStatement(stmt: Statement, map: Map): void { + private processVariableStatement( + stmt: Statement, + map: Readonly>, + ): void { if (!Node.isVariableStatement(stmt) || !stmt.isExported()) { return; } for (const decl of stmt.getDeclarations()) { - this.recordExport(map, decl.getName(), false); + this.recordExport(map, decl.getName(), { typeOnly: false }); } } /** * Checks whether the source file has any form of default export. + * @param sourceFile TODO: describe parameter + * @returns TODO: describe return value */ - private hasDefaultExport(sourceFile: SourceFile): boolean { + private hasDefaultExport(sourceFile: Readonly): boolean { if (sourceFile.getDefaultExportSymbol()) { return true; } @@ -232,15 +308,17 @@ export class ExportParser { /** * Detects aliased default exports (export { foo as default }). + * @param sourceFile TODO: describe parameter + * @returns TODO: describe return value */ - private hasAliasedDefault(sourceFile: SourceFile): boolean { + private hasAliasedDefault(sourceFile: Readonly): boolean { for (const exportDecl of sourceFile.getExportDeclarations()) { if (exportDecl.getModuleSpecifier()) { continue; } const hasDefaultAlias = exportDecl .getNamedExports() - .some((e) => e.getAliasNode()?.getText() === 'default'); + .some(this.isDefaultAliasSpecifier.bind(this)); if (hasDefaultAlias) { return true; } @@ -248,15 +326,28 @@ export class ExportParser { return false; } + /** + * Checks whether an export specifier uses the default export name as its alias. + * @param specifier - The export specifier to check. + * @returns True if the specifier's alias is the default export name. + */ + private isDefaultAliasSpecifier(specifier: Readonly): boolean { + return specifier.getAliasNode()?.getText() === DEFAULT_EXPORT_NAME; + } + /** * Detects default export statements (class/function/export assignment). + * @param sourceFile TODO: describe parameter + * @returns TODO: describe return value */ - private hasDefaultStatement(sourceFile: SourceFile): boolean { - return sourceFile.getStatements().some((stmt) => this.isDefaultExportStatement(stmt)); + private hasDefaultStatement(sourceFile: Readonly): boolean { + return sourceFile.getStatements().some(this.isDefaultExportStatement.bind(this)); } /** * Determines whether a statement represents a default export. + * @param stmt TODO: describe parameter + * @returns TODO: describe return value */ private isDefaultExportStatement(stmt: Statement): boolean { if (Node.isExportAssignment(stmt)) { @@ -273,10 +364,17 @@ export class ExportParser { /** * Inserts or merges an export entry, preserving type-only status. + * @param map TODO: describe parameter + * @param name TODO: describe parameter + * @param options TODO: describe parameter */ - private recordExport(map: Map, name: string, typeOnly: boolean): void { + private recordExport( + map: Readonly>, + name: string, + options: Readonly, + ): void { const existing = map.get(name); - const merged = existing ? existing.typeOnly && typeOnly : typeOnly; + const merged = existing ? existing.typeOnly && options.typeOnly : options.typeOnly; map.set(name, { name, typeOnly: merged }); } } diff --git a/src/extension.ts b/src/extension.ts index 854e170..7993c6b 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -68,19 +68,26 @@ class BarrelCommandQueue { } this.isProcessing = true; - while (this.queue.length > 0) { - const operation = this.queue.shift()!; - try { - await operation(); - } catch (error) { - // Log error but continue processing queue - console.error('Barrel command failed:', error); - } + await this.runNextOperation(); } - this.isProcessing = false; } + + /** + * Dequeues and runs the next operation, logging any errors. + * @returns Promise that resolves when the operation completes. + */ + private async runNextOperation(): Promise { + const operation = this.queue.shift(); + if (!operation) return; + try { + await operation(); + } catch (error) { + // Log error but continue processing queue + console.error('Barrel command failed:', error); + } + } } const commandQueue = new BarrelCommandQueue(); @@ -89,7 +96,7 @@ const commandQueue = new BarrelCommandQueue(); * Activates the Barrel Roll extension. * @param context The extension context provided by VS Code. */ -export function activate(context: vscode.ExtensionContext) { +export function activate(context: Readonly) { console.log('Barrel Roll extension is now active'); const outputChannel = vscode.window.createOutputChannel('Barrel Roll'); @@ -99,7 +106,17 @@ export function activate(context: vscode.ExtensionContext) { const generator = new BarrelFileGenerator(); - const descriptors: CommandDescriptor[] = [ + for (const descriptor of createCommandDescriptors()) { + context.subscriptions.push(registerBarrelCommand(generator, descriptor)); + } +} + +/** + * Returns the list of barrel command descriptors for registration. + * @returns Array of command descriptors. + */ +function createCommandDescriptors(): CommandDescriptor[] { + return [ { id: 'barrel-roll.generateBarrel', options: { @@ -119,11 +136,6 @@ export function activate(context: vscode.ExtensionContext) { successMessage: 'Barrel Roll: index.ts files updated recursively.', }, ]; - - for (const descriptor of descriptors) { - const disposable = registerBarrelCommand(generator, descriptor); - context.subscriptions.push(disposable); - } } /** @@ -138,49 +150,30 @@ export function deactivate(): void { * @param generator The barrel file generator instance. * @param descriptor The command descriptor containing options and messages. * @returns A disposable for the registered command. + * @throws {Error} TODO: describe error condition */ -function registerBarrelCommand( - generator: BarrelFileGenerator, - descriptor: CommandDescriptor, -): vscode.Disposable { - return vscode.commands.registerCommand(descriptor.id, async (uri?: vscode.Uri) => { - try { - const targetDirectory = await resolveTargetDirectory(uri); - if (!targetDirectory) { - return; - } - - await commandQueue.enqueue(async () => { - await withProgress(descriptor.progressTitle, async () => { - await generator.generateBarrelFile(targetDirectory, descriptor.options); - }); - }); - - vscode.window.showInformationMessage(descriptor.successMessage); - } catch (error) { - const message = getErrorMessage(error); - vscode.window.showErrorMessage(`Barrel Roll: ${message}`); +async function ensureDirectoryUri(uri: Readonly): Promise { + try { + const stat = await vscode.workspace.fs.stat(uri); + if (stat.type === vscode.FileType.Directory) { + return uri; } - }); + if (stat.type === vscode.FileType.File) { + return vscode.Uri.file(path.dirname(uri.fsPath)); + } + } catch (error) { + const message = getErrorMessage(error); + throw new Error(`Unable to access selected resource: ${message}`); + } + + return uri; } /** * Resolves the target directory for barrel generation from the provided URI or user prompt. * @param uri Optional URI from the command invocation. * @returns Promise that resolves to the target directory URI, or undefined if cancelled. - */ -async function resolveTargetDirectory(uri?: vscode.Uri): Promise { - const initial = uri ?? (await promptForDirectory()); - if (!initial) { - return undefined; - } - - return ensureDirectoryUri(initial); -} - -/** - * Prompts the user to select a directory for barrel generation. - * @returns Promise that resolves to the selected directory URI, or undefined if cancelled. + * @param descriptor TODO: describe parameter */ async function promptForDirectory(): Promise { const selected = await vscode.window.showOpenDialog({ @@ -197,26 +190,50 @@ async function promptForDirectory(): Promise { return selected[0]; } +/** + * Prompts the user to select a directory for barrel generation. + * @returns Promise that resolves to the selected directory URI, or undefined if cancelled. + * @param uri TODO: describe parameter + * @param descriptor TODO: describe parameter + */ +function registerBarrelCommand( + generator: Readonly, + descriptor: Readonly, +): vscode.Disposable { + return vscode.commands.registerCommand(descriptor.id, async (uri?: Readonly) => { + try { + const targetDirectory = await resolveTargetDirectory(uri); + if (!targetDirectory) { + return; + } + + await commandQueue.enqueue(async () => { + await withProgress(descriptor.progressTitle, async () => { + await generator.generateBarrelFile(targetDirectory, descriptor.options); + }); + }); + + vscode.window.showInformationMessage(descriptor.successMessage); + } catch (error) { + const message = getErrorMessage(error); + vscode.window.showErrorMessage(`Barrel Roll: ${message}`); + } + }); +} + /** * Ensures the provided URI points to a directory, converting file URIs to their parent directory. * @param uri The URI to validate and potentially convert. * @returns Promise that resolves to a directory URI, or undefined if validation fails. + * @throws {Error} TODO: describe error condition */ -async function ensureDirectoryUri(uri: vscode.Uri): Promise { - try { - const stat = await vscode.workspace.fs.stat(uri); - if (stat.type === vscode.FileType.Directory) { - return uri; - } - if (stat.type === vscode.FileType.File) { - return vscode.Uri.file(path.dirname(uri.fsPath)); - } - } catch (error) { - const message = getErrorMessage(error); - throw new Error(`Unable to access selected resource: ${message}`); +async function resolveTargetDirectory(uri?: Readonly): Promise { + const initial = uri ?? (await promptForDirectory()); + if (!initial) { + return undefined; } - return uri; + return ensureDirectoryUri(initial); } /** diff --git a/src/logging/index.ts b/src/logging/index.ts index c4b4513..2fee17a 100644 --- a/src/logging/index.ts +++ b/src/logging/index.ts @@ -21,7 +21,7 @@ */ export { - type LoggerOptions, + type ILoggerOptions, LogLevel, type LogMetadata, OutputChannelLogger, diff --git a/src/logging/output-channel.logger.ts b/src/logging/output-channel.logger.ts index 17ad50b..719065d 100644 --- a/src/logging/output-channel.logger.ts +++ b/src/logging/output-channel.logger.ts @@ -15,8 +15,7 @@ * */ -import type { OutputChannel } from 'vscode'; - +import type { IOutputChannel } from '../types/logger.js'; import { formatErrorForLog, isError, safeStringify } from '../utils/index.js'; export type LogMetadata = Record; @@ -32,35 +31,54 @@ export enum LogLevel { /** * Configuration options for the OutputChannelLogger. */ -export interface LoggerOptions { +export interface ILoggerOptions { /** Minimum log level to emit. Defaults to LogLevel.Info. */ level?: LogLevel; /** Whether to also log to the console. Defaults to true. */ console?: boolean; } +const LOG_LEVEL_DEBUG = 0; +const LOG_LEVEL_INFO = 1; +const LOG_LEVEL_WARN = 2; +const LOG_LEVEL_ERROR = 3; +const LOG_LEVEL_FATAL = 4; + +/** + * Normalizes a single metadata entry by replacing Error values with their message. + * @param accumulator - The accumulating normalized record. + * @param entry - The key-value pair to normalize. + * @returns The updated accumulator. + */ +function normalizeMetadataEntry( + accumulator: Readonly>, + [key, value]: readonly [string, unknown], +): Record { + return { ...accumulator, [key]: isError(value) ? value.message : value }; +} + /** * A logger abstraction over VS Code's OutputChannel API. * Provides structured logging with metadata support and optional console output. */ export class OutputChannelLogger { - private static sharedOutputChannel?: OutputChannel; - private readonly options: Required; + private static sharedOutputChannel?: IOutputChannel; + private readonly options: Required; private bindings: LogMetadata = {}; private static readonly LOG_LEVELS: Record = { - [LogLevel.Debug]: 0, - [LogLevel.Info]: 1, - [LogLevel.Warn]: 2, - [LogLevel.Error]: 3, - [LogLevel.Fatal]: 4, + [LogLevel.Debug]: LOG_LEVEL_DEBUG, + [LogLevel.Info]: LOG_LEVEL_INFO, + [LogLevel.Warn]: LOG_LEVEL_WARN, + [LogLevel.Error]: LOG_LEVEL_ERROR, + [LogLevel.Fatal]: LOG_LEVEL_FATAL, }; /** * Creates a new OutputChannelLogger instance. * @param options - Optional configuration for the logger. */ - constructor(options?: LoggerOptions) { + constructor(options?: Readonly) { this.options = { level: options?.level ?? LogLevel.Info, console: options?.console ?? true, @@ -71,7 +89,7 @@ export class OutputChannelLogger { * Configure a shared VS Code output channel used by all logger instances. * @param channel - Output channel to use for log messages. */ - static configureOutputChannel(channel: OutputChannel | undefined): void { + static configureOutputChannel(channel: IOutputChannel | undefined): void { OutputChannelLogger.sharedOutputChannel = channel; } @@ -88,7 +106,7 @@ export class OutputChannelLogger { * @param message - The message to log. * @param metadata - Optional metadata to include with the log. */ - info(message: string, metadata?: LogMetadata): void { + info(message: string, metadata?: Readonly): void { this.log(LogLevel.Info, message, metadata); } @@ -97,7 +115,7 @@ export class OutputChannelLogger { * @param message - The message to log. * @param metadata - Optional metadata to include with the log. */ - debug(message: string, metadata?: LogMetadata): void { + debug(message: string, metadata?: Readonly): void { this.log(LogLevel.Debug, message, metadata); } @@ -106,7 +124,7 @@ export class OutputChannelLogger { * @param message - The message to log. * @param metadata - Optional metadata to include with the log. */ - warn(message: string, metadata?: LogMetadata): void { + warn(message: string, metadata?: Readonly): void { this.log(LogLevel.Warn, message, metadata); } @@ -115,7 +133,7 @@ export class OutputChannelLogger { * @param message - The message to log. * @param metadata - Optional metadata to include with the log. */ - error(message: string, metadata?: LogMetadata): void { + error(message: string, metadata?: Readonly): void { this.log(LogLevel.Error, message, metadata); } @@ -124,7 +142,7 @@ export class OutputChannelLogger { * @param message - The failure message. * @param metadata - Optional metadata to include with the failure. */ - fatal(message: string, metadata?: LogMetadata): void { + fatal(message: string, metadata?: Readonly): void { this.log(LogLevel.Fatal, `Action failed: ${message}`, metadata); } @@ -133,7 +151,7 @@ export class OutputChannelLogger { * @param bindings - Additional metadata to include with all logs from the child logger. * @returns A new logger instance with the bindings applied. */ - child(bindings: LogMetadata): OutputChannelLogger { + child(bindings: Readonly): OutputChannelLogger { const childLogger = new OutputChannelLogger(this.options); childLogger.bindings = { ...this.bindings, ...bindings }; return childLogger; @@ -144,6 +162,7 @@ export class OutputChannelLogger { * @param name - The name of the group. * @param fn - The function to execute within the group. * @returns A promise that resolves when the group operation completes. + * @throws {Error} TODO: describe error condition */ async group(name: string, fn: () => Promise): Promise { const childLogger = this.child({ group: name }); @@ -167,7 +186,7 @@ export class OutputChannelLogger { * @param message - The message to log. * @param metadata - Optional metadata to include. */ - private log(level: LogLevel, message: string, metadata?: LogMetadata): void { + private log(level: Readonly, message: string, metadata?: Readonly): void { if (!this.shouldLog(level)) return; const mergedMetadata = { ...this.bindings, ...metadata }; @@ -182,7 +201,7 @@ export class OutputChannelLogger { * @param level - The log level to check. * @returns True if the message should be logged; otherwise false. */ - private shouldLog(level: LogLevel): boolean { + private shouldLog(level: Readonly): boolean { return ( OutputChannelLogger.LOG_LEVELS[level] >= OutputChannelLogger.LOG_LEVELS[this.options.level] ); @@ -201,7 +220,7 @@ export class OutputChannelLogger { * @param level - The log level. * @param line - The formatted log line to write. */ - private writeToConsole(level: LogLevel, line: string): void { + private writeToConsole(level: Readonly, line: string): void { if (!this.options.console) return; const consoleMethods: Record void> = { @@ -222,7 +241,11 @@ export class OutputChannelLogger { * @param metadata - Optional metadata to include. * @returns The formatted log line. */ - private formatLine(level: LogLevel, message: string, metadata?: LogMetadata): string { + private formatLine( + level: Readonly, + message: string, + metadata?: Readonly, + ): string { const timestamp = new Date().toISOString(); const formattedMetadata = this.formatMetadata(metadata); const levelTag = `[${level.toUpperCase()}]`; @@ -237,19 +260,14 @@ export class OutputChannelLogger { * @param metadata - The metadata to format. * @returns The formatted metadata string, or undefined if no metadata. */ - private formatMetadata(metadata?: LogMetadata): string | undefined { + private formatMetadata(metadata?: Readonly): string | undefined { if (!metadata || Object.keys(metadata).length === 0) { return undefined; } - const normalized = Object.entries(metadata).reduce>( - (accumulator, [key, value]) => { - accumulator[key] = isError(value) ? value.message : value; - return accumulator; - }, + normalizeMetadataEntry, {}, ); - return safeStringify(normalized); } diff --git a/src/test/integration/core/parser/export.parser.integration.test.ts b/src/test/integration/core/parser/export.parser.integration.test.ts index fb352db..db80ea2 100644 --- a/src/test/integration/core/parser/export.parser.integration.test.ts +++ b/src/test/integration/core/parser/export.parser.integration.test.ts @@ -57,10 +57,15 @@ describe('ExportParser Integration Tests', () => { return; } - for (const filePath of files) { - const content = await readFile(filePath, 'utf8'); - const exports = parser.extractExports(content); + const results = await Promise.all( + files.map(async (filePath) => { + const content = await readFile(filePath, 'utf8'); + const exports = parser.extractExports(content); + return { filePath, exports }; + }), + ); + for (const { filePath, exports } of results) { // Test files should have no real exports - they only contain test code // with export statements inside strings as test fixtures assert.deepStrictEqual( diff --git a/src/test/runTest.ts b/src/test/runTest.ts index de7e47e..bc51ed2 100644 --- a/src/test/runTest.ts +++ b/src/test/runTest.ts @@ -64,6 +64,7 @@ async function main(): Promise { /** * Determines whether to skip VS Code integration tests based on environment conditions. + * @returns TODO: describe return value */ function shouldSkipVscodeTests(): boolean { return Boolean(process.env.CI) || !process.stdout.isTTY || process.platform === 'linux'; diff --git a/src/test/testTypes.ts b/src/test/testTypes.ts index 594d175..9caac7d 100644 --- a/src/test/testTypes.ts +++ b/src/test/testTypes.ts @@ -41,6 +41,8 @@ export type FakeUri = { fsPath: string }; /** * + * @param fsPath TODO: describe parameter + * @returns TODO: describe return value */ export function uriFile(fsPath: string): FakeUri { return { fsPath: path.normalize(fsPath) }; @@ -66,4 +68,4 @@ export type ActivateFn = (context: ExtensionContext) => Promise | void; export type DeactivateFn = () => void; // Minimal runtime shape for the OutputChannelLogger class used in tests -export type { LoggerConstructor, LoggerInstance } from '../types/index.js'; +export type { ILoggerConstructor, ILoggerInstance } from '../types/index.js'; diff --git a/src/test/unit/core/barrel/barrel-file.generator.smoke.test.ts b/src/test/unit/core/barrel/barrel-file.generator.smoke.test.ts index eb5ee0a..279068e 100644 --- a/src/test/unit/core/barrel/barrel-file.generator.smoke.test.ts +++ b/src/test/unit/core/barrel/barrel-file.generator.smoke.test.ts @@ -20,10 +20,10 @@ import * as fs from 'node:fs/promises'; import * as os from 'node:os'; import * as path from 'node:path'; -import type { Uri } from 'vscode'; - import { afterEach, beforeEach, describe, it } from 'node:test'; +import type { Uri } from 'vscode'; + import { BarrelFileGenerator } from '../../../../core/barrel/barrel-file.generator.js'; /** @@ -43,8 +43,8 @@ describe('BarrelFileGenerator Test Suite', () => { afterEach(async () => { try { await fs.rm(testDir, { recursive: true, force: true }); - } catch { - // Swallow cleanup errors to avoid masking test outcomes. + } catch (err) { + void err; // Intentionally swallow cleanup errors to avoid masking test failures } }); diff --git a/src/test/unit/core/barrel/barrel-file.generator.test.ts b/src/test/unit/core/barrel/barrel-file.generator.test.ts index c0acf27..bb9656d 100644 --- a/src/test/unit/core/barrel/barrel-file.generator.test.ts +++ b/src/test/unit/core/barrel/barrel-file.generator.test.ts @@ -19,19 +19,19 @@ import assert from 'node:assert/strict'; import * as os from 'node:os'; import * as path from 'node:path'; -import type { Uri } from 'vscode'; - import { afterEach, beforeEach, describe, it } from 'node:test'; -import type { LoggerInstance } from '../../../../types/index.js'; -import { BarrelGenerationMode, INDEX_FILENAME } from '../../../../types/index.js'; -import { FileSystemService } from '../../../../core/io/file-system.service.js'; +import type { Uri } from 'vscode'; + import { BarrelFileGenerator } from '../../../../core/barrel/barrel-file.generator.js'; +import { FileSystemService } from '../../../../core/io/file-system.service.js'; +import type { ILoggerInstance } from '../../../../types/index.js'; +import { BarrelGenerationMode, INDEX_FILENAME } from '../../../../types/index.js'; /** * Creates a mock logger that captures log calls for testing. */ -function createMockLogger(): LoggerInstance & { calls: { level: string; message: string }[] } { +function createMockLogger(): ILoggerInstance & { calls: { level: string; message: string }[] } { const calls: { level: string; message: string }[] = []; return { calls, @@ -141,7 +141,7 @@ describe('BarrelFileGenerator', () => { mode: BarrelGenerationMode.UpdateExisting, }); - const exists = await fileSystem.fileExists(path.join(nestedDir, INDEX_FILENAME)); + const exists = await fileSystem.hasFile(path.join(nestedDir, INDEX_FILENAME)); assert.strictEqual(exists, false); }); @@ -169,7 +169,7 @@ describe('BarrelFileGenerator', () => { const keepIndex = await fileSystem.readFile(path.join(keepDir, INDEX_FILENAME)); assert.strictEqual(keepIndex, ["export { keep } from './keep';", ''].join('\n')); - const skipIndexExists = await fileSystem.fileExists(path.join(skipDir, INDEX_FILENAME)); + const skipIndexExists = await fileSystem.hasFile(path.join(skipDir, INDEX_FILENAME)); assert.strictEqual(skipIndexExists, false); const rootIndex = await fileSystem.readFile(path.join(tmpDir, INDEX_FILENAME)); @@ -193,7 +193,7 @@ describe('BarrelFileGenerator', () => { await generator.generateBarrelFile(emptyDirUri, { recursive: true }); - const exists = await fileSystem.fileExists(path.join(tmpDir, INDEX_FILENAME)); + const exists = await fileSystem.hasFile(path.join(tmpDir, INDEX_FILENAME)); assert.strictEqual(exists, false); }); diff --git a/src/test/unit/core/barrel/content-sanitizer.test.ts b/src/test/unit/core/barrel/content-sanitizer.test.ts index 9b4cadb..4eb4851 100644 --- a/src/test/unit/core/barrel/content-sanitizer.test.ts +++ b/src/test/unit/core/barrel/content-sanitizer.test.ts @@ -26,7 +26,7 @@ import { BarrelContentSanitizer } from '../../../../core/barrel/content-sanitize * @param paths - Array of paths to sanitize. * @returns The preserved lines as a single string. */ -function runSanitize(lines: string[], paths: string[]): string { +function runSanitize(lines: readonly string[], paths: readonly string[]): string { const sanitizer = new BarrelContentSanitizer(); const existingContent = lines.join('\n'); const result = sanitizer.preserveDefinitionsAndSanitizeExports( diff --git a/src/test/unit/core/barrel/export-cache.test.ts b/src/test/unit/core/barrel/export-cache.test.ts index ca11868..3a897c6 100644 --- a/src/test/unit/core/barrel/export-cache.test.ts +++ b/src/test/unit/core/barrel/export-cache.test.ts @@ -18,8 +18,8 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; -import type { IParsedExport } from '../../../../types/index.js'; import { ExportCache } from '../../../../core/barrel/export-cache.js'; +import type { IParsedExport } from '../../../../types/index.js'; /** Fake file system service for testing ExportCache. */ class FakeFileSystemService { @@ -27,7 +27,7 @@ class FakeFileSystemService { private readonly contents = new Map(); /** Registers fake file content and modification time. */ - setFile(filePath: string, content: string, mtime: Date): void { + setFile(filePath: string, content: string, mtime: Readonly): void { this.contents.set(filePath, content); this.stats.set(filePath, mtime); } @@ -75,8 +75,8 @@ describe('ExportCache', () => { const mtime = new Date('2025-01-01T00:00:00Z'); fileSystem.setFile(filePath, 'alpha,beta', mtime); - const first = await cache.getExports(filePath); - const second = await cache.getExports(filePath); + const first = await cache.resolveExports(filePath); + const second = await cache.resolveExports(filePath); assert.deepStrictEqual(first, second); assert.strictEqual(parser.calls, 1); @@ -92,9 +92,9 @@ describe('ExportCache', () => { fileSystem.setFile(firstPath, 'first', new Date('2025-01-01T00:00:00Z')); fileSystem.setFile(secondPath, 'second', new Date('2025-01-02T00:00:00Z')); - await cache.getExports(firstPath); - await cache.getExports(secondPath); - await cache.getExports(firstPath); + await cache.resolveExports(firstPath); + await cache.resolveExports(secondPath); + await cache.resolveExports(firstPath); assert.strictEqual(cache.size, 1); assert.strictEqual(parser.calls, 3); diff --git a/src/test/unit/core/io/file-system.service.test.ts b/src/test/unit/core/io/file-system.service.test.ts index 993a6bf..5fcec34 100644 --- a/src/test/unit/core/io/file-system.service.test.ts +++ b/src/test/unit/core/io/file-system.service.test.ts @@ -20,8 +20,8 @@ import { Dirent } from 'node:fs'; import * as path from 'node:path'; import { beforeEach, describe, it } from 'node:test'; -import { INDEX_FILENAME } from '../../../../types/index.js'; import { FileSystemService } from '../../../../core/io/file-system.service.js'; +import { INDEX_FILENAME } from '../../../../types/index.js'; describe('FileSystemService', () => { let service: FileSystemService; @@ -45,14 +45,14 @@ describe('FileSystemService', () => { * */ async function testEntriesFiltering( - testCases: Array<{ entry: Dirent; shouldInclude: boolean }>, + testCases: Readonly>, methodUnderTest: (path: string) => Promise, directoryPath: string, testNamePrefix: string, ): Promise { for (const [index, { entry, shouldInclude }] of testCases.entries()) { it(`${testNamePrefix} ${index}`, async () => { - mockFs.readdir.mockResolvedValue([entry] as never); + mockFs.readdir.mockResolvedValueOnce([entry] as never); const result = await methodUnderTest(directoryPath); @@ -82,12 +82,12 @@ describe('FileSystemService', () => { }) as any; mockFn.mock = { calls }; - mockFn.mockResolvedValue = (value: any) => { + mockFn.mockResolvedValueOnce = (value: any) => { resolvedValue = value; rejectedValue = undefined; return mockFn; }; - mockFn.mockRejectedValue = (error: any) => { + mockFn.mockRejectedValueOnce = (error: any) => { rejectedValue = error; resolvedValue = undefined; return mockFn; @@ -108,13 +108,13 @@ describe('FileSystemService', () => { }; // Set default implementations - mockFs.readFile.mockResolvedValue(''); - mockFs.writeFile.mockResolvedValue(undefined); - mockFs.mkdir.mockResolvedValue(undefined); - mockFs.rm.mockResolvedValue(undefined); - mockFs.mkdtemp.mockResolvedValue(''); - mockFs.access.mockResolvedValue(undefined); - mockFs.readdir.mockResolvedValue([]); + mockFs.readFile.mockResolvedValueOnce(''); + mockFs.writeFile.mockResolvedValueOnce(undefined); + mockFs.mkdir.mockResolvedValueOnce(undefined); + mockFs.rm.mockResolvedValueOnce(undefined); + mockFs.mkdtemp.mockResolvedValueOnce(''); + mockFs.access.mockResolvedValueOnce(undefined); + mockFs.readdir.mockResolvedValueOnce([]); service = new FileSystemService(mockFs); }); @@ -141,7 +141,7 @@ describe('FileSystemService', () => { createFileEntry('component.test.tsx'), createDirectoryEntry('nested'), ]; - mockFs.readdir.mockResolvedValue(mockEntries as never); + mockFs.readdir.mockResolvedValueOnce(mockEntries as never); const result = await service.getTypeScriptFiles(directoryPath); @@ -173,7 +173,7 @@ describe('FileSystemService', () => { ); it('should throw error if directory read fails', async () => { - mockFs.readdir.mockRejectedValue(new Error('Read error')); + mockFs.readdir.mockRejectedValueOnce(new Error('Read error')); await assert.rejects( service.getTypeScriptFiles('/invalid/path'), @@ -182,7 +182,7 @@ describe('FileSystemService', () => { }); it('should throw error if directory read fails with non-Error object', async () => { - mockFs.readdir.mockRejectedValue('String error'); + mockFs.readdir.mockRejectedValueOnce('String error'); await assert.rejects( service.getTypeScriptFiles('/invalid/path'), @@ -201,7 +201,7 @@ describe('FileSystemService', () => { createDirectoryEntry('.hidden'), createFileEntry('file.ts'), ]; - mockFs.readdir.mockResolvedValue(mockEntries as never); + mockFs.readdir.mockResolvedValueOnce(mockEntries as never); const result = await service.getSubdirectories(directoryPath); @@ -224,7 +224,7 @@ describe('FileSystemService', () => { ); it('should throw error if directory read fails', async () => { - mockFs.readdir.mockRejectedValue(new Error('Read error')); + mockFs.readdir.mockRejectedValueOnce(new Error('Read error')); await assert.rejects( service.getSubdirectories('/invalid/path'), @@ -233,7 +233,7 @@ describe('FileSystemService', () => { }); it('should throw error if directory read fails with non-Error object', async () => { - mockFs.readdir.mockRejectedValue('String error'); + mockFs.readdir.mockRejectedValueOnce('String error'); await assert.rejects( service.getSubdirectories('/invalid/path'), @@ -244,8 +244,8 @@ describe('FileSystemService', () => { describe('readFile', () => { it('should read file content successfully', async () => { - mockFs.stat.mockResolvedValue({ size: 1024, mtime: new Date() }); - mockFs.readFile.mockResolvedValue('file content'); + mockFs.stat.mockResolvedValueOnce({ size: 1024, mtime: new Date() }); + mockFs.readFile.mockResolvedValueOnce('file content'); const result = await service.readFile('/path/to/file.ts'); @@ -254,8 +254,8 @@ describe('FileSystemService', () => { }); it('should throw error if file read fails', async () => { - mockFs.stat.mockResolvedValue({ size: 1024, mtime: new Date() }); - mockFs.readFile.mockRejectedValue(new Error('Read error')); + mockFs.stat.mockResolvedValueOnce({ size: 1024, mtime: new Date() }); + mockFs.readFile.mockRejectedValueOnce(new Error('Read error')); await assert.rejects( service.readFile('/invalid/path'), @@ -264,8 +264,8 @@ describe('FileSystemService', () => { }); it('should throw error if file read fails with non-Error object', async () => { - mockFs.stat.mockResolvedValue({ size: 1024, mtime: new Date() }); - mockFs.readFile.mockRejectedValue({ custom: 'error' }); + mockFs.stat.mockResolvedValueOnce({ size: 1024, mtime: new Date() }); + mockFs.readFile.mockRejectedValueOnce({ custom: 'error' }); await assert.rejects( service.readFile('/invalid/path'), @@ -274,7 +274,7 @@ describe('FileSystemService', () => { }); it('should throw error if file is too large', async () => { - mockFs.stat.mockResolvedValue({ size: 15 * 1024 * 1024, mtime: new Date() }); // 15MB + mockFs.stat.mockResolvedValueOnce({ size: 15 * 1024 * 1024, mtime: new Date() }); // 15MB await assert.rejects( service.readFile('/path/to/large-file.ts'), @@ -285,7 +285,7 @@ describe('FileSystemService', () => { describe('writeFile', () => { it('should write file content successfully', async () => { - mockFs.writeFile.mockResolvedValue(undefined as never); + mockFs.writeFile.mockResolvedValueOnce(undefined as never); await service.writeFile('/path/to/file.ts', 'content'); @@ -295,7 +295,7 @@ describe('FileSystemService', () => { }); it('should throw error if file write fails', async () => { - mockFs.writeFile.mockRejectedValue(new Error('Write error')); + mockFs.writeFile.mockRejectedValueOnce(new Error('Write error')); await assert.rejects( service.writeFile('/invalid/path', 'content'), @@ -304,7 +304,7 @@ describe('FileSystemService', () => { }); it('should throw error if file write fails with non-Error object', async () => { - mockFs.writeFile.mockRejectedValue('String error'); + mockFs.writeFile.mockRejectedValueOnce('String error'); await assert.rejects( service.writeFile('/invalid/path', 'content'), @@ -315,7 +315,7 @@ describe('FileSystemService', () => { describe('ensureDirectory', () => { it('should create directory recursively', async () => { - mockFs.mkdir.mockResolvedValue(undefined as never); + mockFs.mkdir.mockResolvedValueOnce(undefined as never); await service.ensureDirectory('/path/to/dir'); @@ -323,7 +323,7 @@ describe('FileSystemService', () => { }); it('should throw error when directory creation fails', async () => { - mockFs.mkdir.mockRejectedValue(new Error('mkdir error')); + mockFs.mkdir.mockRejectedValueOnce(new Error('mkdir error')); await assert.rejects( service.ensureDirectory('/path/to/dir'), @@ -332,7 +332,7 @@ describe('FileSystemService', () => { }); it('should throw error when directory creation fails with non-Error object', async () => { - mockFs.mkdir.mockRejectedValue('String error'); + mockFs.mkdir.mockRejectedValueOnce('String error'); await assert.rejects( service.ensureDirectory('/path/to/dir'), @@ -343,7 +343,7 @@ describe('FileSystemService', () => { describe('removePath', () => { it('should remove path recursively', async () => { - mockFs.rm.mockResolvedValue(undefined as never); + mockFs.rm.mockResolvedValueOnce(undefined as never); await service.removePath('/path/to/remove'); @@ -353,7 +353,7 @@ describe('FileSystemService', () => { }); it('should throw error when removal fails', async () => { - mockFs.rm.mockRejectedValue(new Error('rm error')); + mockFs.rm.mockRejectedValueOnce(new Error('rm error')); await assert.rejects( service.removePath('/path/to/remove'), @@ -362,7 +362,7 @@ describe('FileSystemService', () => { }); it('should throw error when removal fails with non-Error object', async () => { - mockFs.rm.mockRejectedValue('String error'); + mockFs.rm.mockRejectedValueOnce('String error'); await assert.rejects( service.removePath('/path/to/remove'), @@ -373,7 +373,7 @@ describe('FileSystemService', () => { describe('createTempDirectory', () => { it('should create temp directory with prefix', async () => { - mockFs.mkdtemp.mockResolvedValue('/tmp/foo123' as never); + mockFs.mkdtemp.mockResolvedValueOnce('/tmp/foo123' as never); const result = await service.createTempDirectory('/tmp/foo-'); @@ -382,7 +382,7 @@ describe('FileSystemService', () => { }); it('should throw error when temp directory creation fails', async () => { - mockFs.mkdtemp.mockRejectedValue(new Error('mkdtemp error')); + mockFs.mkdtemp.mockRejectedValueOnce(new Error('mkdtemp error')); await assert.rejects( service.createTempDirectory('/tmp/foo-'), @@ -391,7 +391,7 @@ describe('FileSystemService', () => { }); it('should throw error when temp directory creation fails with non-Error object', async () => { - mockFs.mkdtemp.mockRejectedValue('String error'); + mockFs.mkdtemp.mockRejectedValueOnce('String error'); await assert.rejects( service.createTempDirectory('/tmp/foo-'), @@ -400,19 +400,19 @@ describe('FileSystemService', () => { }); }); - describe('fileExists', () => { + describe('hasFile', () => { const fileExistsCases = [true, false] as const; for (const [index, expected] of fileExistsCases.entries()) { it(`should evaluate file existence ${index}`, async () => { const filePath = expected ? '/path/to/file.ts' : '/invalid/path'; if (expected) { - mockFs.access.mockResolvedValue(undefined as never); + mockFs.access.mockResolvedValueOnce(undefined as never); } else { - mockFs.access.mockRejectedValue(new Error('Access error')); + mockFs.access.mockRejectedValueOnce(new Error('Access error')); } - const result = await service.fileExists(filePath); + const result = await service.hasFile(filePath); assert.strictEqual(result, expected); assert.deepStrictEqual(mockFs.access.mock.calls, [[filePath]]); @@ -426,7 +426,7 @@ describe('FileSystemService', () => { for (const [index, expected] of isDirectoryCases.entries()) { it(`should evaluate if path is directory ${index}`, async () => { const filePath = expected ? '/path/to/directory' : '/path/to/file.ts'; - mockFs.stat.mockResolvedValue({ + mockFs.stat.mockResolvedValueOnce({ isDirectory: () => expected, } as never); @@ -439,7 +439,7 @@ describe('FileSystemService', () => { it('should return false when stat fails', async () => { const filePath = '/invalid/path'; - mockFs.stat.mockRejectedValue(new Error('Stat error')); + mockFs.stat.mockRejectedValueOnce(new Error('Stat error')); const result = await service.isDirectory(filePath); diff --git a/src/test/unit/core/parser/export.parser.smoke.test.ts b/src/test/unit/core/parser/export.parser.smoke.test.ts index f95d2c3..966bf57 100644 --- a/src/test/unit/core/parser/export.parser.smoke.test.ts +++ b/src/test/unit/core/parser/export.parser.smoke.test.ts @@ -73,7 +73,7 @@ describe('ExportParser Test Suite', () => { const content = 'export default class MyClass {}'; const exports = parser.extractExports(content); - assert.ok(exports.some((entry) => entry.name === 'default' && entry.typeOnly === false)); + assert.ok(exports.some((entry) => entry.name === 'default' && !entry.typeOnly)); }); it('should extract multiple exports', () => { @@ -120,7 +120,7 @@ describe('ExportParser Test Suite', () => { const content = 'export { MyClass as RenamedClass };'; const exports = parser.extractExports(content); - assert.ok(exports.some((entry) => entry.name === 'RenamedClass' && entry.typeOnly === false)); + assert.ok(exports.some((entry) => entry.name === 'RenamedClass' && !entry.typeOnly)); }); it('should ignore comments', () => { diff --git a/src/test/unit/core/rules/no-instanceof-error-autofix.test.ts b/src/test/unit/core/rules/no-instanceof-error-autofix.test.ts index cd2e6f8..36ccd16 100644 --- a/src/test/unit/core/rules/no-instanceof-error-autofix.test.ts +++ b/src/test/unit/core/rules/no-instanceof-error-autofix.test.ts @@ -15,9 +15,9 @@ * */ +import assert from 'node:assert/strict'; import path from 'node:path'; import { describe, it } from 'node:test'; -import assert from 'node:assert/strict'; import * as mod from '../../../../../scripts/eslint-plugin-local.mjs'; describe('no-instanceof-error-autofix rule', () => { diff --git a/src/test/unit/extension.test.ts b/src/test/unit/extension.test.ts index 51dd3a2..a1710e9 100644 --- a/src/test/unit/extension.test.ts +++ b/src/test/unit/extension.test.ts @@ -18,6 +18,7 @@ import assert from 'node:assert/strict'; import * as path from 'node:path'; import { beforeEach, describe, it, mock } from 'node:test'; +import { BarrelGenerationMode } from '../../types/index.js'; import type { FakeUri, CommandHandler, @@ -29,7 +30,6 @@ import type { ProgressOptions, } from '../testTypes.js'; import { uriFile } from '../testTypes.js'; -import { BarrelGenerationMode } from '../../types/index.js'; /** * Creates a mock ExtensionContext for testing. @@ -91,7 +91,7 @@ describe('Extension', () => { showOpenDialogCalls += 1; return showOpenDialogResult; }, - async withProgress(options: ProgressOptions, task: () => Promise) { + async withProgress(options: Readonly, task: () => Promise) { progressCalls.push({ options }); return task(); }, @@ -116,7 +116,7 @@ describe('Extension', () => { const workspaceApi: { fs: { stat(uri: FakeUri): Promise<{ type: number }> } } = { fs: { - stat(uri: FakeUri) { + stat(uri: Readonly) { return workspaceStatImpl(uri); }, }, @@ -135,10 +135,13 @@ describe('Extension', () => { /** * Fake implementation of generateBarrelFile for testing purposes. */ - async generateBarrelFile(targetDirectory: FakeUri, options: unknown): Promise { + async generateBarrelFile(targetDirectory: Readonly, options: unknown): Promise { this.calls.push({ targetDirectory, options }); if (generatorFailure) { - throw generatorFailure; + if (generatorFailure instanceof Error) { + throw new Error(generatorFailure.message); + } + throw new Error(String(generatorFailure)); } } } @@ -222,9 +225,12 @@ describe('Extension', () => { */ function lastGeneratorCall(): { targetDirectory: FakeUri; options: unknown } { assert.ok(generatorInstances.length > 0, 'No generator instances were created'); - const instance = generatorInstances.at(-1)!; + const instance = generatorInstances.at(-1); + if (!instance) throw new TypeError('No generator instances were created'); assert.ok(instance.calls.length > 0, 'Generator was not invoked'); - return instance.calls.at(-1)!; + const call = instance.calls.at(-1); + if (!call) throw new TypeError('Generator was not invoked'); + return call; } describe('extension activation', () => { diff --git a/src/test/unit/logging/output-channel.logger.test.ts b/src/test/unit/logging/output-channel.logger.test.ts index cdd540a..82c2125 100644 --- a/src/test/unit/logging/output-channel.logger.test.ts +++ b/src/test/unit/logging/output-channel.logger.test.ts @@ -45,16 +45,16 @@ describe('OutputChannelLogger', () => { }; // Mock console methods - console.log = mock.fn((...args: unknown[]) => { + console.log = mock.fn((...args: readonly unknown[]) => { consoleOutput.push({ level: 'info', message: String(args[0]) }); }); - console.debug = mock.fn((...args: unknown[]) => { + console.debug = mock.fn((...args: readonly unknown[]) => { consoleOutput.push({ level: 'debug', message: String(args[0]) }); }); - console.warn = mock.fn((...args: unknown[]) => { + console.warn = mock.fn((...args: readonly unknown[]) => { consoleOutput.push({ level: 'warn', message: String(args[0]) }); }); - console.error = mock.fn((...args: unknown[]) => { + console.error = mock.fn((...args: readonly unknown[]) => { consoleOutput.push({ level: 'error', message: String(args[0]) }); }); @@ -217,13 +217,13 @@ describe('OutputChannelLogger', () => { it('should log errors from grouped operations', async () => { const logger = new OutputChannelLogger({ console: false }); - const failure = new Error('group failure'); + const failureMessage = 'group failure'; await assert.rejects( logger.group('failures', async () => { - throw failure; + throw new Error(failureMessage); }), - (error) => error === failure, + (error) => error instanceof Error && error.message === failureMessage, ); assert.strictEqual(outputLines.length, 2); diff --git a/src/test/unit/types/contracts.test.ts b/src/test/unit/types/contracts.test.ts index 4bc47bd..7ae4dad 100644 --- a/src/test/unit/types/contracts.test.ts +++ b/src/test/unit/types/contracts.test.ts @@ -18,15 +18,6 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; -import { - assert as customAssert, - assertDefined, - assertEqual, - assertString, - assertNumber, - assertBoolean, -} from '../../../utils/assert.js'; - import { BarrelEntryKind, BarrelExportKind, @@ -42,6 +33,15 @@ import { type NormalizedBarrelGenerationOptions, } from '../../../types/index.js'; +import { + assert as customAssert, + assertDefined, + assertEqual, + assertString, + assertNumber, + assertBoolean, +} from '../../../utils/assert.js'; + /** * Contract validation tests to ensure type safety and behavioral expectations */ @@ -254,7 +254,7 @@ describe('Contract Validation', () => { describe('Behavioral Contracts', () => { describe('Enum Exhaustiveness', () => { it('should handle all BarrelExportKind values in switch', () => { - const testAllKinds = (kind: BarrelExportKind): string => { + const testAllKinds = (kind: Readonly): string => { switch (kind) { case BarrelExportKind.Value: return 'value'; @@ -273,7 +273,7 @@ describe('Contract Validation', () => { }); it('should handle all BarrelEntryKind values in switch', () => { - const testAllKinds = (kind: BarrelEntryKind): string => { + const testAllKinds = (kind: Readonly): string => { switch (kind) { case BarrelEntryKind.File: return 'file'; @@ -289,7 +289,7 @@ describe('Contract Validation', () => { }); it('should handle all BarrelGenerationMode values in switch', () => { - const testAllModes = (mode: BarrelGenerationMode): string => { + const testAllModes = (mode: Readonly): string => { switch (mode) { case BarrelGenerationMode.CreateOrUpdate: return 'createOrUpdate'; @@ -307,31 +307,31 @@ describe('Contract Validation', () => { describe('Type Guards', () => { const isValueExport = ( - exp: BarrelExport, + exp: Readonly, ): exp is BarrelExport & { kind: BarrelExportKind.Value } => { return exp.kind === BarrelExportKind.Value; }; const isTypeExport = ( - exp: BarrelExport, + exp: Readonly, ): exp is BarrelExport & { kind: BarrelExportKind.Type } => { return exp.kind === BarrelExportKind.Type; }; const isDefaultExport = ( - exp: BarrelExport, + exp: Readonly, ): exp is BarrelExport & { kind: BarrelExportKind.Default } => { return exp.kind === BarrelExportKind.Default; }; const isFileEntry = ( - entry: BarrelEntry, + entry: Readonly, ): entry is BarrelEntry & { kind: BarrelEntryKind.File } => { return entry.kind === BarrelEntryKind.File; }; const isDirectoryEntry = ( - entry: BarrelEntry, + entry: Readonly, ): entry is BarrelEntry & { kind: BarrelEntryKind.Directory } => { return entry.kind === BarrelEntryKind.Directory; }; diff --git a/src/test/unit/utils/array.test.ts b/src/test/unit/utils/array.test.ts index 0d5d60d..817b107 100644 --- a/src/test/unit/utils/array.test.ts +++ b/src/test/unit/utils/array.test.ts @@ -15,8 +15,8 @@ * */ -import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; import { isEmptyArray } from '../../../utils/array.js'; /** diff --git a/src/test/unit/utils/assert.test.ts b/src/test/unit/utils/assert.test.ts index b531f49..7528888 100644 --- a/src/test/unit/utils/assert.test.ts +++ b/src/test/unit/utils/assert.test.ts @@ -15,8 +15,8 @@ * */ -import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; import { assert as customAssert, @@ -237,7 +237,7 @@ describe('assert utils', () => { it('should throw TypeError when function does not throw', () => { assert.throws(() => assertThrows(() => {}), TypeError); - assert.throws(() => assertThrows(() => 1 + 1), TypeError); + assert.throws(() => assertThrows(() => 1 + 2), TypeError); }); it('should not throw when function throws expected error type', () => { @@ -304,7 +304,7 @@ describe('assert utils', () => { describe('assertDoesNotThrow', () => { it('should not throw when function does not throw', () => { assert.doesNotThrow(() => assertDoesNotThrow(() => {})); - assert.doesNotThrow(() => assertDoesNotThrow(() => 1 + 1)); + assert.doesNotThrow(() => assertDoesNotThrow(() => 1 + 2)); assert.doesNotThrow(() => assertDoesNotThrow(() => 'string')); }); diff --git a/src/test/unit/utils/errors.test.ts b/src/test/unit/utils/errors.test.ts index a7993c7..bbeeea7 100644 --- a/src/test/unit/utils/errors.test.ts +++ b/src/test/unit/utils/errors.test.ts @@ -15,8 +15,8 @@ * */ -import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; import { getErrorMessage, formatErrorForLog } from '../../../utils/errors.js'; describe('error utils', () => { diff --git a/src/test/unit/utils/eslint-plugin-local.test.ts b/src/test/unit/utils/eslint-plugin-local.test.ts index d2fabe3..33ec827 100644 --- a/src/test/unit/utils/eslint-plugin-local.test.ts +++ b/src/test/unit/utils/eslint-plugin-local.test.ts @@ -15,9 +15,9 @@ * */ +import assert from 'node:assert/strict'; import path from 'node:path'; import { describe, it } from 'node:test'; -import assert from 'node:assert/strict'; import * as mod from '../../../../scripts/eslint-plugin-local.mjs'; const { computeImportPath, mergeNamedImportText, canMergeNamedImport, hasNamedImport } = mod; diff --git a/src/test/unit/utils/format.test.ts b/src/test/unit/utils/format.test.ts index f7685e1..b7a9981 100644 --- a/src/test/unit/utils/format.test.ts +++ b/src/test/unit/utils/format.test.ts @@ -15,8 +15,8 @@ * */ -import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; import { safeStringify } from '../../../utils/format.js'; diff --git a/src/test/unit/utils/guards.test.ts b/src/test/unit/utils/guards.test.ts index 796c0ba..3a6235e 100644 --- a/src/test/unit/utils/guards.test.ts +++ b/src/test/unit/utils/guards.test.ts @@ -15,8 +15,8 @@ * */ -import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; import { isObject, isString, isError } from '../../../utils/guards.js'; describe('guards utils', () => { diff --git a/src/types/index.ts b/src/types/index.ts index f04d8cf..a2ace59 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -37,4 +37,4 @@ export { PARENT_DIRECTORY_SEGMENT, } from './constants.js'; export type { IEnvironmentVariables } from './env.js'; -export type { LoggerConstructor, LoggerInstance, OutputChannel } from './logger.js'; +export type { ILoggerConstructor, ILoggerInstance, IOutputChannel } from './logger.js'; diff --git a/src/types/logger.ts b/src/types/logger.ts index b5c30e3..5341adb 100644 --- a/src/types/logger.ts +++ b/src/types/logger.ts @@ -18,7 +18,7 @@ /** * Minimal runtime shape for logging implementations and test doubles. */ -export interface LoggerInstance { +export interface ILoggerInstance { isLoggerAvailable(): boolean; info(message: string, metadata?: Record): void; debug(message: string, metadata?: Record): void; @@ -26,20 +26,20 @@ export interface LoggerInstance { error(message: string, metadata?: Record): void; fatal(message: string, metadata?: Record): void; group?(name: string, fn: () => Promise): Promise; - child?(bindings: Record): LoggerInstance; + child?(bindings: Record): ILoggerInstance; } /** * Interface for output channel used by logger. */ -export interface OutputChannel { +export interface IOutputChannel { appendLine(value: string): void; } /** * Constructor interface for logger implementations. */ -export interface LoggerConstructor { - new (...args: unknown[]): LoggerInstance; - configureOutputChannel(channel?: OutputChannel): void; +export interface ILoggerConstructor { + new (...args: unknown[]): ILoggerInstance; + configureOutputChannel(channel?: IOutputChannel): void; } diff --git a/src/utils/assert.ts b/src/utils/assert.ts index 0085cc0..787b92f 100644 --- a/src/utils/assert.ts +++ b/src/utils/assert.ts @@ -26,6 +26,7 @@ import { isString } from './guards.js'; * Asserts that a condition is truthy. Throws an Error with the provided message if not. * @param condition The condition to check * @param message The error message to throw if condition is falsy + * @throws {Error} TODO: describe error condition */ export function assert(condition: unknown, message?: string): asserts condition { if (!condition) { @@ -38,13 +39,23 @@ export function assert(condition: unknown, message?: string): asserts condition * @param actual The actual value * @param expected The expected value * @param message The error message to throw if values are not equal + * @throws {Error} TODO: describe error condition */ -export function assertEqual(actual: T, expected: T, message?: string): void { - if (actual !== expected) { - throw new TypeError( - message ?? - `Assertion failed: expected ${expected} (${typeof expected}), but got ${actual} (${typeof actual})`, - ); +export function assertBoolean(value: unknown, message?: string): asserts value is boolean { + if (typeof value !== 'boolean') { + throw new TypeError(message ?? `Assertion failed: expected boolean, but got ${typeof value}`); + } +} + +/** + * Asserts that a value is not null or undefined. + * @param value The value to check + * @param message The error message to throw if value is not a boolean + * @throws {Error} TODO: describe error condition + */ +export function assertDefined(value: T, message?: string): asserts value is NonNullable { + if (value == null) { + throw new TypeError(message ?? `Assertion failed: value is null or undefined`); } } @@ -53,39 +64,32 @@ export function assertEqual(actual: T, expected: T, message?: string): void { * @param actual The actual value * @param unexpected The unexpected value * @param message The error message to throw if values are equal + * @throws {Error} TODO: describe error condition */ -export function assertNotEqual(actual: T, unexpected: T, message?: string): void { - if (actual === unexpected) { +export function assertDoesNotThrow(fn: () => void, message?: string): void { + try { + fn(); + } catch (error) { throw new TypeError( - message ?? `Assertion failed: expected not ${unexpected}, but got ${actual}`, + message ?? + `Assertion failed: expected function not to throw, but it threw: ${getErrorMessage(error)}`, ); } } -/** - * Asserts that a value is not null or undefined. - * @param value The value to check - * @param message The error message to throw if value is null or undefined - */ -export function assertDefined(value: T, message?: string): asserts value is NonNullable { - if (value == null) { - throw new TypeError(message ?? `Assertion failed: value is null or undefined`); - } -} - /** * Asserts that a value is an instance of the specified constructor. * @param value The value to check * @param constructor The constructor to check against * @param message The error message to throw if value is not an instance + * @throws {Error} TODO: describe error condition */ -export function assertInstanceOf( - value: unknown, - constructor: new (...args: any[]) => T, - message?: string, -): asserts value is T { - if (!(value instanceof constructor)) { - throw new TypeError(message || 'Instance type assertion failed'); +export function assertEqual(actual: T, expected: T, message?: string): void { + if (actual !== expected) { + throw new TypeError( + message ?? + `Assertion failed: expected ${expected} (${typeof expected}), but got ${actual} (${typeof actual})`, + ); } } @@ -94,53 +98,43 @@ export function assertInstanceOf( * @param fn The function to call * @param expectedError Optional error constructor or error message to match * @param message The error message to throw if function doesn't throw + * @throws {Error} TODO: describe error condition */ -export function assertThrows( - fn: () => void, - expectedError?: (new (...args: any[]) => Error) | string, +export function assertInstanceOf( + value: unknown, + constructor: new (...args: any[]) => T, message?: string, -): void { - try { - fn(); - } catch (error) { - validateThrownError(error, expectedError); - return; +): asserts value is T { + if (!(value instanceof constructor)) { + throw new TypeError(message || 'Instance type assertion failed'); } - throw new TypeError(message ?? 'Assertion failed: expected function to throw, but it did not'); } /** * Validates that a thrown error matches the expected error type or message. * @param error - The error that was thrown. * @param expectedError - The expected error constructor or message. + * @throws {Error} TODO: describe error condition + * @param message TODO: describe parameter */ -function validateThrownError( - error: unknown, - expectedError?: (new (...args: any[]) => Error) | string, -): void { - if (!expectedError) { - return; // Any error is fine - } - - if (isString(expectedError)) { - checkErrorMessage(error, expectedError); - return; +export function assertNotEqual(actual: T, unexpected: T, message?: string): void { + if (actual === unexpected) { + throw new TypeError( + message ?? `Assertion failed: expected not ${unexpected}, but got ${actual}`, + ); } - - checkErrorType(error, expectedError); } /** * Checks that an error message contains the expected substring. * @param error - The error to check. * @param expectedMessage - The expected message substring. + * @throws {Error} TODO: describe error condition + * @param message TODO: describe parameter */ -function checkErrorMessage(error: unknown, expectedMessage: string): void { - const errorMessage = getErrorMessage(error); - if (!errorMessage.includes(expectedMessage)) { - throw new TypeError( - `Assertion failed: expected error message to contain "${expectedMessage}", but got "${errorMessage}"`, - ); +export function assertNumber(value: unknown, message?: string): asserts value is number { + if (typeof value !== 'number') { + throw new TypeError(message ?? `Assertion failed: expected number, but got ${typeof value}`); } } @@ -148,12 +142,12 @@ function checkErrorMessage(error: unknown, expectedMessage: string): void { * Checks that an error is an instance of the expected constructor. * @param error - The error to check. * @param expectedConstructor - The expected error constructor. + * @throws {Error} TODO: describe error condition + * @param message TODO: describe parameter */ -function checkErrorType(error: unknown, expectedConstructor: new (...args: any[]) => Error): void { - if (!(error instanceof expectedConstructor)) { - throw new TypeError( - `Assertion failed: expected error of type ${expectedConstructor.name}, but got ${error?.constructor?.name ?? typeof error}`, - ); +export function assertString(value: unknown, message?: string): asserts value is string { + if (typeof value !== 'string') { + throw new TypeError(message ?? `Assertion failed: expected string, but got ${typeof value}`); } } @@ -161,26 +155,35 @@ function checkErrorType(error: unknown, expectedConstructor: new (...args: any[] * Asserts that a function does not throw an error. * @param fn The function to call * @param message The error message to throw if function throws + * @throws {Error} TODO: describe error condition + * @param message TODO: describe parameter */ -export function assertDoesNotThrow(fn: () => void, message?: string): void { +export function assertThrows( + fn: () => void, + expectedError?: (new (...args: any[]) => Error) | string, + message?: string, +): void { try { fn(); } catch (error) { - throw new TypeError( - message ?? - `Assertion failed: expected function not to throw, but it threw: ${getErrorMessage(error)}`, - ); + validateThrownError(error, expectedError); + return; } + throw new TypeError(message ?? 'Assertion failed: expected function to throw, but it did not'); } /** * Asserts that a value is a string. * @param value The value to check * @param message The error message to throw if value is not a string + * @throws {Error} TODO: describe error condition */ -export function assertString(value: unknown, message?: string): asserts value is string { - if (typeof value !== 'string') { - throw new TypeError(message ?? `Assertion failed: expected string, but got ${typeof value}`); +function checkErrorMessage(error: unknown, expectedMessage: string): void { + const errorMessage = getErrorMessage(error); + if (!errorMessage.includes(expectedMessage)) { + throw new TypeError( + `Assertion failed: expected error message to contain "${expectedMessage}", but got "${errorMessage}"`, + ); } } @@ -188,10 +191,14 @@ export function assertString(value: unknown, message?: string): asserts value is * Asserts that a value is a number. * @param value The value to check * @param message The error message to throw if value is not a number + * @throws {Error} TODO: describe error condition */ -export function assertNumber(value: unknown, message?: string): asserts value is number { - if (typeof value !== 'number') { - throw new TypeError(message ?? `Assertion failed: expected number, but got ${typeof value}`); +function checkErrorType(error: unknown, expectedConstructor: new (...args: any[]) => Error): void { + if (!(error instanceof expectedConstructor)) { + const errorTypeName = error instanceof Error ? error.name : String(typeof error); + throw new TypeError( + `Assertion failed: expected error of type ${expectedConstructor.name}, but got ${errorTypeName}`, + ); } } @@ -199,9 +206,20 @@ export function assertNumber(value: unknown, message?: string): asserts value is * Asserts that a value is a boolean. * @param value The value to check * @param message The error message to throw if value is not a boolean + * @throws {Error} TODO: describe error condition */ -export function assertBoolean(value: unknown, message?: string): asserts value is boolean { - if (typeof value !== 'boolean') { - throw new TypeError(message ?? `Assertion failed: expected boolean, but got ${typeof value}`); +function validateThrownError( + error: unknown, + expectedError?: (new (...args: any[]) => Error) | string, +): void { + if (!expectedError) { + return; // Any error is fine } + + if (isString(expectedError)) { + checkErrorMessage(error, expectedError); + return; + } + + checkErrorType(error, expectedError); } diff --git a/src/utils/errors.ts b/src/utils/errors.ts index 1f55f44..0f61578 100644 --- a/src/utils/errors.ts +++ b/src/utils/errors.ts @@ -23,16 +23,18 @@ import { isError, isObject } from './guards.js'; * @param value The thrown value * @returns The extracted message string */ -export function getErrorMessage(value: unknown): string { - return isError(value) ? value.message : String(value); +export function formatErrorForLog(error: unknown): string { + if (isError(error)) return error.stack || error.message; + if (isObject(error)) return safeStringify(error); + return getErrorMessage(error); } /** * Formats an error for logging. If an Error instance, uses stack or message. * If an object, uses JSON safe stringification. Otherwise, falls back to getErrorMessage. + * @param value TODO: describe parameter + * @returns TODO: describe return value */ -export function formatErrorForLog(error: unknown): string { - if (isError(error)) return error.stack || error.message; - if (isObject(error)) return safeStringify(error); - return getErrorMessage(error); +export function getErrorMessage(value: unknown): string { + return isError(value) ? value.message : String(value); } diff --git a/src/utils/format.ts b/src/utils/format.ts index 9371e82..6fb3602 100644 --- a/src/utils/format.ts +++ b/src/utils/format.ts @@ -21,6 +21,8 @@ import { isString } from './guards.js'; * Safely stringify a value for logging/serialization. * Returns the original string if provided, otherwise attempts JSON.stringify and falls back to String(value) on failure. * Returns an empty string for undefined values. + * @param value TODO: describe parameter + * @returns TODO: describe return value */ export function safeStringify(value: unknown): string { if (isString(value)) return value; diff --git a/src/utils/guards.ts b/src/utils/guards.ts index 11c17da..de23d3e 100644 --- a/src/utils/guards.ts +++ b/src/utils/guards.ts @@ -20,8 +20,10 @@ * @param value The value to check * @returns True when the value is a non-null object; otherwise false. */ -export function isObject(value: unknown): value is Record { - return typeof value === 'object' && value !== null; +export function isError(value: unknown): value is Error { + return ( + value instanceof Error || (isObject(value) && 'message' in value && isString(value.message)) + ); } /** @@ -29,17 +31,15 @@ export function isObject(value: unknown): value is Record { * @param value The value to check * @returns True when the value is a string; otherwise false. */ -export function isString(value: unknown): value is string { - return typeof value === 'string'; +export function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null; } /** * Returns true if the value looks like an Error (has a message string or is an Error instance). * @param value The value to check + * @returns TODO: describe return value */ -export function isError(value: unknown): value is Error { - return ( - value instanceof Error || - (isObject(value) && 'message' in value && isString((value as any).message)) - ); +export function isString(value: unknown): value is string { + return typeof value === 'string'; } diff --git a/src/utils/semaphore.ts b/src/utils/semaphore.ts index 35d8ba4..67d053a 100644 --- a/src/utils/semaphore.ts +++ b/src/utils/semaphore.ts @@ -28,6 +28,14 @@ export class Semaphore { */ constructor(private permits: number) {} + /** + * Enqueues a resolve callback for later execution when a permit becomes available. + * @param resolve - The resolve callback to enqueue. + */ + private enqueueResolve(resolve: () => void): void { + this.waiting.push(resolve); + } + /** * Acquires a permit from the semaphore. * If no permits are available, the promise will wait until one is released. @@ -38,10 +46,7 @@ export class Semaphore { this.permits--; return; } - - return new Promise((resolve) => { - this.waiting.push(resolve); - }); + return new Promise(this.enqueueResolve.bind(this)); } /** @@ -53,8 +58,10 @@ export class Semaphore { if (this.waiting.length === 0) { return; } - - const resolve = this.waiting.shift()!; + const resolve = this.waiting.shift(); + if (!resolve) { + return; + } this.permits--; resolve(); } @@ -84,20 +91,39 @@ export class Semaphore { * @returns Promise that resolves to array of results. */ export async function processConcurrently( - items: T[], + items: readonly T[], concurrencyLimit: number, processor: (item: T) => Promise, ): Promise { const semaphore = new Semaphore(concurrencyLimit); - const promises = items.map(async (item) => { - await semaphore.acquire(); - try { - return await processor(item); - } finally { - semaphore.release(); - } - }); + /** + * Processes a single item under semaphore control. + * @param item - The item to process. + * @returns Promise resolving to the processed result. + */ + const processItem = (item: Readonly): Promise => + runWithSemaphore(semaphore, processor, item); + + return Promise.all(items.map(processItem)); +} - return Promise.all(promises); +/** + * Runs a single item under semaphore control. + * @param semaphore - The semaphore to use for concurrency control. + * @param processor - The function that processes the item. + * @param item - The item to process. + * @returns Promise resolving to the processed result. + */ +async function runWithSemaphore( + semaphore: Readonly, + processor: (item: T) => Promise, + item: Readonly, +): Promise { + await semaphore.acquire(); + try { + return await processor(item); + } finally { + semaphore.release(); + } } diff --git a/src/utils/string.ts b/src/utils/string.ts index 1a21d41..e5b3a5c 100644 --- a/src/utils/string.ts +++ b/src/utils/string.ts @@ -15,6 +15,57 @@ * */ +/** + * Compares two strings using default locale comparison. + * @param a - The first string. + * @param b - The second string. + * @returns Negative, zero, or positive comparison result. + */ +function compareDefault(a: string, b: string): number { + return a.localeCompare(b); +} + +/** + * Trims leading and trailing whitespace from a string fragment. + * @param fragment - The string fragment to trim. + * @returns The trimmed string. + */ +function isNonEmpty(fragment: string): boolean { + return fragment.length > 0; +} + +/** + * Checks whether a string fragment is non-empty. + * @param fragment - The fragment to check. + * @returns True if the fragment is non-empty. + * @param locale TODO: describe parameter + * @param options TODO: describe parameter + */ +export function sortAlphabetically( + values: Readonly>, + locale?: string | string[], + options?: Readonly, +): string[] { + const entries = Array.from(values); + + if (entries.length <= 1) { + return entries; + } + + if (locale === undefined && options === undefined) { + return entries.toSorted(compareDefault); + } + + /** + * Compares two strings using locale settings. + * @param a - First string to compare. + * @param b - Second string to compare. + * @returns Negative, zero, or positive comparison result. + */ + const localeCompare = (a: string, b: string): number => a.localeCompare(b, locale, options); + return entries.toSorted(localeCompare); +} + /** * Splits a string by the given delimiter, trims whitespace from each fragment, * and removes any empty fragments. @@ -22,12 +73,10 @@ * @param value - The string to split and clean. * @param delimiter - The delimiter to split the string by. Defaults to a comma. * @returns An array of cleaned string fragments. + * @param options TODO: describe parameter */ export function splitAndClean(value: string, delimiter: string | RegExp = /,/): string[] { - return value - .split(delimiter) - .map((fragment) => fragment.trim()) - .filter((fragment) => fragment.length > 0); + return value.split(delimiter).map(trimFragment).filter(isNonEmpty); } /** @@ -38,20 +87,6 @@ export function splitAndClean(value: string, delimiter: string | RegExp = /,/): * @param options - Optional Intl.Collator configuration for fine-grained control. * @returns A new array containing the sorted values. */ -export function sortAlphabetically( - values: Iterable, - locale?: string | string[], - options?: Intl.CollatorOptions, -): string[] { - const entries = Array.from(values); - - if (entries.length <= 1) { - return entries; - } - - if (locale === undefined && options === undefined) { - return entries.sort((a, b) => a.localeCompare(b)); - } - - return entries.sort((a, b) => a.localeCompare(b, locale, options)); +function trimFragment(fragment: string): string { + return fragment.trim(); } diff --git a/tsconfig.json b/tsconfig.json index fe0fc5d..efd5754 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,7 +8,7 @@ "forceConsistentCasingInFileNames": true, "importHelpers": true, "isolatedModules": true, - "lib": ["ES2022"], + "lib": ["ES2022", "ES2023.Array"], "module": "ES2022", "moduleResolution": "Node", "noEmit": false,