feat(docutils): do not fail on first validation error

This modifies the validator to collect all of the errors from validation (except those that are "unexpected" though not _so_ unexpected that I did not account for them; stuff like "unparseable output from `pip list --json`") and display them as they happen without aborting the entire validation process.
This commit is contained in:
Christopher Hiller
2023-02-01 16:49:53 -08:00
parent 8fbbdb93a2
commit 4f457468bb
6 changed files with 628 additions and 305 deletions
+3 -1
View File
@@ -13517,7 +13517,8 @@
},
"node_modules/pluralize": {
"version": "8.0.0",
"license": "MIT",
"resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz",
"integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==",
"engines": {
"node": ">=4"
}
@@ -17094,6 +17095,7 @@
"json5": "2.2.3",
"lodash": "4.17.21",
"pkg-dir": "5.0.0",
"pluralize": "8.0.0",
"read-pkg": "5.2.0",
"semver": "7.3.8",
"source-map-support": "0.5.21",
+47 -2
View File
@@ -1,5 +1,10 @@
import pluralize from 'pluralize';
import {CommandModule, InferredOptionTypes, Options} from 'yargs';
import {validate} from '../../validate';
import {DocutilsError} from '../../error';
import {DocutilsValidator, ValidationKind} from '../../validate';
import logger from '../../logger';
const log = logger.withTag('validate');
const NAME_GROUP_VALIDATE = 'Validation:';
@@ -49,6 +54,24 @@ const opts = {
group: NAME_GROUP_VALIDATE,
type: 'boolean',
},
mkdocsYml: {
defaultDescription: './mkdocs.yml',
description: 'Path to mkdocs.yml',
group: NAME_GROUP_VALIDATE,
nargs: 1,
normalize: true,
requiresArg: true,
type: 'string',
},
npmPath: {
defaultDescription: '(derived from shell)',
description: 'Path to npm executable',
group: NAME_GROUP_VALIDATE,
nargs: 1,
normalize: true,
requiresArg: true,
type: 'string',
},
} as const;
opts as Record<string, Options>;
type ValidateOptions = InferredOptionTypes<typeof opts>;
@@ -63,7 +86,29 @@ const validateCommand: CommandModule<{}, ValidateOptions> = {
'No validation targets specified; one or more of --python, --typescript or --typedoc must be provided'
);
}
await validate(args);
let errorCount = 0;
const validator = new DocutilsValidator(args)
.once(DocutilsValidator.BEGIN, (kinds: ValidationKind[]) => {
log.info(`Validating: ${kinds.join(', ')}`);
})
.once(DocutilsValidator.END, (errCount: number) => {
errorCount = errCount;
})
.on(DocutilsValidator.FAILURE, (err: DocutilsError) => {
log.error(err.message);
})
.on(DocutilsValidator.SUCCESS, (msg: string) => {
log.success(msg);
});
await validator.validate();
if (errorCount) {
throw new DocutilsError(
`Validation failed with ${errorCount} ${pluralize('error', errorCount)}`
);
}
},
};
+2 -2
View File
@@ -7,7 +7,7 @@ import yargs from 'yargs/yargs';
import {build, init, validate} from './command';
import {DocutilsError} from '../error';
import log from '../logger';
import {DEFAULT_LOG_LEVEL} from '../constants';
import {DEFAULT_LOG_LEVEL, NAME_BIN} from '../constants';
const LogLevelName = {
silent: LogLevel.Silent,
@@ -20,7 +20,7 @@ const LogLevelName = {
export async function main(argv = hideBin(process.argv)) {
const y = yargs(argv);
return await y
.scriptName('appium-docs')
.scriptName(NAME_BIN)
.command(validate)
.command(build)
.command(init)
+55 -6
View File
@@ -8,20 +8,69 @@ import {fs} from '@appium/support';
import path from 'node:path';
import {PackageJson} from 'type-fest';
/**
* CLI executable name
*/
export const NAME_BIN = 'appium-docs';
export const NAME_MKDOCS_YML = 'mkdocs.yml';
export const NAME_TSCONFIG_JSON = 'tsconfig.json';
export const NAME_PYTHON = 'python';
export const NAME_TYPEDOC_JSON = 'typedoc.json';
export const NAME_PACKAGE_JSON = 'package.json';
export const NAME_REQUIREMENTS_TXT = 'requirements.txt';
/**
* Expected name of the `mkdocs.yml` config file
*/
export const NAME_MKDOCS_YML = 'mkdocs.yml';
/**
* Default name of the `tsconfig.json` config file
*/
export const NAME_TSCONFIG_JSON = 'tsconfig.json';
/**
* `python` executable
*/
export const NAME_PYTHON = 'python';
/**
* Default name of the `typedoc.json` config file
*/
export const NAME_TYPEDOC_JSON = 'typedoc.json';
/**
* It's `package.json`!
*/
export const NAME_PACKAGE_JSON = 'package.json';
/**
* Name of the `requirements.txt` file for `pip`
*/
export const NAME_REQUIREMENTS_TXT = 'requirements.txt';
/**
* Name of the `$schema` property which can be present in JSON files; it may need to be removed to
* avoid warnings/errors by 3p libs
*/
export const NAME_SCHEMA = '$schema';
/**
* Name of the `mkdocs` executable
*/
export const NAME_MKDOCS = 'mkdocs';
/**
* Name of the `typedoc` executable
*/
export const NAME_TYPEDOC = 'typedoc';
/**
* Name of the `pip` module.
*
* @remarks We don't execute the `pip` executable; but rather use `python -m pip` since that seems
* to work better ... on my computer.
*/
export const NAME_PIP = 'pip';
/**
* Name of the `npm` executable, which we use to check for
*/
export const NAME_NPM = 'npm';
/**
* The name of the `typescript` package (not the `tsc` executable)
*/
export const NAME_TYPESCRIPT = 'typescript';
export const DEFAULT_LOG_LEVEL = 'info';
/**
* Blocking I/O
+520 -294
View File
@@ -1,7 +1,10 @@
import {satisfies} from 'semver';
import path from 'node:path';
import {fs} from '@appium/support';
import chalk from 'chalk';
import _ from 'lodash';
import {EventEmitter} from 'node:events';
import path from 'node:path';
import pluralize from 'pluralize';
import {satisfies} from 'semver';
import {exec} from 'teen_process';
import {
DEFAULT_REL_TYPEDOC_OUT_PATH,
@@ -13,327 +16,550 @@ import {
NAME_PYTHON,
NAME_REQUIREMENTS_TXT,
NAME_TSCONFIG_JSON,
NAME_TYPEDOC,
NAME_TYPEDOC_JSON,
NAME_TYPESCRIPT,
REQUIREMENTS_TXT_PATH,
} from './constants';
import {DocutilsError} from './error';
import {PipPackage, TypeDocJson} from './model';
import {findPkgDir, readJson, readJson5, readTypedocJson, relative} from './util';
import logger from './logger';
import {PipPackage, TypeDocJson} from './model';
import {findPkgDir, readJson5, readTypedocJson, relative} from './util';
/**
* Matches the Python version string from `python --version`
*/
const PYTHON_VER_STR = 'Python 3.';
/**
* Matches the TypeScript version string from `tsc --version`
*/
const TYPESCRIPT_VERSION_REGEX = /Version\s(\d+\.\d+\..+)/;
/**
* Matches the TypeDoc version string from `typedoc --version`
*/
const TYPEDOC_VERSION_REGEX = /TypeDoc\s(\d+\.\d+\..+)/;
const log = logger.withTag('validate');
async function parseRequirementsTxt(requirementsTxtPath = REQUIREMENTS_TXT_PATH) {
let requiredPackages: PipPackage[] = [];
try {
let requirementsTxt = await fs.readFile(requirementsTxtPath, 'utf8');
requirementsTxt = requirementsTxt.trim();
log.debug('Raw %s: %s', NAME_REQUIREMENTS_TXT, requirementsTxt);
for (const line of requirementsTxt.split(/\r?\n/)) {
const [name, version] = line.trim().split('==');
log.debug('Need Python package %s @ %s', name, version);
requiredPackages.push({name, version});
}
log.debug('Parsed %s: %O', NAME_REQUIREMENTS_TXT, requiredPackages);
} catch {
throw new DocutilsError(`Could not find ${requirementsTxtPath}. This is a bug`);
}
return requiredPackages;
}
export async function assertPythonVersion(pythonPath = NAME_PYTHON) {
try {
const {stdout} = await exec(pythonPath, ['-V']);
if (!stdout.includes(PYTHON_VER_STR)) {
throw new DocutilsError(
`Could not find Python 3.x in PATH; found ${stdout}. Please use --python-path`
);
}
log.success('Python version OK');
} catch {
throw new DocutilsError(`Could not find Python 3.x in PATH. Is it installed?`);
}
}
export async function assertPythonDependencies(pythonPath = NAME_PYTHON) {
let pipListOutput: string;
try {
({stdout: pipListOutput} = await exec(pythonPath, ['-m', 'pip', 'list', '--format', 'json']));
} catch {
throw new DocutilsError(`Could not find ${NAME_PIP} in PATH. Is it installed?`);
}
let installedPkgs: PipPackage[];
try {
installedPkgs = JSON.parse(pipListOutput) as PipPackage[];
} catch {
throw new DocutilsError(
`Could not parse output of "${NAME_PIP} list" as JSON: ${pipListOutput}`
);
}
const pkgsByName = _.mapValues(_.keyBy(installedPkgs, 'name'), 'version');
log.debug('Installed Python packages: %O', pkgsByName);
const requiredPackages = await parseRequirementsTxt();
for (const reqdPkg of requiredPackages) {
const version = pkgsByName[reqdPkg.name];
if (!version) {
throw new DocutilsError(
`Required Python package ${reqdPkg.name} @ ${reqdPkg.version} is not installed; "${NAME_BIN} init" can help`
);
}
if (version !== reqdPkg.version) {
throw new DocutilsError(
`Required Python package ${reqdPkg.name} @ ${reqdPkg.version} is installed, but ${version} is installed instead`
);
}
}
log.success('Python dependencies OK');
}
export async function assertNpmVersion(npmPath = NAME_NPM) {
const npmEngineRange = DOCUTILS_PKG.engines!.npm!;
try {
const {stdout: npmVersion} = await exec(npmPath, ['-v']);
if (!satisfies(npmVersion.trim(), npmEngineRange)) {
throw new DocutilsError(
`${NAME_NPM} is version ${npmVersion}, but ${npmEngineRange} is required`
);
}
} catch {
throw new DocutilsError(`Could not find ${npmPath} in PATH. Is it installed?`);
}
log.success('npm version OK');
}
async function requirePkgDir(cwd = process.cwd(), packageJsonPath?: string) {
const pkgDir = packageJsonPath ? path.dirname(packageJsonPath) : await findPkgDir(cwd);
if (!pkgDir) {
throw new DocutilsError(`Could not find ${NAME_PACKAGE_JSON} from ${cwd}`);
}
return pkgDir;
}
export type ValidationKind =
| typeof NAME_PYTHON
| typeof NAME_TYPESCRIPT
| typeof NAME_TYPEDOC
| typeof NAME_NPM;
/**
* Asserts that TypeScript is installed, runnable, the correct version, and a parseable `tsconfig.json` exists.
* @param opts Path options
* This class is designed to run _all_ validation checks (as requested by the user), and emit events for
* each failure encountered.
*
* Whenever a method _rejects or throws_, this is considered an "unexpected" error, and the validation
* will abort.
*/
export async function assertTypeScript({
cwd = process.cwd(),
packageJsonPath,
tsconfigJsonPath,
}: AssertTypeScriptOpts = {}) {
const pkgDir = await requirePkgDir(cwd, packageJsonPath);
export class DocutilsValidator extends EventEmitter {
/**
* Mapping of error messages to errors.
*
* Used to prevent duplicate emission of errors and track error count; if non-empty, the validation
* process should be considered to have failed.
*
* Reset after {@linkcode DocutilsValidator.validate validate} completes.
*/
private emittedErrors = new Map<string, DocutilsError>();
let typeScriptVersion: string;
let rawTypeScriptVersion: string;
try {
({stdout: rawTypeScriptVersion} = await exec('npm', ['exec', 'tsc', '--', '--version'], {
cwd: pkgDir,
}));
} catch {
throw new DocutilsError(`Could not find TypeScript compiler ("tsc") from ${pkgDir}`);
}
/**
* Current working directory. Defaults to `process.cwd()`
* @todo This cannot yet be overriden by user
*/
protected readonly cwd: string;
/**
* Path to `npm` executable. Defaults to `npm`
*/
protected readonly npmPath: string;
/**
* Path to `python` executable. Defaults to `python`
*/
protected readonly pythonPath: string;
/**
* List of validations to perform
*/
protected readonly validations = new Set<ValidationKind>();
let match = rawTypeScriptVersion.match(TYPESCRIPT_VERSION_REGEX);
if (match) {
typeScriptVersion = match[1];
} else {
throw new DocutilsError(
`Could not parse TypeScript version from "tsc --version"; output was:\n ${rawTypeScriptVersion}`
);
}
/**
* Path to `mkdocs.yml`. If not provided, will be lazily resolved.
*/
protected mkDocsYmlPath: string | undefined;
/**
* Path to `package.json`. If not provided, will be lazily resolved.
*/
protected packageJsonPath: string | undefined;
/**
* Path to the package directory. If not provided, will be lazily resolved.
*/
protected pkgDir: string | undefined;
/**
* Path to `tsconfig.json`. If not provided, will be lazily resolved.
*/
protected tsconfigJsonPath: string | undefined;
/**
* Path to `typedoc.json`. If not provided, will be lazily resolved.
*/
protected typeDocJsonPath: string | undefined;
const reqdTypeScriptVersion = DOCUTILS_PKG.dependencies!.typescript!;
/**
* Emitted when validation begins with a list of validation kinds to be performed
* @event
*/
public static readonly BEGIN = 'begin';
/**
* Emitted when validation ends with an error count
* @event
*/
public static readonly END = 'end';
/**
* Emitted when a validation fails, with the associated {@linkcode DocutilsError}
* @event
*/
public static readonly FAILURE = 'fail';
/**
* Emitted when a validation succeeds
* @event
*/
public static readonly SUCCESS = 'ok';
if (!satisfies(typeScriptVersion, reqdTypeScriptVersion)) {
throw new DocutilsError(
`Found TypeScript version ${typeScriptVersion}, but ${reqdTypeScriptVersion} is required`
);
}
log.success('TypesScript install OK');
tsconfigJsonPath = tsconfigJsonPath ?? path.join(pkgDir, NAME_TSCONFIG_JSON);
const relTsconfigJsonPath = relative(cwd, tsconfigJsonPath);
try {
await readJson5(tsconfigJsonPath);
} catch (e) {
if (e instanceof SyntaxError) {
throw new DocutilsError(`Unparseable ${NAME_TSCONFIG_JSON} at ${relTsconfigJsonPath}: ${e}`);
/**
* Creates a listener to track errors emitted
*/
constructor(opts: DocutilsValidatorOpts = {}) {
super();
this.packageJsonPath = opts.packageJson;
this.pythonPath = opts.pythonPath ?? NAME_PYTHON;
this.cwd = opts.cwd ?? process.cwd();
this.tsconfigJsonPath = opts.tsconfigJson;
this.typeDocJsonPath = opts.typedocJson;
this.npmPath = opts.npm ?? NAME_NPM;
this.mkDocsYmlPath = opts.mkdocsYml;
if (opts.python) {
this.validations.add(NAME_PYTHON);
}
throw new DocutilsError(
`Missing ${NAME_TSCONFIG_JSON} at ${relTsconfigJsonPath}; "${NAME_BIN} init" can help`
);
}
log.success('TypeScript config OK');
}
/**
* Asserts TypeDoc is installed, runnable, the correct version, and that the config file is readable
* and constaints required options
* @param opts Path options
*/
export async function assertTypeDoc({
cwd = process.cwd(),
packageJsonPath,
typeDocJsonPath: typeDocJsonPath,
}: AssertTypeDocOpts = {}) {
const pkgDir = await requirePkgDir(cwd, packageJsonPath);
let rawTypeDocVersion: string;
let typeDocVersion: string;
try {
({stdout: rawTypeDocVersion} = await exec('npm', ['exec', 'typedoc', '--', '--version'], {
cwd: pkgDir,
}));
} catch {
throw new DocutilsError(`Could not find "typedoc" executable from ${pkgDir}`);
}
let match = rawTypeDocVersion.match(TYPEDOC_VERSION_REGEX);
if (match) {
typeDocVersion = match[1];
} else {
throw new DocutilsError(
`Could not parse TypeDoc version from "typedoc --version"; output was:\n ${rawTypeDocVersion}`
);
}
const reqdTypeDocVersion = DOCUTILS_PKG.dependencies!.typedoc!;
if (!satisfies(typeDocVersion, reqdTypeDocVersion)) {
throw new DocutilsError(
`Found TypeDoc version ${typeDocVersion}, but ${reqdTypeDocVersion} is required`
);
}
log.success('TypeDoc install OK');
typeDocJsonPath = typeDocJsonPath ?? path.join(pkgDir, NAME_TYPEDOC_JSON);
const relTypeDocJsonPath = relative(cwd, typeDocJsonPath);
let typeDocJson: TypeDocJson;
// handle the case where the user passes a JS file as the typedoc config
// (which is allowed by TypeDoc)
if (typeDocJsonPath?.endsWith('.js')) {
try {
typeDocJson = require(typeDocJsonPath);
} catch (err) {
throw new DocutilsError(`TypeDoc config at ${relTypeDocJsonPath} threw an exception: ${err}`);
if (opts.typescript) {
this.validations.add(NAME_TYPESCRIPT);
// npm validation is required for both typescript and typedoc validation
this.validations.add(NAME_NPM);
}
} else {
if (opts.typedoc) {
this.validations.add(NAME_TYPEDOC);
this.validations.add(NAME_NPM);
}
// this just tracks the emitted errors
this.on(DocutilsValidator.FAILURE, (err: DocutilsError) => {
this.emittedErrors.set(err.message, err);
});
}
/**
* Runs the configured validations, then resets internal state upon completion or rejection.
*/
public async validate() {
try {
typeDocJson = readTypedocJson(typeDocJsonPath);
this.emit(DocutilsValidator.BEGIN, [...this.validations]);
if (this.validations.has(NAME_PYTHON)) {
await this.validatePythonVersion();
await this.validatePythonDeps();
}
if (this.validations.has(NAME_NPM)) {
await this.validateNpmVersion();
}
if (this.validations.has(NAME_TYPESCRIPT)) {
await this.validateTypeScript();
await this.validateTypeScriptConfig();
}
if (this.validations.has(NAME_TYPEDOC)) {
await this.validateTypeDoc();
await this.validateTypeDocConfig();
}
this.emit(DocutilsValidator.END, this.emittedErrors.size);
} finally {
this.reset();
}
}
/**
* If a thing like `err` has not already been emitted, emit
* {@linkcode DocutilsValidator.FAILURE}.
* @param err A validation error
* @returns
*/
protected fail(err: DocutilsError) {
if (!this.emittedErrors.has(err.message)) {
this.emit(DocutilsValidator.FAILURE, err);
}
}
/**
* Resolves with a the parent directory of `package.json`, if we can find it.
*/
protected async findPkgDir(): Promise<string | undefined> {
return (
this.pkgDir ??
(this.pkgDir = this.packageJsonPath
? path.dirname(this.packageJsonPath)
: await findPkgDir(this.cwd))
);
}
protected ok(message: string) {
this.emit(DocutilsValidator.SUCCESS, message);
}
/**
* Parses a `requirements.txt` file and returns an array of packages
* @param requirementsTxtPath Path to `requirements.txt`
* @returns List of package data w/ name and version
*/
protected async parseRequirementsTxt(
requirementsTxtPath = REQUIREMENTS_TXT_PATH
): Promise<PipPackage[]> {
let requiredPackages: PipPackage[] = [];
try {
let requirementsTxt = await fs.readFile(requirementsTxtPath, 'utf8');
requirementsTxt = requirementsTxt.trim();
log.debug('Raw %s: %s', NAME_REQUIREMENTS_TXT, requirementsTxt);
for (const line of requirementsTxt.split(/\r?\n/)) {
const [name, version] = line.trim().split('==');
log.debug('Need Python package %s @ %s', name, version);
requiredPackages.push({name, version});
}
log.debug('Parsed %s: %O', NAME_REQUIREMENTS_TXT, requiredPackages);
} catch {
throw new DocutilsError(`Could not find ${requirementsTxtPath}. This is a bug`);
}
return requiredPackages;
}
protected reset() {
this.emittedErrors.clear();
}
protected async validateMkDocs() {}
/**
* Validates that the version of `npm` matches what's described in this package's `engines` field.
*
* This is required because other validators need `npm exec` to work, which is only available in npm 7+.
*/
protected async validateNpmVersion() {
const npmEngineRange = DOCUTILS_PKG.engines?.npm;
if (!npmEngineRange) {
throw new DocutilsError('Could not find property engines.npm in package.json. This is a bug');
}
try {
const {stdout: npmVersion} = await exec(this.npmPath, ['-v']);
if (!satisfies(npmVersion.trim(), npmEngineRange)) {
this.fail(
new DocutilsError(
`${NAME_NPM} is version ${npmVersion}, but ${npmEngineRange} is required`
)
);
return;
}
} catch {
this.fail(new DocutilsError(`Could not find ${this.npmPath} in PATH. Is it installed?`));
return;
}
this.ok(`${NAME_NPM} version OK`);
}
/**
* Asserts that the dependencies as listed in `requirements.txt` are installed.
*
* @privateRemarks This lists all installed packages with `pip` and then compares them to the
* contents of our `requirements.txt`. Versions _must_ match exactly.
*/
protected async validatePythonDeps() {
let pipListOutput: string;
try {
({stdout: pipListOutput} = await exec(this.pythonPath, [
'-m',
NAME_PIP,
'list',
'--format',
'json',
]));
} catch {
this.fail(new DocutilsError(`Could not find ${NAME_PIP} in PATH. Is it installed?`));
return;
}
let installedPkgs: PipPackage[];
try {
installedPkgs = JSON.parse(pipListOutput) as PipPackage[];
} catch {
throw new DocutilsError(
`Could not parse output of "${NAME_PIP} list" as JSON: ${pipListOutput}`
);
}
const pkgsByName = _.mapValues(_.keyBy(installedPkgs, 'name'), 'version');
log.debug('Installed Python packages: %O', pkgsByName);
const requiredPackages = await this.parseRequirementsTxt();
const missingPackages: PipPackage[] = [];
const invalidVersionPackages: [expected: PipPackage, actual: PipPackage][] = [];
for (const reqdPkg of requiredPackages) {
const version = pkgsByName[reqdPkg.name];
if (!version) {
missingPackages.push(reqdPkg);
}
if (version !== reqdPkg.version) {
invalidVersionPackages.push([reqdPkg, {name: reqdPkg.name, version}]);
}
}
const msgParts = [];
if (missingPackages.length) {
msgParts.push(
`The following required ${pluralize(
'package',
missingPackages.length
)} could not be found:\n${missingPackages
.map((p) => chalk`- {yellow ${p.name}} @ {yellow ${p.version}}`)
.join('\n')}`
);
}
if (invalidVersionPackages.length) {
msgParts.push(
`The following required ${pluralize(
'package',
invalidVersionPackages.length
)} are installed, but at the wrong version:\n${invalidVersionPackages
.map(
([expected, actual]) =>
chalk`- {yellow ${expected.name}} @ {yellow ${expected.version}} (found {red ${actual.version}})`
)
.join('\n')}`
);
}
if (msgParts.length) {
this.fail(
new DocutilsError(
`Required Python dependency validation failed:\n\n${msgParts.join('\n\n')}`
)
);
return;
}
this.ok('Python dependencies OK');
}
/**
* Asserts that the Python version is 3.x
*/
protected async validatePythonVersion() {
try {
const {stdout} = await exec(this.pythonPath, ['--version']);
if (!stdout.includes(PYTHON_VER_STR)) {
this.fail(
new DocutilsError(
`Could not find Python 3.x in PATH; found ${stdout}. Please use --python-path`
)
);
return;
}
} catch {
this.fail(new DocutilsError(`Could not find Python 3.x in PATH.`));
return;
}
this.ok('Python version OK');
}
/**
* Asserts TypeDoc is installed, runnable, the correct version, and that the config file is readable
* and constaints required options
*/
protected async validateTypeDoc() {
const pkgDir = await this.findPkgDir();
let rawTypeDocVersion: string;
let typeDocVersion: string;
try {
({stdout: rawTypeDocVersion} = await exec('npm', ['exec', NAME_TYPEDOC, '--', '--version'], {
cwd: pkgDir,
}));
} catch {
this.fail(new DocutilsError(`Could not find ${NAME_TYPEDOC} executable from ${pkgDir}`));
return;
}
if (rawTypeDocVersion) {
let match = rawTypeDocVersion.match(TYPEDOC_VERSION_REGEX);
if (match) {
typeDocVersion = match[1];
} else {
throw new DocutilsError(
`Could not parse TypeDoc version from "typedoc --version"; output was:\n ${rawTypeDocVersion}`
);
}
const reqdTypeDocVersion = DOCUTILS_PKG.dependencies!.typedoc!;
if (!satisfies(typeDocVersion, reqdTypeDocVersion)) {
this.fail(
new DocutilsError(
`Found TypeDoc version ${typeDocVersion}, but ${reqdTypeDocVersion} is required`
)
);
return;
}
this.ok('TypeDoc install OK');
}
}
/**
* Validates the `typedoc.json` file
*/
protected async validateTypeDocConfig() {
const pkgDir = await this.findPkgDir();
if (!pkgDir) {
this.fail(new DocutilsError(`Could not find package.json in ${this.cwd}`));
return;
}
const typeDocJsonPath = (this.typeDocJsonPath =
this.typeDocJsonPath ?? path.join(pkgDir, NAME_TYPEDOC_JSON));
const relTypeDocJsonPath = relative(this.cwd, typeDocJsonPath);
let typeDocJson: TypeDocJson;
// handle the case where the user passes a JS file as the typedoc config
// (which is allowed by TypeDoc)
if (typeDocJsonPath.endsWith('.js')) {
try {
typeDocJson = require(typeDocJsonPath);
} catch (err) {
throw new DocutilsError(
`TypeDoc config at ${relTypeDocJsonPath} threw an exception: ${err}`
);
}
} else {
try {
typeDocJson = readTypedocJson(typeDocJsonPath);
} catch (e) {
if (e instanceof SyntaxError) {
return this.fail(
new DocutilsError(`Unparseable ${NAME_TYPEDOC_JSON} at ${relTypeDocJsonPath}: ${e}`)
);
}
return this.fail(
new DocutilsError(
`Missing ${NAME_TYPEDOC_JSON} at ${relTypeDocJsonPath}; "${NAME_BIN} init" can help`
)
);
}
}
if (!typeDocJson.out) {
return this.fail(
new DocutilsError(
`Missing "out" property in ${relTypeDocJsonPath}; path "${DEFAULT_REL_TYPEDOC_OUT_PATH} is recommended`
)
);
}
this.ok('TypeDoc config OK');
}
/**
* Asserts that TypeScript is installed, runnable, the correct version, and a parseable `tsconfig.json` exists.
*/
protected async validateTypeScript() {
const pkgDir = await this.findPkgDir();
if (!pkgDir) {
return this.fail(new DocutilsError(`Could not find package.json in ${this.cwd}`));
}
let typeScriptVersion: string;
let rawTypeScriptVersion: string;
try {
({stdout: rawTypeScriptVersion} = await exec(NAME_NPM, ['exec', 'tsc', '--', '--version'], {
cwd: pkgDir,
}));
} catch {
return this.fail(
new DocutilsError(`Could not find TypeScript compiler ("tsc") from ${pkgDir}`)
);
}
let match = rawTypeScriptVersion.match(TYPESCRIPT_VERSION_REGEX);
if (match) {
typeScriptVersion = match[1];
} else {
return this.fail(
new DocutilsError(
`Could not parse TypeScript version from "tsc --version"; output was:\n ${rawTypeScriptVersion}`
)
);
}
const reqdTypeScriptVersion = DOCUTILS_PKG.dependencies?.typescript;
if (!reqdTypeScriptVersion) {
throw new DocutilsError(
`Could not find a dep for ${NAME_TYPESCRIPT} in ${NAME_PACKAGE_JSON}. This is a bug.`
);
}
if (!satisfies(typeScriptVersion, reqdTypeScriptVersion)) {
return this.fail(
new DocutilsError(
`Found TypeScript version ${typeScriptVersion}, but ${reqdTypeScriptVersion} is required`
)
);
}
this.ok('TypeScript install OK');
}
protected async validateTypeScriptConfig() {
const pkgDir = await this.findPkgDir();
if (!pkgDir) {
return this.fail(new DocutilsError(`Could not find package.json in ${this.cwd}`));
}
const tsconfigJsonPath = (this.tsconfigJsonPath =
this.tsconfigJsonPath ?? path.join(pkgDir, NAME_TSCONFIG_JSON));
const relTsconfigJsonPath = relative(this.cwd, tsconfigJsonPath);
try {
await readJson5(tsconfigJsonPath);
} catch (e) {
if (e instanceof SyntaxError) {
throw new DocutilsError(`Unparseable ${NAME_TYPEDOC_JSON} at ${relTypeDocJsonPath}: ${e}`);
return this.fail(
new DocutilsError(`Unparseable ${NAME_TSCONFIG_JSON} at ${relTsconfigJsonPath}: ${e}`)
);
}
throw new DocutilsError(
`Missing ${NAME_TYPEDOC_JSON} at ${relTypeDocJsonPath}; "${NAME_BIN} init" can help`
return this.fail(
new DocutilsError(
`Missing ${NAME_TSCONFIG_JSON} at ${relTsconfigJsonPath}; "${NAME_BIN} init" can help`
)
);
}
this.ok('TypeScript config OK');
}
if (!typeDocJson.out) {
throw new DocutilsError(
`Missing "out" property in ${relTypeDocJsonPath}; path "${DEFAULT_REL_TYPEDOC_OUT_PATH} is recommended`
);
}
log.success('TypeDoc config OK');
}
export async function assertPython({pythonPath}: AssertPythonOpts = {}) {
await assertPythonVersion(pythonPath);
await assertPythonDependencies(pythonPath);
}
export interface AssertPythonOpts {
pythonPath?: string;
}
export interface AssertTypeScriptOpts {
export interface DocutilsValidatorOpts {
cwd?: string;
packageJsonPath?: string;
tsconfigJsonPath?: string;
}
export interface AssertTypeDocOpts {
cwd?: string;
packageJsonPath?: string;
typeDocJsonPath?: string;
}
/**
* @todo Implement
*/
export async function assertMkdocs() {}
export async function validate({
packageJson: packageJsonPath,
pythonPath,
python,
typedoc,
typescript,
tsconfigJson: tsconfigJsonPath,
typedocJson: typeDocJsonPath,
}: ValidateOpts = {}) {
let failed = false;
if (python) {
try {
await assertPython({pythonPath});
} catch (e) {
failed = true;
log.error(e instanceof DocutilsError ? e.message : e);
}
}
if (typescript || typedoc) {
try {
await assertNpmVersion();
} catch (e) {
failed = true;
log.error(e);
}
}
if (typescript) {
try {
await assertTypeScript({tsconfigJsonPath, packageJsonPath});
} catch (e) {
failed = true;
log.error(e instanceof DocutilsError ? e.message : e);
}
}
if (typedoc) {
try {
await assertTypeDoc({typeDocJsonPath, packageJsonPath});
} catch (e) {
failed = true;
log.error(e instanceof DocutilsError ? e.message : e);
}
}
if (failed) {
throw new DocutilsError('Validation failed');
}
log.success('Everything looks good!');
}
export interface ValidateOpts {
pythonPath?: string;
python?: boolean;
typedoc?: boolean;
typescript?: boolean;
tsconfigJson?: string;
typedocJson?: string;
packageJson?: string;
mkdocsYml?: string;
npm?: string;
packageJson?: string;
python?: boolean;
pythonPath?: string;
tsconfigJson?: string;
typedoc?: boolean;
typedocJson?: string;
typescript?: boolean;
}
+1
View File
@@ -58,6 +58,7 @@
"json5": "2.2.3",
"lodash": "4.17.21",
"pkg-dir": "5.0.0",
"pluralize": "8.0.0",
"read-pkg": "5.2.0",
"semver": "7.3.8",
"source-map-support": "0.5.21",