From a5c5d4643b118344d99da687a9d21c43915a62d1 Mon Sep 17 00:00:00 2001 From: Shukaaa Date: Tue, 20 Jan 2026 00:28:45 +0100 Subject: [PATCH 1/2] feat: implement --force and --config path for init command --- ROADMAP.md | 2 +- bin/commands/init.command.ts | 50 ++++--- bin/commands/interfaces/swerr-command.ts | 6 - bin/commands/run.command.ts | 161 ++++++++++++----------- bin/swerr.ts | 19 +-- 5 files changed, 123 insertions(+), 115 deletions(-) delete mode 100644 bin/commands/interfaces/swerr-command.ts diff --git a/ROADMAP.md b/ROADMAP.md index b4eb992..9876934 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -3,8 +3,8 @@ - implement unit tests for converters # 1.1 Refinement -- Better cmd description and help texts - Alternative config path via `--config` flag +- `--force` flag to overwrite existing output files - `@error` tag should not be required in JSDoc comments with default config - You can change this behavior via config if needed - `sourceFile.requireErrorTag` config option diff --git a/bin/commands/init.command.ts b/bin/commands/init.command.ts index 7157b92..eae3b9b 100644 --- a/bin/commands/init.command.ts +++ b/bin/commands/init.command.ts @@ -1,4 +1,3 @@ -import {SwerrCommand} from "./interfaces/swerr-command.js"; import path from "path"; import * as fs from "node:fs"; import {LogUtils} from "@swerr/core"; @@ -6,9 +5,10 @@ import {SWERR_CONFIG_FILE} from "../config.js"; const initConfigTemplate = `import {markdownConverter, htmlConverter} from "@swerr/converter" +// See more configuration options at https://swerr.apidocumentation.com/guide/introduction/config export default { sourceFile: { - inputDir: "./src/errors", // Directory to scan for error definitions + inputDir: "./src", // Directory to scan for error definitions meta: { projectName: "Your Application Name", description: "The Application description", @@ -19,7 +19,7 @@ export default { }, options: { ignoreDirs: [], // Directories to ignore during scanning (optional) - whitelistExtensions: [".js"] // File extensions to include during scanning (optional) + whitelistExtensions: [".js", ".ts"] // File extensions to include during scanning (optional) } }, converter: [ // Example converters @@ -38,23 +38,35 @@ export default { ] }`; -export const initCommand: SwerrCommand<[string, string]> = { - command: "init", - description: "Create a basic swerr config file.", - action: async () => { - const configFilePath = path.resolve(process.cwd(), SWERR_CONFIG_FILE); - - if (fs.existsSync(configFilePath)) { - LogUtils.error(`A ${SWERR_CONFIG_FILE} file already exists in the current directory.`); - process.exit(1); - } - +export const initCommand = async (options: { force?: boolean; config?: string }) => { + const configFilePath = path.resolve(process.cwd(), options.config ?? SWERR_CONFIG_FILE); + const targetName = path.basename(configFilePath); + + const existedBefore = fs.existsSync(configFilePath); + if (existedBefore && !options.force) { try { - await fs.promises.writeFile(configFilePath, initConfigTemplate); - LogUtils.success(SWERR_CONFIG_FILE + " file has been created successfully."); - } catch (err) { - LogUtils.error(`Failed to create ${SWERR_CONFIG_FILE} file: ${err}`); - process.exit(1); + const stat = fs.lstatSync(configFilePath); + if (stat.isDirectory()) { + LogUtils.error(`A directory named ${targetName} already exists in the current directory.`); + } else { + LogUtils.error(`A ${targetName} file already exists in the current directory.`); + } + } catch { + LogUtils.error(`A ${targetName} entry already exists in the current directory.`); + } + process.exit(1); + } + + try { + await fs.promises.mkdir(path.dirname(configFilePath), { recursive: true }); + await fs.promises.writeFile(configFilePath, initConfigTemplate, { encoding: "utf8" }); + if (existedBefore && options.force) { + LogUtils.success(`${targetName} file has been overwritten successfully.`); + } else { + LogUtils.success(`${targetName} file has been created successfully.`); } + } catch (err) { + LogUtils.error(`Failed to create ${targetName} file: ${err}`); + process.exit(1); } } \ No newline at end of file diff --git a/bin/commands/interfaces/swerr-command.ts b/bin/commands/interfaces/swerr-command.ts deleted file mode 100644 index 0bd7db3..0000000 --- a/bin/commands/interfaces/swerr-command.ts +++ /dev/null @@ -1,6 +0,0 @@ -export type Action = (...args: T) => void; -export interface SwerrCommand { - command: string; - description: string; - action: Action; -} \ No newline at end of file diff --git a/bin/commands/run.command.ts b/bin/commands/run.command.ts index 07059c1..f6947bf 100644 --- a/bin/commands/run.command.ts +++ b/bin/commands/run.command.ts @@ -1,4 +1,3 @@ -import {SwerrCommand} from "./interfaces/swerr-command.js"; import path from "path"; import * as fs from "node:fs"; import {scanJsdocs} from "../extraction/swerr-scan.js"; @@ -8,92 +7,94 @@ import {existsSync} from "node:fs"; import {pathToFileURL} from "node:url"; import {LogUtils, SwerrConfig, SwerrScheme} from "@swerr/core"; -export const runCommand: SwerrCommand<[string, string]> = { - command: "run [configPath]", - description: "Create swerr documentation based on the config file.", - action: async (configPath: string | undefined) => { - const swerrConfig = await config(configPath); - const sourceDir = swerrConfig?.sourceFile?.inputDir || null - const outputDir = swerrConfig?.sourceFile?.export?.outputDir || null - LogUtils.success("Swerr Configuration loaded."); - - if (!sourceDir || !outputDir) { - LogUtils.error("Source and output directories must be specified either via configuration file."); - process.exit(1); - } - - const absoluteSourceDir = path.resolve(process.cwd(), sourceDir); - const absoluteOutputDir = path.resolve(process.cwd(), outputDir); - - const sourceExists = fs.existsSync(absoluteSourceDir); - if (!sourceExists) { - LogUtils.error(`Source directory "${absoluteSourceDir}" does not exist.`); - process.exit(1); - } - - try { - await fs.promises.mkdir(absoluteOutputDir, {recursive: true}); - } catch (err) { - LogUtils.error(`Failed to create output directory "${absoluteOutputDir}": ${err}`); - process.exit(1); - } - - scanJsdocs(absoluteSourceDir, swerrConfig?.sourceFile.options || {}).then(async result => { - LogUtils.info(`Scanned ${result.blocks.length} JSDocs block(s) from ${result.scannedFiles} file(s).`); - const scheme = translateToSourceScheme(result, swerrConfig) - LogUtils.info(`Translated scan result to swerr Scheme with ${scheme.errors.length} error(s).`); - await saveSourceScheme(swerrConfig!, absoluteOutputDir, scheme); - await runConverter(swerrConfig!, scheme); - }).catch(err => { - LogUtils.error(`Error during scanning: ${err}`); - }) - } +export const runCommand = async (configPath: string | undefined) => { + const swerrConfig = await config(configPath); + const sourceDir = swerrConfig?.sourceFile?.inputDir || null + const outputDir = swerrConfig?.sourceFile?.export?.outputDir || null + + if (!swerrConfig) { + LogUtils.error(`Swerr configuration file not found. Please create a ${SWERR_CONFIG_FILE} file in the current directory or specify a custom path using --config option.`); + process.exit(1); + } + + if (!sourceDir || !outputDir) { + LogUtils.error("Source and output directories must be specified either via configuration file."); + process.exit(1); + } + + LogUtils.success("Swerr Configuration loaded."); + + const absoluteSourceDir = path.resolve(process.cwd(), sourceDir); + const absoluteOutputDir = path.resolve(process.cwd(), outputDir); + + const sourceExists = fs.existsSync(absoluteSourceDir); + if (!sourceExists) { + LogUtils.error(`Source directory "${absoluteSourceDir}" does not exist.`); + process.exit(1); + } + + try { + await fs.promises.mkdir(absoluteOutputDir, {recursive: true}); + } catch (err) { + LogUtils.error(`Failed to create output directory "${absoluteOutputDir}": ${err}`); + process.exit(1); + } + + scanJsdocs(absoluteSourceDir, swerrConfig?.sourceFile.options || {}).then(async result => { + LogUtils.info(`Scanned ${result.blocks.length} JSDocs block(s) from ${result.scannedFiles} file(s).`); + const scheme = translateToSourceScheme(result, swerrConfig) + LogUtils.info(`Translated scan result to swerr Scheme with ${scheme.errors.length} error(s).`); + await saveSourceScheme(swerrConfig!, absoluteOutputDir, scheme); + await runConverter(swerrConfig!, scheme); + }).catch(err => { + LogUtils.error(`Error during scanning: ${err}`); + }) } async function config(configPath: string | undefined): Promise { - try { - if (!configPath) { - configPath = path.resolve(process.cwd(), SWERR_CONFIG_FILE); - } else { - configPath = path.resolve(process.cwd(), configPath); - } - let cfg: SwerrConfig | null = null; - - if (existsSync(configPath)) { - LogUtils.info(`Loading configuration from ${configPath}`); - try { - const imported = await import(pathToFileURL(configPath).href); - cfg = imported.default ?? imported; - return cfg; - } catch (err) { - LogUtils.error(`Failed to load configuration from ${configPath}: ${err}`); - process.exit(1); - } - } - - return null; - } catch (err) { - LogUtils.error(`Error loading configuration: ${err}`); - process.exit(1); - } + try { + if (!configPath) { + configPath = path.resolve(process.cwd(), SWERR_CONFIG_FILE); + } else { + configPath = path.resolve(process.cwd(), configPath); + } + let cfg: SwerrConfig | null = null; + + if (existsSync(configPath)) { + LogUtils.info(`Loading configuration from ${configPath}`); + try { + const imported = await import(pathToFileURL(configPath).href); + cfg = imported.default ?? imported; + return cfg; + } catch (err) { + LogUtils.error(`Failed to load configuration from ${configPath}: ${err}`); + process.exit(1); + } + } + + return null; + } catch (err) { + LogUtils.error(`Error loading configuration: ${err}`); + process.exit(1); + } } async function saveSourceScheme(config: SwerrConfig, absoluteOutputDir: string, scheme: SwerrScheme) { - if (!config?.sourceFile?.export?.saveToFile) return - const fileName = config?.sourceFile?.export?.fileName || "swerr-docs.json"; - const outputFilePath = path.join(absoluteOutputDir, fileName); - const docContent = JSON.stringify(scheme, null, 2); - try { - await fs.promises.writeFile(outputFilePath, docContent, "utf8"); - LogUtils.success(`Swerr Source File written to ${outputFilePath}`); - } catch (err) { - console.error(`Failed to write documentation to "${outputFilePath}":`, err); - process.exit(1); - } + if (!config?.sourceFile?.export?.saveToFile) return + const fileName = config?.sourceFile?.export?.fileName || "swerr-docs.json"; + const outputFilePath = path.join(absoluteOutputDir, fileName); + const docContent = JSON.stringify(scheme, null, 2); + try { + await fs.promises.writeFile(outputFilePath, docContent, "utf8"); + LogUtils.success(`Swerr Source File written to ${outputFilePath}`); + } catch (err) { + console.error(`Failed to write documentation to "${outputFilePath}":`, err); + process.exit(1); + } } async function runConverter(config: SwerrConfig, scheme: SwerrScheme) { - for (const converter of config.converter) { - await converter.factory(converter.config, scheme); - } + for (const converter of config.converter) { + await converter.factory(converter.config, scheme); + } } \ No newline at end of file diff --git a/bin/swerr.ts b/bin/swerr.ts index 4156fd0..a4af0f6 100644 --- a/bin/swerr.ts +++ b/bin/swerr.ts @@ -19,15 +19,16 @@ cli .description("Create documentation from your errors.") .version(packageJson.version); -const commandModules = [ - runCommand, initCommand -]; +cli + .command("init") + .description("Create a basic swerr config file.") + .option("-f, --force", "Overwrite existing config file if it exists.") + .option("-c, --config ", "Path to save the swerr config file.", "swerr.config.js") + .action(initCommand); -for (const commandModule of commandModules) { - cli - .command(commandModule.command) - .description(commandModule.description) - .action(commandModule.action); -} +cli + .command("run [configPath]") + .description("Create swerr documentation based on the config file.") + .action(runCommand); cli.parse(); From 58c0d8e9837b46cf6f0303c94eace7d0fd4fcc62 Mon Sep 17 00:00:00 2001 From: Lucas Bernard Date: Thu, 5 Mar 2026 17:18:15 +0100 Subject: [PATCH 2/2] feat: implement error class detection strategy --- bin/commands/init.command.ts | 14 +++++++- bin/extraction/swerr-scan.ts | 26 ++++++++++++-- bin/extraction/types/jsdoc.d.ts | 17 ++++++++- bin/extraction/types/scan.d.ts | 12 ++++++- example/src/exceptions/DontDetectMe.js | 5 +++ example/swerr.config.js | 3 ++ package-lock.json | 9 +++++ tests/extraction/translate-and-scan.spec.ts | 40 +++++++++++++++++++++ 8 files changed, 120 insertions(+), 6 deletions(-) create mode 100644 example/src/exceptions/DontDetectMe.js diff --git a/bin/commands/init.command.ts b/bin/commands/init.command.ts index eae3b9b..4eee0c6 100644 --- a/bin/commands/init.command.ts +++ b/bin/commands/init.command.ts @@ -19,7 +19,19 @@ export default { }, options: { ignoreDirs: [], // Directories to ignore during scanning (optional) - whitelistExtensions: [".js", ".ts"] // File extensions to include during scanning (optional) + whitelistExtensions: [".js", ".ts"], // File extensions to include during scanning (optional) + // Optional: Custom error class detector function + // errorClassDetector: (ctx) => { + // // Example: Only include blocks with @error tag + // return ctx.jsDocTags.some(tag => tag.name === "error"); + // + // // Example: Include blocks in files ending with "Exception.js" OR with @error tag + // // return ctx.fileName.endsWith("Exception.js") || + // // ctx.jsDocTags.some(tag => tag.name === "error"); + // + // // Example: Check if the file content contains "extends Error" + // // return ctx.fileContent.includes("extends Error"); + // } } }, converter: [ // Example converters diff --git a/bin/extraction/swerr-scan.ts b/bin/extraction/swerr-scan.ts index 5e11cdf..5b21bb6 100644 --- a/bin/extraction/swerr-scan.ts +++ b/bin/extraction/swerr-scan.ts @@ -1,6 +1,6 @@ import { promises as fs, Dirent } from "node:fs"; import * as path from "node:path"; -import {JsdocBlock, JsdocTag} from "./types/jsdoc.js"; +import {JsdocBlock, JsdocTag, ErrorClassDetectorContext} from "./types/jsdoc.js"; import {ScanOptions, ScanResult} from "./types/scan.js"; import {DEFAULT_IGNORE_DIRS, DEFAULT_MAX_FILE_SIZE} from "../config.js"; import {LogUtils} from "@swerr/core"; @@ -204,19 +204,39 @@ export async function scanJsdocs(rootDir: string, options: ScanOptions): Promise const newlineIdx = collectNewlineIndices(text); const found = findJsdocBlocks(text); + const fileName = path.basename(filePath); for (const b of found) { const startLine = indexToLine(b.index, newlineIdx); const lines = normalizeJsdocContentLines(b.raw); const parsed = parseNormalizedJsdocLines(lines); - blocks.push({ + const block: JsdocBlock = { filePath, startLine, raw: b.raw, description: parsed.description, tags: parsed.tags, - }); + }; + + // Apply custom error class detector if provided + if (options.errorClassDetector) { + const ctx: ErrorClassDetectorContext = { + jsDocTags: block.tags, + fileName, + fileContent: text, + block, + }; + + if (options.errorClassDetector(ctx)) { + blocks.push(block); + } else { + LogUtils.debug(`Custom detector: Skipping JSDoc block in ${filePath} at line ${startLine}`); + } + } else { + // Default behavior: include all blocks (filtering happens in translate-to-source-scheme) + blocks.push(block); + } } } diff --git a/bin/extraction/types/jsdoc.d.ts b/bin/extraction/types/jsdoc.d.ts index 22763b2..e6a5439 100644 --- a/bin/extraction/types/jsdoc.d.ts +++ b/bin/extraction/types/jsdoc.d.ts @@ -15,4 +15,19 @@ export type JsdocBlock = { description: string; /** All tags in encounter order */ tags: JsdocTag[]; -}; \ No newline at end of file +}; + +/** + * Context object passed to the error class detector function. + * Provides all relevant information to determine if a js file represents an error class. + */ +export type ErrorClassDetectorContext = { + /** The JSDoc tags found in the block */ + jsDocTags: JsdocTag[]; + /** The file name (basename) */ + fileName: string; + /** The full file content */ + fileContent: string; + /** The JSDoc block itself */ + block: JsdocBlock; +}; diff --git a/bin/extraction/types/scan.d.ts b/bin/extraction/types/scan.d.ts index 41a1de3..1ab9329 100644 --- a/bin/extraction/types/scan.d.ts +++ b/bin/extraction/types/scan.d.ts @@ -1,4 +1,5 @@ import {SwerrConfig} from "../../core/interfaces/swerr-config.js"; +import {ErrorClassDetectorContext} from "./jsdoc.js"; export type ScanResult = { rootDir: string; @@ -7,4 +8,13 @@ export type ScanResult = { skippedFiles: number; }; -export type ScanOptions = SwerrConfig["sourceFile"]["options"] \ No newline at end of file +export type ScanOptions = SwerrConfig["sourceFile"]["options"] & { + /** + * Custom function to determine if a JSDoc block represents an error class. + * If not provided, defaults to checking for the @error tag. + * + * @param ctx - Context object containing jsDocTags, fileName, fileContent, and the block itself + * @returns true if the block represents an error class, false otherwise + */ + errorClassDetector?: (ctx: ErrorClassDetectorContext) => boolean; +} diff --git a/example/src/exceptions/DontDetectMe.js b/example/src/exceptions/DontDetectMe.js new file mode 100644 index 0000000..78b78ac --- /dev/null +++ b/example/src/exceptions/DontDetectMe.js @@ -0,0 +1,5 @@ +/** + * Exception class that should not be detected. + * @error + */ +export class DontDetectMe extends Error { } \ No newline at end of file diff --git a/example/swerr.config.js b/example/swerr.config.js index 6b2130f..8070275 100644 --- a/example/swerr.config.js +++ b/example/swerr.config.js @@ -18,6 +18,9 @@ export default { whitelistExtensions: [ ".js" ], + errorClassDetector: (ctx) => { + return ctx.fileName.endsWith("Exception.js"); + } } }, converter: [ diff --git a/package-lock.json b/package-lock.json index 7ef3b5e..328d15b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -589,6 +589,7 @@ "integrity": "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.3", @@ -1324,6 +1325,7 @@ "integrity": "sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -3142,6 +3144,7 @@ "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", "dev": true, "license": "MIT", + "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -5376,6 +5379,7 @@ "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -6128,6 +6132,7 @@ "integrity": "sha512-6qGjWccl5yoyugHt3jTgztJ9Y0JVzyH8/Voc/D8PlLat9pwxQYXz7W1Dpnq5h0/G5GCYGUaDSlYcyk3AMh5A6g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@semantic-release/commit-analyzer": "^13.0.1", "@semantic-release/error": "^4.0.0", @@ -6827,6 +6832,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -6909,6 +6915,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -7038,6 +7045,7 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -7131,6 +7139,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, diff --git a/tests/extraction/translate-and-scan.spec.ts b/tests/extraction/translate-and-scan.spec.ts index f933e82..b3e0c1a 100644 --- a/tests/extraction/translate-and-scan.spec.ts +++ b/tests/extraction/translate-and-scan.spec.ts @@ -172,4 +172,44 @@ describe("swerr-scan (scanJsdocs)", () => { expect(b.description).toContain("A test error description"); expect(b.tags.some((t: any) => t.name === "error" && t.raw.includes("E_TEST"))).toBeTruthy(); }); + + it("uses custom errorClassDetector to filter blocks", async () => { + const fsMock: any = await import("node:fs"); + const root = path.resolve("vfs-custom-detector"); + const errorFileContent = `/** + * Custom error class + * @description This should be included + */ +class CustomError extends Error {}`; + const normalFileContent = `/** + * Normal class + * @description This should be excluded + */ +class NormalClass {}`; + + fsMock.__setMockFiles(root, { + files: { + "CustomException.js": errorFileContent, + "NormalClass.js": normalFileContent, + }, + dirs: { + ".": [ + { name: "CustomException.js", isDir: false }, + { name: "NormalClass.js", isDir: false }, + ], + }, + }); + + const { scanJsdocs } = await import("../../bin/extraction/swerr-scan.js"); + + const result = await scanJsdocs(root, { + errorClassDetector: (ctx: any) => { + return ctx.fileName.endsWith("Exception.js"); + } + }); + + expect(result.blocks.length).toBe(1); + expect(result.blocks[0].filePath).toContain("CustomException.js"); + expect(result.blocks[0].description).toContain("Custom error class"); + }); });