UNPKG

@definitelytyped/dts-critic

Version:

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

619 lines (616 loc) 27.5 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.defaultErrors = exports.ErrorKind = void 0; exports.dtsCritic = dtsCritic; exports.findDtsName = findDtsName; exports.checkSource = checkSource; exports.dtToNpmName = dtToNpmName; exports.parseExportErrorKind = parseExportErrorKind; const yargs = require("yargs"); const headerParser = require("@definitelytyped/header-parser"); const fs = require("fs"); const path = require("path"); const typescript_1 = __importDefault(require("typescript")); var ErrorKind; (function (ErrorKind) { /** Declaration needs to use `export =` to match the JavaScript module's behavior. */ ErrorKind["NeedsExportEquals"] = "NeedsExportEquals"; /** Declaration has a default export, but JavaScript module does not have a default export. */ ErrorKind["NoDefaultExport"] = "NoDefaultExport"; /** JavaScript exports property not found in declaration exports. */ ErrorKind["JsPropertyNotInDts"] = "JsPropertyNotInDts"; /** Declaration exports property not found in JavaScript exports. */ ErrorKind["DtsPropertyNotInJs"] = "DtsPropertyNotInJs"; /** JavaScript module has signatures, but declaration module does not. */ ErrorKind["JsSignatureNotInDts"] = "JsSignatureNotInDts"; /** Declaration module has signatures, but JavaScript module does not. */ ErrorKind["DtsSignatureNotInJs"] = "DtsSignatureNotInJs"; })(ErrorKind || (exports.ErrorKind = ErrorKind = {})); function dtsCritic(dtsPath, sourcePath, options = { errors: new Map() }, debug = false) { if (!sourcePath && require.main !== module) { // dtslint will issue an error. return []; } const name = findDtsName(dtsPath); const packageJsonPath = path.join(path.dirname(path.resolve(dtsPath)), "package.json"); const header = parsePackageJson(name, JSON.parse(fs.readFileSync(packageJsonPath, "utf-8")), path.dirname(dtsPath)); if (header === undefined) { return []; } else if (header.nonNpm) { const errors = []; if (sourcePath) { errors.push(...checkSource(name, dtsPath, sourcePath, options.errors, debug)); } else if (require.main === module) { 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 { let sourceEntry; if (!fs.statSync(sourcePath).isDirectory()) { sourceEntry = sourcePath; } else { sourceEntry = require.resolve(path.resolve(sourcePath)); } return checkSource(name, dtsPath, sourceEntry, options.errors, debug); } } function parsePackageJson(packageName, packageJson, dirPath) { const result = headerParser.validatePackageJson(packageName, packageJson, headerParser.getTypesVersions(dirPath)); if (Array.isArray(result)) console.log(result.join("\n")); return Array.isArray(result) ? undefined : result; } exports.defaultErrors = [ErrorKind.NeedsExportEquals, ErrorKind.NoDefaultExport]; function main() { const argv = yargs .usage("$0 --dts path-to-d.ts --js path-to-source [--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", }) .demandOption("js", "Please provide a path to a JavaScript file for me to compare the d.ts against.") .option("debug", { describe: "Turn debug logging on.", type: "boolean", default: false, }) .help() .parseSync(); const 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); } } } /** * If dtsName is 'index' (as with DT) then look to the parent directory for the name. */ function findDtsName(dtsPath) { const resolved = path.resolve(dtsPath); const baseName = path.basename(resolved, ".d.ts"); if (baseName && baseName !== "index") { return baseName; } return path.basename(path.dirname(resolved)); } function checkSource(name, dtsPath, srcPath, enabledErrors, debug) { const diagnostics = checkExports(name, dtsPath, srcPath); if (debug) { console.log(formatDebug(name, diagnostics)); } return diagnostics.errors.filter((err) => enabledErrors.get(err.kind) ?? exports.defaultErrors.includes(err.kind)); } function formatDebug(name, diagnostics) { const lines = []; 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) { const lines = []; //@ts-ignore property `checker` of `ts.Type` is marked internal. The alternative is to have a TypeChecker parameter. const checker = 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, dtsPath, sourcePath) { const tscOpts = { allowJs: true, }; const jsProgram = typescript_1.default.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 = []; 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}.`, }; 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, checker, packageName) { 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-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, checker, exportKind) { 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 = 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, name) { 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, checker) { if (isBadType(type)) { return inferenceError(`Inferred type '${checker.typeToString(type)}' is not good enough to be analyzed.`); } const isObject = type.getFlags() & typescript_1.default.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 = typescript_1.default.TypeFlags.Boolean | typescript_1.default.TypeFlags.String | typescript_1.default.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) { return type.getCallSignatures().length > 0 || type.getConstructSignatures().length > 0; } function inspectDts(dtsPath, name) { 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) { const dtsDir = path.dirname(dtsPath); const configPath = path.join(dtsDir, "tsconfig.json"); const { config } = typescript_1.default.readConfigFile(configPath, (p) => fs.readFileSync(p, { encoding: "utf8" })); const parseConfigHost = { fileExists: fs.existsSync, readDirectory: typescript_1.default.sys.readDirectory, readFile: (file) => fs.readFileSync(file, { encoding: "utf8" }), useCaseSensitiveFileNames: true, }; const parsed = typescript_1.default.parseJsonConfigFileContent(config, parseConfigHost, path.resolve(dtsDir)); const host = typescript_1.default.createCompilerHost(parsed.options, true); return typescript_1.default.createProgram([path.resolve(dtsPath)], parsed.options, host); } function getDtsModuleSymbol(sourceFile, checker, name) { if (matches(sourceFile, (node) => typescript_1.default.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() & typescript_1.default.SymbolFlags.ValueModule) { return inferenceSuccess(fileSymbol); } return inferenceError(`Could not find module symbol for source file node.`); } function getDtsExportKind(sourceFile) { 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, checker, symbolResult, exportKindResult) { if (isSuccess(symbolResult) && isSuccess(exportKindResult)) { const symbol = symbolResult.result; const exportKind = exportKindResult.result; switch (exportKind) { case DtsExportKind.ExportEquals: { const exportSymbol = symbol.exports.get(exportEqualsSymbolName); 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, moduleType) { 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?.declarations) { 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) { 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, sourceType, dtsType, dtsExportKind) { 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 = []; 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 = { 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) { return !!(type.getFlags() & (typescript_1.default.TypeFlags.Any | typescript_1.default.TypeFlags.Unknown | typescript_1.default.TypeFlags.Undefined | typescript_1.default.TypeFlags.Null)); } function isExportEquals(node) { return typescript_1.default.isExportAssignment(node) && !!node.isExportEquals; } function isExportConstruct(node) { return typescript_1.default.isExportAssignment(node) || typescript_1.default.isExportDeclaration(node) || hasExportModifier(node); } function hasExportModifier(node) { if (typescript_1.default.canHaveModifiers(node)) { return !!typescript_1.default.getModifiers(node)?.some((modifier) => modifier.kind === typescript_1.default.SyntaxKind.ExportKeyword); } return false; } function matches(srcFile, predicate) { function matchesNode(node) { 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 isRealExportDefault(name) { 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 */ function dtToNpmName(baseName) { if (/__/.test(baseName)) { return "@" + baseName.replace("__", "/"); } return baseName; } /** * @param error case-insensitive name of the error */ function parseExportErrorKind(error) { 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; } var JsExportKind; (function (JsExportKind) { JsExportKind["CommonJs"] = "CommonJs"; JsExportKind["ES6"] = "ES6"; })(JsExportKind || (JsExportKind = {})); var ExportEqualsJudgement; (function (ExportEqualsJudgement) { ExportEqualsJudgement["Required"] = "Required"; ExportEqualsJudgement["NotRequired"] = "Not required"; })(ExportEqualsJudgement || (ExportEqualsJudgement = {})); var DtsExportKind; (function (DtsExportKind) { DtsExportKind["ExportEquals"] = "export ="; DtsExportKind["ES6Like"] = "ES6-like"; })(DtsExportKind || (DtsExportKind = {})); var InferenceResultKind; (function (InferenceResultKind) { InferenceResultKind[InferenceResultKind["Error"] = 0] = "Error"; InferenceResultKind[InferenceResultKind["Success"] = 1] = "Success"; })(InferenceResultKind || (InferenceResultKind = {})); function inferenceError(reason) { return { kind: InferenceResultKind.Error, reason }; } function inferenceSuccess(result) { return { kind: InferenceResultKind.Success, result }; } function isSuccess(inference) { return inference.kind === InferenceResultKind.Success; } function isError(inference) { return inference.kind === InferenceResultKind.Error; } function mergeErrors(...results) { const reasons = []; 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 (require.main === module) { main(); } //# sourceMappingURL=index.js.map