Merge pull request #21650 from backstage/freben/no-alpha

repo-tools: detect and delete unnecessary api reports
This commit is contained in:
Fredrik Adelöw
2023-12-05 11:56:06 +01:00
committed by GitHub
3 changed files with 224 additions and 242 deletions
+11
View File
@@ -0,0 +1,11 @@
---
'@backstage/repo-tools': patch
---
The `api-reports` command now checks for api report files that no longer apply.
If it finds such files, it's treated basically the same as report errors do, and
the check fails.
For example, if you had an `api-report-alpha.md` but then removed the alpha
export, the reports generator would now report that this file needs to be
deleted.
@@ -14,6 +14,7 @@
* limitations under the License.
*/
import { groupBy } from 'lodash';
import {
resolve as resolvePath,
relative as relativePath,
@@ -363,7 +364,9 @@ export async function runApiExtraction({
}
const warnings = new Array<string>();
for (const { packageDir, name } of packageEntryPoints) {
for (const [packageDir, group] of Object.entries(
groupBy(packageEntryPoints, ep => ep.packageDir),
)) {
console.log(`## Processing ${packageDir}`);
const noBail = Array.isArray(allowWarnings)
? allowWarnings.some(aw => aw === packageDir || minimatch(packageDir, aw))
@@ -374,209 +377,242 @@ export async function runApiExtraction({
'./dist-types',
packageDir,
);
const names = group.map(ep => ep.name);
const suffix = name === 'index' ? '' : `-${name}`;
const reportFileName = `api-report${suffix}.md`;
const reportPath = resolvePath(projectFolder, reportFileName);
const remainingReportFiles = new Set(
fs
.readdirSync(projectFolder)
.filter(
filename =>
filename.match(/^(.+)-api-report\.md$/) ||
filename.match(/^api-report(-.+)?\.md$/),
),
);
const warningCountBefore = await countApiReportWarnings(reportPath);
for (const name of names) {
const suffix = name === 'index' ? '' : `-${name}`;
const reportFileName = `api-report${suffix}.md`;
const reportPath = resolvePath(projectFolder, reportFileName);
remainingReportFiles.delete(reportFileName);
const extractorConfig = ExtractorConfig.prepare({
configObject: {
mainEntryPointFilePath: resolvePath(packageFolder, `src/${name}.d.ts`),
bundledPackages: [],
const warningCountBefore = await countApiReportWarnings(reportPath);
compiler: {
tsconfigFilePath,
},
apiReport: {
enabled: true,
reportFileName,
reportFolder: projectFolder,
reportTempFolder: resolvePath(
outputDir,
`<unscopedPackageName>${suffix}`,
const extractorConfig = ExtractorConfig.prepare({
configObject: {
mainEntryPointFilePath: resolvePath(
packageFolder,
`src/${name}.d.ts`,
),
},
bundledPackages: [],
docModel: {
// TODO(Rugvip): This skips docs for non-index entry points. We can try to work around it, but
// most likely it makes sense to wait for API Extractor to natively support exports.
enabled: name === 'index',
apiJsonFilePath: resolvePath(
outputDir,
`<unscopedPackageName>${suffix}.api.json`,
),
},
compiler: {
tsconfigFilePath,
},
dtsRollup: {
enabled: false,
},
apiReport: {
enabled: true,
reportFileName,
reportFolder: projectFolder,
reportTempFolder: resolvePath(
outputDir,
`<unscopedPackageName>${suffix}`,
),
},
tsdocMetadata: {
enabled: false,
},
docModel: {
// TODO(Rugvip): This skips docs for non-index entry points. We can try to work around it, but
// most likely it makes sense to wait for API Extractor to natively support exports.
enabled: name === 'index',
apiJsonFilePath: resolvePath(
outputDir,
`<unscopedPackageName>${suffix}.api.json`,
),
},
messages: {
// Silence compiler warnings, as these will prevent the CI build to work
compilerMessageReporting: {
default: {
logLevel: 'none' as ExtractorLogLevel.None,
// These contain absolute file paths, so can't be included in the report
// addToApiReportFile: true,
},
},
extractorMessageReporting: {
default: {
logLevel: 'warning' as ExtractorLogLevel.Warning,
addToApiReportFile: true,
},
...messagesConf,
},
tsdocMessageReporting: {
default: {
logLevel: 'warning' as ExtractorLogLevel.Warning,
addToApiReportFile: true,
dtsRollup: {
enabled: false,
},
tsdocMetadata: {
enabled: false,
},
messages: {
// Silence compiler warnings, as these will prevent the CI build to work
compilerMessageReporting: {
default: {
logLevel: 'none' as ExtractorLogLevel.None,
// These contain absolute file paths, so can't be included in the report
// addToApiReportFile: true,
},
},
extractorMessageReporting: {
default: {
logLevel: 'warning' as ExtractorLogLevel.Warning,
addToApiReportFile: true,
},
...messagesConf,
},
tsdocMessageReporting: {
default: {
logLevel: 'warning' as ExtractorLogLevel.Warning,
addToApiReportFile: true,
},
},
},
newlineKind: 'lf',
projectFolder,
},
newlineKind: 'lf',
projectFolder,
},
configObjectFullPath: projectFolder,
packageJsonFullPath: resolvePath(projectFolder, 'package.json'),
tsdocConfigFile: await getTsDocConfig(),
ignoreMissingEntryPoint: true,
});
// The `packageFolder` needs to point to the location within `dist-types` in order for relative
// paths to be logged. Unfortunately the `prepare` method above derives it from the `packageJsonFullPath`,
// which needs to point to the actual file, so we override `packageFolder` afterwards.
(
extractorConfig as {
packageFolder: string;
}
).packageFolder = packageFolder;
if (!compilerState) {
compilerState = CompilerState.create(extractorConfig, {
additionalEntryPoints: entryPoints,
configObjectFullPath: projectFolder,
packageJsonFullPath: resolvePath(projectFolder, 'package.json'),
tsdocConfigFile: await getTsDocConfig(),
ignoreMissingEntryPoint: true,
});
}
// Message verbosity can't be configured, so just skip the check instead
(Extractor as any)._checkCompilerCompatibility = () => {};
let shouldLogInstructions = false;
let conflictingFile: undefined | string = undefined;
// Invoke API Extractor
const extractorResult = Extractor.invoke(extractorConfig, {
localBuild: isLocalBuild,
showVerboseMessages: false,
showDiagnostics: false,
messageCallback(message) {
if (message.text.includes('The API report file is missing')) {
shouldLogInstructions = true;
// The `packageFolder` needs to point to the location within `dist-types` in order for relative
// paths to be logged. Unfortunately the `prepare` method above derives it from the `packageJsonFullPath`,
// which needs to point to the actual file, so we override `packageFolder` afterwards.
(
extractorConfig as {
packageFolder: string;
}
if (
message.text.includes(
'You have changed the public API signature for this project.',
)
) {
shouldLogInstructions = true;
const match = message.text.match(
/Please copy the file "(.*)" to "api-report\.md"/,
);
if (match) {
conflictingFile = match[1];
).packageFolder = packageFolder;
if (!compilerState) {
compilerState = CompilerState.create(extractorConfig, {
additionalEntryPoints: entryPoints,
});
}
// Message verbosity can't be configured, so just skip the check instead
(Extractor as any)._checkCompilerCompatibility = () => {};
let shouldLogInstructions = false;
let conflictingFile: undefined | string = undefined;
// Invoke API Extractor
const extractorResult = Extractor.invoke(extractorConfig, {
localBuild: isLocalBuild,
showVerboseMessages: false,
showDiagnostics: false,
messageCallback(message) {
if (message.text.includes('The API report file is missing')) {
shouldLogInstructions = true;
}
}
},
compilerState,
});
// This release tag validation makes sure that the release tag of known entry points match expectations.
// The root index entrypoint is only allowed @public exports, while /alpha and /beta only allow @alpha and @beta.
if (
validateReleaseTags &&
fs.pathExistsSync(extractorConfig.reportFilePath)
) {
if (['index', 'alpha', 'beta'].includes(name)) {
const report = await fs.readFile(
extractorConfig.reportFilePath,
'utf8',
);
const lines = report.split(/\r?\n/);
const expectedTag = name === 'index' ? 'public' : name;
for (let i = 0; i < lines.length; i += 1) {
const line = lines[i];
const match = line.match(/^\/\/ @(alpha|beta|public)/);
if (match && match[1] !== expectedTag) {
// Because of limitations in the type script rollup logic we need to allow public exports from the other release stages
// TODO(Rugvip): Try to work around the need for this exception
if (expectedTag !== 'public' && match[1] === 'public') {
continue;
}
throw new Error(
`Unexpected release tag ${match[1]} in ${
extractorConfig.reportFilePath
} at line ${i + 1}`,
if (
message.text.includes(
'You have changed the public API signature for this project.',
)
) {
shouldLogInstructions = true;
const match = message.text.match(
/Please copy the file "(.*)" to "api-report\.md"/,
);
if (match) {
conflictingFile = match[1];
}
}
},
compilerState,
});
// This release tag validation makes sure that the release tag of known entry points match expectations.
// The root index entrypoint is only allowed @public exports, while /alpha and /beta only allow @alpha and @beta.
if (
validateReleaseTags &&
fs.pathExistsSync(extractorConfig.reportFilePath)
) {
if (['index', 'alpha', 'beta'].includes(name)) {
const report = await fs.readFile(
extractorConfig.reportFilePath,
'utf8',
);
const lines = report.split(/\r?\n/);
const expectedTag = name === 'index' ? 'public' : name;
for (let i = 0; i < lines.length; i += 1) {
const line = lines[i];
const match = line.match(/^\/\/ @(alpha|beta|public)/);
if (match && match[1] !== expectedTag) {
// Because of limitations in the type script rollup logic we need to allow public exports from the other release stages
// TODO(Rugvip): Try to work around the need for this exception
if (expectedTag !== 'public' && match[1] === 'public') {
continue;
}
throw new Error(
`Unexpected release tag ${match[1]} in ${
extractorConfig.reportFilePath
} at line ${i + 1}`,
);
}
}
}
}
}
if (!extractorResult.succeeded) {
if (shouldLogInstructions) {
logApiReportInstructions();
if (conflictingFile) {
console.log('');
console.log(
`The conflicting file is ${relativePath(
tmpDir,
conflictingFile,
)}, with the following content:`,
);
console.log('');
const content = await fs.readFile(conflictingFile, 'utf8');
console.log(content);
if (!extractorResult.succeeded) {
if (shouldLogInstructions) {
logApiReportInstructions();
if (conflictingFile) {
console.log('');
console.log(
`The conflicting file is ${relativePath(
tmpDir,
conflictingFile,
)}, with the following content:`,
);
console.log('');
const content = await fs.readFile(conflictingFile, 'utf8');
console.log(content);
logApiReportInstructions();
}
}
throw new Error(
`API Extractor completed with ${extractorResult.errorCount} errors` +
` and ${extractorResult.warningCount} warnings`,
);
}
throw new Error(
`API Extractor completed with ${extractorResult.errorCount} errors` +
` and ${extractorResult.warningCount} warnings`,
);
const warningCountAfter = await countApiReportWarnings(reportPath);
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 && allowWarningPkg.includes(packageDir)) {
console.log(
`No need to allow warnings for ${packageDir}, it does not have any`,
);
}
if (warningCountAfter > warningCountBefore) {
warnings.push(
`The API Report for ${packageDir} introduces new warnings. ` +
'Please fix these warnings in order to keep the API Reports tidy.',
);
}
}
const warningCountAfter = await countApiReportWarnings(reportPath);
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 && allowWarningPkg.includes(packageDir)) {
console.log(
`No need to allow warnings for ${packageDir}, it does not have any`,
);
}
if (warningCountAfter > warningCountBefore) {
warnings.push(
`The API Report for ${packageDir} introduces new warnings. ` +
'Please fix these warnings in order to keep the API Reports tidy.',
);
if (remainingReportFiles.size > 0) {
if (isLocalBuild) {
for (const f of remainingReportFiles) {
fs.rmSync(resolvePath(projectFolder, f));
console.log(`Deleted deprecated API report ${f}`);
}
} else {
const staleList = [...remainingReportFiles]
.map(f => join(packageDir, f))
.join(', ');
throw new Error(
`The API Report(s) ${staleList} are no longer relevant and should be deleted`,
);
}
}
}
@@ -1,65 +0,0 @@
## API Report File for "@backstage/plugin-sonarqube-react"
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { ApiRef } from '@backstage/core-plugin-api';
import { Entity } from '@backstage/catalog-model';
// @public (undocumented)
export interface FindingSummary {
// (undocumented)
getComponentMeasuresUrl: SonarUrlProcessorFunc;
// (undocumented)
getIssuesUrl: SonarUrlProcessorFunc;
// (undocumented)
getSecurityHotspotsUrl: () => string;
// (undocumented)
lastAnalysis: string;
// (undocumented)
metrics: Metrics;
// (undocumented)
projectUrl: string;
}
// @public (undocumented)
export type MetricKey =
| 'alert_status'
| 'bugs'
| 'reliability_rating'
| 'vulnerabilities'
| 'security_rating'
| 'code_smells'
| 'sqale_rating'
| 'security_hotspots_reviewed'
| 'security_review_rating'
| 'coverage'
| 'duplicated_lines_density';
// @public
export type Metrics = {
[key in MetricKey]: string | undefined;
};
// @public (undocumented)
export type SonarQubeApi = {
getFindingSummary(options: {
componentKey?: string;
projectInstance?: string;
}): Promise<FindingSummary | undefined>;
};
// @public (undocumented)
export const sonarQubeApiRef: ApiRef<SonarQubeApi>;
// @public (undocumented)
export type SonarUrlProcessorFunc = (identifier: string) => string;
// @public
export const useProjectInfo: (entity: Entity) => {
projectInstance: string | undefined;
projectKey: string | undefined;
};
// (No @packageDocumentation comment for this package)
```