Repository: typescript-eslint/tslint-to-eslint-config (ts)

Issue:
Add opt-in flag to convert `tslint:disable` comments to `eslint-disable`
### 🚀 Feature Request

Per a comment in https://github.com/sourcegraph/sourcegraph/pull/5072 - it'd be swell to automate that process as well. `tslint-to-eslint-config` already has data from rule mergers, so when the config is generated, mapping through `tslint-disable` comments can use the same information generated by them.

#### Existing Behavior

Nothing happens.

#### Change Proposal

Add an optional CLI flag that takes in a glob or globs of source files to convert, and changes `tslint:disable-next-line: no-floating-promises` to the equivalent `eslint-disable-next-line: @typescript-eslint/no-floating-promises` lines.

Pull request description:
[Experimental Feature] Opt in flag convert disable comments
<!--
👋 Hi, thanks for sending a PR to tslint-to-eslint-config! 💖
Please fill out all fields below to ensure your PR is reviewed quickly.
-->

## PR Checklist

-   [x] Addresses an existing issue: fixes #136
-   [x] That issue was marked as [`status: accepting prs`](https://github.com/typescript-eslint/tslint-to-eslint-config/labels/status%3A%20accepting%20prs)

## Overview

~It is a work-in-progress (WIP) so any help would be welcome!~

~So far, I managed to pull the proper regex pattern to match any `tslint` comment and split it into capture groups. Each one has enough information to be used on the `convertComments` logic inside the `for-loop` (Please check the file).~

_Updated 4/25 by @JoshuaKGoldberg:_

Fleshed out the excellent initial scaffolding from @KingDarBoja and added unit tests. Summarizing the major changes in this PR:

1. Added an optional `--comments` flag marked as `(experimental)` to take in globs of source files that should have their comments replaced
    * _Followup work: look into allowing tsconfig files and/or also listening to `--typescript`_
2. Each of those files files is parsed into a TypeScript AST, its comments iterated over, and a list of comments that match a [TSLint rule flag](https://palantir.github.io/tslint/usage/rule-flags) is produced
3. For each matching comment, the equivalent [ESLint inline comment](https://eslint.org/docs/user-guide/configuring#disabling-rules-with-inline-comments) is generated, with any TSLint rule names replaced by a newly called rule converter's output ESLint rules
    * _Followup work: re-use the rules generated by the existing `tslint.json`->`.eslintrc.js` logic, so that rule arguments are accounted for_
4. A summary of those results are printed out to the console

![Screenshot of the new, correctly formatted CLI output including purple lines for comments](https://user-images.githubusercontent.com/3335181/80295523-4fd1e080-8741-11ea-965f-e5971bcd6075.png)


Patch:
diff --git a/README.md b/README.md
index f88739e..cdda729 100644
--- a/README.md
+++ b/README.md
@@ -42,6 +42,7 @@ We **strongly** advise reading [docs/FAQs.md](./docs/FAQs.md) before planning yo
 
 Each of these flags is optional:
 
+-   **[`comments`](#comments)**: File glob path(s) to convert TSLint rule flags to ESLint within.
 -   **[`config`](#config)**: Path to print the generated ESLint configuration file to.
 -   **[`editor`](#editor)**: Path to an editor configuration file to convert linter settings within.
 -   **[`eslint`](#eslint)**: Path to an ESLint configuration file to read settings from.
@@ -50,6 +51,17 @@ Each of these flags is optional:
 -   **[`tslint`](#tslint)**: Path to a TSLint configuration file to read settings from.
 -   **[`typescript`](#typescript)**: Path to a TypeScript configuration file to read TypeScript compiler options from.
 
+#### `comments`
+
+```shell
+npx tslint-to-eslint-config --comments src/**/*.ts
+```
+
+_Default: none_
+
+File glob path(s) to convert [TSLint rule flags](https://palantir.github.io/tslint/usage/rule-flags) to [ESLint inline comments](https://eslint.org/docs/user-guide/configuring#disabling-rules-with-inline-comments) in.
+Comments such as `// tslint:disable: tslint-rule-name` will be converted to equivalents like `// eslint-disable eslint-rule-name`.
+
 #### `config`
 
 ```shell
diff --git a/docs/Architecture.md b/docs/Architecture.md
index d7c10dc..4ec5bc3 100644
--- a/docs/Architecture.md
+++ b/docs/Architecture.md
@@ -16,7 +16,8 @@ Within `src/conversion/convertConfig.ts`, the following steps occur:
 3. ESLint configurations are simplified based on extended ESLint and TSLint presets
     - 3a. If no output rules conflict with `eslint-config-prettier`, it's added in
 4. The simplified configuration is written to the output config file
-5. A summary of the results is printed to the user's console
+5. Files to transform comments in have source text rewritten using the same rule conversion logic
+6. A summary of the results is printed to the user's console
 
 ### Conversion Results
 
diff --git a/package-lock.json b/package-lock.json
index 9d14223..70cd76e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -3208,6 +3208,23 @@
             "integrity": "sha512-OCutwjDZ4aFS6PB1UZ988C4YgwlBHJd6wCeQqaLdmadZ/7e+w79+hbMUFC1QXDNCmdyoRfAFdm0RypzwR+Qpag==",
             "dev": true
         },
+        "@types/events": {
+            "version": "3.0.0",
+            "resolved": "https://registry.npmjs.org/@types/events/-/events-3.0.0.tgz",
+            "integrity": "sha512-EaObqwIvayI5a8dCzhFrjKzVwKLxjoG9T6Ppd5CEo07LRKfQ8Yokw54r5+Wq7FaBQ+yXRvQAYPrHwya1/UFt9g==",
+            "dev": true
+        },
+        "@types/glob": {
+            "version": "7.1.1",
+            "resolved": "https://registry.npmjs.org/@types/glob/-/glob-7.1.1.tgz",
+            "integrity": "sha512-1Bh06cbWJUHMC97acuD6UMG29nMt0Aqz1vF3guLfG+kHHJhy3AyohZFFxYk2f7Q1SQIrNwvncxAE0N/9s70F2w==",
+            "dev": true,
+            "requires": {
+                "@types/events": "*",
+                "@types/minimatch": "*",
+                "@types/node": "*"
+            }
+        },
         "@types/istanbul-lib-coverage": {
             "version": "2.0.1",
             "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.1.tgz",
@@ -3249,6 +3266,12 @@
             "integrity": "sha512-8+KAKzEvSUdeo+kmqnKrqgeE+LcA0tjYWFY7RPProVYwnqDjukzO+3b6dLD56rYX5TdWejnEOLJYOIeh4CXKuA==",
             "dev": true
         },
+        "@types/minimatch": {
+            "version": "3.0.3",
+            "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.3.tgz",
+            "integrity": "sha512-tHq6qdbT9U1IRSGf14CL0pUlULksvY9OZ+5eEgl1N7t+OA3tGvNpxJCzuKQlsNgCVwbAs670L1vcVQi8j9HjnA==",
+            "dev": true
+        },
         "@types/node": {
             "version": "12.12.21",
             "resolved": "https://registry.npmjs.org/@types/node/-/node-12.12.21.tgz",
@@ -5083,9 +5106,9 @@
             }
         },
         "glob": {
-            "version": "7.1.4",
-            "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.4.tgz",
-            "integrity": "sha512-hkLPepehmnKk41pUGm3sYxoFs/umurYfYJCerbXEyFIWcAzvpipAgVkBqqT9RBKMGjnq6kMuyYwha6csxbiM1A==",
+            "version": "7.1.6",
+            "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz",
+            "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==",
             "requires": {
                 "fs.realpath": "^1.0.0",
                 "inflight": "^1.0.4",
diff --git a/package.json b/package.json
index f4283c0..aed0754 100644
--- a/package.json
+++ b/package.json
@@ -13,6 +13,7 @@
         "chalk": "4.0.0",
         "commander": "5.1.0",
         "eslint-config-prettier": "6.11.0",
+        "glob": "7.1.6",
         "strip-json-comments": "3.1.0",
         "tslint": "6.1.2",
         "typescript": "3.8.3"
@@ -23,6 +24,7 @@
         "@babel/plugin-proposal-optional-chaining": "7.9.0",
         "@babel/preset-env": "7.9.5",
         "@babel/preset-typescript": "7.9.0",
+        "@types/glob": "^7.1.1",
         "@types/jest": "25.2.1",
         "@types/node": "12.12.21",
         "@typescript-eslint/eslint-plugin": "2.29.0",
diff --git a/src/adapters/fileSystem.stub.ts b/src/adapters/fileSystem.stub.ts
index eb1fdcc..b962f53 100644
--- a/src/adapters/fileSystem.stub.ts
+++ b/src/adapters/fileSystem.stub.ts
@@ -3,9 +3,3 @@ export const createStubFileSystem = ({ data = {}, exists = true } = {}) => ({
     readFile: jest.fn().mockReturnValue(Promise.resolve(data)),
     writeFile: jest.fn(),
 });
-
-export const createStubThrowingFileSystem = ({ err = "" } = {}) => ({
-    fileExists: jest.fn().mockRejectedValue(Promise.resolve(new Error(err))),
-    readFile: jest.fn().mockRejectedValue(Promise.resolve(new Error(err))),
-    writeFile: jest.fn().mockRejectedValue(Promise.resolve(new Error(err))),
-});
diff --git a/src/adapters/globAsync.ts b/src/adapters/globAsync.ts
new file mode 100644
index 0000000..d9d91bb
--- /dev/null
+++ b/src/adapters/globAsync.ts
@@ -0,0 +1,11 @@
+import glob from "glob";
+
+export const globAsync = async (pattern: string) => {
+    return new Promise<string[] | Error>((resolve) => {
+        glob(pattern, (error, matches) => {
+            resolve(error ?? matches);
+        });
+    });
+};
+
+export type GlobAsync = typeof globAsync;
diff --git a/src/cli/main.ts b/src/cli/main.ts
index a55117c..0b0f4bb 100644
--- a/src/cli/main.ts
+++ b/src/cli/main.ts
@@ -2,9 +2,15 @@ import { EOL } from "os";
 
 import { childProcessExec } from "../adapters/childProcessExec";
 import { fsFileSystem } from "../adapters/fsFileSystem";
+import { globAsync } from "../adapters/globAsync";
 import { nativeImporter } from "../adapters/nativeImporter";
 import { processLogger } from "../adapters/processLogger";
 import { bind } from "../binding";
+import { convertComments, ConvertCommentsDependencies } from "../comments/convertComments";
+import {
+    ConvertFileCommentsDependencies,
+    convertFileComments,
+} from "../comments/convertFileComments";
 import { convertConfig, ConvertConfigDependencies } from "../conversion/convertConfig";
 import {
     convertEditorConfig,
@@ -45,9 +51,13 @@ import { findTypeScriptConfiguration } from "../input/findTypeScriptConfiguratio
 import { importer, ImporterDependencies } from "../input/importer";
 import { mergeLintConfigurations } from "../input/mergeLintConfigurations";
 import {
-    ChoosePackageManagerDependencies,
     choosePackageManager,
+    ChoosePackageManagerDependencies,
 } from "../reporting/packages/choosePackageManager";
+import {
+    reportCommentResults,
+    ReportCommentResultsDependencies,
+} from "../reporting/reportCommentResults";
 import {
     logMissingPackages,
     LogMissingPackagesDependencies,
@@ -62,6 +72,16 @@ import { mergers } from "../rules/mergers";
 import { rulesConverters } from "../rules/rulesConverters";
 import { runCli, RunCliDependencies } from "./runCli";
 
+const convertFileCommentsDependencies: ConvertFileCommentsDependencies = {
+    converters: rulesConverters,
+    fileSystem: fsFileSystem,
+};
+
+const convertCommentsDependencies: ConvertCommentsDependencies = {
+    convertFileComments: bind(convertFileComments, convertFileCommentsDependencies),
+    globAsync,
+};
+
 const convertRulesDependencies: ConvertRulesDependencies = {
     converters: rulesConverters,
     mergers,
@@ -97,6 +117,10 @@ const findOriginalConfigurationsDependencies: FindOriginalConfigurationsDependen
     mergeLintConfigurations,
 };
 
+const reportCommentResultsDependencies: ReportCommentResultsDependencies = {
+    logger: processLogger,
+};
+
 const choosePackageManagerDependencies: ChoosePackageManagerDependencies = {
     fileSystem: fsFileSystem,
 };
@@ -124,12 +148,16 @@ const writeConversionResultsDependencies: WriteConversionResultsDependencies = {
     fileSystem: fsFileSystem,
 };
 
+const reportEditorSettingConversionResultsDependencies = {
+    logger: processLogger,
+};
+
 const convertEditorConfigDependencies: ConvertEditorConfigDependencies = {
     findEditorConfiguration: bind(findEditorConfiguration, findEditorConfigurationDependencies),
     convertEditorSettings: bind(convertEditorSettings, convertEditorSettingsDependencies),
     reportConversionResults: bind(
         reportEditorSettingConversionResults,
-        reportConversionResultsDependencies,
+        reportEditorSettingConversionResultsDependencies,
     ),
     writeConversionResults: bind(
         writeEditorConfigConversionResults,
@@ -138,12 +166,14 @@ const convertEditorConfigDependencies: ConvertEditorConfigDependencies = {
 };
 
 const convertConfigDependencies: ConvertConfigDependencies = {
+    convertComments: bind(convertComments, convertCommentsDependencies),
     convertRules: bind(convertRules, convertRulesDependencies),
     findOriginalConfigurations: bind(
         findOriginalConfigurations,
         findOriginalConfigurationsDependencies,
     ),
     logMissingPackages: bind(logMissingPackages, logMissingPackagesDependencies),
+    reportCommentResults: bind(reportCommentResults, reportCommentResultsDependencies),
     reportConversionResults: bind(reportConversionResults, reportConversionResultsDependencies),
     simplifyPackageRules: bind(simplifyPackageRules, simplifyPackageRulesDependencies),
     writeConversionResults: bind(writeConversionResults, writeConversionResultsDependencies),
diff --git a/src/cli/runCli.ts b/src/cli/runCli.ts
index 92223d4..3cff71f 100644
--- a/src/cli/runCli.ts
+++ b/src/cli/runCli.ts
@@ -19,6 +19,10 @@ export const runCli = async (
 ): Promise<ResultStatus> => {
     const command = new Command()
         .usage("[options] <file ...> --language [language]")
+        .option(
+            "--comments [files]",
+            "convert tslint:disable rule flags in globbed files (experimental)",
+        )
         .option("--config [config]", "eslint configuration file to output to")
         .option("--editor [editor]", "editor configuration file to convert")
         .option("--eslint [eslint]", "eslint configuration file to convert using")
diff --git a/src/comments/convertComments.ts b/src/comments/convertComments.ts
new file mode 100644
index 0000000..dad74e0
--- /dev/null
+++ b/src/comments/convertComments.ts
@@ -0,0 +1,60 @@
+import { GlobAsync } from "../adapters/globAsync";
+import { SansDependencies } from "../binding";
+import { ResultStatus, ResultWithDataStatus } from "../types";
+import { separateErrors, uniqueFromSources, isError } from "../utils";
+import { convertFileComments } from "./convertFileComments";
+
+export type ConvertCommentsDependencies = {
+    convertFileComments: SansDependencies<typeof convertFileComments>;
+    globAsync: GlobAsync;
+};
+
+const noGlobsResult: ResultWithDataStatus<string[]> = {
+    errors: [new Error("--comment requires file path globs to be passed.")],
+    status: ResultStatus.Failed,
+};
+
+export const convertComments = async (
+    dependencies: ConvertCommentsDependencies,
+    filePathGlobs: true | string | string[] | undefined,
+): Promise<ResultWithDataStatus<string[]>> => {
+    if (filePathGlobs === true) {
+        return noGlobsResult;
+    }
+
+    const uniqueFilePathGlobs = uniqueFromSources(filePathGlobs);
+    if (uniqueFilePathGlobs.join("") === "") {
+        return noGlobsResult;
+    }
+
+    const [fileGlobErrors, globbedFilePaths] = separateErrors(
+        await Promise.all(uniqueFilePathGlobs.map(dependencies.globAsync)),
+    );
+    if (fileGlobErrors.length !== 0) {
+        return {
+            errors: fileGlobErrors,
+            status: ResultStatus.Failed,
+        };
+    }
+
+    const ruleConversionCache = new Map<string, string | undefined>();
+    const uniqueGlobbedFilePaths = uniqueFromSources(...globbedFilePaths);
+    const fileFailures = (
+        await Promise.all(
+            uniqueGlobbedFilePaths.map(async (filePath) =>
+                dependencies.convertFileComments(filePath, ruleConversionCache),
+            ),
+        )
+    ).filter(isError);
+    if (fileFailures.length !== 0) {
+        return {
+            errors: fileFailures,
+            status: ResultStatus.Failed,
+        };
+    }
+
+    return {
+        data: uniqueGlobbedFilePaths,
+        status: ResultStatus.Succeeded,
+    };
+};
diff --git a/src/comments/convertFileComments.ts b/src/comments/convertFileComments.ts
new file mode 100644
index 0000000..de129eb
--- /dev/null
+++ b/src/comments/convertFileComments.ts
@@ -0,0 +1,32 @@
+import { FileSystem } from "../adapters/fileSystem";
+import { parseFileComments } from "./parseFileComments";
+import { replaceFileComments } from "./replaceFileComments";
+import { RuleConverter } from "../rules/converter";
+
+export type ConvertFileCommentsDependencies = {
+    converters: Map<string, RuleConverter>;
+    fileSystem: Pick<FileSystem, "readFile" | "writeFile">;
+};
+
+export const convertFileComments = async (
+    dependencies: ConvertFileCommentsDependencies,
+    filePath: string,
+    ruleConversionCache: Map<string, string | undefined>,
+) => {
+    const fileContent = await dependencies.fileSystem.readFile(filePath);
+    if (fileContent instanceof Error) {
+        return fileContent;
+    }
+
+    const comments = parseFileComments(filePath, fileContent);
+    const newFileContent = replaceFileComments(
+        fileContent,
+        comments,
+        dependencies.converters,
+        ruleConversionCache,
+    );
+
+    return fileContent === newFileContent
+        ? undefined
+        : await dependencies.fileSystem.writeFile(filePath, newFileContent);
+};
diff --git a/src/comments/parseFileComments.ts b/src/comments/parseFileComments.ts
new file mode 100644
index 0000000..a93490d
--- /dev/null
+++ b/src/comments/parseFileComments.ts
@@ -0,0 +1,64 @@
+import * as utils from "tsutils";
+import * as ts from "typescript";
+
+export type FileComment = {
+    commentKind: ts.CommentKind;
+    directive: TSLintDirective;
+    end: number;
+    pos: number;
+    ruleNames: string[];
+};
+
+export type TSLintDirective = "tslint:disable" | "tslint:disable-next-line" | "tslint:enable";
+
+/**
+ * @see https://github.com/Microsoft/TypeScript/issues/21049
+ */
+export const parseFileComments = (filePath: string, content: string) => {
+    const sourceFile = ts.createSourceFile(
+        filePath,
+        content,
+        ts.ScriptTarget.Latest,
+        /*setParentNodes */ true,
+    );
+    const directives: FileComment[] = [];
+
+    utils.forEachComment(sourceFile, (fullText, comment) => {
+        const parsedComment = parseFileComment(fullText, comment);
+        if (parsedComment !== undefined) {
+            directives.push(parsedComment);
+        }
+    });
+
+    return directives;
+};
+
+/**
+ * @see https://github.com/palantir/tslint/blob/master/src/enableDisableRules.ts
+ */
+const tslintRegex = new RegExp(/tslint:(enable|disable)(?:-(line|next-line))?(:|\s|$)/g);
+
+const parseFileComment = (fullText: string, comment: ts.CommentRange): FileComment | undefined => {
+    const commentText = (comment.kind === ts.SyntaxKind.SingleLineCommentTrivia
+        ? fullText.substring(comment.pos + 2, comment.end)
+        : fullText.substring(comment.pos + 2, comment.end - 2)
+    ).trim();
+    const match = commentText.match(tslintRegex);
+    if (match === null) {
+        return undefined;
+    }
+
+    return {
+        commentKind: comment.kind,
+        directive: match[0].replace("e:", "e") as TSLintDirective,
+        end: comment.end,
+        pos: comment.pos,
+        ruleNames: commentText.slice(match[0].length).split(/\s+/).map(trimColons).filter(Boolean),
+    };
+};
+
+const trimColons = (text: string) =>
+    text
+        .replace(/^(:|\s)*/, "")
+        .replace(/(:|\s)*$/, "")
+        .trim();
diff --git a/src/comments/replaceFileComments.ts b/src/comments/replaceFileComments.ts
new file mode 100644
index 0000000..81835b1
--- /dev/null
+++ b/src/comments/replaceFileComments.ts
@@ -0,0 +1,55 @@
+import * as ts from "typescript";
+
+import { RuleConverter } from "../rules/converter";
+import { uniqueFromSources } from "../utils";
+import { ConversionError } from "../errors/conversionError";
+import { FileComment } from "./parseFileComments";
+
+export const replaceFileComments = (
+    content: string,
+    comments: FileComment[],
+    converters: Map<string, RuleConverter>,
+    ruleConversionCache: Map<string, string | undefined>,
+) => {
+    const getNewRuleLists = (ruleName: string) => {
+        if (ruleConversionCache.has(ruleName)) {
+            return ruleConversionCache.get(ruleName);
+        }
+
+        const converter = converters.get(ruleName);
+        if (converter === undefined) {
+            ruleConversionCache.set(ruleName, undefined);
+            return undefined;
+        }
+
+        const converted = converter({ ruleArguments: [] });
+        return converted instanceof ConversionError
+            ? undefined
+            : converted.rules.map((conversion) => conversion.ruleName).join(", ");
+    };
+
+    for (const comment of [...comments].reverse()) {
+        const directive = comment.directive
+            .replace("tslint:", "eslint-")
+            .replace("next-line", "line");
+        const ruleLists = uniqueFromSources(comment.ruleNames.map(getNewRuleLists)).filter(Boolean);
+        const [left, right] =
+            comment.commentKind === ts.SyntaxKind.SingleLineCommentTrivia
+                ? ["// ", ""]
+                : ["/* ", " */"];
+
+        content = [
+            content.slice(0, comment.pos),
+            left,
+            directive,
+            ruleLists.length !== 0 && " ",
+            ruleLists.join(", "),
+            right,
+            content.slice(comment.end),
+        ]
+            .filter(Boolean)
+            .join("");
+    }
+
+    return content;
+};
diff --git a/src/conversion/convertConfig.ts b/src/conversion/convertConfig.ts
index 4d96531..774f808 100644
--- a/src/conversion/convertConfig.ts
+++ b/src/conversion/convertConfig.ts
@@ -1,16 +1,20 @@
 import { SansDependencies } from "../binding";
+import { convertComments } from "../comments/convertComments";
 import { simplifyPackageRules } from "../creation/simplification/simplifyPackageRules";
 import { writeConversionResults } from "../creation/writeConversionResults";
 import { findOriginalConfigurations } from "../input/findOriginalConfigurations";
 import { logMissingPackages } from "../reporting/packages/logMissingPackages";
 import { reportConversionResults } from "../reporting/reportConversionResults";
+import { reportCommentResults } from "../reporting/reportCommentResults";
 import { convertRules } from "../rules/convertRules";
 import { ResultStatus, ResultWithStatus, TSLintToESLintSettings } from "../types";
 
 export type ConvertConfigDependencies = {
+    convertComments: SansDependencies<typeof convertComments>;
     convertRules: SansDependencies<typeof convertRules>;
     findOriginalConfigurations: SansDependencies<typeof findOriginalConfigurations>;
     logMissingPackages: SansDependencies<typeof logMissingPackages>;
+    reportCommentResults: SansDependencies<typeof reportCommentResults>;
     reportConversionResults: SansDependencies<typeof reportConversionResults>;
     simplifyPackageRules: SansDependencies<typeof simplifyPackageRules>;
     writeConversionResults: SansDependencies<typeof writeConversionResults>;
@@ -56,14 +60,16 @@ export const convertConfig = async (
         };
     }
 
-    // 5. A summary of the results is printed to the user's console
+    // 5. Files to transform comments in have source text rewritten using the same rule conversion logic
+    const commentsResult = await dependencies.convertComments(settings.comments);
+
+    // 6. A summary of the results is printed to the user's console
     await dependencies.reportConversionResults(settings.config, simplifiedConfiguration);
+    dependencies.reportCommentResults(settings.comments, commentsResult);
     await dependencies.logMissingPackages(
         simplifiedConfiguration,
         originalConfigurations.data.packages,
     );
 
-    return {
-        status: ResultStatus.Succeeded,
-    };
+    return commentsResult;
 };
diff --git a/src/input/findOriginalConfigurations.ts b/src/input/findOriginalConfigurations.ts
index 872709f..4364c47 100644
--- a/src/input/findOriginalConfigurations.ts
+++ b/src/input/findOriginalConfigurations.ts
@@ -1,5 +1,10 @@
 import { SansDependencies } from "../binding";
-import { ResultStatus, TSLintToESLintSettings, ResultWithDataStatus } from "../types";
+import {
+    ConfigurationErrorResult,
+    ResultStatus,
+    ResultWithDataStatus,
+    TSLintToESLintSettings,
+} from "../types";
 import { isDefined } from "../utils";
 import { findESLintConfiguration, ESLintConfiguration } from "./findESLintConfiguration";
 import { PackagesConfiguration, findPackagesConfiguration } from "./findPackagesConfiguration";
@@ -43,7 +48,7 @@ export type AllOriginalConfigurations = {
 export const findOriginalConfigurations = async (
     dependencies: FindOriginalConfigurationsDependencies,
     rawSettings: TSLintToESLintSettings,
-): Promise<ResultWithDataStatus<AllOriginalConfigurations>> => {
+): Promise<ConfigurationErrorResult | ResultWithDataStatus<AllOriginalConfigurations>> => {
     // Simultaneously search for all required configuration types
     const [eslint, packages, tslint, typescript] = await Promise.all([
         dependencies.findESLintConfiguration(rawSettings),
diff --git a/src/reporting/reportCommentResults.ts b/src/reporting/reportCommentResults.ts
new file mode 100644
index 0000000..d2d4f11
--- /dev/null
+++ b/src/reporting/reportCommentResults.ts
@@ -0,0 +1,51 @@
+import chalk from "chalk";
+import { EOL } from "os";
+
+import { Logger } from "../adapters/logger";
+import { ResultWithDataStatus, ResultStatus } from "../types";
+
+export type ReportCommentResultsDependencies = {
+    logger: Logger;
+};
+
+export const reportCommentResults = (
+    dependencies: ReportCommentResultsDependencies,
+    commentGlobs: string | string[] | undefined,
+    commentsResult: ResultWithDataStatus<string[]>,
+) => {
+    if (commentGlobs === undefined) {
+        dependencies.logger.stdout.write(
+            chalk.magentaBright(
+                `${EOL}♻ Consider using --comment to replace TSLint comment directives in your source files. ♻${EOL}`,
+            ),
+        );
+        return;
+    }
+
+    if (commentsResult.status === ResultStatus.Failed) {
+        const headline = `${commentsResult.errors.length} error${
+            commentsResult.errors.length === 1 ? "" : "s"
+        } converting TSLint comment directives in --comment files`;
+
+        dependencies.logger.stderr.write(chalk.magentaBright(`${EOL}❌ ${headline}. ❌${EOL}`));
+        dependencies.logger.stderr.write(
+            chalk.magenta(`  Check ${dependencies.logger.debugFileName} for details.${EOL}`),
+        );
+
+        dependencies.logger.info.write(`${headline}:${EOL}`);
+        dependencies.logger.info.write(
+            commentsResult.errors.map((error) => `  * ${error.message}${EOL}`).join(""),
+        );
+        dependencies.logger.info.write(EOL);
+        return;
+    }
+
+    dependencies.logger.stdout.write(chalk.magentaBright(`${EOL}♻ ${commentsResult.data.length}`));
+    dependencies.logger.stdout.write(
+        chalk.magenta(` file${commentsResult.data.length === 1 ? "" : "s"}`),
+    );
+    dependencies.logger.stdout.write(
+        chalk.magenta(` of TSLint comment directives converted to ESLint.`),
+    );
+    dependencies.logger.stdout.write(chalk.magentaBright(` ♻${EOL}`));
+};
diff --git a/src/rules/converter.stubs.ts b/src/rules/converter.stubs.ts
new file mode 100644
index 0000000..587c42d
--- /dev/null
+++ b/src/rules/converter.stubs.ts
@@ -0,0 +1,11 @@
+import { ConversionError } from "../errors/conversionError";
+
+export const createStubConverter = (result: ConversionError | string[]) => {
+    return () => {
+        return result instanceof ConversionError
+            ? result
+            : {
+                  rules: result.map((ruleName) => ({ ruleName })),
+              };
+    };
+};
diff --git a/src/types.ts b/src/types.ts
index 8a3d7ec..156efc7 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -4,6 +4,11 @@ export type TSLintToESLintSettings = {
      */
     config: string;
 
+    /**
+     * File globs to convert `tslint:disable` comments within to `eslint-disable`.
+     */
+    comments?: string | string[];
+
     /**
      * Original Editor configuration file path, such as `.vscode/settings.json`.
      */
@@ -45,10 +50,7 @@ export enum ResultStatus {
 
 export type ResultWithStatus = ConfigurationErrorResult | FailedResult | SucceededResult;
 
-export type ResultWithDataStatus<Data> =
-    | ConfigurationErrorResult
-    | FailedResult
-    | SucceededDataResult<Data>;
+export type ResultWithDataStatus<Data> = FailedResult | SucceededDataResult<Data>;
 
 export type ConfigurationErrorResult = {
     readonly complaints: string[];
diff --git a/src/utils.ts b/src/utils.ts
index 29c8d5e..46de58c 100644
--- a/src/utils.ts
+++ b/src/utils.ts
@@ -4,11 +4,20 @@ export const isError = <Item>(item: Item | Error): item is Error => item instanc
 
 export const isTruthy = <Item>(item: Item | false | undefined | null | 0): item is Item => !!item;
 
-export type RemoveErrors<Items> = {
-    [P in keyof Items]: Exclude<Items[P], Error>;
-};
+export const separateErrors = <Item>(mixed: (Error | Item)[]): [Error[], Item[]] => {
+    const errors: Error[] = [];
+    const items: Item[] = [];
+
+    for (const item of mixed) {
+        if (item instanceof Error) {
+            errors.push(item);
+        } else {
+            items.push(item);
+        }
+    }
 
-export type PromiseValue<T> = T extends Promise<infer R> ? R : never;
+    return [errors, items];
+};
 
 export type RequireAtLeastOne<T, Keys extends keyof T = keyof T> = Pick<T, Exclude<keyof T, Keys>> &
     {


Test patch:
diff --git a/src/comments/convertComments.test.ts b/src/comments/convertComments.test.ts
new file mode 100644
index 0000000..7f8eb27
--- /dev/null
+++ b/src/comments/convertComments.test.ts
@@ -0,0 +1,88 @@
+import { ResultStatus } from "../types";
+import { convertComments, ConvertCommentsDependencies } from "./convertComments";
+
+const createStubDependencies = (
+    overrides: Partial<ConvertCommentsDependencies> = {},
+): ConvertCommentsDependencies => ({
+    convertFileComments: jest.fn(),
+    globAsync: jest.fn().mockResolvedValue(["src/index.ts"]),
+    ...overrides,
+});
+
+describe("convertComments", () => {
+    it("returns an error when --comment is given as a boolean value", async () => {
+        // Arrange
+        const dependencies = createStubDependencies();
+
+        // Act
+        const result = await convertComments(dependencies, true);
+
+        // Assert
+        expect(result).toEqual({
+            errors: expect.arrayContaining([expect.any(Error)]),
+            status: ResultStatus.Failed,
+        });
+    });
+
+    it("returns an error when there are no file path globs", async () => {
+        // Arrange
+        const dependencies = createStubDependencies();
+
+        // Act
+        const result = await convertComments(dependencies, []);
+
+        // Assert
+        expect(result).toEqual({
+            errors: expect.arrayContaining([expect.any(Error)]),
+            status: ResultStatus.Failed,
+        });
+    });
+
+    it("returns the failure result when a file path glob fails", async () => {
+        // Arrange
+        const globAsyncError = new Error();
+        const dependencies = createStubDependencies({
+            globAsync: jest.fn().mockResolvedValueOnce(globAsyncError),
+        });
+
+        // Act
+        const result = await convertComments(dependencies, ["*.ts"]);
+
+        // Assert
+        expect(result).toEqual({
+            errors: [globAsyncError],
+            status: ResultStatus.Failed,
+        });
+    });
+
+    it("returns the failure result when a file conversion fails", async () => {
+        // Arrange
+        const fileConversionError = new Error();
+        const dependencies = createStubDependencies({
+            convertFileComments: jest.fn().mockResolvedValueOnce(fileConversionError),
+        });
+
+        // Act
+        const result = await convertComments(dependencies, ["*.ts"]);
+
+        // Assert
+        expect(result).toEqual({
+            errors: [fileConversionError],
+            status: ResultStatus.Failed,
+        });
+    });
+
+    it("returns a success result when all steps succeed", async () => {
+        // Arrange
+        const dependencies = createStubDependencies();
+
+        // Act
+        const result = await convertComments(dependencies, ["*.ts"]);
+
+        // Assert
+        expect(result).toEqual({
+            data: ["src/index.ts"],
+            status: ResultStatus.Succeeded,
+        });
+    });
+});
diff --git a/src/comments/convertFileComments.test.ts b/src/comments/convertFileComments.test.ts
new file mode 100644
index 0000000..244eb87
--- /dev/null
+++ b/src/comments/convertFileComments.test.ts
@@ -0,0 +1,183 @@
+import { createStubFileSystem } from "../adapters/fileSystem.stub";
+import { ConversionError } from "../errors/conversionError";
+import { createStubConverter } from "../rules/converter.stubs";
+import { convertFileComments, ConvertFileCommentsDependencies } from "./convertFileComments";
+
+const createStubDependencies = (
+    readFileResult: string | Error,
+): ConvertFileCommentsDependencies => ({
+    converters: new Map([
+        ["ts-a", createStubConverter(["es-a"])],
+        ["ts-b", createStubConverter(["es-b1", "es-b2"])],
+        ["ts-error", createStubConverter(ConversionError.forMerger("unknown"))],
+    ]),
+    fileSystem: {
+        ...createStubFileSystem(),
+        readFile: jest.fn().mockResolvedValueOnce(readFileResult),
+    },
+});
+
+const stubFileName = "src/index.ts";
+
+describe("convertFileComments", () => {
+    it("returns the failure result when reading the file fails", async () => {
+        // Arrange
+        const readFileError = new Error();
+        const dependencies = createStubDependencies(readFileError);
+
+        // Act
+        const result = await convertFileComments(dependencies, stubFileName, new Map());
+
+        // Assert
+        expect(result).toBe(readFileError);
+    });
+
+    it("doesn't overwrite a file when there are no matching comment directives", async () => {
+        // Arrange
+        const dependencies = createStubDependencies(`
+// Hello, world!
+`);
+
+        // Act
+        await convertFileComments(dependencies, stubFileName, new Map());
+
+        // Assert
+        expect(dependencies.fileSystem.writeFile).not.toHaveBeenCalled();
+    });
+
+    it("parses TSLint directives to their matching ESLint directives", async () => {
+        // Arrange
+        const dependencies = createStubDependencies(`
+// tslint:disable
+export const a = true;
+
+// tslint:disable-next-line
+export const b = true;
+
+// tslint:enable
+export const c = true;
+
+/* tslint:disable */
+export const d = true;
+
+/* tslint:disable-next-line */
+export const e = true;
+
+/* tslint:enable */
+export const f = true;
+`);
+
+        // Act
+        await convertFileComments(dependencies, stubFileName, new Map());
+
+        // Assert
+        expect(dependencies.fileSystem.writeFile).toHaveBeenCalledWith(
+            stubFileName,
+            `
+// eslint-disable
+export const a = true;
+
+// eslint-disable-line
+export const b = true;
+
+// eslint-enable
+export const c = true;
+
+/* eslint-disable */
+export const d = true;
+
+/* eslint-disable-line */
+export const e = true;
+
+/* eslint-enable */
+export const f = true;
+`,
+        );
+    });
+
+    it("parses rule names when they exist", async () => {
+        // Arrange
+        const dependencies = createStubDependencies(`
+// tslint:disable:ts-a
+export const a = true;
+
+// tslint:disable-next-line: ts-a ts-b
+export const b = true;
+`);
+
+        // Act
+        await convertFileComments(dependencies, stubFileName, new Map());
+
+        // Assert
+        expect(dependencies.fileSystem.writeFile).toHaveBeenCalledWith(
+            stubFileName,
+            `
+// eslint-disable es-a
+export const a = true;
+
+// eslint-disable-line es-a, es-b1, es-b2
+export const b = true;
+`,
+        );
+    });
+
+    it("re-uses a rule conversion from cache when it was already converted", async () => {
+        // Arrange
+        const dependencies = createStubDependencies(`
+// tslint:disable:ts-a
+export const a = true;
+`);
+
+        // Act
+        await convertFileComments(dependencies, stubFileName, new Map([["ts-a", "es-cached"]]));
+
+        // Assert
+        expect(dependencies.fileSystem.writeFile).toHaveBeenCalledWith(
+            stubFileName,
+            `
+// eslint-disable es-cached
+export const a = true;
+`,
+        );
+    });
+
+    it("ignores comment text when there is no matching converter", async () => {
+        // Arrange
+        const dependencies = createStubDependencies(`
+// tslint:disable:ts-z
+export const a = true;
+`);
+
+        // Act
+        await convertFileComments(dependencies, stubFileName, new Map());
+
+        // Assert
+        expect(dependencies.fileSystem.writeFile).toHaveBeenCalledWith(
+            stubFileName,
+            `
+// eslint-disable
+export const a = true;
+`,
+        );
+    });
+
+    it("ignores comment text when its matching converter results in an error", async () => {
+        // Arrange
+        const dependencies = createStubDependencies(`
+// tslint:disable:ts-error
+export const a = true;
+`);
+
+        // Act
+        await convertFileComments(dependencies, stubFileName, new Map());
+
+        // Assert
+        expect(dependencies.fileSystem.writeFile).toHaveBeenCalledWith(
+            stubFileName,
+            `
+// eslint-disable
+export const a = true;
+`,
+        );
+    });
+});
diff --git a/src/conversion/convertConfig.test.ts b/src/conversion/convertConfig.test.ts
index 3fa1e53..d674366 100644
--- a/src/conversion/convertConfig.test.ts
+++ b/src/conversion/convertConfig.test.ts
@@ -8,11 +8,13 @@ const stubSettings = {
 const createStubDependencies = (
     overrides: Partial<ConvertConfigDependencies> = {},
 ): ConvertConfigDependencies => ({
+    convertComments: jest.fn(),
     convertRules: jest.fn(),
     findOriginalConfigurations: jest.fn().mockResolvedValue({
         data: createStubOriginalConfigurationsData(),
         status: ResultStatus.Succeeded,
     }),
+    reportCommentResults: jest.fn(),
     reportConversionResults: jest.fn(),
     simplifyPackageRules: async (_configurations, data) => ({
         ...data,
@@ -72,16 +74,36 @@ describe("convertConfig", () => {
         });
     });
 
-    it("returns a successful result when finding the original configurations succeeds", async () => {
+    it("returns the failure result when converting comments fails", async () => {
         // Arrange
-        const dependencies = createStubDependencies();
+        const convertCommentsResult = {
+            errors: [new Error()],
+            status: ResultStatus.Failed,
+        };
+        const dependencies = createStubDependencies({
+            convertComments: jest.fn().mockResolvedValueOnce(convertCommentsResult),
+        });
 
         // Act
         const result = await convertConfig(dependencies, stubSettings);
 
         // Assert
-        expect(result).toEqual({
+        expect(result).toEqual(convertCommentsResult);
+    });
+
+    it("returns a successful result when all steps succeed", async () => {
+        // Arrange
+        const convertCommentsResult = {
             status: ResultStatus.Succeeded,
+        };
+        const dependencies = createStubDependencies({
+            convertComments: jest.fn().mockResolvedValueOnce(convertCommentsResult),
         });
+
+        // Act
+        const result = await convertConfig(dependencies, stubSettings);
+
+        // Assert
+        expect(result).toEqual(convertCommentsResult);
     });
 });
diff --git a/src/reporting/reportCommentResults.test.ts b/src/reporting/reportCommentResults.test.ts
new file mode 100644
index 0000000..114222b
--- /dev/null
+++ b/src/reporting/reportCommentResults.test.ts
@@ -0,0 +1,96 @@
+import { createStubLogger, expectEqualWrites } from "../adapters/logger.stubs";
+import { ResultStatus } from "../types";
+import { reportCommentResults } from "./reportCommentResults";
+
+describe("reportCommentResults", () => {
+    it("logs a suggestion when no comment globs are provided", () => {
+        // Arrange
+        const logger = createStubLogger();
+
+        // Act
+        reportCommentResults({ logger }, undefined, { data: [], status: ResultStatus.Succeeded });
+
+        // Assert
+        expectEqualWrites(
+            logger.stdout.write,
+            `♻ Consider using --comment to replace TSLint comment directives in your source files. ♻`,
+        );
+    });
+
+    it("logs a singular complaint when one comment conversion fails", () => {
+        // Arrange
+        const logger = createStubLogger();
+        const errors = [new Error("Hello")];
+
+        // Act
+        reportCommentResults({ logger }, ["src/index.ts"], { errors, status: ResultStatus.Failed });
+
+        // Assert
+        expectEqualWrites(
+            logger.stderr.write,
+            `❌ 1 error converting TSLint comment directives in --comment files. ❌`,
+            `  Check ${logger.debugFileName} for details.`,
+        );
+        expectEqualWrites(
+            logger.info.write,
+            `1 error converting TSLint comment directives in --comment files:`,
+            `  * Hello`,
+        );
+    });
+
+    it("logs a plural complaint when multiple comment conversions fail", () => {
+        // Arrange
+        const logger = createStubLogger();
+        const errors = [new Error("Hello"), new Error("World")];
+
+        // Act
+        reportCommentResults({ logger }, ["src/index.ts"], { errors, status: ResultStatus.Failed });
+
+        // Assert
+        expectEqualWrites(
+            logger.stderr.write,
+            `❌ 2 errors converting TSLint comment directives in --comment files. ❌`,
+            `  Check ${logger.debugFileName} for details.`,
+        );
+        expectEqualWrites(
+            logger.info.write,
+            `2 errors converting TSLint comment directives in --comment files:`,
+            `  * Hello`,
+            `  * World`,
+        );
+    });
+
+    it("logs a singular success message when comment conversions succeeded on one file", () => {
+        // Arrange
+        const logger = createStubLogger();
+
+        // Act
+        reportCommentResults({ logger }, ["src/*.ts"], {
+            data: ["src/index.ts"],
+            status: ResultStatus.Succeeded,
+        });
+
+        // Assert
+        expectEqualWrites(
+            logger.stdout.write,
+            `♻ 1 file of TSLint comment directives converted to ESLint. ♻ `,
+        );
+    });
+
+    it("logs a plural success message when comment conversions succeeded on two files", () => {
+        // Arrange
+        const logger = createStubLogger();
+
+        // Act
+        reportCommentResults({ logger }, ["src/*.ts"], {
+            data: ["src/index.ts", "src/data.ts"],
+            status: ResultStatus.Succeeded,
+        });
+
+        // Assert
+        expectEqualWrites(
+            logger.stdout.write,
+            `♻ 2 files of TSLint comment directives converted to ESLint. ♻ `,
+        );
+    });
+});
diff --git a/src/utils.test.ts b/src/utils.test.ts
index f0180a1..e93823a 100644
--- a/src/utils.test.ts
+++ b/src/utils.test.ts
@@ -1,4 +1,4 @@
-import { isDefined, isError, uniqueFromSources } from "./utils";
+import { isDefined, isError, uniqueFromSources, separateErrors } from "./utils";
 
 describe("isDefined", () => {
     it("returns true when the item is defined", () => {
@@ -48,6 +48,19 @@ describe("isError", () => {
     });
 });
 
+describe("separateErrors", () => {
+    it("splits the input array into errors and items", () => {
+        // Arrange
+        const mixed = ["value", new Error()];
+
+        // Act
+        const result = separateErrors(mixed);
+
+        // Assert
+        expect(result).toEqual([[mixed[1]], [mixed[0]]]);
+    });
+});
+
 describe("uniqueFromSources", () => {
     it("returns unique items when multiple are given", () => {
         // Arange