Merge pull request #14908 from backstage/sharks/repo-tools-config

add config options to allow warnings and omit messages
This commit is contained in:
Patrik Oldsberg
2022-12-13 11:33:17 +01:00
committed by GitHub
12 changed files with 870 additions and 134 deletions
+11
View File
@@ -0,0 +1,11 @@
---
'@backstage/repo-tools': patch
---
Add new command options to the `api-report`
- added `--allow-warnings`, `-a` to continue processing packages if selected packages have warnings
- added `--allow-all-warnings` to continue processing packages any packages have warnings
- added `--omit-messages`, `-o` to pass some warnings messages code to be omitted from the api-report.md files
- The `paths` argument for this command now takes as default the value on `workspaces.packages` inside the root package.json
- change the path resolution to use the `@backstage/cli-common` packages instead
+1 -1
View File
@@ -11,7 +11,7 @@
"build:backend": "yarn workspace backend build",
"build:all": "backstage-cli repo build --all",
"build:api-reports": "yarn build:api-reports:only --tsc",
"build:api-reports:only": "backstage-repo-tools api-reports",
"build:api-reports:only": "backstage-repo-tools api-reports --allow-warnings 'packages/core-components,plugins/+(catalog|catalog-import|git-release-manager|jenkins|kubernetes)'",
"build:api-docs": "LANG=en_EN yarn build:api-reports --docs",
"tsc": "tsc",
"tsc:full": "backstage-cli repo clean && tsc --skipLibCheck false --incremental false",
+5 -2
View File
@@ -12,7 +12,7 @@ Options:
-h, --help
Commands:
api-reports [options] [path...]
api-reports [options] [paths...]
type-deps
help [command]
```
@@ -20,12 +20,15 @@ Commands:
### `backstage-repo-tools api-reports`
```
Usage: backstage-repo-tools api-reports [options] [path...]
Usage: backstage-repo-tools api-reports [options] [paths...]
Options:
--ci
--tsc
--docs
-a, --allow-warnings <allowWarningsPaths>
--allow-all-warnings
-o, --omit-messages <messageCodes>
-h, --help
```
+10
View File
@@ -30,6 +30,7 @@
"backstage-repo-tools": "bin/backstage-repo-tools"
},
"dependencies": {
"@backstage/cli-common": "workspace:^",
"@backstage/errors": "workspace:^",
"@manypkg/get-packages": "^1.1.3",
"@microsoft/api-documenter": "^7.17.11",
@@ -39,8 +40,17 @@
"chalk": "^4.0.0",
"commander": "^9.1.0",
"fs-extra": "10.1.0",
"glob": "^8.0.3",
"is-glob": "^4.0.3",
"minimatch": "^5.1.1",
"ts-node": "^10.0.0"
},
"devDependencies": {
"@backstage/cli": "workspace:^",
"@types/is-glob": "^4.0.2",
"@types/mock-fs": "^4.13.0",
"mock-fs": "^5.1.0"
},
"files": [
"bin",
"dist/**/*.js"
@@ -65,9 +65,11 @@ import {
} from '@microsoft/api-documenter/lib/markdown/CustomMarkdownEmitter';
import { IMarkdownEmitterContext } from '@microsoft/api-documenter/lib/markdown/MarkdownEmitter';
import { AstDeclaration } from '@microsoft/api-extractor/lib/analyzer/AstDeclaration';
import { paths as cliPaths } from '../../lib/paths';
const tmpDir = resolvePath(
process.cwd(),
import minimatch from 'minimatch';
const tmpDir = cliPaths.resolveTargetRoot(
'./node_modules/.cache/api-extractor',
);
@@ -219,77 +221,8 @@ ApiReportGenerator.generateReviewFileContent =
});
};
const PACKAGE_ROOTS = ['packages', 'plugins'];
const ALLOW_WARNINGS = [
'packages/core-components',
'plugins/catalog',
'plugins/catalog-import',
'plugins/git-release-manager',
'plugins/jenkins',
'plugins/kubernetes',
];
async function resolvePackagePath(
packagePath: string,
): Promise<string | undefined> {
const projectRoot = resolvePath(process.cwd());
const fullPackageDir = resolvePath(projectRoot, packagePath);
const stat = await fs.stat(fullPackageDir);
if (!stat.isDirectory()) {
return undefined;
}
try {
const packageJsonPath = join(fullPackageDir, 'package.json');
await fs.access(packageJsonPath);
} catch (_) {
return undefined;
}
return relativePath(projectRoot, fullPackageDir);
}
export async function findSpecificPackageDirs(unresolvedPackageDirs: string[]) {
const packageDirs = new Array<string>();
for (const unresolvedPackageDir of unresolvedPackageDirs) {
const packageDir = await resolvePackagePath(unresolvedPackageDir);
if (!packageDir) {
throw new Error(`'${unresolvedPackageDir}' is not a valid package path`);
}
packageDirs.push(packageDir);
}
if (packageDirs.length === 0) {
return undefined;
}
return packageDirs;
}
export async function findPackageDirs() {
const packageDirs = new Array<string>();
const projectRoot = resolvePath(process.cwd());
for (const packageRoot of PACKAGE_ROOTS) {
const dirs = await fs.readdir(resolvePath(projectRoot, packageRoot));
for (const dir of dirs) {
const packageDir = await resolvePackagePath(join(packageRoot, dir));
if (!packageDir) {
continue;
}
packageDirs.push(packageDir);
}
}
return packageDirs;
}
export async function createTemporaryTsConfig(includedPackageDirs: string[]) {
const path = resolvePath(process.cwd(), 'tsconfig.tmp.json');
const path = cliPaths.resolveTargetRoot('tsconfig.tmp.json');
process.once('exit', () => {
fs.removeSync(path);
@@ -375,6 +308,8 @@ interface ApiExtractionOptions {
outputDir: string;
isLocalBuild: boolean;
tsconfigFilePath: string;
allowWarnings?: boolean | string[];
omitMessages?: string[];
}
export async function runApiExtraction({
@@ -382,25 +317,37 @@ export async function runApiExtraction({
outputDir,
isLocalBuild,
tsconfigFilePath,
allowWarnings = false,
omitMessages = [],
}: ApiExtractionOptions) {
await fs.remove(outputDir);
const entryPoints = packageDirs.map(packageDir => {
return resolvePath(
process.cwd(),
return cliPaths.resolveTargetRoot(
`./dist-types/${packageDir}/src/index.d.ts`,
);
});
let compilerState: CompilerState | undefined = undefined;
const allowWarningPkg = Array.isArray(allowWarnings) ? allowWarnings : [];
const messagesConf: { [key: string]: { logLevel: string } } = {};
for (const messageCode of omitMessages) {
messagesConf[messageCode] = {
logLevel: 'none',
};
}
const warnings = new Array<string>();
for (const packageDir of packageDirs) {
console.log(`## Processing ${packageDir}`);
const projectFolder = resolvePath(process.cwd(), packageDir);
const packageFolder = resolvePath(
process.cwd(),
const noBail = Array.isArray(allowWarnings)
? allowWarnings.some(aw => aw === packageDir || minimatch(packageDir, aw))
: allowWarnings;
const projectFolder = cliPaths.resolveTargetRoot(packageDir);
const packageFolder = cliPaths.resolveTargetRoot(
'./dist-types',
packageDir,
);
@@ -453,6 +400,7 @@ export async function runApiExtraction({
logLevel: 'warning' as ExtractorLogLevel.Warning,
addToApiReportFile: true,
},
...messagesConf,
},
tsdocMessageReporting: {
default: {
@@ -543,12 +491,15 @@ export async function runApiExtraction({
}
const warningCountAfter = await countApiReportWarnings(projectFolder);
if (warningCountAfter > 0 && !ALLOW_WARNINGS.includes(packageDir)) {
if (noBail) {
console.log(`Skipping warnings check for ${packageDir}`);
}
if (warningCountAfter > 0 && !noBail) {
throw new Error(
`The API Report for ${packageDir} is not allowed to have warnings`,
);
}
if (warningCountAfter === 0 && ALLOW_WARNINGS.includes(packageDir)) {
if (warningCountAfter === 0 && allowWarningPkg.includes(packageDir)) {
console.log(
`No need to allow warnings for ${packageDir}, it does not have any`,
);
@@ -1131,10 +1082,7 @@ export async function buildDocs({
documenter.generateFiles();
}
export async function categorizePackageDirs(
projectRoot: string,
packageDirs: any[],
) {
export async function categorizePackageDirs(packageDirs: any[]) {
const dirs = packageDirs.slice();
const tsPackageDirs = new Array<string>();
const cliPackageDirs = new Array<string>();
@@ -1150,7 +1098,7 @@ export async function categorizePackageDirs(
}
const pkgJson = await fs
.readJson(resolvePath(projectRoot, dir, 'package.json'))
.readJson(cliPaths.resolveTargetRoot(dir, 'package.json'))
.catch(error => {
if (error.code === 'ENOENT') {
return undefined;
@@ -1204,6 +1152,7 @@ function parseHelpPage(helpPageContent: string) {
let options = new Array<string>();
let commands = new Array<string>();
let commandArguments = new Array<string>();
while (lines.length > 0) {
while (lines.length > 0 && !lines[0].endsWith(':')) {
@@ -1228,6 +1177,8 @@ function parseHelpPage(helpPageContent: string) {
options = sectionItems;
} else if (sectionName?.toLocaleLowerCase('en-US') === 'commands:') {
commands = sectionItems;
} else if (sectionName?.toLocaleLowerCase('en-US') === 'arguments:') {
commandArguments = sectionItems;
} else {
throw new Error(`Unknown CLI section: ${sectionName}`);
}
@@ -1238,6 +1189,7 @@ function parseHelpPage(helpPageContent: string) {
usage,
options,
commands,
commandArguments,
};
}
@@ -1249,6 +1201,7 @@ interface CliHelpPage {
usage: string | undefined;
options: string[];
commands: string[];
commandArguments: string[];
}
async function exploreCliHelpPages(
@@ -1316,19 +1269,17 @@ function generateCliReport(name: string, models: CliModel[]): string {
}
interface CliExtractionOptions {
projectRoot: string;
packageDirs: string[];
isLocalBuild: boolean;
}
export async function runCliExtraction({
projectRoot,
packageDirs,
isLocalBuild,
}: CliExtractionOptions) {
for (const packageDir of packageDirs) {
console.log(`## Processing ${packageDir}`);
const fullDir = resolvePath(projectRoot, packageDir);
const fullDir = cliPaths.resolveTargetRoot(packageDir);
const pkgJson = await fs.readJson(resolvePath(fullDir, 'package.json'));
if (!pkgJson.bin) {
@@ -1371,7 +1322,7 @@ export async function runCliExtraction({
console.log('');
console.log(
`The conflicting file is ${relativePath(
projectRoot,
cliPaths.targetRoot,
reportPath,
)}, expecting the following content:`,
);
@@ -0,0 +1,467 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import mockFs from 'mock-fs';
import { resolve as resolvePath } from 'path';
import * as pathsLib from '../../lib/paths';
import {
buildDocs,
runCliExtraction,
runApiExtraction,
categorizePackageDirs,
} from './api-extractor';
import { buildApiReports } from './api-reports';
import { generateTypeDeclarations } from './generateTypeDeclarations';
jest.mock('./generateTypeDeclarations');
// create mocks for the dependencies of the `buildApiReports` function
jest.mock('./api-extractor', () => ({
createTemporaryTsConfig: jest.fn(),
categorizePackageDirs: jest.fn().mockImplementation(async (p: string[]) => {
console.log('categorizePackageDirs', p);
return {
tsPackageDirs: p,
cliPackageDirs: p,
};
}),
runApiExtraction: jest.fn(),
runCliExtraction: jest.fn(),
buildDocs: jest.fn(),
}));
const projectPaths = pathsLib.paths;
jest.spyOn(projectPaths, 'targetRoot', 'get').mockReturnValue('/root');
jest.spyOn(projectPaths, 'resolveTargetRoot').mockImplementation((...path) => {
return resolvePath('/root', ...path);
});
describe('buildApiReports', () => {
beforeEach(() => {
mockFs({
[projectPaths.targetRoot]: {
'package.json': JSON.stringify({
workspaces: { packages: ['packages/*', 'plugins/*'] },
}),
packages: {
'package-a': {
'package.json': '{}',
},
'package-b': {
'package.json': '{}',
},
'package-c': {},
'README.md': 'Hello World',
},
plugins: {
'plugin-a': {
'package.json': '{}',
},
'plugin-b': {
'package.json': '{}',
},
'plugin-c': {
'package.json': '{}',
},
},
},
});
});
afterEach(() => {
mockFs.restore();
jest.clearAllMocks();
});
it('should run whitout any options', async () => {
const opts = {};
const paths: string[] = [];
await buildApiReports(paths, opts);
expect(categorizePackageDirs).toHaveBeenCalledWith([
'packages/package-a',
'packages/package-b',
'plugins/plugin-a',
'plugins/plugin-b',
'plugins/plugin-c',
]);
expect(generateTypeDeclarations).not.toHaveBeenCalled();
expect(runApiExtraction).toHaveBeenCalledWith({
packageDirs: [
'packages/package-a',
'packages/package-b',
'plugins/plugin-a',
'plugins/plugin-b',
'plugins/plugin-c',
],
tsconfigFilePath: '/root/tsconfig.json',
allowWarnings: [],
omitMessages: [],
isLocalBuild: true,
outputDir: '/root/node_modules/.cache/api-extractor',
});
expect(runCliExtraction).toHaveBeenCalledWith({
packageDirs: [
'packages/package-a',
'packages/package-b',
'plugins/plugin-a',
'plugins/plugin-b',
'plugins/plugin-c',
],
isLocalBuild: true,
});
expect(buildDocs).not.toHaveBeenCalled();
});
describe('paths', () => {
it('should generate API reports for one specific package', async () => {
const paths = ['packages/package-a'];
const opts = {};
await buildApiReports(paths, opts);
expect(categorizePackageDirs).toHaveBeenCalledWith([
'packages/package-a',
]);
expect(runApiExtraction).toHaveBeenCalledWith({
packageDirs: ['packages/package-a'],
tsconfigFilePath: '/root/tsconfig.json',
allowWarnings: [],
omitMessages: [],
isLocalBuild: true,
outputDir: '/root/node_modules/.cache/api-extractor',
});
expect(runCliExtraction).toHaveBeenCalledWith({
packageDirs: ['packages/package-a'],
isLocalBuild: true,
});
expect(buildDocs).not.toHaveBeenCalled();
});
it('should generate API reports for multiple specific packages', async () => {
const paths = ['packages/package-a', 'packages/package-b'];
const opts = {};
await buildApiReports(paths, opts);
expect(categorizePackageDirs).toHaveBeenCalledWith([
'packages/package-a',
'packages/package-b',
]);
expect(runApiExtraction).toHaveBeenCalledWith({
packageDirs: ['packages/package-a', 'packages/package-b'],
tsconfigFilePath: '/root/tsconfig.json',
allowWarnings: [],
omitMessages: [],
isLocalBuild: true,
outputDir: '/root/node_modules/.cache/api-extractor',
});
expect(runCliExtraction).toHaveBeenCalledWith({
packageDirs: ['packages/package-a', 'packages/package-b'],
isLocalBuild: true,
});
expect(buildDocs).not.toHaveBeenCalled();
});
it('should generate API reports for all packages matching the glob pattern', async () => {
const paths = ['packages/*'];
const opts = {};
await buildApiReports(paths, opts);
expect(categorizePackageDirs).toHaveBeenCalledWith([
'packages/package-a',
'packages/package-b',
]);
expect(runApiExtraction).toHaveBeenCalledWith({
packageDirs: ['packages/package-a', 'packages/package-b'],
tsconfigFilePath: '/root/tsconfig.json',
allowWarnings: [],
omitMessages: [],
isLocalBuild: true,
outputDir: '/root/node_modules/.cache/api-extractor',
});
expect(runCliExtraction).toHaveBeenCalledWith({
packageDirs: ['packages/package-a', 'packages/package-b'],
isLocalBuild: true,
});
expect(buildDocs).not.toHaveBeenCalled();
});
it('should generate API reports for all packages matching multiple glob patterns', async () => {
const paths = ['packages/*', 'plugins/*a'];
const opts = {};
await buildApiReports(paths, opts);
expect(categorizePackageDirs).toHaveBeenCalledWith([
'packages/package-a',
'packages/package-b',
'plugins/plugin-a',
]);
expect(runApiExtraction).toHaveBeenCalledWith({
packageDirs: [
'packages/package-a',
'packages/package-b',
'plugins/plugin-a',
],
tsconfigFilePath: '/root/tsconfig.json',
allowWarnings: [],
omitMessages: [],
isLocalBuild: true,
outputDir: '/root/node_modules/.cache/api-extractor',
});
expect(runCliExtraction).toHaveBeenCalledWith({
packageDirs: [
'packages/package-a',
'packages/package-b',
'plugins/plugin-a',
],
isLocalBuild: true,
});
expect(buildDocs).not.toHaveBeenCalled();
});
it('should generate API reports for specific packages and glob pattern', async () => {
const opts = {};
const paths = ['packages/package-a', 'plugins/*'];
await buildApiReports(paths, opts);
expect(categorizePackageDirs).toHaveBeenCalledWith([
'packages/package-a',
'plugins/plugin-a',
'plugins/plugin-b',
'plugins/plugin-c',
]);
expect(runApiExtraction).toHaveBeenCalledWith({
packageDirs: [
'packages/package-a',
'plugins/plugin-a',
'plugins/plugin-b',
'plugins/plugin-c',
],
tsconfigFilePath: '/root/tsconfig.json',
allowWarnings: [],
omitMessages: [],
isLocalBuild: true,
outputDir: '/root/node_modules/.cache/api-extractor',
});
expect(runCliExtraction).toHaveBeenCalledWith({
packageDirs: [
'packages/package-a',
'plugins/plugin-a',
'plugins/plugin-b',
'plugins/plugin-c',
],
isLocalBuild: true,
});
expect(buildDocs).not.toHaveBeenCalled();
});
});
describe('allowWarnings', () => {
it('should accept single path value', async () => {
const opts = {
allowWarnings: 'packages/package-a',
};
const paths = ['packages/*'];
await buildApiReports(paths, opts);
expect(runApiExtraction).toHaveBeenCalledWith({
packageDirs: ['packages/package-a', 'packages/package-b'],
tsconfigFilePath: '/root/tsconfig.json',
allowWarnings: ['packages/package-a'],
omitMessages: [],
isLocalBuild: true,
outputDir: '/root/node_modules/.cache/api-extractor',
});
});
it('should accept multiple path values as comma separated string', async () => {
const opts = {
allowWarnings: 'packages/package-a,packages/package-b',
};
const paths = ['packages/*'];
await buildApiReports(paths, opts);
expect(runApiExtraction).toHaveBeenCalledWith({
packageDirs: ['packages/package-a', 'packages/package-b'],
tsconfigFilePath: '/root/tsconfig.json',
allowWarnings: ['packages/package-a', 'packages/package-b'],
omitMessages: [],
isLocalBuild: true,
outputDir: '/root/node_modules/.cache/api-extractor',
});
});
it('should accept multiple path values as comma separated string with spaces', async () => {
const opts = {
allowWarnings: 'packages/package-a, packages/package-b',
};
const paths = ['packages/*'];
await buildApiReports(paths, opts);
expect(runApiExtraction).toHaveBeenCalledWith({
packageDirs: ['packages/package-a', 'packages/package-b'],
tsconfigFilePath: '/root/tsconfig.json',
allowWarnings: ['packages/package-a', 'packages/package-b'],
omitMessages: [],
isLocalBuild: true,
outputDir: '/root/node_modules/.cache/api-extractor',
});
});
});
describe('allowAllWarnings', () => {
it('should accept boolean values', async () => {
const opts = {
allowAllWarnings: true,
};
const paths = ['packages/*'];
await buildApiReports(paths, opts);
expect(runApiExtraction).toHaveBeenCalledWith({
packageDirs: ['packages/package-a', 'packages/package-b'],
tsconfigFilePath: '/root/tsconfig.json',
allowWarnings: true,
omitMessages: [],
isLocalBuild: true,
outputDir: '/root/node_modules/.cache/api-extractor',
});
});
});
describe('omitMessages', () => {
it('should accept single message value', async () => {
const opts = {
omitMessages: 'ae-missing-release-tag',
};
const paths = ['packages/*'];
await buildApiReports(paths, opts);
expect(runApiExtraction).toHaveBeenCalledWith({
packageDirs: ['packages/package-a', 'packages/package-b'],
tsconfigFilePath: '/root/tsconfig.json',
allowWarnings: [],
omitMessages: ['ae-missing-release-tag'],
isLocalBuild: true,
outputDir: '/root/node_modules/.cache/api-extractor',
});
});
it('should accept multiple message values as comma separated string', async () => {
const opts = {
omitMessages: 'ae-missing-release-tag,ae-missing-annotations',
};
const paths = ['packages/*'];
await buildApiReports(paths, opts);
expect(runApiExtraction).toHaveBeenCalledWith({
packageDirs: ['packages/package-a', 'packages/package-b'],
tsconfigFilePath: '/root/tsconfig.json',
allowWarnings: [],
omitMessages: ['ae-missing-release-tag', 'ae-missing-annotations'],
isLocalBuild: true,
outputDir: '/root/node_modules/.cache/api-extractor',
});
});
it('should accept multiple message values as comma separated string with spaces', async () => {
const opts = {
omitMessages: 'ae-missing-release-tag, ae-missing-annotations',
};
const paths = ['packages/*'];
await buildApiReports(paths, opts);
expect(runApiExtraction).toHaveBeenCalledWith({
packageDirs: ['packages/package-a', 'packages/package-b'],
tsconfigFilePath: '/root/tsconfig.json',
allowWarnings: [],
omitMessages: ['ae-missing-release-tag', 'ae-missing-annotations'],
isLocalBuild: true,
outputDir: '/root/node_modules/.cache/api-extractor',
});
});
});
describe('isCI', () => {
it('should set localBuild to false if CI option is passed', async () => {
const opts = {
ci: true,
};
const paths = ['packages/*'];
await buildApiReports(paths, opts);
expect(runApiExtraction).toHaveBeenCalledWith({
packageDirs: ['packages/package-a', 'packages/package-b'],
tsconfigFilePath: '/root/tsconfig.json',
allowWarnings: [],
omitMessages: [],
isLocalBuild: false,
outputDir: '/root/node_modules/.cache/api-extractor',
});
expect(runCliExtraction).toHaveBeenCalledWith({
packageDirs: ['packages/package-a', 'packages/package-b'],
isLocalBuild: false,
});
});
});
describe('docs', () => {
it('should run typedoc if docs option is passed', async () => {
const opts = {
docs: true,
};
const paths = ['packages/*'];
await buildApiReports(paths, opts);
expect(buildDocs).toHaveBeenCalledWith({
inputDir: '/root/node_modules/.cache/api-extractor',
outputDir: '/root/docs/reference',
});
});
});
describe('tsc', () => {
it('should run tsc if tsc option is passed', async () => {
const opts = {
tsc: true,
};
const paths = ['packages/*'];
await buildApiReports(paths, opts);
expect(generateTypeDeclarations).toHaveBeenCalled();
});
});
});
@@ -15,44 +15,52 @@
*/
import { OptionValues } from 'commander';
import { resolve as resolvePath } from 'path';
import fs from 'fs-extra';
import { spawnSync } from 'child_process';
import {
findSpecificPackageDirs,
createTemporaryTsConfig,
findPackageDirs,
categorizePackageDirs,
runApiExtraction,
runCliExtraction,
buildDocs,
} from './api-extractor';
import { findPackageDirs, paths as cliPaths } from '../../lib/paths';
import { generateTypeDeclarations } from './generateTypeDeclarations';
export default async (paths: string[], opts: OptionValues) => {
const tmpDir = resolvePath(
process.cwd(),
type Options = {
ci?: boolean;
docs?: boolean;
tsc?: boolean;
allowWarnings?: string;
allowAllWarnings?: boolean;
omitMessages?: string;
} & OptionValues;
export const buildApiReports = async (paths: string[] = [], opts: Options) => {
const tmpDir = cliPaths.resolveTargetRoot(
'./node_modules/.cache/api-extractor',
);
const projectRoot = resolvePath(process.cwd());
const isCiBuild = opts.ci;
const isDocsBuild = opts.docs;
const runTsc = opts.tsc;
const allowWarnings = parseArrayOption(opts.allowWarnings);
const allowAllWarnings = opts.allowAllWarnings;
const omitMessages = parseArrayOption(opts.omitMessages);
const selectedPackageDirs = await findSpecificPackageDirs(paths);
const isAllPackages = !paths?.length;
const selectedPaths = isAllPackages
? await getWorkspacePackagePathPatterns()
: paths;
const selectedPackageDirs = await findPackageDirs(selectedPaths);
if (selectedPackageDirs && isCiBuild) {
throw new Error(
'Package path arguments are not supported together with the --ci flag',
);
}
if (!selectedPackageDirs && !isCiBuild && !isDocsBuild) {
if (isAllPackages && !isCiBuild && !isDocsBuild) {
console.log('');
console.log(
'TIP: You can generate api-reports for select packages by passing package paths:',
);
console.log('');
console.log(
' yarn build:api-reports packages/config packages/core-plugin-api',
' yarn build:api-reports packages/config packages/core-plugin-api plugins/*',
);
console.log('');
}
@@ -62,34 +70,15 @@ export default async (paths: string[], opts: OptionValues) => {
temporaryTsConfigPath = await createTemporaryTsConfig(selectedPackageDirs);
}
const tsconfigFilePath =
temporaryTsConfigPath ?? resolvePath(projectRoot, 'tsconfig.json');
temporaryTsConfigPath ?? cliPaths.resolveTargetRoot('tsconfig.json');
if (runTsc) {
await fs.remove(resolvePath(projectRoot, 'dist-types'));
const { status } = spawnSync(
'yarn',
[
'tsc',
['--project', tsconfigFilePath],
['--skipLibCheck', 'false'],
['--incremental', 'false'],
].flat(),
{
stdio: 'inherit',
shell: true,
cwd: projectRoot,
},
);
if (status !== 0) {
process.exit(status || undefined);
}
console.log('# Compiling TypeScript');
await generateTypeDeclarations(tsconfigFilePath);
}
const packageDirs = selectedPackageDirs ?? (await findPackageDirs());
const { tsPackageDirs, cliPackageDirs } = await categorizePackageDirs(
projectRoot,
packageDirs,
selectedPackageDirs,
);
if (tsPackageDirs.length > 0) {
@@ -99,12 +88,13 @@ export default async (paths: string[], opts: OptionValues) => {
outputDir: tmpDir,
isLocalBuild: !isCiBuild,
tsconfigFilePath,
allowWarnings: allowAllWarnings || allowWarnings,
omitMessages: Array.isArray(omitMessages) ? omitMessages : [],
});
}
if (cliPackageDirs.length > 0) {
console.log('# Generating package CLI reports');
await runCliExtraction({
projectRoot,
packageDirs: cliPackageDirs,
isLocalBuild: !isCiBuild,
});
@@ -114,7 +104,48 @@ export default async (paths: string[], opts: OptionValues) => {
console.log('# Generating package documentation');
await buildDocs({
inputDir: tmpDir,
outputDir: resolvePath(projectRoot, 'docs/reference'),
outputDir: cliPaths.resolveTargetRoot('docs/reference'),
});
}
};
/**
* Retrieves the list of package names in the "workspaces" field of the `package.json` file in the current workspace root.
*
* If the file does not exist, or the "workspaces" field is not present, returns `undefined`.
*
* @returns {Promise<string[] | undefined>} The list of package names, or `undefined` if not found.
*/
async function getWorkspacePackagePathPatterns() {
const pkgJson = await fs
.readJson(cliPaths.resolveTargetRoot('package.json'))
.catch(error => {
if (error.code === 'ENOENT') {
return undefined;
}
throw error;
});
const workspaces = pkgJson?.workspaces?.packages;
return workspaces;
}
/**
* Splits the input string on comma, and returns an array of the resulting substrings.
* for `undefined` or an empty string, returns an empty array.
*
* @param value A string to be split on comma.
* @returns An array of the resulting substrings, or an empty array if the input value is `undefined` or an empty string.
*
* @example
* parseOption('foo,bar,baz')
* // returns ['foo', 'bar', 'baz']
*
* parseOption('')
* // returns []
*
* parseOption()
* // returns []
*/
function parseArrayOption(value: string | undefined) {
return value ? value.split(',').map(s => s.trim()) : [];
}
@@ -0,0 +1,50 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import fs from 'fs-extra';
import { spawnSync } from 'child_process';
import { paths as cliPaths } from '../../lib/paths';
/**
* Generates the TypeScript declaration files for the specified project, using the provided `tsconfig.json` file.
*
* Any existing declaration files in the `dist-types` directory will be deleted before generating the new ones.
*
* If the `tsc` command exits with a non-zero exit code, the process will be terminated with the same exit code.
*
* @param tsconfigFilePath {string} The path to the `tsconfig.json` file to use for generating the declaration files.
* @returns {Promise<void>} A promise that resolves when the declaration files have been generated.
*/
export async function generateTypeDeclarations(tsconfigFilePath: string) {
await fs.remove(cliPaths.resolveTargetRoot('dist-types'));
const { status } = spawnSync(
'yarn',
[
'tsc',
['--project', tsconfigFilePath],
['--skipLibCheck', 'false'],
['--incremental', 'false'],
].flat(),
{
stdio: 'inherit',
shell: true,
cwd: cliPaths.targetRoot,
},
);
if (status !== 0) {
process.exit(status || undefined);
}
}
+17 -2
View File
@@ -20,13 +20,28 @@ import { exitWithError } from '../lib/errors';
export function registerCommands(program: Command) {
program
.command('api-reports [path...]')
.command('api-reports [paths...]')
.option('--ci', 'CI run checks that there is no changes on API reports')
.option('--tsc', 'executes the tsc compilation before extracting the APIs')
.option('--docs', 'generates the api documentation')
.option(
'-a, --allow-warnings <allowWarningsPaths>',
'continue processing packages after getting errors on selected packages Allows glob patterns and comma separated values (i.e. packages/core,plugins/core-*)',
)
.option(
'--allow-all-warnings',
'continue processing packages after getting errors on all packages',
false,
)
.option(
'-o, --omit-messages <messageCodes>',
'select some message code to be omited on the API Extractor (comma separated values i.e ae-cyclic-inherit-doc,ae-missing-getter )',
)
.description('Generate an API report for selected packages')
.action(
lazy(() => import('./api-reports/api-reports').then(m => m.default)),
lazy(() =>
import('./api-reports/api-reports').then(m => m.buildApiReports),
),
);
program
+115
View File
@@ -0,0 +1,115 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import mockFs from 'mock-fs';
import { resolve as resolvePath } from 'path';
import { resolvePackagePath, paths, findPackageDirs } from './paths';
describe('paths', () => {
jest.spyOn(paths, 'targetRoot', 'get').mockReturnValue('/root');
jest.spyOn(paths, 'resolveTargetRoot').mockImplementation((...path) => {
return resolvePath('/root', ...path);
});
beforeEach(() => {
mockFs({
[paths.targetRoot]: {
'package.json': JSON.stringify({ name: 'test' }),
packages: {
'package-a': {
'package.json': '{}',
},
'package-b': {
'package.json': '{}',
},
'package-c': {},
'README.md': 'Hello World',
},
plugins: {
'plugin-a': {
'package.json': '{}',
},
'plugin-b': {
'package.json': '{}',
},
},
},
});
});
afterEach(() => {
mockFs.restore();
});
describe('resolvePackagePath', () => {
it('should return undefined if the package does not exist or does not contain a package.json', async () => {
expect(await resolvePackagePath('packages/package-d')).toBeUndefined();
expect(await resolvePackagePath('packages/package-c')).toBeUndefined();
});
it('should return the path to the package if it exists and has a package.json', async () => {
expect(await resolvePackagePath('packages/package-a')).toBe(
'packages/package-a',
);
expect(await resolvePackagePath('packages/package-b')).toBe(
'packages/package-b',
);
});
it('should work with absolute paths', async () => {
expect(await resolvePackagePath('/root/packages/package-a')).toBe(
'packages/package-a',
);
});
it('should return undefined if the pat is not a directory', async () => {
expect(await resolvePackagePath('packages/README.md')).toBeUndefined();
});
});
describe('findPackageDirs', () => {
it('should return only the given packages', async () => {
expect(await findPackageDirs(['packages/package-a'])).toEqual([
'packages/package-a',
]);
});
it('should return only the given packages when using glob patterns', async () => {
expect(await findPackageDirs(['packages/*'])).toEqual([
'packages/package-a',
'packages/package-b',
]);
expect(await findPackageDirs(['packages/*', 'plugins/*'])).toEqual([
'packages/package-a',
'packages/package-b',
'plugins/plugin-a',
'plugins/plugin-b',
]);
});
it('should return only the given packages when using absolute paths', async () => {
expect(
await findPackageDirs([
'/root/packages/package-a',
'/root/plugins/plugin-b',
]),
).toEqual(['packages/package-a', 'plugins/plugin-b']);
});
it('should return only the given packages when using absolute paths with glob patterns', async () => {
expect(
await findPackageDirs(['/root/packages/*', '/root/plugins/*-a']),
).toEqual([
'packages/package-a',
'packages/package-b',
'plugins/plugin-a',
]);
});
});
});
+68
View File
@@ -0,0 +1,68 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { findPaths } from '@backstage/cli-common';
import { relative as relativePath, join } from 'path';
import fs from 'fs-extra';
import g from 'glob';
import isGlob from 'is-glob';
import { promisify } from 'util';
const glob = promisify(g);
/* eslint-disable-next-line no-restricted-syntax */
export const paths = findPaths(__dirname);
export async function resolvePackagePath(
packagePath: string,
): Promise<string | undefined> {
const fullPackageDir = paths.resolveTargetRoot(packagePath);
try {
const stat = await fs.stat(fullPackageDir);
if (!stat.isDirectory()) {
return undefined;
}
const packageJsonPath = join(fullPackageDir, 'package.json');
await fs.access(packageJsonPath);
} catch (e) {
console.log(`folder omitted: ${fullPackageDir}, cause: ${e}`);
return undefined;
}
return relativePath(paths.targetRoot, fullPackageDir);
}
export async function findPackageDirs(selectedPaths: string[] = []) {
const packageDirs = new Array<string>();
for (const packageRoot of selectedPaths) {
const fullPath = paths.resolveTargetRoot(packageRoot);
// if the path contain any glob notation we resolve all the paths to process one by one
const dirs = isGlob(fullPath) ? await glob(fullPath) : [fullPath];
for (const dir of dirs) {
const packageDir = await resolvePackagePath(dir);
if (!packageDir) {
continue;
}
packageDirs.push(packageDir);
}
}
return packageDirs;
}
+15
View File
@@ -8490,15 +8490,23 @@ __metadata:
version: 0.0.0-use.local
resolution: "@backstage/repo-tools@workspace:packages/repo-tools"
dependencies:
"@backstage/cli": "workspace:^"
"@backstage/cli-common": "workspace:^"
"@backstage/errors": "workspace:^"
"@manypkg/get-packages": ^1.1.3
"@microsoft/api-documenter": ^7.17.11
"@microsoft/api-extractor": ^7.23.0
"@microsoft/api-extractor-model": ^7.17.2
"@microsoft/tsdoc": 0.14.1
"@types/is-glob": ^4.0.2
"@types/mock-fs": ^4.13.0
chalk: ^4.0.0
commander: ^9.1.0
fs-extra: 10.1.0
glob: ^8.0.3
is-glob: ^4.0.3
minimatch: ^5.1.1
mock-fs: ^5.1.0
ts-node: ^10.0.0
bin:
backstage-repo-tools: bin/backstage-repo-tools
@@ -14285,6 +14293,13 @@ __metadata:
languageName: node
linkType: hard
"@types/is-glob@npm:^4.0.2":
version: 4.0.2
resolution: "@types/is-glob@npm:4.0.2"
checksum: 50b0a52b6d179781b36bfce35155e1e0dc66b62e2943153d7d7c7079c40ba6236528a254de8be6c52ff9a7a351996887802efd7fd763da3e2121315e4ffe2edf
languageName: node
linkType: hard
"@types/istanbul-lib-coverage@npm:*, @types/istanbul-lib-coverage@npm:^2.0.0, @types/istanbul-lib-coverage@npm:^2.0.1":
version: 2.0.1
resolution: "@types/istanbul-lib-coverage@npm:2.0.1"