mirror of
https://github.com/appium/appium.git
synced 2026-04-24 12:28:51 -05:00
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:
Generated
+3
-1
@@ -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",
|
||||
|
||||
@@ -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)}`
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user