diff --git a/packages/dtslint/src/index-helpers.ts b/packages/dtslint/src/index-helpers.ts
new file mode 100644
index 0000000000..3927306e6c
--- /dev/null
+++ b/packages/dtslint/src/index-helpers.ts
@@ -0,0 +1,120 @@
+import { deepEquals } from "@definitelytyped/utils";
+import fs from "fs";
+import { basename, dirname, join as joinPaths } from "path";
+
+/** @internal */
+export function findDTRoot(dirPath: string) {
+ let path = dirPath;
+ while (basename(path) !== "types" && dirname(path) !== "." && dirname(path) !== "/" && dirname(path) !== path) {
+ path = dirname(path);
+ }
+ return dirname(path);
+}
+
+/** @internal */
+export function assertPathIsInDefinitelyTyped(dirPath: string, dtRoot: string): void {
+ // TODO: It's not clear whether this assertion makes sense, and it's broken on Azure Pipelines (perhaps because DT isn't cloned into DefinitelyTyped)
+ // Re-enable it later if it makes sense.
+ // if (basename(dtRoot) !== "DefinitelyTyped")) {
+ if (!fs.existsSync(joinPaths(dtRoot, "types"))) {
+ throw new Error(
+ "Since this type definition includes a header (a comment starting with `// Type definitions for`), " +
+ "assumed this was a DefinitelyTyped package.\n" +
+ "But it is not in a `DefinitelyTyped/types/xxx` directory: " +
+ dirPath,
+ );
+ }
+}
+
+/**
+ * Starting at some point in time, npm has banned all new packages whose names
+ * contain the word `download`. However, some older packages exist that still
+ * contain this name.
+ * @NOTE for contributors: The list of literal exceptions below should ONLY be
+ * extended with packages for which there already exists a corresponding type
+ * definition package in the `@types` scope. More information:
+ * https://github.com/microsoft/DefinitelyTyped-tools/pull/381.
+ * @internal
+ */
+export function assertPathIsNotBanned(packageName: string) {
+ if (
+ /(^|\W)download($|\W)/.test(packageName) &&
+ packageName !== "download" &&
+ packageName !== "downloadjs" &&
+ packageName !== "s3-download-stream"
+ ) {
+ // Since npm won't release their banned-words list, we'll have to manually add to this list.
+ throw new Error(`${packageName}: Contains the word 'download', which is banned by npm.`);
+ }
+}
+
+/** @internal */
+export function combineErrorsAndWarnings(errors: string[], warnings: string[]): Error | string {
+ const message = errors.concat(warnings).join("\n\n");
+ return errors.length ? new Error(message) : message;
+}
+
+/** @internal */
+export function checkExpectedFiles(dirPath: string, isLatest: boolean): { errors: string[] } {
+ const errors = [];
+
+ if (isLatest) {
+ const expectedNpmIgnore = ["*", "!**/*.d.ts", "!**/*.d.cts", "!**/*.d.mts", "!**/*.d.*.ts"];
+
+ if (basename(dirname(dirPath)) === "types") {
+ for (const subdir of fs.readdirSync(dirPath, { withFileTypes: true })) {
+ if (subdir.isDirectory() && /^v(\d+)(\.(\d+))?$/.test(subdir.name)) {
+ expectedNpmIgnore.push(`/${subdir.name}/`);
+ }
+ }
+ } else {
+ const thisDir = `/${basename(dirPath)}/`;
+ const parentNpmIgnorePath = joinPaths(dirname(dirPath), ".npmignore");
+ if (!fs.existsSync(parentNpmIgnorePath)) {
+ errors.push(`${dirPath}: Missing parent '.npmignore'`);
+ } else {
+ const parentNpmIgnore = tryReadFileSync(parentNpmIgnorePath)?.trim().split(/\r?\n/);
+ if (!parentNpmIgnore || !parentNpmIgnore.includes(thisDir)) {
+ errors.push(`${dirPath}: Parent package '.npmignore' should contain ${thisDir}`);
+ }
+ }
+ }
+
+ const expectedNpmIgnoreAsString = expectedNpmIgnore.join("\n");
+ const npmIgnorePath = joinPaths(dirPath, ".npmignore");
+ if (!fs.existsSync(npmIgnorePath)) {
+ errors.push(`${dirPath}: Missing '.npmignore'; should contain:\n${expectedNpmIgnoreAsString}`);
+ }
+
+ const actualNpmIgnore = tryReadFileSync(npmIgnorePath)?.trim().split(/\r?\n/);
+ if (!actualNpmIgnore || !deepEquals(actualNpmIgnore, expectedNpmIgnore)) {
+ errors.push(`${dirPath}: Incorrect '.npmignore'; should be:\n${expectedNpmIgnoreAsString}`);
+ }
+
+ if (fs.existsSync(joinPaths(dirPath, "OTHER_FILES.txt"))) {
+ errors.push(
+ `${dirPath}: Should not contain 'OTHER_FILES.txt'. All files matching "**/*.d.{ts,cts,mts,*.ts}" are automatically included.`,
+ );
+ }
+ }
+
+ if (!fs.existsSync(joinPaths(dirPath, "index.d.ts"))) {
+ errors.push(`${dirPath}: Must contain 'index.d.ts'.`);
+ }
+
+ if (fs.existsSync(joinPaths(dirPath, "tslint.json"))) {
+ errors.push(
+ `${dirPath}: Should not contain 'tslint.json'. This file is no longer required; place all lint-related options into .eslintrc.json.`,
+ );
+ }
+
+ return { errors };
+}
+
+function tryReadFileSync(path: string): string | undefined {
+ try {
+ return fs.readFileSync(path, "utf-8");
+ } catch {
+ return undefined;
+ }
+}
diff --git a/packages/dtslint/src/index.ts b/packages/dtslint/src/index.ts
index 64ca690d68..39a50f85bc 100644
--- a/packages/dtslint/src/index.ts
+++ b/packages/dtslint/src/index.ts
@@ -2,15 +2,22 @@
import { getTypesVersions } from "@definitelytyped/header-parser";
import { AllTypeScriptVersion, TypeScriptVersion } from "@definitelytyped/typescript-versions";
-import { deepEquals, readJson } from "@definitelytyped/utils";
+import { readJson } from "@definitelytyped/utils";
import fs from "fs";
-import { basename, dirname, join as joinPaths, resolve } from "path";
+import { join as joinPaths, resolve } from "path";
import {
checkNpmVersionAndGetMatchingImplementationPackage,
checkPackageJson,
checkTsconfig,
runAreTheTypesWrong,
} from "./checks";
+import {
+ assertPathIsInDefinitelyTyped,
+ assertPathIsNotBanned,
+ checkExpectedFiles,
+ combineErrorsAndWarnings,
+ findDTRoot,
+} from "./index-helpers";
import { TsVersion, lint } from "./lint";
import { getCompilerOptions, packageDirectoryNameWithVersionFromPath, packageNameFromPath } from "./util";
import assert = require("assert");
@@ -261,10 +268,6 @@ async function runTests(
return result;
}
-function combineErrorsAndWarnings(errors: string[], warnings: string[]): Error | string {
- const message = errors.concat(warnings).join("\n\n");
- return errors.length ? new Error(message) : message;
-}
function maxVersion(v1: AllTypeScriptVersion, v2: TypeScriptVersion): TypeScriptVersion {
// Note: For v1 to be later than v2, it must be a current Typescript version. So the type assertion is safe.
@@ -306,48 +309,6 @@ async function testTypesVersion(
return { errors };
}
-function findDTRoot(dirPath: string) {
- let path = dirPath;
- while (basename(path) !== "types" && dirname(path) !== "." && dirname(path) !== "/") {
- path = dirname(path);
- }
- return dirname(path);
-}
-
-function assertPathIsInDefinitelyTyped(dirPath: string, dtRoot: string): void {
- // TODO: It's not clear whether this assertion makes sense, and it's broken on Azure Pipelines (perhaps because DT isn't cloned into DefinitelyTyped)
- // Re-enable it later if it makes sense.
- // if (basename(dtRoot) !== "DefinitelyTyped")) {
- if (!fs.existsSync(joinPaths(dtRoot, "types"))) {
- throw new Error(
- "Since this type definition includes a header (a comment starting with `// Type definitions for`), " +
- "assumed this was a DefinitelyTyped package.\n" +
- "But it is not in a `DefinitelyTyped/types/xxx` directory: " +
- dirPath,
- );
- }
-}
-
-/**
- * Starting at some point in time, npm has banned all new packages whose names
- * contain the word `download`. However, some older packages exist that still
- * contain this name.
- * @NOTE for contributors: The list of literal exceptions below should ONLY be
- * extended with packages for which there already exists a corresponding type
- * definition package in the `@types` scope. More information:
- * https://github.com/microsoft/DefinitelyTyped-tools/pull/381.
- */
-function assertPathIsNotBanned(packageName: string) {
- if (
- /(^|\W)download($|\W)/.test(packageName) &&
- packageName !== "download" &&
- packageName !== "downloadjs" &&
- packageName !== "s3-download-stream"
- ) {
- // Since npm won't release their banned-words list, we'll have to manually add to this list.
- throw new Error(`${packageName}: Contains the word 'download', which is banned by npm.`);
- }
-}
export function assertPackageIsNotDeprecated(packageName: string, notNeededPackages: string) {
const unneeded = JSON.parse(notNeededPackages).packages;
@@ -365,66 +326,5 @@ if (require.main === module) {
});
}
-function checkExpectedFiles(dirPath: string, isLatest: boolean): { errors: string[] } {
- const errors = [];
-
- if (isLatest) {
- const expectedNpmIgnore = ["*", "!**/*.d.ts", "!**/*.d.cts", "!**/*.d.mts", "!**/*.d.*.ts"];
-
- if (basename(dirname(dirPath)) === "types") {
- for (const subdir of fs.readdirSync(dirPath, { withFileTypes: true })) {
- if (subdir.isDirectory() && /^v(\d+)(\.(\d+))?$/.test(subdir.name)) {
- expectedNpmIgnore.push(`/${subdir.name}/`);
- }
- }
- } else {
- const thisDir = `/${basename(dirPath)}/`;
- const parentNpmIgnorePath = joinPaths(dirname(dirPath), ".npmignore");
- if (!fs.existsSync(parentNpmIgnorePath)) {
- errors.push(`${dirPath}: Missing parent '.npmignore'`);
- } else {
- const parentNpmIgnore = tryReadFileSync(parentNpmIgnorePath)?.trim().split(/\r?\n/);
- if (!parentNpmIgnore || !parentNpmIgnore.includes(thisDir)) {
- errors.push(`${dirPath}: Parent package '.npmignore' should contain ${thisDir}`);
- }
- }
- }
-
- const expectedNpmIgnoreAsString = expectedNpmIgnore.join("\n");
- const npmIgnorePath = joinPaths(dirPath, ".npmignore");
- if (!fs.existsSync(npmIgnorePath)) {
- errors.push(`${dirPath}: Missing '.npmignore'; should contain:\n${expectedNpmIgnoreAsString}`);
- }
-
- const actualNpmIgnore = tryReadFileSync(npmIgnorePath)?.trim().split(/\r?\n/);
- if (!actualNpmIgnore || !deepEquals(actualNpmIgnore, expectedNpmIgnore)) {
- errors.push(`${dirPath}: Incorrect '.npmignore'; should be:\n${expectedNpmIgnoreAsString}`);
- }
-
- if (fs.existsSync(joinPaths(dirPath, "OTHER_FILES.txt"))) {
- errors.push(
- `${dirPath}: Should not contain 'OTHER_FILES.txt'. All files matching "**/*.d.{ts,cts,mts,*.ts}" are automatically included.`,
- );
- }
- }
-
- if (!fs.existsSync(joinPaths(dirPath, "index.d.ts"))) {
- errors.push(`${dirPath}: Must contain 'index.d.ts'.`);
- }
-
- if (fs.existsSync(joinPaths(dirPath, "tslint.json"))) {
- errors.push(
- `${dirPath}: Should not contain 'tslint.json'. This file is no longer required; place all lint-related options into .eslintrc.json.`,
- );
- }
- return { errors };
-}
-function tryReadFileSync(path: string): string | undefined {
- try {
- return fs.readFileSync(path, "utf-8");
- } catch {
- return undefined;
- }
-}
diff --git a/packages/dtslint/src/lint.ts b/packages/dtslint/src/lint.ts
index e560b21290..aef55be84c 100644
--- a/packages/dtslint/src/lint.ts
+++ b/packages/dtslint/src/lint.ts
@@ -136,31 +136,36 @@ export function isExternalDependency(file: TsType.SourceFile, dirPath: string, p
return !startsWithDirectory(file.fileName, dirPath) || program.isSourceFileFromExternalLibrary(file);
}
-function normalizePath(file: string) {
+/** @internal */
+export 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 isTypesVersionPath(fileName: string, dirPath: string) {
+/** @internal */
+export function isTypesVersionPath(fileName: string, dirPath: string) {
const normalFileName = normalizePath(fileName);
const normalDirPath = normalizePath(dirPath);
const subdirPath = withoutStart(normalFileName, normalDirPath);
return subdirPath && /^\/ts\d+\.\d/.test(subdirPath);
}
-function startsWithDirectory(filePath: string, dirPath: string): boolean {
+/** @internal */
+export function startsWithDirectory(filePath: string, dirPath: string): boolean {
const normalFilePath = normalizePath(filePath);
const normalDirPath = normalizePath(dirPath).replace(/\/$/, "");
return normalFilePath.startsWith(normalDirPath + "/") || normalFilePath.startsWith(normalDirPath + "\\");
}
-interface Err {
+/** @internal */
+export interface Err {
pos: number;
message: string;
}
-function testNoLintDisables(text: string): Err | undefined {
+/** @internal */
+export function testNoLintDisables(text: string): Err | undefined {
const disabler = "eslint-disable";
let lastIndex = 0;
while (true) {
@@ -182,7 +187,8 @@ function testNoLintDisables(text: string): Err | undefined {
}
}
-function range(minVersion: TsVersion, maxVersion: TsVersion): readonly TsVersion[] {
+/** @internal */
+export function range(minVersion: TsVersion, maxVersion: TsVersion): readonly TsVersion[] {
if (minVersion === "local") {
assert(maxVersion === "local");
return ["local"];
diff --git a/packages/dtslint/test/checks.test.ts b/packages/dtslint/test/checks.test.ts
new file mode 100644
index 0000000000..645cce0936
--- /dev/null
+++ b/packages/dtslint/test/checks.test.ts
@@ -0,0 +1,199 @@
+///
+import { CompilerOptionsRaw, checkTsconfig } from "../src/checks";
+
+describe("checks - additional coverage", () => {
+ const base: CompilerOptionsRaw = {
+ module: "commonjs",
+ lib: ["es6"],
+ noImplicitAny: true,
+ noImplicitThis: true,
+ strictNullChecks: true,
+ strictFunctionTypes: true,
+ types: [],
+ noEmit: true,
+ forceConsistentCasingInFileNames: true,
+ };
+ function based(extra: object) {
+ return { compilerOptions: { ...base, ...extra }, files: ["index.d.ts", "base.test.ts"] };
+ }
+
+ describe("checkTsconfig - mustHave validations", () => {
+ it("errors when noEmit is false", () => {
+ const errors = checkTsconfig(based({ noEmit: false }));
+ expect(errors).toContainEqual(expect.stringContaining("noEmit"));
+ });
+
+ it("errors when noEmit is missing", () => {
+ const opts = { ...base };
+ delete (opts as any).noEmit;
+ const errors = checkTsconfig({ compilerOptions: opts, files: ["index.d.ts", "test.ts"] });
+ expect(errors).toContainEqual(expect.stringContaining("noEmit"));
+ });
+
+ it("errors when forceConsistentCasingInFileNames is false", () => {
+ const errors = checkTsconfig(based({ forceConsistentCasingInFileNames: false }));
+ expect(errors).toContainEqual(expect.stringContaining("forceConsistentCasingInFileNames"));
+ });
+
+ it("errors when types is not empty array", () => {
+ const errors = checkTsconfig(based({ types: ["node"] }));
+ expect(errors).toContainEqual(expect.stringContaining("types"));
+ });
+ });
+
+ describe("checkTsconfig - strict mode", () => {
+ it("allows strict: true without individual strict options", () => {
+ const compilerOptions = {
+ module: "commonjs",
+ lib: ["es6"],
+ strict: true,
+ types: [],
+ noEmit: true,
+ forceConsistentCasingInFileNames: true,
+ };
+ const errors = checkTsconfig({ compilerOptions, files: ["index.d.ts", "test.ts"] });
+ expect(errors).toEqual([]);
+ });
+
+ it("errors when strict is false", () => {
+ const compilerOptions = {
+ module: "commonjs",
+ lib: ["es6"],
+ strict: false,
+ types: [],
+ noEmit: true,
+ forceConsistentCasingInFileNames: true,
+ };
+ const errors = checkTsconfig({ compilerOptions, files: ["index.d.ts", "test.ts"] });
+ expect(errors).toContainEqual(expect.stringContaining('When "strict" is present, it must be set to `true`.'));
+ });
+
+ it("throws when strict is true and noImplicitAny is also set", () => {
+ const compilerOptions = {
+ module: "commonjs",
+ lib: ["es6"],
+ strict: true,
+ noImplicitAny: true,
+ types: [],
+ noEmit: true,
+ forceConsistentCasingInFileNames: true,
+ };
+ expect(() => checkTsconfig({ compilerOptions, files: ["index.d.ts", "test.ts"] })).toThrow(
+ '"noImplicitAny" to not be set when "strict" is `true`',
+ );
+ });
+
+ it("throws when strict is true and strictNullChecks is also set", () => {
+ const compilerOptions = {
+ module: "commonjs",
+ lib: ["es6"],
+ strict: true,
+ strictNullChecks: true,
+ types: [],
+ noEmit: true,
+ forceConsistentCasingInFileNames: true,
+ };
+ expect(() => checkTsconfig({ compilerOptions, files: ["index.d.ts", "test.ts"] })).toThrow(
+ '"strictNullChecks" to not be set when "strict" is `true`',
+ );
+ });
+
+ it("errors when not strict and noImplicitAny is missing", () => {
+ const opts = { ...base };
+ delete (opts as any).noImplicitAny;
+ const errors = checkTsconfig({ compilerOptions: opts, files: ["index.d.ts", "test.ts"] });
+ expect(errors).toContainEqual(expect.stringContaining('"noImplicitAny": true'));
+ });
+
+ it("errors when not strict and strictFunctionTypes is missing", () => {
+ const opts = { ...base };
+ delete (opts as any).strictFunctionTypes;
+ const errors = checkTsconfig({ compilerOptions: opts, files: ["index.d.ts", "test.ts"] });
+ expect(errors).toContainEqual(expect.stringContaining('"strictFunctionTypes": true'));
+ });
+ });
+
+ describe("checkTsconfig - module values", () => {
+ it("errors for module: 'amd'", () => {
+ const errors = checkTsconfig(based({ module: "amd" }));
+ expect(errors).toContainEqual(
+ expect.stringContaining('When "module" is present, it must be set to "commonjs" or "node16"'),
+ );
+ });
+
+ it("errors for module: 'esnext'", () => {
+ const errors = checkTsconfig(based({ module: "esnext" }));
+ expect(errors).toContainEqual(
+ expect.stringContaining('When "module" is present, it must be set to "commonjs" or "node16"'),
+ );
+ });
+
+ it("allows module: 'commonjs' (case-insensitive)", () => {
+ const errors = checkTsconfig(based({ module: "CommonJS" }));
+ expect(errors).toEqual([]);
+ });
+
+ it("allows module: 'Node16' (case-insensitive)", () => {
+ const errors = checkTsconfig(based({ module: "Node16" }));
+ expect(errors).toEqual([]);
+ });
+ });
+
+ describe("checkTsconfig - lib", () => {
+ it("errors when lib is missing", () => {
+ const opts = { ...base };
+ delete (opts as any).lib;
+ const errors = checkTsconfig({ compilerOptions: opts, files: ["index.d.ts", "test.ts"] });
+ expect(errors).toContainEqual(expect.stringContaining('Must specify "lib"'));
+ });
+ });
+
+ describe("checkTsconfig - types with entries", () => {
+ it("errors when types array has entries", () => {
+ const errors = checkTsconfig(based({ types: ["node", "jest"] }));
+ expect(errors).toContainEqual(expect.stringContaining("reference types"));
+ });
+ });
+
+ describe("checkTsconfig - allowed optional options", () => {
+ it("allows target", () => {
+ expect(checkTsconfig(based({ target: "es2020" }))).toEqual([]);
+ });
+
+ it("allows jsx", () => {
+ expect(checkTsconfig(based({ jsx: "react" }))).toEqual([]);
+ });
+
+ it("allows jsxFactory", () => {
+ expect(checkTsconfig(based({ jsxFactory: "h" }))).toEqual([]);
+ });
+
+ it("allows jsxImportSource", () => {
+ expect(checkTsconfig(based({ jsxImportSource: "preact" }))).toEqual([]);
+ });
+
+ it("allows experimentalDecorators", () => {
+ expect(checkTsconfig(based({ experimentalDecorators: true }))).toEqual([]);
+ });
+
+ it("allows noUnusedLocals", () => {
+ expect(checkTsconfig(based({ noUnusedLocals: true }))).toEqual([]);
+ });
+
+ it("allows noUnusedParameters", () => {
+ expect(checkTsconfig(based({ noUnusedParameters: true }))).toEqual([]);
+ });
+
+ it("allows esModuleInterop", () => {
+ expect(checkTsconfig(based({ esModuleInterop: true }))).toEqual([]);
+ });
+
+ it("allows allowSyntheticDefaultImports", () => {
+ expect(checkTsconfig(based({ allowSyntheticDefaultImports: true }))).toEqual([]);
+ });
+
+ it("allows noUncheckedIndexedAccess", () => {
+ expect(checkTsconfig(based({ noUncheckedIndexedAccess: true }))).toEqual([]);
+ });
+ });
+});
diff --git a/packages/dtslint/test/createProgram.test.ts b/packages/dtslint/test/createProgram.test.ts
new file mode 100644
index 0000000000..36ed467ba7
--- /dev/null
+++ b/packages/dtslint/test/createProgram.test.ts
@@ -0,0 +1,61 @@
+///
+import { createProgram } from "../src/createProgram";
+import fs from "fs";
+import path from "path";
+import os from "os";
+
+describe("createProgram", () => {
+ let tmpDir: string;
+
+ beforeEach(() => {
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "dtslint-createprogram-"));
+ });
+
+ afterEach(() => {
+ fs.rmSync(tmpDir, { recursive: true, force: true });
+ });
+
+ it("creates a program from a valid tsconfig", () => {
+ const indexFile = path.join(tmpDir, "index.d.ts");
+ fs.writeFileSync(indexFile, "export declare const x: number;");
+ const tsconfigPath = path.join(tmpDir, "tsconfig.json");
+ fs.writeFileSync(
+ tsconfigPath,
+ JSON.stringify({
+ compilerOptions: { strict: true, noEmit: true },
+ files: ["index.d.ts"],
+ }),
+ );
+
+ const program = createProgram(tsconfigPath);
+ expect(program).toBeDefined();
+ const sourceFiles = program.getSourceFiles();
+ const userFiles = sourceFiles.filter((f) => !program.isSourceFileDefaultLibrary(f));
+ expect(userFiles.length).toBeGreaterThanOrEqual(1);
+ expect(userFiles.some((f) => f.fileName.includes("index.d.ts"))).toBe(true);
+ });
+
+ it("throws for a tsconfig with read errors", () => {
+ expect(() => createProgram(path.join(tmpDir, "nonexistent.json"))).toThrow();
+ });
+
+ it("throws for a tsconfig with invalid JSON", () => {
+ const tsconfigPath = path.join(tmpDir, "tsconfig.json");
+ fs.writeFileSync(tsconfigPath, "{ invalid json }}}");
+ expect(() => createProgram(tsconfigPath)).toThrow();
+ });
+
+ it("ignores TS18003 (no inputs found) warning", () => {
+ const tsconfigPath = path.join(tmpDir, "tsconfig.json");
+ fs.writeFileSync(
+ tsconfigPath,
+ JSON.stringify({
+ compilerOptions: { strict: true, noEmit: true },
+ }),
+ );
+
+ // Should not throw despite no input files
+ const program = createProgram(tsconfigPath);
+ expect(program).toBeDefined();
+ });
+});
diff --git a/packages/dtslint/test/index-helpers.test.ts b/packages/dtslint/test/index-helpers.test.ts
new file mode 100644
index 0000000000..16e3ea2ea6
--- /dev/null
+++ b/packages/dtslint/test/index-helpers.test.ts
@@ -0,0 +1,258 @@
+///
+import {
+ findDTRoot,
+ assertPathIsNotBanned,
+ combineErrorsAndWarnings,
+ assertPathIsInDefinitelyTyped,
+ checkExpectedFiles,
+} from "../src/index-helpers";
+import fs from "fs";
+import path from "path";
+import os from "os";
+
+describe("index-helpers", () => {
+ describe("findDTRoot", () => {
+ it("finds root for a path with /types/ directory", () => {
+ // findDTRoot walks up until basename is "types", then returns dirname
+ const result = findDTRoot("/home/user/DefinitelyTyped/types/jquery");
+ expect(result).toBe("/home/user/DefinitelyTyped");
+ });
+
+ it("finds root for a versioned subpackage path", () => {
+ const result = findDTRoot("/home/user/DefinitelyTyped/types/react/v16");
+ expect(result).toBe("/home/user/DefinitelyTyped");
+ });
+
+ it("returns parent of '.' for non-types path (stops at '.')", () => {
+ // When no 'types' directory is found, it stops when dirname is "." or "/"
+ const result = findDTRoot("some/random/path");
+ expect(result).toBe(".");
+ });
+ });
+
+ describe("assertPathIsNotBanned", () => {
+ it("allows regular package names", () => {
+ expect(() => assertPathIsNotBanned("lodash")).not.toThrow();
+ });
+
+ it("allows 'download' (exempted)", () => {
+ expect(() => assertPathIsNotBanned("download")).not.toThrow();
+ });
+
+ it("allows 'downloadjs' (exempted)", () => {
+ expect(() => assertPathIsNotBanned("downloadjs")).not.toThrow();
+ });
+
+ it("allows 's3-download-stream' (exempted)", () => {
+ expect(() => assertPathIsNotBanned("s3-download-stream")).not.toThrow();
+ });
+
+ it("bans packages containing 'download' as a word boundary", () => {
+ expect(() => assertPathIsNotBanned("my-download-tool")).toThrow("banned by npm");
+ });
+
+ it("bans packages starting with 'download-'", () => {
+ expect(() => assertPathIsNotBanned("download-helper")).toThrow("banned by npm");
+ });
+
+ it("bans packages ending with '-download'", () => {
+ expect(() => assertPathIsNotBanned("file-download")).toThrow("banned by npm");
+ });
+
+ it("allows packages where 'download' is part of a larger word", () => {
+ // 'downloads' ends with a word character, so ($|\W) won't match for the 's'
+ // But actually /(^|\W)download($|\W)/ checks for word boundaries
+ // 'mydownloader' - 'download' is embedded, but preceded by 'y' (a word char), so (^|\W) fails
+ expect(() => assertPathIsNotBanned("mydownloader")).not.toThrow();
+ });
+ });
+
+ describe("combineErrorsAndWarnings", () => {
+ it("returns empty string when no errors and no warnings", () => {
+ const result = combineErrorsAndWarnings([], []);
+ expect(result).toBe("");
+ });
+
+ it("returns warning string when no errors", () => {
+ const result = combineErrorsAndWarnings([], ["warning1", "warning2"]);
+ expect(typeof result).toBe("string");
+ expect(result as string).toContain("warning1");
+ expect(result as string).toContain("warning2");
+ });
+
+ it("returns Error when there are errors", () => {
+ const result = combineErrorsAndWarnings(["error1"], []);
+ expect(result).toBeInstanceOf(Error);
+ expect((result as Error).message).toContain("error1");
+ });
+
+ it("returns Error with both errors and warnings", () => {
+ const result = combineErrorsAndWarnings(["error1"], ["warning1"]);
+ expect(result).toBeInstanceOf(Error);
+ expect((result as Error).message).toContain("error1");
+ expect((result as Error).message).toContain("warning1");
+ });
+
+ it("separates messages with double newlines", () => {
+ const result = combineErrorsAndWarnings(["err1", "err2"], []);
+ expect((result as Error).message).toBe("err1\n\nerr2");
+ });
+ });
+
+ describe("assertPathIsInDefinitelyTyped", () => {
+ it("throws when types directory does not exist under dtRoot", () => {
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "dtslint-test-"));
+ try {
+ expect(() => assertPathIsInDefinitelyTyped("/some/path", tmpDir)).toThrow(
+ "not in a `DefinitelyTyped/types/xxx` directory",
+ );
+ } finally {
+ fs.rmdirSync(tmpDir);
+ }
+ });
+
+ it("does not throw when types directory exists under dtRoot", () => {
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "dtslint-test-"));
+ const typesDir = path.join(tmpDir, "types");
+ fs.mkdirSync(typesDir);
+ try {
+ expect(() => assertPathIsInDefinitelyTyped("/some/path", tmpDir)).not.toThrow();
+ } finally {
+ fs.rmdirSync(typesDir);
+ fs.rmdirSync(tmpDir);
+ }
+ });
+ });
+
+ describe("checkExpectedFiles", () => {
+ let tmpDir: string;
+
+ beforeEach(() => {
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "dtslint-expected-"));
+ });
+
+ afterEach(() => {
+ fs.rmSync(tmpDir, { recursive: true, force: true });
+ });
+
+ it("errors when index.d.ts is missing (isLatest=false)", () => {
+ const result = checkExpectedFiles(tmpDir, false);
+ expect(result.errors).toContainEqual(expect.stringContaining("Must contain 'index.d.ts'"));
+ });
+
+ it("no errors for missing index.d.ts when index.d.ts exists (isLatest=false)", () => {
+ fs.writeFileSync(path.join(tmpDir, "index.d.ts"), "export {};");
+ const result = checkExpectedFiles(tmpDir, false);
+ expect(result.errors.filter((e) => e.includes("index.d.ts"))).toEqual([]);
+ });
+
+ it("errors when tslint.json exists", () => {
+ fs.writeFileSync(path.join(tmpDir, "index.d.ts"), "export {};");
+ fs.writeFileSync(path.join(tmpDir, "tslint.json"), "{}");
+ const result = checkExpectedFiles(tmpDir, false);
+ expect(result.errors).toContainEqual(expect.stringContaining("Should not contain 'tslint.json'"));
+ });
+
+ it("errors when .npmignore is missing (isLatest=true, under types/)", () => {
+ // Create a structure: /types/mypackage/
+ const typesDir = path.join(tmpDir, "types");
+ fs.mkdirSync(typesDir);
+ const pkgDir = path.join(typesDir, "mypackage");
+ fs.mkdirSync(pkgDir);
+ fs.writeFileSync(path.join(pkgDir, "index.d.ts"), "export {};");
+
+ const result = checkExpectedFiles(pkgDir, true);
+ expect(result.errors).toContainEqual(expect.stringContaining("Missing '.npmignore'"));
+ });
+
+ it("errors when .npmignore is incorrect (isLatest=true, under types/)", () => {
+ const typesDir = path.join(tmpDir, "types");
+ fs.mkdirSync(typesDir);
+ const pkgDir = path.join(typesDir, "mypackage");
+ fs.mkdirSync(pkgDir);
+ fs.writeFileSync(path.join(pkgDir, "index.d.ts"), "export {};");
+ fs.writeFileSync(path.join(pkgDir, ".npmignore"), "wrong content");
+
+ const result = checkExpectedFiles(pkgDir, true);
+ expect(result.errors).toContainEqual(expect.stringContaining("Incorrect '.npmignore'"));
+ });
+
+ it("no .npmignore errors when correct (isLatest=true, under types/)", () => {
+ const typesDir = path.join(tmpDir, "types");
+ fs.mkdirSync(typesDir);
+ const pkgDir = path.join(typesDir, "mypackage");
+ fs.mkdirSync(pkgDir);
+ fs.writeFileSync(path.join(pkgDir, "index.d.ts"), "export {};");
+ const npmIgnore = ["*", "!**/*.d.ts", "!**/*.d.cts", "!**/*.d.mts", "!**/*.d.*.ts"].join("\n");
+ fs.writeFileSync(path.join(pkgDir, ".npmignore"), npmIgnore);
+
+ const result = checkExpectedFiles(pkgDir, true);
+ const npmIgnoreErrors = result.errors.filter((e) => e.includes(".npmignore"));
+ expect(npmIgnoreErrors).toEqual([]);
+ });
+
+ it("includes version directories in expected .npmignore", () => {
+ const typesDir = path.join(tmpDir, "types");
+ fs.mkdirSync(typesDir);
+ const pkgDir = path.join(typesDir, "mypackage");
+ fs.mkdirSync(pkgDir);
+ fs.writeFileSync(path.join(pkgDir, "index.d.ts"), "export {};");
+ fs.mkdirSync(path.join(pkgDir, "v16"));
+ // The .npmignore should include /v16/
+ const npmIgnoreWithVersion = [
+ "*",
+ "!**/*.d.ts",
+ "!**/*.d.cts",
+ "!**/*.d.mts",
+ "!**/*.d.*.ts",
+ "/v16/",
+ ].join("\n");
+ fs.writeFileSync(path.join(pkgDir, ".npmignore"), npmIgnoreWithVersion);
+
+ const result = checkExpectedFiles(pkgDir, true);
+ const npmIgnoreErrors = result.errors.filter((e) => e.includes(".npmignore"));
+ expect(npmIgnoreErrors).toEqual([]);
+ });
+
+ it("errors when OTHER_FILES.txt exists (isLatest=true)", () => {
+ const typesDir = path.join(tmpDir, "types");
+ fs.mkdirSync(typesDir);
+ const pkgDir = path.join(typesDir, "mypackage");
+ fs.mkdirSync(pkgDir);
+ fs.writeFileSync(path.join(pkgDir, "index.d.ts"), "export {};");
+ const npmIgnore = ["*", "!**/*.d.ts", "!**/*.d.cts", "!**/*.d.mts", "!**/*.d.*.ts"].join("\n");
+ fs.writeFileSync(path.join(pkgDir, ".npmignore"), npmIgnore);
+ fs.writeFileSync(path.join(pkgDir, "OTHER_FILES.txt"), "some files");
+
+ const result = checkExpectedFiles(pkgDir, true);
+ expect(result.errors).toContainEqual(expect.stringContaining("OTHER_FILES.txt"));
+ });
+
+ it("checks parent .npmignore for versioned subdirectory (isLatest=true)", () => {
+ // Structure: /mypackage/v16/ (parent is not "types")
+ const pkgDir = path.join(tmpDir, "mypackage");
+ fs.mkdirSync(pkgDir);
+ const versionDir = path.join(pkgDir, "v16");
+ fs.mkdirSync(versionDir);
+ fs.writeFileSync(path.join(versionDir, "index.d.ts"), "export {};");
+ // No parent .npmignore
+ const result = checkExpectedFiles(versionDir, true);
+ expect(result.errors).toContainEqual(expect.stringContaining("Missing parent '.npmignore'"));
+ });
+
+ it("errors when parent .npmignore doesn't contain versioned dir entry", () => {
+ const pkgDir = path.join(tmpDir, "mypackage");
+ fs.mkdirSync(pkgDir);
+ const versionDir = path.join(pkgDir, "v16");
+ fs.mkdirSync(versionDir);
+ fs.writeFileSync(path.join(versionDir, "index.d.ts"), "export {};");
+ const npmIgnore = ["*", "!**/*.d.ts", "!**/*.d.cts", "!**/*.d.mts", "!**/*.d.*.ts"].join("\n");
+ fs.writeFileSync(path.join(versionDir, ".npmignore"), npmIgnore);
+ // Parent .npmignore without /v16/ entry
+ fs.writeFileSync(path.join(pkgDir, ".npmignore"), "* \n!**/*.d.ts");
+
+ const result = checkExpectedFiles(versionDir, true);
+ expect(result.errors).toContainEqual(expect.stringContaining("should contain /v16/"));
+ });
+ });
+});
diff --git a/packages/dtslint/test/lint.test.ts b/packages/dtslint/test/lint.test.ts
new file mode 100644
index 0000000000..1826a8e1e0
--- /dev/null
+++ b/packages/dtslint/test/lint.test.ts
@@ -0,0 +1,159 @@
+///
+import { testNoLintDisables, normalizePath, isTypesVersionPath, startsWithDirectory, range } from "../src/lint";
+import { TypeScriptVersion } from "@definitelytyped/typescript-versions";
+
+describe("lint", () => {
+ describe("normalizePath", () => {
+ it("replaces backslashes with forward slashes", () => {
+ expect(normalizePath("C:\\Users\\test\\file.ts")).toContain("/");
+ expect(normalizePath("C:\\Users\\test\\file.ts")).not.toContain("\\");
+ });
+
+ it("uppercases drive letters", () => {
+ const result = normalizePath("c:/Users/test/file.ts");
+ expect(result).toMatch(/^C:/);
+ });
+
+ it("keeps already-uppercased drive letters", () => {
+ const result = normalizePath("C:/Users/test/file.ts");
+ expect(result).toMatch(/^C:/);
+ });
+
+ it("handles Unix-style paths", () => {
+ const result = normalizePath("/home/user/file.ts");
+ expect(result).toBe("/home/user/file.ts");
+ });
+ });
+
+ describe("isTypesVersionPath", () => {
+ it("returns truthy for ts-versioned subdirectory", () => {
+ expect(isTypesVersionPath("/types/react/ts3.6/index.d.ts", "/types/react")).toBeTruthy();
+ });
+
+ it("returns truthy for ts5.0 subdirectory", () => {
+ expect(isTypesVersionPath("/types/node/ts5.0/index.d.ts", "/types/node")).toBeTruthy();
+ });
+
+ it("returns falsy for non-ts-versioned path", () => {
+ expect(isTypesVersionPath("/types/react/index.d.ts", "/types/react")).toBeFalsy();
+ });
+
+ it("returns falsy for file not under dirPath", () => {
+ expect(isTypesVersionPath("/types/other/ts3.6/index.d.ts", "/types/react")).toBeFalsy();
+ });
+
+ it("returns falsy for non-matching version pattern", () => {
+ expect(isTypesVersionPath("/types/react/v16/index.d.ts", "/types/react")).toBeFalsy();
+ });
+ });
+
+ describe("startsWithDirectory", () => {
+ it("returns true when file is in directory", () => {
+ expect(startsWithDirectory("/types/react/index.d.ts", "/types/react")).toBe(true);
+ });
+
+ it("returns false when file is not in directory", () => {
+ expect(startsWithDirectory("/types/other/index.d.ts", "/types/react")).toBe(false);
+ });
+
+ it("returns false for partial directory name match", () => {
+ expect(startsWithDirectory("/types/react-native/index.d.ts", "/types/react")).toBe(false);
+ });
+
+ it("handles trailing slash in dirPath", () => {
+ expect(startsWithDirectory("/types/react/index.d.ts", "/types/react/")).toBe(true);
+ });
+
+ it("handles Windows-style paths", () => {
+ expect(startsWithDirectory("C:\\types\\react\\index.d.ts", "C:\\types\\react")).toBe(true);
+ });
+ });
+
+ describe("testNoLintDisables", () => {
+ it("returns undefined for text without eslint-disable", () => {
+ expect(testNoLintDisables("const x = 1;\nconst y = 2;")).toBeUndefined();
+ });
+
+ it("allows eslint-disable-line", () => {
+ expect(testNoLintDisables("const x = 1; // eslint-disable-line")).toBeUndefined();
+ });
+
+ it("allows eslint-disable-next-line", () => {
+ expect(testNoLintDisables("// eslint-disable-next-line\nconst x = 1;")).toBeUndefined();
+ });
+
+ it("allows eslint-disable with specific rule (space then rulename)", () => {
+ expect(testNoLintDisables("// eslint-disable no-console")).toBeUndefined();
+ });
+
+ it("forbids bare eslint-disable (end of string)", () => {
+ const result = testNoLintDisables("// eslint-disable");
+ expect(result).toBeDefined();
+ expect(result!.message).toContain("forbidden");
+ });
+
+ it("forbids eslint-disable with newline immediately after", () => {
+ const result = testNoLintDisables("// eslint-disable\nconst x = 1;");
+ expect(result).toBeDefined();
+ expect(result!.message).toContain("forbidden");
+ });
+
+ it("forbids eslint-disable followed by space and star (wildcard)", () => {
+ const result = testNoLintDisables("/* eslint-disable */");
+ expect(result).toBeDefined();
+ expect(result!.message).toContain("forbidden");
+ });
+
+ it("reports correct position for eslint-disable", () => {
+ const text = "const x = 1;\n/* eslint-disable */";
+ const result = testNoLintDisables(text);
+ expect(result).toBeDefined();
+ expect(result!.pos).toBe(text.indexOf("eslint-disable"));
+ });
+
+ it("handles multiple eslint-disable occurrences, first allowed, second forbidden", () => {
+ const text = "// eslint-disable-line\n/* eslint-disable */";
+ const result = testNoLintDisables(text);
+ expect(result).toBeDefined();
+ expect(result!.pos).toBe(text.lastIndexOf("eslint-disable"));
+ });
+ });
+
+ describe("range", () => {
+ it("returns ['local'] when both min and max are 'local'", () => {
+ expect(range("local", "local")).toEqual(["local"]);
+ });
+
+ it("returns [latest] when both min and max are latest", () => {
+ expect(range(TypeScriptVersion.latest, TypeScriptVersion.latest)).toEqual([TypeScriptVersion.latest]);
+ });
+
+ it("returns supported versions from min to max inclusive", () => {
+ const supported = TypeScriptVersion.supported;
+ if (supported.length >= 2) {
+ const min = supported[0];
+ const max = supported[1];
+ const result = range(min, max);
+ expect(result).toContain(min);
+ expect(result).toContain(max);
+ expect(result.length).toBe(2);
+ }
+ });
+
+ it("returns versions from min through latest when max is latest", () => {
+ const supported = TypeScriptVersion.supported;
+ const min = supported[0];
+ const result = range(min, TypeScriptVersion.latest);
+ expect(result[0]).toBe(min);
+ expect(result[result.length - 1]).toBe(TypeScriptVersion.latest);
+ // Should include all supported versions plus latest
+ expect(result.length).toBe(supported.length + 1);
+ });
+
+ it("returns single element when min equals max in supported", () => {
+ const v = TypeScriptVersion.supported[0];
+ const result = range(v, v);
+ expect(result).toEqual([v]);
+ });
+ });
+});
diff --git a/packages/dtslint/test/typescript-installer.test.ts b/packages/dtslint/test/typescript-installer.test.ts
new file mode 100644
index 0000000000..fd4a427b69
--- /dev/null
+++ b/packages/dtslint/test/typescript-installer.test.ts
@@ -0,0 +1,28 @@
+///
+import { typeScriptPath } from "../src/typescript-installer";
+import * as typeScriptPackages from "@definitelytyped/typescript-packages";
+import { TypeScriptVersion } from "@definitelytyped/typescript-versions";
+
+describe("typescript-installer", () => {
+ describe("typeScriptPath", () => {
+ it("returns local path with /typescript.js appended when version is 'local'", () => {
+ const result = typeScriptPath("local", "/my/local/ts");
+ expect(result).toBe("/my/local/ts/typescript.js");
+ });
+
+ it("returns resolved path for a supported TypeScript version", () => {
+ const version = TypeScriptVersion.supported[0];
+ const result = typeScriptPath(version, undefined);
+ // Should return a path resolved by typeScriptPackages
+ expect(typeof result).toBe("string");
+ expect(result.length).toBeGreaterThan(0);
+ });
+
+ it("returns the same result as typeScriptPackages.resolve for non-local versions", () => {
+ const version = TypeScriptVersion.latest;
+ const expected = typeScriptPackages.resolve(version);
+ const result = typeScriptPath(version, undefined);
+ expect(result).toBe(expected);
+ });
+ });
+});
diff --git a/packages/dtslint/test/util.test.ts b/packages/dtslint/test/util.test.ts
new file mode 100644
index 0000000000..59d64a5849
--- /dev/null
+++ b/packages/dtslint/test/util.test.ts
@@ -0,0 +1,112 @@
+///
+import { packageNameFromPath, packageDirectoryNameWithVersionFromPath, getCompilerOptions } from "../src/util";
+import fs from "fs";
+import path from "path";
+
+describe("util", () => {
+ describe("packageNameFromPath", () => {
+ it("returns basename for regular package path", () => {
+ expect(packageNameFromPath("/home/user/DefinitelyTyped/types/jquery")).toBe("jquery");
+ });
+
+ it("returns basename for Windows-style path", () => {
+ expect(packageNameFromPath("C:\\DefinitelyTyped\\types\\lodash")).toBe("lodash");
+ });
+
+ it("returns parent name for versioned directory (v2)", () => {
+ expect(packageNameFromPath("/home/user/DefinitelyTyped/types/react/v16")).toBe("react");
+ });
+
+ it("returns parent name for versioned directory with minor (v0.5)", () => {
+ expect(packageNameFromPath("/home/user/DefinitelyTyped/types/express/v0.5")).toBe("express");
+ });
+
+ it("returns parent name for ts-versioned directory (ts3.6)", () => {
+ expect(packageNameFromPath("/home/user/DefinitelyTyped/types/node/ts3.6")).toBe("node");
+ });
+
+ it("returns parent name for ts-versioned directory (ts5.0)", () => {
+ expect(packageNameFromPath("/home/user/DefinitelyTyped/types/node/ts5.0")).toBe("node");
+ });
+
+ it("returns basename when it does not match version pattern", () => {
+ expect(packageNameFromPath("/home/user/DefinitelyTyped/types/some-package")).toBe("some-package");
+ });
+
+ it("returns basename for scoped-like package names", () => {
+ expect(packageNameFromPath("/home/user/DefinitelyTyped/types/foo__bar")).toBe("foo__bar");
+ });
+ });
+
+ describe("packageDirectoryNameWithVersionFromPath", () => {
+ it("returns just package name for non-versioned path", () => {
+ expect(packageDirectoryNameWithVersionFromPath("/home/user/DefinitelyTyped/types/jquery")).toBe("jquery");
+ });
+
+ it("returns name/version for versioned path", () => {
+ expect(packageDirectoryNameWithVersionFromPath("/home/user/DefinitelyTyped/types/react/v16")).toBe("react/v16");
+ });
+
+ it("returns name/version for minor-versioned path", () => {
+ expect(packageDirectoryNameWithVersionFromPath("/home/user/DefinitelyTyped/types/express/v0.5")).toBe(
+ "express/v0.5",
+ );
+ });
+
+ it("returns just package name for ts-versioned path", () => {
+ // ts3.6 is not a version directory for packageDirectoryNameWithVersionFromPath
+ expect(packageDirectoryNameWithVersionFromPath("/home/user/DefinitelyTyped/types/node/ts3.6")).toBe("node");
+ });
+
+ it("handles Windows paths", () => {
+ expect(packageDirectoryNameWithVersionFromPath("C:\\DefinitelyTyped\\types\\lodash")).toBe("lodash");
+ });
+ });
+
+ describe("getCompilerOptions", () => {
+ it("throws when tsconfig does not exist", () => {
+ expect(() => getCompilerOptions("/nonexistent/tsconfig.json")).toThrow("does not exist");
+ });
+
+ it("reads and parses tsconfig.json", () => {
+ const tmpDir = fs.mkdtempSync(path.join(require("os").tmpdir(), "dtslint-test-"));
+ const tsconfigPath = path.join(tmpDir, "tsconfig.json");
+ fs.writeFileSync(
+ tsconfigPath,
+ JSON.stringify({
+ compilerOptions: { strict: true, target: "es6" },
+ files: ["index.d.ts"],
+ }),
+ );
+ try {
+ const result = getCompilerOptions(tsconfigPath);
+ expect(result.compilerOptions).toBeDefined();
+ expect(result.files).toEqual(["index.d.ts"]);
+ } finally {
+ fs.unlinkSync(tsconfigPath);
+ fs.rmdirSync(tmpDir);
+ }
+ });
+
+ it("reads tsconfig.json with comments", () => {
+ const tmpDir = fs.mkdtempSync(path.join(require("os").tmpdir(), "dtslint-test-"));
+ const tsconfigPath = path.join(tmpDir, "tsconfig.json");
+ fs.writeFileSync(
+ tsconfigPath,
+ `{
+ // This is a comment
+ "compilerOptions": { "strict": true },
+ "files": ["index.d.ts"]
+ }`,
+ );
+ try {
+ const result = getCompilerOptions(tsconfigPath);
+ expect(result.compilerOptions).toBeDefined();
+ expect(result.compilerOptions.strict).toBe(true);
+ } finally {
+ fs.unlinkSync(tsconfigPath);
+ fs.rmdirSync(tmpDir);
+ }
+ });
+ });
+});