diff --git a/.gitignore b/.gitignore index 5ece14df21..bc6d55027a 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,5 @@ node_modules dist *.tsbuildinfo *.lerna_backup +packages/linting/cache packages/utils/cache \ No newline at end of file diff --git a/README.md b/README.md index 19a99ecdad..19b98407d3 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ A monorepo for formerly disparate DefinitelyTyped-related tools: - [definitions-parser](packages/definitions-parser): the part of [microsoft/types-publisher](https://github.com/microsoft/types-publisher) that reads DefinitelyTyped repository data - [dtslint-runner](packages/dtslint-runner): [DefinitelyTyped/dtslint-runner](https://github.com/DefinitelyTyped/dtslint-runner) - [header-parser](packages/header-parser): [microsoft/definitelytyped-header-parser](https://github.com/microsoft/definitelytyped-header-parser) +- [linting](packages/linting): linting utilities, mostly extracted from [microsoft/dtslint](https://github.com/microsoft/dtslint) - [perf](packages/perf): [andrewbranch/definitely-not-slow](https://github.com/andrewbranch/definitely-not-slow) - [publisher](packages/publisher): the rest of [microsoft/types-publisher](https://github.com/microsoft/types-publisher) - [retag](packages/retag): [DefinitelyTyped/dt-retag](https://github.com/DefinitelyTyped/dt-retag) diff --git a/packages/linting/.gitignore b/packages/linting/.gitignore new file mode 100644 index 0000000000..cd3d225360 --- /dev/null +++ b/packages/linting/.gitignore @@ -0,0 +1 @@ +logs \ No newline at end of file diff --git a/packages/linting/.npmignore b/packages/linting/.npmignore new file mode 100644 index 0000000000..ff80e43b60 --- /dev/null +++ b/packages/linting/.npmignore @@ -0,0 +1,7 @@ +src +test +logs +.DS_Store +.vscode +*.tsbuildinfo +tsconfig.json diff --git a/packages/linting/README.md b/packages/linting/README.md new file mode 100644 index 0000000000..09796e60e9 --- /dev/null +++ b/packages/linting/README.md @@ -0,0 +1,3 @@ +# @definitelytyped/linting + +Linting utilities for DefinitelyTyped. Mostly extracted from [microsoft/dtslint](https://github.com/microsoft/dtslint). diff --git a/packages/linting/package.json b/packages/linting/package.json new file mode 100644 index 0000000000..2cca3ae77a --- /dev/null +++ b/packages/linting/package.json @@ -0,0 +1,43 @@ +{ + "name": "@definitelytyped/linting", + "version": "0.0.84", + "description": "Linting utilities for DefinitelyTyped", + "homepage": "https://github.com/microsoft/DefinitelyTyped-tools/tree/master/packages/linting#readme", + "license": "MIT", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "repository": { + "type": "git", + "url": "git+https://github.com/microsoft/DefinitelyTyped-tools.git", + "directory": "packages/linting" + }, + "scripts": { + "build": "tsc -b" + }, + "bugs": { + "url": "https://github.com/microsoft/DefinitelyTyped-tools/issues" + }, + "dependencies": { + "@definitelytyped/header-parser": "^0.0.84", + "@definitelytyped/typescript-versions": "^0.0.84", + "@definitelytyped/utils": "^0.0.84", + "@types/node": "^14.14.35", + "charm": "^1.0.2", + "fs-extra": "^8.1.0", + "fstream": "^1.0.12", + "npm-registry-client": "^8.6.0", + "strip-json-comments": "^4.0.0", + "tar": "^2.2.2", + "tar-stream": "^2.1.4", + "typescript": "^4.1.0" + }, + "devDependencies": { + "@types/charm": "^1.0.1", + "@types/fs-extra": "^8.1.0", + "@types/tar": "^4.0.3", + "@types/tar-stream": "^2.1.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/linting/src/checkPackageJson.ts b/packages/linting/src/checkPackageJson.ts new file mode 100644 index 0000000000..005d556de6 --- /dev/null +++ b/packages/linting/src/checkPackageJson.ts @@ -0,0 +1,52 @@ +import { makeTypesVersionsForPackageJson } from "@definitelytyped/header-parser"; +import { TypeScriptVersion } from "@definitelytyped/typescript-versions"; +import assert = require("assert"); +import { pathExists } from "fs-extra"; +import { join as joinPaths } from "path"; + +import { readJsonWithComments } from "./readJsonWithComments"; + +export async function checkPackageJson( + dirPath: string, + typesVersions: readonly TypeScriptVersion[], +): Promise { + const pkgJsonPath = joinPaths(dirPath, "package.json"); + const needsTypesVersions = typesVersions.length !== 0; + if (!await pathExists(pkgJsonPath)) { + if (needsTypesVersions) { + throw new Error(`${dirPath}: Must have 'package.json' for "typesVersions"`); + } + return; + } + + const pkgJson = await readJsonWithComments(pkgJsonPath) as Record; + + if ((pkgJson as any).private !== true) { + throw new Error(`${pkgJsonPath} should set \`"private": true\``); + } + + if (needsTypesVersions) { + assert.strictEqual((pkgJson as any).types, "index", `"types" in '${pkgJsonPath}' should be "index".`); + const expected = makeTypesVersionsForPackageJson(typesVersions); + assert.deepEqual((pkgJson as any).typesVersions, expected, + `"typesVersions" in '${pkgJsonPath}' is not set right. Should be: ${JSON.stringify(expected, undefined, 4)}`); + } + + for (const key in pkgJson) { // tslint:disable-line forin + switch (key) { + case "private": + case "dependencies": + case "license": + // "private"/"typesVersions"/"types" checked above, "dependencies" / "license" checked by types-publisher, + break; + case "typesVersions": + case "types": + if (!needsTypesVersions) { + throw new Error(`${pkgJsonPath} doesn't need to set "${key}" when no 'ts3.x' directories exist.`); + } + break; + default: + throw new Error(`${pkgJsonPath} should not include field ${key}`); + } + } +} diff --git a/packages/linting/src/checkTsconfig.ts b/packages/linting/src/checkTsconfig.ts new file mode 100644 index 0000000000..156ad62203 --- /dev/null +++ b/packages/linting/src/checkTsconfig.ts @@ -0,0 +1,98 @@ +import { getCompilerOptions } from "./util"; + +export interface DefinitelyTypedInfo { + /** "../" or "../../" or "../../../". This should use '/' even on windows. */ + readonly relativeBaseUrl: string; +} + +export async function checkTsconfig(dirPath: string, dt: DefinitelyTypedInfo | undefined): Promise { + const options = await getCompilerOptions(dirPath); + + if (dt) { + const { relativeBaseUrl } = dt; + + const mustHave = { + module: "commonjs", + noEmit: true, + forceConsistentCasingInFileNames: true, + baseUrl: relativeBaseUrl, + typeRoots: [relativeBaseUrl], + types: [], + }; + + for (const key of Object.getOwnPropertyNames(mustHave) as (keyof typeof mustHave)[]) { + const expected = mustHave[key]; + const actual = options[key]; + if (!deepEquals(expected, actual)) { + throw new Error(`Expected compilerOptions[${JSON.stringify(key)}] === ${JSON.stringify(expected)}`); + } + } + + for (const key in options) { // tslint:disable-line forin + switch (key) { + case "lib": + case "noImplicitAny": + case "noImplicitThis": + case "strict": + case "strictNullChecks": + case "noUncheckedIndexedAccess": + case "strictFunctionTypes": + case "esModuleInterop": + case "allowSyntheticDefaultImports": + // Allow any value + break; + case "target": + case "paths": + case "jsx": + case "jsxFactory": + case "experimentalDecorators": + case "noUnusedLocals": + case "noUnusedParameters": + // OK. "paths" checked further by types-publisher + break; + default: + if (!(key in mustHave)) { + throw new Error(`Unexpected compiler option ${key}`); + } + } + } + } + + if (!("lib" in options)) { + throw new Error('Must specify "lib", usually to `"lib": ["es6"]` or `"lib": ["es6", "dom"]`.'); + } + + if ("strict" in options) { + if (options.strict !== true) { + throw new Error('When "strict" is present, it must be set to `true`.'); + } + + for (const key of ["noImplicitAny", "noImplicitThis", "strictNullChecks", "strictFunctionTypes"]) { + if (key in options) { + throw new TypeError(`Expected "${key}" to not be set when "strict" is \`true\`.`); + } + } + } else { + for (const key of ["noImplicitAny", "noImplicitThis", "strictNullChecks", "strictFunctionTypes"]) { + if (!(key in options)) { + throw new Error(`Expected \`"${key}": true\` or \`"${key}": false\`.`); + } + } + } + + if (options.types && options.types.length) { + throw new Error( + 'Use `/// ` directives in source files and ensure ' + + 'that the "types" field in your tsconfig is an empty array.'); + } +} + +function deepEquals(expected: unknown, actual: unknown): boolean { + if (expected instanceof Array) { + return actual instanceof Array + && actual.length === expected.length + && expected.every((e, i) => deepEquals(e, actual[i])); + } else { + return expected === actual; + } +} diff --git a/packages/linting/src/checkTslintJson.ts b/packages/linting/src/checkTslintJson.ts new file mode 100644 index 0000000000..9b18364465 --- /dev/null +++ b/packages/linting/src/checkTslintJson.ts @@ -0,0 +1,29 @@ +import { pathExists } from "fs-extra"; +import { join as joinPaths } from "path"; + +import { readJsonWithComments } from "./readJsonWithComments"; + +export async function checkTslintJson(dirPath: string, dt: boolean): Promise { + const configPath = getConfigPath(dirPath); + const shouldExtend = `dtslint/${dt ? "dt" : "dtslint"}.json`; + const validateExtends = (extend: string | string[]) => + extend === shouldExtend || (!dt && Array.isArray(extend) && extend.some(val => val === shouldExtend)); + + if (!await pathExists(configPath)) { + if (dt) { + throw new Error( + `On DefinitelyTyped, must include \`tslint.json\` containing \`{ "extends": "${shouldExtend}" }\`.\n` + + "This was inferred as a DefinitelyTyped package because it contains a `// Type definitions for` header."); + } + return; + } + + const tslintJson = await readJsonWithComments(configPath); + if (!validateExtends(tslintJson.extends)) { + throw new Error(`If 'tslint.json' is present, it should extend "${shouldExtend}"`); + } +} + +function getConfigPath(dirPath: string): string { + return joinPaths(dirPath, "tslint.json"); +} diff --git a/packages/linting/src/getProgram.ts b/packages/linting/src/getProgram.ts new file mode 100644 index 0000000000..83bd3257fe --- /dev/null +++ b/packages/linting/src/getProgram.ts @@ -0,0 +1,35 @@ +import { existsSync, readFileSync } from "fs"; +import { dirname, resolve as resolvePath } from "path"; +import * as TsType from "typescript"; + +const programCache = new WeakMap>(); + +/** Maps a Program to one created with the version specified in `options`. */ +export function getProgram(configFile: string, ts: typeof TsType, versionName: string, lintProgram: TsType.Program): TsType.Program { + let versionToProgram = programCache.get(lintProgram); + if (versionToProgram === undefined) { + versionToProgram = new Map(); + programCache.set(lintProgram, versionToProgram); + } + + let newProgram = versionToProgram.get(versionName); + if (newProgram === undefined) { + newProgram = createProgram(configFile, ts); + versionToProgram.set(versionName, newProgram); + } + return newProgram; +} + +function createProgram(configFile: string, ts: typeof TsType): TsType.Program { + const projectDirectory = dirname(configFile); + const { config } = ts.readConfigFile(configFile, ts.sys.readFile); + const parseConfigHost: TsType.ParseConfigHost = { + fileExists: existsSync, + readDirectory: ts.sys.readDirectory, + readFile: file => readFileSync(file, "utf8"), + useCaseSensitiveFileNames: true, + }; + const parsed = ts.parseJsonConfigFileContent(config, parseConfigHost, resolvePath(projectDirectory), { noEmit: true }); + const host = ts.createCompilerHost(parsed.options, true); + return ts.createProgram(parsed.fileNames, parsed.options, host); +} diff --git a/packages/linting/src/index.ts b/packages/linting/src/index.ts new file mode 100644 index 0000000000..1f2da39ee7 --- /dev/null +++ b/packages/linting/src/index.ts @@ -0,0 +1,4 @@ +export * from "./checkPackageJson"; +export * from "./checkTsconfig"; +export * from "./checkTslintJson"; +export * from "./testDependencies"; diff --git a/packages/linting/src/readJsonWithComments.ts b/packages/linting/src/readJsonWithComments.ts new file mode 100644 index 0000000000..20260d856e --- /dev/null +++ b/packages/linting/src/readJsonWithComments.ts @@ -0,0 +1,7 @@ +import { readFile } from "fs-extra"; +import stripJsonComments from "strip-json-comments"; + +export async function readJsonWithComments(path: string) { + const text = await readFile(path, "utf-8"); + return JSON.parse(stripJsonComments(text)); +} diff --git a/packages/linting/src/testDependencies.ts b/packages/linting/src/testDependencies.ts new file mode 100644 index 0000000000..2fef0a6b4a --- /dev/null +++ b/packages/linting/src/testDependencies.ts @@ -0,0 +1,68 @@ +import { TypeScriptVersion } from "@definitelytyped/typescript-versions"; +import { typeScriptPath } from "@definitelytyped/utils"; +import assert = require("assert"); +import { dirname, join as joinPaths, normalize } from "path"; +import * as TsType from "typescript"; + +import { getProgram } from "./getProgram"; + +export type TsVersion = TypeScriptVersion | "local"; + +export function testDependencies( + version: TsVersion, + dirPath: string, + lintProgram: TsType.Program, + tsLocal: string | undefined, +): string | undefined { + const tsconfigPath = joinPaths(dirPath, "tsconfig.json"); + assert(version !== "local" || tsLocal); + const ts: typeof TsType = require(typeScriptPath(version, tsLocal)); + const program = getProgram(tsconfigPath, ts, version, lintProgram); + const diagnostics = ts.getPreEmitDiagnostics(program).filter(d => !d.file || isExternalDependency(d.file, dirPath, program)); + if (!diagnostics.length) { return undefined; } + + const showDiags = ts.formatDiagnostics(diagnostics, { + getCanonicalFileName: f => f, + getCurrentDirectory: () => dirPath, + getNewLine: () => "\n", + }); + + const message = `Errors in typescript@${version} for external dependencies:\n${showDiags}`; + + // Add an edge-case for someone needing to `npm install` in react when they first edit a DT module which depends on it - #226 + const cannotFindDepsDiags = diagnostics.find(d => d.code === 2307 && d.messageText.toString().includes("Cannot find module")); + if (cannotFindDepsDiags && cannotFindDepsDiags.file) { + const path = cannotFindDepsDiags.file.fileName; + const typesFolder = dirname(path); + + return ` +A module look-up failed, this often occurs when you need to run \`npm install\` on a dependent module before you can lint. + +Before you debug, first try running: + + npm install --prefix ${typesFolder} + +Then re-run. Full error logs are below. + +${message}`; + } else { + return message; + } +} + +function isExternalDependency(file: TsType.SourceFile, dirPath: string, program: TsType.Program): boolean { + return !startsWithDirectory(file.fileName, dirPath) || program.isSourceFileFromExternalLibrary(file); +} + +function normalizePath(file: string) { + // replaces '\' with '/' and forces all DOS drive letters to be upper-case + return normalize(file) + .replace(/\\/g, "/") + .replace(/^[a-z](?=:)/, c => c.toUpperCase()); +} + +function startsWithDirectory(filePath: string, dirPath: string): boolean { + const normalFilePath = normalizePath(filePath); + const normalDirPath = normalizePath(dirPath).replace(/\/$/, ""); + return normalFilePath.startsWith(normalDirPath + "/") || normalFilePath.startsWith(normalDirPath + "\\"); +} diff --git a/packages/linting/src/util.ts b/packages/linting/src/util.ts new file mode 100644 index 0000000000..165bd08c60 --- /dev/null +++ b/packages/linting/src/util.ts @@ -0,0 +1,13 @@ +import { pathExists } from "fs-extra"; +import { join } from "path"; +import * as ts from "typescript"; + +import { readJsonWithComments } from "./readJsonWithComments"; + +export async function getCompilerOptions(dirPath: string): Promise { + const tsconfigPath = join(dirPath, "tsconfig.json"); + if (!await pathExists(tsconfigPath)) { + throw new Error(`Need a 'tsconfig.json' file in ${dirPath}`); + } + return (await readJsonWithComments(tsconfigPath)).compilerOptions; +} diff --git a/packages/linting/tsconfig.json b/packages/linting/tsconfig.json new file mode 100644 index 0000000000..8f04253575 --- /dev/null +++ b/packages/linting/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src"], + "references": [{ "path": "../header-parser" }, { "path": "../typescript-versions" }, { "path": "../utils" }] +} diff --git a/tsconfig.json b/tsconfig.json index 7494cf8de1..f5cbf7f3ad 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,6 +5,7 @@ { "path": "packages/definitions-parser" }, { "path": "packages/dtslint-runner" }, { "path": "packages/header-parser" }, + { "path": "packages/linting" }, { "path": "packages/perf" }, { "path": "packages/publisher" }, { "path": "packages/typescript-versions" }, diff --git a/yarn.lock b/yarn.lock index 956b4e755d..31e145d283 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8787,6 +8787,11 @@ strip-json-comments@^3.1.0, strip-json-comments@^3.1.1: resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006" integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== +strip-json-comments@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-4.0.0.tgz#6fd3a79f1b956905483769b0bf66598b8f87da50" + integrity sha512-LzWcbfMbAsEDTRmhjWIioe8GcDRl0fa35YMXFoJKDdiD/quGFmjJjdgPjFJJNwCMaLyQqFIDqCdHD2V4HfLgYA== + strong-log-transformer@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/strong-log-transformer/-/strong-log-transformer-2.1.0.tgz#0f5ed78d325e0421ac6f90f7f10e691d6ae3ae10"