Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Lint to ensure CompilerOptions and options in commandLineParser are synchronized #58312

Open
wants to merge 13 commits into
base: main
Choose a base branch
from
Open
  •  
  •  
  •  
3 changes: 2 additions & 1 deletion .eslintrc.json
Expand Up @@ -144,7 +144,8 @@
"local/no-in-operator": "error",
"local/debug-assert": "error",
"local/no-keywords": "error",
"local/jsdoc-format": "error"
"local/jsdoc-format": "error",
"local/compiler-options-consistency": "error"
},
"overrides": [
// By default, the ESLint CLI only looks at .js files. But, it will also look at
Expand Down
176 changes: 176 additions & 0 deletions scripts/eslint/rules/compiler-options-consistency.cjs
@@ -0,0 +1,176 @@
const { AST_NODE_TYPES, ESLintUtils } = require("@typescript-eslint/utils");
const { createRule } = require("./utils.cjs");
const ts = require("typescript");

/**
* @typedef {{name: string, internal: boolean}} OptionData
*/

module.exports = createRule({
name: "compiler-options-consistency",
meta: {
docs: {
description: ``,
},
messages: {
notInOptionsParser: `Add this option to src/compiler/commandLineParser.ts`,
notInOptionsType: `Add this option to CompilerOptions in src/compiler/types.ts`,
missingAtInternal: `Add @internal to this option to align it with the internal setting on the option description.`,
missingInternalSetting: `Add internal: true to this option descriptor to align it with the CompilerOption type member.`,
},
schema: [],
type: "problem",
fixable: "code",
},
defaultOptions: [],

create(context) {
const sourceCode = context.sourceCode;
const parserServices = ESLintUtils.getParserServices(context, /*allowWithoutFullTypeInformation*/ true);
if (!parserServices.program) {
return {};
}
const types = parserServices.program.getSourceFile("src/compiler/types.ts");
if (!types) {
return {};
}
const commandLineParser = parserServices.program.getSourceFile("src/compiler/commandLineParser.ts");
if (!commandLineParser) {
return {};
}

// The names of the declarations in commandLineParser.ts that vontain the options definitions we want to compare against
const targetParserDeclarations = ["commonOptionsWithBuild", "targetOptionDeclaration", "moduleOptionDeclaration", "commandOptionsWithoutBuild"];

// The extra internal members on CompilerOptions that we only use to shuttle around calculated information
const ignoredCompilerOptionTypeMembers = ["configFile", "configFilePath", "pathsBasePath"];

const interfaceDecl = /** @type {ts.InterfaceDeclaration} */ (types.statements.find(s => s.kind === ts.SyntaxKind.InterfaceDeclaration && /** @type {ts.InterfaceDeclaration} */ (s).name.text === "CompilerOptions"));

const optionsFromType = interfaceDecl.members.reduce((map, elem) => {
if (!elem.name || elem.name.kind !== ts.SyntaxKind.Identifier || elem.kind !== ts.SyntaxKind.PropertySignature) return map;
const name = elem.name.text;
if (ignoredCompilerOptionTypeMembers.includes(name)) return map;
const internal = !!elem.getFullText().includes("@internal");
/** @type {OptionData} */
const option = {
name,
internal,
};
map[name] = option;
return map;
}, /** @type {Record<string, OptionData>} */ ({}));

/** @type {Record<string, OptionData>} */
const optionsFromParser = {};

/**
* @param {ts.Node} node
*/
const parserVisitor = node => {
// Collects what are, by-convention, all the options defined in the command line parser
if (node.kind === ts.SyntaxKind.ObjectLiteralExpression) {
const obj = /** @type {ts.ObjectLiteralExpression} */ (node);
const nameProp = obj.properties.find(p => p.kind === ts.SyntaxKind.PropertyAssignment && p.name.kind === ts.SyntaxKind.Identifier && p.name.escapedText === "name" && /** @type {ts.PropertyAssignment} */ (p).initializer.kind === ts.SyntaxKind.StringLiteral);
const typeProp = obj.properties.find(p => p.kind === ts.SyntaxKind.PropertyAssignment && p.name.kind === ts.SyntaxKind.Identifier && p.name.escapedText === "type");
if (nameProp && typeProp) {
const name = /** @type {ts.StringLiteral} */ (/** @type {ts.PropertyAssignment} */ (nameProp).initializer).text;
const internalProp = /** @type {ts.PropertyAssignment | undefined} */ (obj.properties.find(p => p.kind === ts.SyntaxKind.PropertyAssignment && p.name.kind === ts.SyntaxKind.Identifier && p.name.escapedText === "internal"));
optionsFromParser[name] = { name, internal: !!(internalProp && internalProp.initializer.kind === ts.SyntaxKind.TrueKeyword) };
}
return; // no nested objects - these are element descriptors
}
return ts.visitEachChild(node, parserVisitor, /*context*/ undefined);
};
commandLineParser.statements.filter(d => {
if (d.kind !== ts.SyntaxKind.VariableStatement) return false;
const name = /** @type {ts.VariableStatement} */ (d).declarationList.declarations[0].name;
if (name.kind !== ts.SyntaxKind.Identifier) return false;
return targetParserDeclarations.includes(name.text);
}).forEach(d => {
ts.visitNode(d, parserVisitor);
});

/** @type {(node: import("@typescript-eslint/utils").TSESTree.TSInterfaceDeclaration) => void} */
const checkInterface = node => {
if (!context.physicalFilename.endsWith("compiler/types.ts") && !context.physicalFilename.endsWith("compiler\\types.ts")) {
return;
}
if (node.id.name !== "CompilerOptions") {
return;
}
node.body.body.forEach(e => {
if (e.type !== AST_NODE_TYPES.TSPropertySignature) return;
if (e.key.type !== AST_NODE_TYPES.Identifier) return;
if (ignoredCompilerOptionTypeMembers.includes(e.key.name)) return;

const comments = sourceCode.getCommentsBefore(e);
/** @type {import("@typescript-eslint/utils").TSESTree.Comment | undefined} */
const comment = comments[comments.length - 1];
let internal = false;
if (comment && comment.type === "Block") {
internal = !!comment.value.includes("@internal");
}

/** @type {OptionData} */
const option = {
name: e.key.name,
internal,
};

if (!optionsFromParser[option.name]) {
context.report({ messageId: "notInOptionsParser", node: e.key });
}
else if (!option.internal && optionsFromParser[option.name].internal) {
context.report({ messageId: "missingAtInternal", node: e.key, fix: fixer => fixer.insertTextBefore(e, "/** @internal */ ") });
}
});
};

/** @type {(node: import("@typescript-eslint/utils").TSESTree.Property) => void} */
const checkProperty = node => {
if (!context.physicalFilename.endsWith("commandLineParser.ts")) {
return;
}
if (node.key.type !== AST_NODE_TYPES.Identifier || node.key.name !== "name" || node.parent.type !== AST_NODE_TYPES.ObjectExpression || node.value.type !== AST_NODE_TYPES.Literal || typeof node.value.value !== "string") {
return;
}
if (!node.parent.properties.some(e => e.type === AST_NODE_TYPES.Property && e.key.type === AST_NODE_TYPES.Identifier && e.key.name === "type")) {
return;
}
/**
* @type {import("@typescript-eslint/utils").TSESTree.Node | undefined}
*/
let p = node.parent.parent;
let isHostedByTarget = false;
while (p) {
if (p.type === AST_NODE_TYPES.ObjectExpression) return; // Nested descriptor
if (p.type === AST_NODE_TYPES.VariableDeclarator) {
if (p.id.type !== AST_NODE_TYPES.Identifier) return; // non-identifier host
if (!targetParserDeclarations.includes(p.id.name)) return; // not a declaration we care about
isHostedByTarget = true;
}
p = p.parent;
}
if (!isHostedByTarget) return; // Not on a variable declaration we care about

/** @type {OptionData} */
const option = {
name: node.value.value,
internal: !!node.parent.properties.some(e => e.type === AST_NODE_TYPES.Property && e.key.type === AST_NODE_TYPES.Identifier && e.key.name === "internal" && e.value.type === AST_NODE_TYPES.Literal && e.value.value === true),
};

if (!optionsFromType[option.name]) {
context.report({ messageId: "notInOptionsType", node: node.key });
}
else if (!option.internal && optionsFromType[option.name].internal) {
context.report({ messageId: "missingInternalSetting", node: node.key, fix: fixer => fixer.insertTextAfterRange([0, node.range[1] + 1], " internal: true,") });
}
};

return {
Property: checkProperty,
TSInterfaceDeclaration: checkInterface,
};
},
});
211 changes: 211 additions & 0 deletions scripts/eslint/tests/compiler-options-consistency.cjs
@@ -0,0 +1,211 @@
const { RuleTester } = require("./support/RuleTester.cjs");
const rule = require("../rules/compiler-options-consistency.cjs");
const ts = require("typescript");
const path = require("path");

const ruleTester = new RuleTester({
parserOptions: {
warnOnUnsupportedTypeScriptVersion: false,
},
parser: require.resolve("@typescript-eslint/parser"),
});

const parserFilename = path.normalize("src/compiler/commandLineParser.ts");
const typesFilename = path.normalize("src/compiler/types.ts");
/**
* @param {string} parserText
* @param {string} typesText
* @returns {ts.Program}
*/
const getProgram = (parserText, typesText) => {
const parserFile = ts.createSourceFile(parserFilename, parserText, ts.ScriptTarget.Latest);
const typesFile = ts.createSourceFile(typesFilename, typesText, ts.ScriptTarget.Latest);
const program = ts.createProgram({
options: {},
rootNames: [parserFilename, typesFilename],
host: {
fileExists: filename => (filename = path.normalize(filename), filename === parserFilename || filename === typesFilename),
getCanonicalFileName: n => n,
getCurrentDirectory: () => path.join(__dirname, "../../../"),
getDefaultLibFileName: () => "lib.d.ts",
getNewLine: () => "\n",
getSourceFile: filename => (filename = path.normalize(filename), filename === parserFilename ? parserFile : filename === typesFilename ? typesFile : undefined),
getSourceFileByPath: name => void console.log(name),
readFile: () => void 0,
useCaseSensitiveFileNames: () => true,
writeFile: () => void 0,
},
});
return program;
};

/**
* @param {string} parserText
* @param {string} typesText
*/
const valid = (parserText, typesText) => {
return {
filename: typesFilename, // any file in the program will do
code: typesText,
parserOptions: { programs: [getProgram(parserText, typesText)] },
};
};

/**
* @param {string} parserText
* @param {string} typesText
* @param {readonly import("@typescript-eslint/utils/ts-eslint").TestCaseError<MessageIds>[]} errors
* @param {(typeof typesFilename | typeof parserFilename)=} outputFile
* @param {string=} output
* @template {string} MessageIds
*/
const invalid = (parserText, typesText, errors, outputFile, output) => {
const partial = valid(parserText, typesText);
return !outputFile ? {
...partial,
errors,
} : {
...partial,
filename: outputFile,
code: outputFile === typesFilename ? typesText : parserText, // code must be set to get fixer results
errors,
output: output || (outputFile === typesFilename ? typesText : parserText),
};
};

ruleTester.run("compiler-options-consistency", rule, {
valid: [
valid(
`
const commandOptionsWithoutBuild: CommandLineOption[] = [
{
name: "allowJs",
type: "boolean",
},
];`,
`
export interface CompilerOptions {
allowJs?: boolean;
}`,
),
valid(
`
const commandOptionsWithoutBuild: CommandLineOption[] = [
{
name: "allowJs",
type: "boolean",
internal: true,
},
];`,
`
export interface CompilerOptions {
/** @internal */ allowJs?: boolean;
}`,
),
valid(
`
const commandOptionsWithoutBuild: CommandLineOption[] = [
{
name: "allowJs",
type: "boolean",
internal: true,
},
];
const doesntMatterOptions: CommandLineOption[] = [
{
name: "include",
type: "list",
element {
name: "glob",
type: "string",
}
},
];`,
`
export interface CompilerOptions {
/** @internal */ allowJs?: boolean;
/** @internal */ configFile?: JsonSourceFile;
/** @internal */ configFilePath?: string;
/** @internal */ pathsBasePath?: string;
}`,
),
],
invalid: [
invalid(
`
const commandOptionsWithoutBuild: CommandLineOption[] = [
{
name: "allowJs",
type: "boolean",
internal: true,
},
];`,
`
export interface CompilerOptions {
allowJs?: boolean;
}`,
[{ messageId: "missingAtInternal" }],
typesFilename,
`
export interface CompilerOptions {
/** @internal */ allowJs?: boolean;
}`,
),
invalid(
`
const commandOptionsWithoutBuild: CommandLineOption[] = [
{
name: "allowJs",
type: "boolean",
},
];`,
`
export interface CompilerOptions {
/** @internal */ allowJs?: boolean;
}`,
[{ messageId: "missingInternalSetting" }],
parserFilename,
`
const commandOptionsWithoutBuild: CommandLineOption[] = [
{
name: "allowJs", internal: true,
type: "boolean",
},
];`,
),
invalid(
`
const commandOptionsWithoutBuild: CommandLineOption[] = [
{
name: "checkJs",
type: "boolean",
},
];`,
`
export interface CompilerOptions {
allowJs?: boolean;
checkJs?: boolean;
}`,
[{ messageId: "notInOptionsParser" }],
),
invalid(
`
const commandOptionsWithoutBuild: CommandLineOption[] = [
{
name: "allowJs",
type: "boolean",
},
{
name: "checkJs",
type: "boolean",
},
];`,
`
export interface CompilerOptions {
allowJs?: boolean;
}`,
[{ messageId: "notInOptionsType" }],
parserFilename,
),
],
});