UNPKG

dts-critic

Version:

Checks a new .d.ts against the Javascript source and tells you what problems it has

1,032 lines (905 loc) 39.1 kB
import yargs = require("yargs"); import headerParser = require("@definitelytyped/header-parser"); import fs = require("fs"); import os = require("os") import cp = require("child_process"); import path = require("path"); import semver = require("semver"); import rimraf = require("rimraf"); import { sync as commandExistsSync } from "command-exists"; import ts from "typescript"; import * as tmp from "tmp"; export enum ErrorKind { /** Declaration is marked as npm in header and has no matching npm package. */ NoMatchingNpmPackage = "NoMatchingNpmPackage", /** Declaration has no npm package matching specified version. */ NoMatchingNpmVersion = "NoMatchingNpmVersion", /** Declaration is not for an npm package, but has a name that conflicts with an existing npm package. */ NonNpmHasMatchingPackage = "NonNpmHasMatchingPackage", /** Declaration needs to use `export =` to match the JavaScript module's behavior. */ NeedsExportEquals = "NeedsExportEquals", /** Declaration has a default export, but JavaScript module does not have a default export. */ NoDefaultExport = "NoDefaultExport", /** JavaScript exports property not found in declaration exports. */ JsPropertyNotInDts = "JsPropertyNotInDts", /** Declaration exports property not found in JavaScript exports. */ DtsPropertyNotInJs = "DtsPropertyNotInJs", /** JavaScript module has signatures, but declaration module does not. */ JsSignatureNotInDts = "JsSignatureNotInDts", /** Declaration module has signatures, but JavaScript module does not. */ DtsSignatureNotInJs = "DtsSignatureNotInJs", } export enum Mode { /** Checks based only on the package name and on the declaration's DefinitelyTyped header. */ NameOnly = "name-only", /** Checks based on the source JavaScript code, in addition to the checks performed in name-only mode. */ Code = "code", } export function parseMode(mode: string): Mode | undefined { switch (mode) { case Mode.NameOnly: return Mode.NameOnly; case Mode.Code: return Mode.Code; } return undefined; } export type CheckOptions = NameOnlyOptions | CodeOptions; export interface NameOnlyOptions { mode: Mode.NameOnly, } export interface CodeOptions { mode: Mode.Code, errors: Map<ExportErrorKind, boolean>, } export type ExportErrorKind = ExportError["kind"]; const defaultOpts: CheckOptions = { mode: Mode.NameOnly }; export function dtsCritic(dtsPath: string, sourcePath?: string, options: CheckOptions = defaultOpts, debug = false): CriticError[] { if (!commandExistsSync("tar")) { throw new Error("You need to have tar installed to run dts-critic, you can get it from https://www.gnu.org/software/tar"); } if (!commandExistsSync("npm")) { throw new Error("You need to have npm installed to run dts-critic, you can get it from https://www.npmjs.com/get-npm"); } const dts = fs.readFileSync(dtsPath, "utf-8"); const header = parseDtHeader(dts); const name = findDtsName(dtsPath); const npmInfo = getNpmInfo(name); if (isNonNpm(header)) { const errors: CriticError[] = []; const nonNpmError = checkNonNpm(name, npmInfo); if (nonNpmError) { errors.push(nonNpmError); } if (sourcePath) { if (options.mode === Mode.Code) { errors.push(...checkSource(name, dtsPath, sourcePath, options.errors, debug)); } } else if (!module.parent) { console.log(`Warning: declaration provided is for a non-npm package. If you want to check the declaration against the JavaScript source code, you must provide a path to the source file.`); } return errors; } else { const npmVersion = checkNpm(name, npmInfo, header); if (typeof npmVersion !== "string") { return [npmVersion]; } if (options.mode === Mode.Code) { let sourceEntry; let packagePath; if (sourcePath) { sourceEntry = sourcePath; } else { const tempDirName = tmp.dirSync({ unsafeCleanup: true }).name packagePath = downloadNpmPackage(name, npmVersion, tempDirName) sourceEntry = require.resolve(path.resolve(packagePath)); } const errors = checkSource(name, dtsPath, sourceEntry, options.errors, debug); if (packagePath) { // Delete the source afterward to avoid running out of space rimraf.sync(packagePath) } return errors; } return []; } } function parseDtHeader(dts: string): headerParser.Header | undefined { try { return headerParser.parseHeaderOrFail(dts); } catch (e) { return undefined; } } function isNonNpm(header: headerParser.Header | undefined): boolean { return !!header && header.nonNpm; } export const defaultErrors: ExportErrorKind[] = [ErrorKind.NeedsExportEquals, ErrorKind.NoDefaultExport]; function main() { const argv = yargs. usage("$0 --dts path-to-d.ts [--js path-to-source] [--mode mode] [--debug]\n\nIf source-folder is not provided, I will look for a matching package on npm."). option("dts", { describe: "Path of declaration file to be critiqued.", type: "string", }). demandOption("dts", "Please provide a path to a d.ts file for me to critique."). option("js", { describe: "Path of JavaScript file to be used as source.", type: "string", }). option("mode", { describe: "Mode defines what checks will be performed.", type: "string", default: Mode.NameOnly, choices: [Mode.NameOnly, Mode.Code], }). option("debug", { describe: "Turn debug logging on.", type: "boolean", default: false, }). help(). argv; let opts; switch (argv.mode) { case Mode.NameOnly: opts = { mode: argv.mode }; break; case Mode.Code: opts = { mode: argv.mode, errors: new Map() }; } const errors = dtsCritic(argv.dts, argv.js, opts, argv.debug); if (errors.length === 0) { console.log("No errors!"); } else { for (const error of errors) { console.log("Error: " + error.message); } } } const npmNotFound = "E404"; export function getNpmInfo(name: string): NpmInfo { const npmName = dtToNpmName(name); const infoResult = cp.spawnSync( "npm", ["info", npmName, "--json", "--silent", "versions", "dist-tags"], { encoding: "utf8" }); const info = JSON.parse(infoResult.stdout || infoResult.stderr); if (info.error !== undefined) { const error = info.error as { code?: string, summary?: string }; if (error.code === npmNotFound) { return { isNpm: false }; } else { throw new Error(`Command 'npm info' for package ${npmName} returned an error. Reason: ${error.summary}.`); } } else if (infoResult.status !== 0) { throw new Error(`Command 'npm info' failed for package ${npmName} with status ${infoResult.status}.`); } return { isNpm: true, versions: info.versions as string[], tags: info["dist-tags"] as { [tag: string]: string | undefined } }; } /** * Checks DefinitelyTyped non-npm package. */ function checkNonNpm(name: string, npmInfo: NpmInfo): NonNpmError | undefined { if (npmInfo.isNpm && !isExistingSquatter(name)) { return { kind: ErrorKind.NonNpmHasMatchingPackage, message: `The non-npm package '${name}' conflicts with the existing npm package '${dtToNpmName(name)}'. Try adding -browser to the end of the name to get ${name}-browser ` }; } return undefined; } /** * Checks DefinitelyTyped npm package. * If all checks are successful, returns the npm version that matches the header. */ function checkNpm(name: string, npmInfo: NpmInfo, header: headerParser.Header | undefined): NpmError | string { if (!npmInfo.isNpm) { return { kind: ErrorKind.NoMatchingNpmPackage, message: `Declaration file must have a matching npm package. To resolve this error, either: 1. Change the name to match an npm package. 2. Add a Definitely Typed header with the first line // Type definitions for non-npm package ${name}-browser Add -browser to the end of your name to make sure it doesn't conflict with existing npm packages.` }; } const target = getHeaderVersion(header); const npmVersion = getMatchingVersion(target, npmInfo); if (!npmVersion) { const versions = npmInfo.versions; const verstring = versions.join(", "); const lateststring = versions[versions.length - 1]; const headerstring = target || "NO HEADER VERSION FOUND"; return { kind: ErrorKind.NoMatchingNpmVersion, message: `The types for '${name}' must match a version that exists on npm. You should copy the major and minor version from the package on npm. To resolve this error, change the version in the header, ${headerstring}, to match one on npm: ${verstring}. For example, if you're trying to match the latest version, use ${lateststring}.`, }; } return npmVersion; } function getHeaderVersion(header: headerParser.Header | undefined): string | undefined { if (!header) { return undefined; } if (header.libraryMajorVersion === 0 && header.libraryMinorVersion === 0) { return undefined; } return `${header.libraryMajorVersion}.${header.libraryMinorVersion}`; } /** * Finds an npm version that matches the target version specified, if it exists. * If the target version is undefined, returns the latest version. * The npm version returned might be a prerelease version. */ function getMatchingVersion(target: string | undefined, npmInfo: Npm): string | undefined { const versions = npmInfo.versions; if (target) { const matchingVersion = semver.maxSatisfying(versions, target, { includePrerelease: true }); return matchingVersion || undefined; } if (npmInfo.tags.latest) { return npmInfo.tags.latest; } return versions[versions.length - 1]; } /** * If dtsName is 'index' (as with DT) then look to the parent directory for the name. */ export function findDtsName(dtsPath: string) { const resolved = path.resolve(dtsPath); const baseName = path.basename(resolved, ".d.ts"); if (baseName && baseName !== "index") { return baseName; } return path.basename(path.dirname(resolved)); } /** Default path to store packages downloaded from npm. */ const sourceDir = path.resolve(path.join(__dirname, "..", "sources")); /** Returns path of downloaded npm package. */ function downloadNpmPackage(name: string, version: string, outDir: string): string { const npmName = dtToNpmName(name); const fullName = `${npmName}@${version}`; const cpOpts = { encoding: "utf8", maxBuffer: 100 * 1024 * 1024 } as const; const npmPack = cp.execFileSync("npm", ["pack", fullName, "--json", "--silent"], cpOpts).trim(); const tarballName = npmPack.endsWith(".tgz") ? npmPack : JSON.parse(npmPack)[0].filename as string; const outPath = path.join(outDir, name); initDir(outPath); const args = os.platform() === "darwin" ? ["-xz", "-f", tarballName, "-C", outPath] : ["-xz", "-f", tarballName, "-C", outPath, "--warning=none"]; cp.execFileSync("tar", args, cpOpts); fs.unlinkSync(tarballName); return path.join(outPath, getPackageDir(outPath)); } function getPackageDir(outPath: string): string { const dirs = fs.readdirSync(outPath, { encoding: "utf8", withFileTypes: true }); for (const dirent of dirs) { if (dirent.isDirectory()) { return dirent.name; } } return "package"; } function initDir(dirPath: string): void { if (!fs.existsSync(dirPath)) { fs.mkdirSync(dirPath, { recursive: true }); } } export function checkSource( name: string, dtsPath: string, srcPath: string, enabledErrors: Map<ExportErrorKind, boolean>, debug: boolean): ExportError[] { const diagnostics = checkExports(name, dtsPath, srcPath); if (debug) { console.log(formatDebug(name, diagnostics)); } return diagnostics.errors.filter(err => enabledErrors.get(err.kind) ?? defaultErrors.includes(err.kind)); } function formatDebug(name: string, diagnostics: ExportsDiagnostics): string { const lines: string[] = []; lines.push(`\tDiagnostics for package ${name}.`); lines.push("\tInferred source module structure:"); if (isSuccess(diagnostics.jsExportKind)) { lines.push(diagnostics.jsExportKind.result); } else { lines.push(`Could not infer type of JavaScript exports. Reason: ${diagnostics.jsExportKind.reason}`); } lines.push("\tInferred source export type:"); if (isSuccess(diagnostics.jsExportType)) { lines.push(formatType(diagnostics.jsExportType.result)); } else { lines.push(`Could not infer type of JavaScript exports. Reason: ${diagnostics.jsExportType.reason}`); } if (diagnostics.dtsExportKind) { lines.push("\tInferred declaration module structure:"); if (isSuccess(diagnostics.dtsExportKind)) { lines.push(diagnostics.dtsExportKind.result); } else { lines.push(`Could not infer type of declaration exports. Reason: ${diagnostics.dtsExportKind.reason}`); } } if (diagnostics.dtsExportType) { lines.push("\tInferred declaration export type:"); if (isSuccess(diagnostics.dtsExportType)) { lines.push(formatType(diagnostics.dtsExportType.result)); } else { lines.push(`Could not infer type of declaration exports. Reason: ${diagnostics.dtsExportType.reason}`); } } return lines.join("\n"); } function formatType(type: ts.Type): string { const lines: string[] = []; //@ts-ignore property `checker` of `ts.Type` is marked internal. The alternative is to have a TypeChecker parameter. const checker: ts.TypeChecker = type.checker; const properties = type.getProperties(); if (properties.length > 0) { lines.push("Type's properties:"); lines.push(...properties.map(p => p.getName())); } const signatures = type.getConstructSignatures().concat(type.getCallSignatures()); if (signatures.length > 0) { lines.push("Type's signatures:"); lines.push(...signatures.map(s => checker.signatureToString(s))); } lines.push(`Type string: ${checker.typeToString(type)}`); return lines.join("\n"); } const exportEqualsLink = "https://www.typescriptlang.org/docs/handbook/modules.html#export--and-import--require"; /** * Checks exports of a declaration file against its JavaScript source. */ function checkExports(name: string, dtsPath: string, sourcePath: string): ExportsDiagnostics { const tscOpts = { allowJs: true, }; const jsProgram = ts.createProgram([sourcePath], tscOpts); const jsFileNode = jsProgram.getSourceFile(sourcePath); if (!jsFileNode) { throw new Error(`TS compiler could not find source file ${sourcePath}.`); } const jsChecker = jsProgram.getTypeChecker(); const errors: ExportError[] = []; const sourceDiagnostics = inspectJs(jsFileNode, jsChecker, name); const dtsDiagnostics = inspectDts(dtsPath, name); if (isSuccess(sourceDiagnostics.exportEquals) && sourceDiagnostics.exportEquals.result.judgement === ExportEqualsJudgement.Required && isSuccess(dtsDiagnostics.exportKind) && dtsDiagnostics.exportKind.result !== DtsExportKind.ExportEquals) { const error = { kind: ErrorKind.NeedsExportEquals, message: `The declaration doesn't match the JavaScript module '${name}'. Reason: The declaration should use 'export =' syntax because the JavaScript source uses 'module.exports =' syntax and ${sourceDiagnostics.exportEquals.result.reason}. To learn more about 'export =' syntax, see ${exportEqualsLink}.`, } as const; errors.push(error); } const compatibility = exportTypesCompatibility( name, sourceDiagnostics.exportType, dtsDiagnostics.exportType, dtsDiagnostics.exportKind); if (isSuccess(compatibility)) { errors.push(...compatibility.result); } if (dtsDiagnostics.defaultExport && !sourceDiagnostics.exportsDefault) { errors.push({ kind: ErrorKind.NoDefaultExport, position: dtsDiagnostics.defaultExport, message: `The declaration doesn't match the JavaScript module '${name}'. Reason: The declaration specifies 'export default' but the JavaScript source does not mention 'default' anywhere. The most common way to resolve this error is to use 'export =' syntax instead of 'export default'. To learn more about 'export =' syntax, see ${exportEqualsLink}.`, }); } return { jsExportKind: sourceDiagnostics.exportKind, jsExportType: sourceDiagnostics.exportType, dtsExportKind: dtsDiagnostics.exportKind, dtsExportType: dtsDiagnostics.exportType, errors }; } function inspectJs(sourceFile: ts.SourceFile, checker: ts.TypeChecker, packageName: string): JsExportsInfo { const exportKind = getJsExportKind(sourceFile); const exportType = getJSExportType(sourceFile, checker, exportKind); const exportsDefault = sourceExportsDefault(sourceFile, packageName); let exportEquals; if (isSuccess(exportType) && isSuccess(exportKind) && exportKind.result === JsExportKind.CommonJs) { exportEquals = moduleTypeNeedsExportEquals(exportType.result, checker); } else { exportEquals = mergeErrors(exportType, exportKind); } return { exportKind, exportType, exportEquals, exportsDefault }; } function getJsExportKind(sourceFile: ts.SourceFile): InferenceResult<JsExportKind> { // @ts-ignore property `commonJsModuleIndicator` of `ts.SourceFile` is marked internal. if (sourceFile.commonJsModuleIndicator) { return inferenceSuccess(JsExportKind.CommonJs); } // @ts-ignore property `externalModuleIndicator` of `ts.SourceFile` is marked internal. if (sourceFile.externalModuleIndicator) { return inferenceSuccess(JsExportKind.ES6); } return inferenceError("Could not infer export kind of source file."); } function getJSExportType( sourceFile: ts.SourceFile, checker: ts.TypeChecker, exportKind: InferenceResult<JsExportKind>): InferenceResult<ts.Type> { if (isSuccess(exportKind)) { switch (exportKind.result) { case JsExportKind.CommonJs: { checker.getSymbolAtLocation(sourceFile); // TODO: get symbol in a safer way? //@ts-ignore property `symbol` of `ts.Node` is marked internal. const fileSymbol: ts.Symbol | undefined = sourceFile.symbol; if (!fileSymbol) { return inferenceError(`TS compiler could not find symbol for file node '${sourceFile.fileName}'.`); } const exportType = checker.getTypeOfSymbolAtLocation(fileSymbol, sourceFile); return inferenceSuccess(exportType); } case JsExportKind.ES6: { const fileSymbol = checker.getSymbolAtLocation(sourceFile); if (!fileSymbol) { return inferenceError(`TS compiler could not find symbol for file node '${sourceFile.fileName}'.`); } const exportType = checker.getTypeOfSymbolAtLocation(fileSymbol, sourceFile); return inferenceSuccess(exportType); } } } return inferenceError(`Could not infer type of exports because exports kind is undefined.`); } /** * Decide if a JavaScript source module could have a default export. */ function sourceExportsDefault(sourceFile: ts.SourceFile, name: string): boolean { const src = sourceFile.getFullText(sourceFile); return isRealExportDefault(name) || src.indexOf("default") > -1 || src.indexOf("__esModule") > -1 || src.indexOf("react-side-effect") > -1 || src.indexOf("@flow") > -1 || src.indexOf("module.exports = require") > -1; } function moduleTypeNeedsExportEquals(type: ts.Type, checker: ts.TypeChecker): InferenceResult<ExportEqualsDiagnostics> { if (isBadType(type)) { return inferenceError(`Inferred type '${checker.typeToString(type)}' is not good enough to be analyzed.`); } const isObject = type.getFlags() & ts.TypeFlags.Object; // @ts-ignore property `isArrayLikeType` of `ts.TypeChecker` is marked internal. if (isObject && !hasSignatures(type) && !checker.isArrayLikeType(type)) { const judgement = ExportEqualsJudgement.NotRequired; const reason = "'module.exports' is an object which is neither a function, class, or array"; return inferenceSuccess({ judgement, reason }); } if (hasSignatures(type)) { const judgement = ExportEqualsJudgement.Required; const reason = "'module.exports' can be called or constructed"; return inferenceSuccess({ judgement, reason }); } const primitive = ts.TypeFlags.Boolean | ts.TypeFlags.String | ts.TypeFlags.Number; if (type.getFlags() & primitive) { const judgement = ExportEqualsJudgement.Required; const reason = `'module.exports' has primitive type ${checker.typeToString(type)}`; return inferenceSuccess({ judgement, reason }); } // @ts-ignore property `isArrayLikeType` of `ts.TypeChecker` is marked internal. if (checker.isArrayLikeType(type)) { const judgement = ExportEqualsJudgement.Required; const reason = `'module.exports' has array-like type ${checker.typeToString(type)}`; return inferenceSuccess({ judgement, reason }); } return inferenceError(`Could not analyze type '${checker.typeToString(type)}'.`); } function hasSignatures(type: ts.Type): boolean { return type.getCallSignatures().length > 0 || type.getConstructSignatures().length > 0; } function inspectDts(dtsPath: string, name: string): DtsExportDiagnostics { dtsPath = path.resolve(dtsPath); const program = createDtProgram(dtsPath); const sourceFile = program.getSourceFile(path.resolve(dtsPath)); if (!sourceFile) { throw new Error(`TS compiler could not find source file '${dtsPath}'.`); } const checker = program.getTypeChecker(); const symbolResult = getDtsModuleSymbol(sourceFile, checker, name); const exportKindResult = getDtsExportKind(sourceFile); const exportType = getDtsExportType(sourceFile, checker, symbolResult, exportKindResult); const defaultExport = getDtsDefaultExport(sourceFile, exportType); return { exportKind: exportKindResult, exportType, defaultExport }; } function createDtProgram(dtsPath: string): ts.Program { const dtsDir = path.dirname(dtsPath); const configPath = path.join(dtsDir, "tsconfig.json"); const { config } = ts.readConfigFile(configPath, p => fs.readFileSync(p, { encoding: "utf8" })); const parseConfigHost: ts.ParseConfigHost = { fileExists: fs.existsSync, readDirectory: ts.sys.readDirectory, readFile: file => fs.readFileSync(file, { encoding: "utf8" }), useCaseSensitiveFileNames: true, }; const parsed = ts.parseJsonConfigFileContent(config, parseConfigHost, path.resolve(dtsDir)); const host = ts.createCompilerHost(parsed.options, true); return ts.createProgram([path.resolve(dtsPath)], parsed.options, host); } function getDtsModuleSymbol(sourceFile: ts.SourceFile, checker: ts.TypeChecker, name: string): InferenceResult<ts.Symbol> { if (matches(sourceFile, node => ts.isModuleDeclaration(node))) { const npmName = dtToNpmName(name); const moduleSymbol = checker.getAmbientModules().find(symbol => symbol.getName() === `"${npmName}"`); if (moduleSymbol) { return inferenceSuccess(moduleSymbol); } } const fileSymbol = checker.getSymbolAtLocation(sourceFile); if (fileSymbol && (fileSymbol.getFlags() & ts.SymbolFlags.ValueModule)) { return inferenceSuccess(fileSymbol); } return inferenceError(`Could not find module symbol for source file node.`); } function getDtsExportKind(sourceFile: ts.SourceFile): InferenceResult<DtsExportKind> { if (matches(sourceFile, isExportEquals)) { return inferenceSuccess(DtsExportKind.ExportEquals); } if (matches(sourceFile, isExportConstruct)) { return inferenceSuccess(DtsExportKind.ES6Like); } return inferenceError("Could not infer export kind of declaration file."); } const exportEqualsSymbolName = "export="; function getDtsExportType( sourceFile: ts.SourceFile, checker: ts.TypeChecker, symbolResult: InferenceResult<ts.Symbol>, exportKindResult: InferenceResult<DtsExportKind>): InferenceResult<ts.Type> { if (isSuccess(symbolResult) && isSuccess(exportKindResult)) { const symbol = symbolResult.result; const exportKind = exportKindResult.result; switch (exportKind) { case (DtsExportKind.ExportEquals): { const exportSymbol = symbol.exports!.get(exportEqualsSymbolName as ts.__String); if (!exportSymbol) { return inferenceError(`TS compiler could not find \`export=\` symbol.`); } const exportType = checker.getTypeOfSymbolAtLocation(exportSymbol, sourceFile); return inferenceSuccess(exportType); } case (DtsExportKind.ES6Like): { const exportType = checker.getTypeOfSymbolAtLocation(symbol, sourceFile); return inferenceSuccess(exportType); } } } return mergeErrors(symbolResult, exportKindResult); } /** * Returns the position of the default export, if it exists. */ function getDtsDefaultExport(sourceFile: ts.SourceFile, moduleType: InferenceResult<ts.Type>): Position | undefined { if (isError(moduleType)) { const src = sourceFile.getFullText(sourceFile); const exportDefault = src.indexOf("export default"); if (exportDefault > -1 && src.indexOf("export =") === -1 && !/declare module ['"]/.test(src)) { return { start: exportDefault, length: "export default".length, }; } return undefined; } const exportDefault = moduleType.result.getProperty("default"); if (exportDefault) { return { start: exportDefault.declarations[0].getStart(), length: exportDefault.declarations[0].getWidth(), }; } return undefined; } const ignoredProperties = ["__esModule", "prototype", "default", "F", "G", "S", "P", "B", "W", "U", "R"]; function ignoreProperty(property: ts.Symbol): boolean { const name = property.getName(); return name.startsWith("_") || ignoredProperties.includes(name); } /* * Given the inferred type of the exports of both source and declaration, we make the following checks: * 1. If source type has call or construct signatures, then declaration type should also have call or construct signatures. * 2. If declaration type has call or construct signatures, then source type should also have call or construct signatures. * 3. If source type has a property named "foo", then declaration type should also have a property named "foo". * 4. If declaration type has a property named "foo", then source type should also have a property named "foo". * Checks (2) and (4) don't work well in practice and should not be used for linting/verification purposes, because * most of the times the error originates because the inferred type of the JavaScript source has missing information. * Those checks are useful for finding examples where JavaScript type inference could be improved. */ function exportTypesCompatibility( name: string, sourceType: InferenceResult<ts.Type>, dtsType: InferenceResult<ts.Type>, dtsExportKind: InferenceResult<DtsExportKind>): InferenceResult<MissingExport[]> { if (isError(sourceType)) { return inferenceError("Could not get type of exports of source module."); } if (isError(dtsType)) { return inferenceError("Could not get type of exports of declaration module."); } if (isBadType(sourceType.result)) { return inferenceError("Could not infer meaningful type of exports of source module."); } if (isBadType(dtsType.result)) { return inferenceError("Could not infer meaningful type of exports of declaration module."); } const errors: MissingExport[] = []; if (hasSignatures(sourceType.result) && !hasSignatures(dtsType.result)) { if (isSuccess(dtsExportKind) && dtsExportKind.result === DtsExportKind.ExportEquals) { errors.push({ kind: ErrorKind.JsSignatureNotInDts, message: `The declaration doesn't match the JavaScript module '${name}'. Reason: The JavaScript module can be called or constructed, but the declaration module cannot.`, }); } else { errors.push({ kind: ErrorKind.JsSignatureNotInDts, message: `The declaration doesn't match the JavaScript module '${name}'. Reason: The JavaScript module can be called or constructed, but the declaration module cannot. The most common way to resolve this error is to use 'export =' syntax. To learn more about 'export =' syntax, see ${exportEqualsLink}.`, }); } } if (hasSignatures(dtsType.result) && !hasSignatures(sourceType.result)) { errors.push({ kind: ErrorKind.DtsSignatureNotInJs, message: `The declaration doesn't match the JavaScript module '${name}'. Reason: The declaration module can be called or constructed, but the JavaScript module cannot.`, }); } const sourceProperties = sourceType.result.getProperties(); const dtsProperties = dtsType.result.getProperties(); for (const sourceProperty of sourceProperties) { // TODO: check `prototype` properties. if (ignoreProperty(sourceProperty)) continue; if (!dtsProperties.find(s => s.getName() === sourceProperty.getName())) { errors.push({ kind: ErrorKind.JsPropertyNotInDts, message: `The declaration doesn't match the JavaScript module '${name}'. Reason: The JavaScript module exports a property named '${sourceProperty.getName()}', which is missing from the declaration module.` }); } } for (const dtsProperty of dtsProperties) { // TODO: check `prototype` properties. if (ignoreProperty(dtsProperty)) continue; if (!sourceProperties.find(s => s.getName() === dtsProperty.getName())) { const error: MissingExport = { kind: ErrorKind.DtsPropertyNotInJs, message: `The declaration doesn't match the JavaScript module '${name}'. Reason: The declaration module exports a property named '${dtsProperty.getName()}', which is missing from the JavaScript module.` }; const declaration = dtsProperty.declarations && dtsProperty.declarations.length > 0 ? dtsProperty.declarations[0] : undefined; if (declaration) { error.position = { start: declaration.getStart(), length: declaration.getWidth(), }; } errors.push(error); } } return inferenceSuccess(errors); } function isBadType(type: ts.Type): boolean { return !!(type.getFlags() & (ts.TypeFlags.Any | ts.TypeFlags.Unknown | ts.TypeFlags.Undefined | ts.TypeFlags.Null)); } function isExportEquals(node: ts.Node): boolean { return ts.isExportAssignment(node) && !!node.isExportEquals; } function isExportConstruct(node: ts.Node): boolean { return ts.isExportAssignment(node) || ts.isExportDeclaration(node) || hasExportModifier(node); } function hasExportModifier(node: ts.Node): boolean { if (node.modifiers) { return node.modifiers.some(modifier => modifier.kind === ts.SyntaxKind.ExportKeyword); } return false; } function matches(srcFile: ts.SourceFile, predicate: (n: ts.Node) => boolean): boolean { function matchesNode(node: ts.Node): boolean { if (predicate(node)) return true; const children = node.getChildren(srcFile); for (const child of children) { if (matchesNode(child)) return true; } return false; } return matchesNode(srcFile); } function isExistingSquatter(name: string) { return name === "atom" || name === "ember__string" || name === "fancybox" || name === "jsqrcode" || name === "node" || name === "geojson" || name === "titanium"; } function isRealExportDefault(name: string) { return name.indexOf("react-native") > -1 || name === "ember-feature-flags" || name === "material-ui-datatables"; } /** * Converts a package name from the name used in DT repository to the name used in npm. * @param baseName DT name of a package */ export function dtToNpmName(baseName: string) { if (/__/.test(baseName)) { return "@" + baseName.replace("__", "/"); } return baseName; } /** * @param error case-insensitive name of the error */ export function parseExportErrorKind(error: string): ExportErrorKind | undefined { error = error.toLowerCase(); switch (error) { case "needsexportequals": return ErrorKind.NeedsExportEquals; case "nodefaultexport": return ErrorKind.NoDefaultExport; case "jspropertynotindts": return ErrorKind.JsPropertyNotInDts; case "dtspropertynotinjs": return ErrorKind.DtsPropertyNotInJs; case "jssignaturenotindts": return ErrorKind.JsSignatureNotInDts; case "dtssignaturenotinjs": return ErrorKind.DtsSignatureNotInJs; } return undefined; } export interface CriticError { kind: ErrorKind, message: string, position?: Position, } interface NpmError extends CriticError { kind: ErrorKind.NoMatchingNpmPackage | ErrorKind.NoMatchingNpmVersion, } interface NonNpmError extends CriticError { kind: ErrorKind.NonNpmHasMatchingPackage, } interface ExportEqualsError extends CriticError { kind: ErrorKind.NeedsExportEquals, } interface DefaultExportError extends CriticError { kind: ErrorKind.NoDefaultExport, position: Position, } interface MissingExport extends CriticError { kind: ErrorKind.JsPropertyNotInDts| ErrorKind.DtsPropertyNotInJs | ErrorKind.JsSignatureNotInDts | ErrorKind.DtsSignatureNotInJs, } interface Position { start: number, length: number, } interface ExportsDiagnostics { jsExportKind: InferenceResult<JsExportKind>, jsExportType: InferenceResult<ts.Type>, dtsExportKind: InferenceResult<DtsExportKind>, dtsExportType: InferenceResult<ts.Type>, errors: ExportError[], } type ExportError = ExportEqualsError | DefaultExportError | MissingExport; interface JsExportsInfo { exportKind: InferenceResult<JsExportKind>, exportType: InferenceResult<ts.Type>, exportEquals: InferenceResult<ExportEqualsDiagnostics>, exportsDefault: boolean, } enum JsExportKind { CommonJs = "CommonJs", ES6 = "ES6", }; interface ExportEqualsDiagnostics { judgement: ExportEqualsJudgement; reason: string; } enum ExportEqualsJudgement { Required = "Required", NotRequired = "Not required", } enum DtsExportKind { ExportEquals = "export =", ES6Like = "ES6-like", } interface DtsExportDiagnostics { exportKind: InferenceResult<DtsExportKind>, exportType: InferenceResult<ts.Type>, defaultExport?: Position, } type NpmInfo = NonNpm | Npm; interface NonNpm { isNpm: false } interface Npm { isNpm: true, versions: string[], tags: { [tag: string]: string | undefined }, } type InferenceResult<T> = InferenceError | InferenceSuccess<T>; enum InferenceResultKind { Error, Success, } interface InferenceError { kind: InferenceResultKind.Error; reason?: string, } interface InferenceSuccess<T> { kind: InferenceResultKind.Success; result: T; } function inferenceError(reason?: string): InferenceError { return { kind: InferenceResultKind.Error, reason }; } function inferenceSuccess<T>(result: T): InferenceSuccess<T> { return { kind: InferenceResultKind.Success, result }; } function isSuccess<T>(inference: InferenceResult<T>): inference is InferenceSuccess<T> { return inference.kind === InferenceResultKind.Success; } function isError<T>(inference: InferenceResult<T>): inference is InferenceError { return inference.kind === InferenceResultKind.Error; } function mergeErrors(...results: (InferenceResult<unknown> | string)[]): InferenceError { const reasons: string[] = []; for (const result of results) { if (typeof result === "string") { reasons.push(result); } else if (isError(result) && result.reason) { reasons.push(result.reason); } } return inferenceError(reasons.join(" ")); } if (!module.parent) { main(); }