Files
fluxer/fluxer_docs/scripts/VerifyDocsSchemas.ts
T

793 lines
24 KiB
TypeScript

// SPDX-License-Identifier: AGPL-3.0-or-later
import {readdir, readFile} from 'node:fs/promises';
import path from 'node:path';
import {fileURLToPath} from 'node:url';
const DOCS_ROOT = fileURLToPath(new URL('../src/content/docs/', import.meta.url));
const REPO_ROOT = fileURLToPath(new URL('../../', import.meta.url));
const MAIN_SPEC = path.join(REPO_ROOT, 'fluxer_api/src/api/openapi/openapi.json');
const ADMIN_SPEC = path.join(REPO_ROOT, 'fluxer_admin/openapi-admin.json');
const ROUTE_HEADER = /<RouteHeader\s+method="([A-Z]+)"\s+path="([^"]+)"/u;
const TABLE_ROW = /^\|\s*([^|]+?)\s*\|/u;
const OBJECT_REFERENCE = /\]\([^)]*#[a-z0-9-]*object\)/u;
interface Spec {
readonly paths: Record<string, Record<string, Operation>>;
readonly components?: {schemas?: Record<string, SchemaNode>};
}
interface Operation {
readonly parameters?: Array<{name: string; in: string; schema?: SchemaNode}>;
readonly requestBody?: {content?: Record<string, {schema?: SchemaNode}>};
readonly responses?: Record<string, {content?: Record<string, {schema?: SchemaNode}>}>;
}
interface SchemaNode {
$ref?: string;
required?: Array<string>;
properties?: Record<string, SchemaNode>;
allOf?: Array<SchemaNode>;
oneOf?: Array<SchemaNode>;
anyOf?: Array<SchemaNode>;
items?: SchemaNode;
type?: string;
}
const SUDO_MERGE_FIELDS = ['mfa_method', 'mfa_code', 'webauthn_response', 'webauthn_challenge'];
const STREAMED_RESPONSE_ROUTES = new Map([
['POST /admin/system/heap-snapshots', "'Content-Type': 'application/octet-stream'"],
]);
const EMPTY_RESPONSE_ROUTES = new Map([['DELETE /admin/users/{}/sessions', 'terminated_count']]);
const MERGED_SCHEMA_ROUTES = new Map([
['POST /auth/sessions/logout', "Validator('json', LogoutAuthSessionsRequest.merge(SudoVerificationSchema))"],
[
'POST /guilds/{}/transfer-ownership',
"Validator('json', GuildTransferOwnershipRequest.merge(SudoVerificationSchema))",
],
]);
interface Mismatch {
readonly page: string;
readonly operation: string;
readonly kind: string;
readonly detail: string;
}
async function walk(directory: string): Promise<Array<string>> {
const entries = await readdir(directory, {withFileTypes: true});
const files: Array<string> = [];
for (const entry of entries) {
if (entry.name === 'node_modules') {
continue;
}
const resolved = path.join(directory, entry.name);
if (entry.isDirectory()) {
files.push(...(await walk(resolved)));
continue;
}
if (/\.mdx?$/u.test(entry.name)) {
files.push(resolved);
}
}
return files;
}
function stripVersion(routePath: string): string {
if (routePath.startsWith('/v1/')) {
return routePath.slice(3);
}
return routePath;
}
function shape(method: string, routePath: string): string {
return `${method} ${routePath.split('?')[0].replace(/\{[^}]*\}/gu, '{}')}`;
}
function resolveRef(spec: Spec, node: SchemaNode | undefined, depth = 0): SchemaNode | undefined {
if (node == null || depth > 12) {
return node;
}
if (node.$ref != null) {
const name = node.$ref.replace('#/components/schemas/', '');
return resolveRef(spec, spec.components?.schemas?.[name], depth + 1);
}
return node;
}
function collectRequired(spec: Spec, node: SchemaNode | undefined, depth = 0): Set<string> {
const out = new Set<string>();
const resolved = resolveRef(spec, node, depth);
if (resolved == null || depth > 12) {
return out;
}
for (const name of resolved.required ?? []) {
out.add(name);
}
for (const branch of resolved.allOf ?? []) {
for (const name of collectRequired(spec, branch, depth + 1)) {
out.add(name);
}
}
const union = [...(resolved.oneOf ?? []), ...(resolved.anyOf ?? [])];
if (union.length > 0) {
const declaringBranches = new Map<string, number>();
const requiringBranches = new Map<string, number>();
for (const branch of union) {
const branchRequired = collectRequired(spec, branch, depth + 1);
for (const name of collectProperties(spec, branch, depth + 1)) {
declaringBranches.set(name, (declaringBranches.get(name) ?? 0) + 1);
if (branchRequired.has(name)) {
requiringBranches.set(name, (requiringBranches.get(name) ?? 0) + 1);
}
}
}
for (const [name, declared] of declaringBranches) {
if ((requiringBranches.get(name) ?? 0) === declared) {
out.add(name);
}
}
}
return out;
}
function collectPropertyTypes(spec: Spec, node: SchemaNode | undefined, depth = 0): Map<string, string> {
const out = new Map<string, string>();
const resolved = resolveRef(spec, node, depth);
if (resolved == null || depth > 12) {
return out;
}
for (const [key, value] of Object.entries(resolved.properties ?? {})) {
const property = resolveRef(spec, value, depth + 1);
const type = property?.type;
if (typeof type === 'string') {
out.set(key, type);
}
}
for (const branch of [...(resolved.allOf ?? [])]) {
for (const [key, type] of collectPropertyTypes(spec, branch, depth + 1)) {
if (!out.has(key)) {
out.set(key, type);
}
}
}
return out;
}
const DOC_TYPE_TO_JSON = new Map([
['snowflake', 'string'],
['string', 'string'],
['integer', 'integer'],
['boolean', 'boolean'],
['iso8601 timestamp', 'string'],
['base64 string', 'string'],
['float', 'number'],
['number', 'number'],
]);
function normaliseDocType(cell: string): string | null {
const text = cell
.replace(/<sup>.*?<\/sup>/gu, '')
.replace(/\[([^\]]*)\]\([^)]*\)/gu, '$1')
.replace(/`/gu, '')
.trim()
.replace(/^\?/u, '')
.toLowerCase();
if (text.startsWith('array')) {
return 'array';
}
if (text.endsWith(' object') || text.includes('object')) {
return 'object';
}
return DOC_TYPE_TO_JSON.get(text) ?? null;
}
function isDeprecatedProperty(property: unknown): boolean {
if (property == null || typeof property !== 'object') {
return false;
}
const node = property as {deprecated?: unknown; description?: unknown};
if (node.deprecated === true) {
return true;
}
return typeof node.description === 'string' && node.description.trimStart().toLowerCase().startsWith('deprecated');
}
function collectProperties(spec: Spec, node: SchemaNode | undefined, depth = 0): Set<string> {
const out = new Set<string>();
const resolved = resolveRef(spec, node, depth);
if (resolved == null || depth > 12) {
return out;
}
for (const [key, property] of Object.entries(resolved.properties ?? {})) {
if (isDeprecatedProperty(property)) {
continue;
}
out.add(key);
}
for (const branch of [...(resolved.allOf ?? []), ...(resolved.oneOf ?? []), ...(resolved.anyOf ?? [])]) {
for (const key of collectProperties(spec, branch, depth + 1)) {
out.add(key);
}
}
return out;
}
function operationIndex(spec: Spec): Map<string, Operation> {
const index = new Map<string, Operation>();
for (const [routePath, item] of Object.entries(spec.paths)) {
for (const [method, operation] of Object.entries(item)) {
index.set(shape(method.toUpperCase(), stripVersion(routePath)), operation);
}
}
return index;
}
function cleanFieldName(cell: string): string | null {
const name = cell
.replace(/<sup>.*?<\/sup>/gu, '')
.replace(/\*\*/gu, '')
.replace(/`/gu, '')
.replace(/\\/gu, '')
.trim()
.replace(/\?$/u, '');
if (name.length === 0) {
return null;
}
if (name === 'Field' || name === '---' || name === 'Status' || name === 'Name') {
return null;
}
if (!/^[a-z_][a-z0-9_.]*$/iu.test(name)) {
return null;
}
return name;
}
function sectionIsByReference(lines: ReadonlyArray<string>, start: number): boolean {
let sawTable = false;
let sawReference = false;
for (let index = start; index < lines.length; index += 1) {
const line = lines[index];
if (line.startsWith('#')) {
break;
}
if (line.startsWith('|')) {
sawTable = true;
}
if (OBJECT_REFERENCE.test(line)) {
sawReference = true;
}
}
return sawReference && !sawTable;
}
function tableOptionality(lines: ReadonlyArray<string>, start: number): Map<string, boolean> {
const out = new Map<string, boolean>();
let index = start;
while (index < lines.length && !lines[index].startsWith('|')) {
if (lines[index].startsWith('#')) {
return out;
}
index += 1;
}
for (; index < lines.length; index += 1) {
const line = lines[index];
if (!line.startsWith('|')) {
break;
}
const cells = line.split('|').slice(1, -1);
if (cells.length < 2) {
continue;
}
const raw = cells[0]
.replace(/<sup>.*?<\/sup>/gu, '')
.replace(/`/gu, '')
.trim();
const name = cleanFieldName(cells[0]);
if (name == null) {
continue;
}
out.set(name, raw.endsWith('?'));
}
return out;
}
function tableFieldTypes(lines: ReadonlyArray<string>, start: number): Map<string, string> {
const out = new Map<string, string>();
let index = start;
while (index < lines.length && !lines[index].startsWith('|')) {
if (lines[index].startsWith('#')) {
return out;
}
index += 1;
}
for (; index < lines.length; index += 1) {
const line = lines[index];
if (!line.startsWith('|')) {
break;
}
const cells = line.split('|').slice(1, -1);
if (cells.length < 2) {
continue;
}
const name = cleanFieldName(cells[0]);
if (name == null) {
continue;
}
const type = normaliseDocType(cells[1]);
if (type != null) {
out.set(name, type);
}
}
return out;
}
function tableFields(lines: ReadonlyArray<string>, start: number): Set<string> {
const fields = new Set<string>();
let index = start;
while (index < lines.length && !lines[index].startsWith('|')) {
if (lines[index].startsWith('#')) {
return fields;
}
index += 1;
}
for (; index < lines.length; index += 1) {
const line = lines[index];
if (!line.startsWith('|')) {
break;
}
const match = line.match(TABLE_ROW);
if (match == null) {
continue;
}
const name = cleanFieldName(match[1]);
if (name != null) {
fields.add(name);
}
}
return fields;
}
const mainSpec: Spec = JSON.parse(await readFile(MAIN_SPEC, 'utf8'));
const adminSpec: Spec = JSON.parse(await readFile(ADMIN_SPEC, 'utf8'));
const mainIndex = operationIndex(mainSpec);
const adminIndex = operationIndex(adminSpec);
function slugifyHeading(heading: string): string {
return heading
.replace(/`/gu, '')
.replace(/\[([^\]]*)\]\([^)]*\)/gu, '$1')
.replace(/<[^>]*>/gu, '')
.toLowerCase()
.replace(/[^a-z0-9\s-]/gu, '')
.trim()
.replace(/\s+/gu, '-');
}
const allFiles = await walk(DOCS_ROOT);
const anchorFields = new Map<string, Set<string>>();
const anchorTypes = new Map<string, Map<string, string>>();
for (const file of allFiles) {
const slug = path
.relative(DOCS_ROOT, file)
.replace(/\.(mdx|md)$/u, '')
.replace(/\/index$/u, '')
.replace(/^index$/u, '');
const lines = (await readFile(file, 'utf8')).split('\n');
let currentAnchor: string | null = null;
for (let i = 0; i < lines.length; i += 1) {
const line = lines[i];
const heading = line.match(/^##\s+(.+?)\s*$/u);
if (heading != null && !line.startsWith('###')) {
currentAnchor = slugifyHeading(heading[1]);
const explicit = line.match(/id=["']([^"']+)["']/u);
if (explicit != null) {
currentAnchor = explicit[1];
}
continue;
}
if (currentAnchor == null || !line.startsWith('|')) {
continue;
}
const row = line.match(TABLE_ROW);
if (row == null) {
continue;
}
const name = cleanFieldName(row[1]);
if (name == null) {
continue;
}
const cells = line.split('|').slice(1, -1);
const declaredType = cells.length >= 2 ? normaliseDocType(cells[1]) : null;
for (const anchorKey of [`${slug}#${currentAnchor}`]) {
const set = anchorFields.get(anchorKey) ?? new Set<string>();
set.add(name);
anchorFields.set(anchorKey, set);
if (declaredType != null) {
const typeMap = anchorTypes.get(anchorKey) ?? new Map<string, string>();
if (!typeMap.has(name)) {
typeMap.set(name, declaredType);
}
anchorTypes.set(anchorKey, typeMap);
}
}
}
}
const mismatches: Array<Mismatch> = [];
let checkedBodies = 0;
let checkedQueries = 0;
let unionBodiesSkipped = 0;
let referencedElsewhere = 0;
let documentedByReference = 0;
let responsesChecked = 0;
let responseFieldsFound = 0;
let typesCompared = 0;
let optionalityCompared = 0;
const optionalityAdvisories: Array<string> = [];
for (const file of await walk(DOCS_ROOT)) {
const relative = path.relative(DOCS_ROOT, file);
if (relative.startsWith('media-proxy/')) {
continue;
}
const lines = (await readFile(file, 'utf8')).split('\n');
const pageFields = new Set<string>();
for (let i = 0; i < lines.length; i += 1) {
if (!lines[i].startsWith('|')) {
continue;
}
const match = lines[i].match(TABLE_ROW);
if (match == null) {
continue;
}
const name = cleanFieldName(match[1]);
if (name != null) {
pageFields.add(name);
}
}
const isAdmin = relative.startsWith('admin-api/');
const spec = isAdmin ? adminSpec : mainSpec;
const index = isAdmin ? adminIndex : mainIndex;
const sections: Array<{start: number; end: number}> = [];
let current = -1;
for (let i = 0; i < lines.length; i += 1) {
if (lines[i].startsWith('## ') && !lines[i].startsWith('### ')) {
if (current !== -1) {
sections.push({start: current, end: i});
}
current = i;
}
}
if (current !== -1) {
sections.push({start: current, end: lines.length});
}
for (const section of sections) {
let header: RegExpMatchArray | null = null;
for (let i = section.start; i < section.end; i += 1) {
const match = lines[i].match(ROUTE_HEADER);
if (match != null) {
header = match;
break;
}
}
if (header == null) {
continue;
}
const key = shape(header[1], stripVersion(header[2]));
const operation = index.get(key);
if (operation == null) {
continue;
}
const referenced = new Set<string>();
for (let i = section.start; i < section.end; i += 1) {
for (const link of lines[i].matchAll(/\]\(([^)\s]*)#([a-z0-9-]+)\)/gu)) {
const targetPage = link[1].replace(/^\//u, '').replace(/\/$/u, '');
if (targetPage.length === 0) {
referenced.add(`${relative.replace(/\.(mdx|md)$/u, '').replace(/\/index$/u, '')}#${link[2]}`);
continue;
}
referenced.add(`${targetPage}#${link[2]}`);
}
}
const referencedFields = new Set<string>();
for (const anchor of referenced) {
for (const field of anchorFields.get(anchor) ?? []) {
referencedFields.add(field);
}
}
const successResponse = Object.entries(operation.responses ?? {}).find(([status]) => status.startsWith('2'));
if (successResponse != null) {
const responseSchema = successResponse[1].content?.['application/json']?.schema;
const resolvedResponse = resolveRef(spec, responseSchema);
const target = resolvedResponse?.type === 'array' ? resolvedResponse.items : resolvedResponse;
const responseProperties = collectProperties(spec, target);
const resolvedTarget = resolveRef(spec, target);
const responseIsUnion =
resolvedTarget != null && ((resolvedTarget.oneOf ?? []).length > 0 || (resolvedTarget.anyOf ?? []).length > 0);
if (!STREAMED_RESPONSE_ROUTES.has(key) && !EMPTY_RESPONSE_ROUTES.has(key)) {
const referencedTypes = new Map<string, string>();
for (const anchor of referenced) {
for (const [field, type] of anchorTypes.get(anchor) ?? []) {
if (!referencedTypes.has(field)) {
referencedTypes.set(field, type);
}
}
}
for (const [field, specType] of collectPropertyTypes(spec, target)) {
const docType = referencedTypes.get(field);
if (docType == null) {
continue;
}
typesCompared += 1;
if (specType === docType) {
continue;
}
if (specType === 'number' && docType === 'integer') {
continue;
}
mismatches.push({
page: relative,
operation: key,
kind: 'type-mismatch',
detail: `${field}: documented ${docType}, response schema ${specType}`,
});
}
}
if (
responseProperties.size > 0 &&
!responseIsUnion &&
!STREAMED_RESPONSE_ROUTES.has(key) &&
!EMPTY_RESPONSE_ROUTES.has(key)
) {
responsesChecked += 1;
for (const field of responseProperties) {
if (pageFields.has(field) || referencedFields.has(field)) {
responseFieldsFound += 1;
continue;
}
mismatches.push({page: relative, operation: key, kind: 'response-missing', detail: field});
}
}
}
for (let i = section.start; i < section.end; i += 1) {
const heading = lines[i].trim();
if (heading === '### JSON body') {
const documented = tableFields(lines, i + 1);
const content = operation.requestBody?.content ?? {};
const jsonSchema = content['application/json']?.schema;
if (jsonSchema == null) {
continue;
}
const actual = collectProperties(spec, jsonSchema);
if (actual.size === 0) {
continue;
}
if (sectionIsByReference(lines, i + 1)) {
documentedByReference += 1;
continue;
}
checkedBodies += 1;
const documentedTypes = tableFieldTypes(lines, i + 1);
const actualTypes = collectPropertyTypes(spec, jsonSchema);
for (const [field, docType] of documentedTypes) {
const specType = actualTypes.get(field);
if (specType == null) {
continue;
}
typesCompared += 1;
if (specType === docType) {
continue;
}
if (specType === 'number' && docType === 'integer') {
continue;
}
mismatches.push({
page: relative,
operation: key,
kind: 'type-mismatch',
detail: `${field}: documented ${docType}, schema ${specType}`,
});
}
const documentedOptional = tableOptionality(lines, i + 1);
const requiredFields = collectRequired(spec, jsonSchema);
const bodyProperties = collectProperties(spec, jsonSchema);
for (const [field, isOptional] of documentedOptional) {
if (!bodyProperties.has(field)) {
continue;
}
const specRequired = requiredFields.has(field);
optionalityCompared += 1;
if (specRequired === !isOptional) {
continue;
}
optionalityAdvisories.push(
specRequired
? `${relative} ${key} ${field}: documented optional, schema marks it required`
: `${relative} ${key} ${field}: documented required, schema marks it optional`,
);
}
const merged = MERGED_SCHEMA_ROUTES.has(key);
for (const field of documented) {
if (actual.has(field)) {
continue;
}
if (merged && SUDO_MERGE_FIELDS.includes(field)) {
continue;
}
mismatches.push({page: relative, operation: key, kind: 'body-extra', detail: field});
}
const resolvedBody = resolveRef(spec, jsonSchema);
const isUnion =
resolvedBody != null && ((resolvedBody.oneOf ?? []).length > 0 || (resolvedBody.anyOf ?? []).length > 0);
if (isUnion) {
unionBodiesSkipped += 1;
continue;
}
for (const field of actual) {
if (documented.has(field)) {
continue;
}
if (pageFields.has(field)) {
referencedElsewhere += 1;
continue;
}
mismatches.push({page: relative, operation: key, kind: 'body-missing', detail: field});
}
}
if (heading === '### Query parameters') {
const documented = tableFields(lines, i + 1);
const actual = new Set((operation.parameters ?? []).filter((p) => p.in === 'query').map((p) => p.name));
if (actual.size === 0) {
continue;
}
checkedQueries += 1;
for (const field of documented) {
if (!actual.has(field)) {
mismatches.push({page: relative, operation: key, kind: 'query-extra', detail: field});
}
}
for (const field of actual) {
if (documented.has(field)) {
continue;
}
if (pageFields.has(field)) {
referencedElsewhere += 1;
continue;
}
mismatches.push({page: relative, operation: key, kind: 'query-missing', detail: field});
}
}
}
}
}
if (process.env.FLUXER_DOCS_SCHEMA_JSON != null) {
const grouped = new Map<string, Array<Mismatch>>();
for (const m of mismatches) {
const list = grouped.get(m.page) ?? [];
list.push(m);
grouped.set(m.page, list);
}
const payload: Record<string, Array<{operation: string; kind: string; field: string}>> = {};
for (const [page, list] of grouped) {
payload[page] = list.map((m) => ({operation: m.operation, kind: m.kind, field: m.detail}));
}
const {writeFile} = await import('node:fs/promises');
await writeFile(process.env.FLUXER_DOCS_SCHEMA_JSON, JSON.stringify(payload, null, 1));
console.log(`wrote ${process.env.FLUXER_DOCS_SCHEMA_JSON}`);
}
const byKind = new Map<string, number>();
for (const m of mismatches) {
byKind.set(m.kind, (byKind.get(m.kind) ?? 0) + 1);
}
const authSource = await readFile(path.join(REPO_ROOT, 'fluxer_api/src/api/auth/AuthController.ts'), 'utf8');
const guildSource = await readFile(
path.join(REPO_ROOT, 'fluxer_api/src/api/guild/controllers/GuildMemberController.ts'),
'utf8',
);
const adminSystemSource = await readFile(
path.join(REPO_ROOT, 'fluxer_api/src/api/admin/controllers/SystemAdminController.ts'),
'utf8',
);
const staleExemptions: Array<string> = [];
for (const [route, anchor] of STREAMED_RESPONSE_ROUTES) {
if (!adminSystemSource.includes(anchor)) {
staleExemptions.push(`${route}: no longer streams a file, drop this exemption`);
}
}
const adminUserSecuritySource = await readFile(
path.join(REPO_ROOT, 'fluxer_api/src/api/admin/services/AdminUserSecurityService.ts'),
'utf8',
);
for (const [route, marker] of EMPTY_RESPONSE_ROUTES) {
if (adminUserSecuritySource.includes(marker)) {
staleExemptions.push(`${route}: the handler now emits ${marker}, drop this exemption`);
}
}
for (const [route, anchor] of MERGED_SCHEMA_ROUTES) {
const present = authSource.includes(anchor) || guildSource.includes(anchor);
if (!present) {
staleExemptions.push(`${route}: the .merge() call is gone, drop this exemption`);
}
}
if (staleExemptions.length > 0) {
console.log('stale exemptions:');
for (const entry of staleExemptions) {
console.log(` ${entry}`);
}
}
console.log(`request body tables checked: ${checkedBodies.toString()}`);
console.log(`union bodies skipped for the missing check: ${unionBodiesSkipped.toString()}`);
console.log(
`fields documented in a shared object section rather than the route table: ${referencedElsewhere.toString()}`,
);
console.log(`bodies documented by reference to an object section: ${documentedByReference.toString()}`);
console.log(
`merged-schema exemptions active: ${MERGED_SCHEMA_ROUTES.size.toString()} (the OpenAPI generator drops .merge() operands)`,
);
console.log(`query parameter tables checked: ${checkedQueries.toString()}`);
console.log(`success response schemas checked: ${responsesChecked.toString()}`);
console.log(
`streamed-response exemptions active: ${STREAMED_RESPONSE_ROUTES.size.toString()} (the spec declares JSON, the implementation streams a file)`,
);
console.log(
`empty-response exemptions active: ${EMPTY_RESPONSE_ROUTES.size.toString()} (the spec declares a body, the handler returns none)`,
);
console.log(`response fields found documented on the page: ${responseFieldsFound.toString()}`);
console.log(`request body field types compared: ${typesCompared.toString()}`);
console.log(`request body optionality compared: ${optionalityCompared.toString()}`);
console.log(`optionality advisories: ${optionalityAdvisories.length.toString()}`);
if (optionalityAdvisories.length > 0) {
console.log(' ADVISORY ONLY, this does not fail the run.');
console.log(' Both current lines were adjudicated by hand. ZERO documentation bugs: in each');
console.log(' case the page is MORE precise than a bare ? marker can be.');
console.log(' POST /stripe/checkout/subscription/preapproval requires country_code in the');
console.log(' handler (StripeCheckoutService.ts:322 throws when it is absent) while the');
console.log(' shared CreateCheckoutSessionRequest requires only price_id.');
console.log(' PATCH /admin/discovery/applications/{guild_id} takes a discriminated union.');
console.log(' reason is required when status is rejected and optional when it is approved,');
console.log(' which the page states in a footnote instead of flattening it to ?.');
console.log(' Re-adjudicate only if this list changes.');
for (const entry of optionalityAdvisories) {
console.log(` ${entry}`);
}
}
for (const [kind, count] of [...byKind.entries()].sort()) {
console.log(` ${kind}: ${count.toString()}`);
}
if (mismatches.length > 0) {
console.log('');
for (const m of mismatches.slice(0, 120)) {
let label = 'in the schema but undocumented';
if (m.kind.endsWith('extra')) {
label = 'documented but not in the schema';
}
if (m.kind === 'type-mismatch') {
label = 'type disagreement';
}
if (m.kind === 'optionality') {
label = 'optionality disagreement';
}
console.log(`${m.page} ${m.operation} ${label}: ${m.detail}`);
}
if (mismatches.length > 120) {
console.log(`... and ${(mismatches.length - 120).toString()} more`);
}
console.error(`FAIL: ${mismatches.length.toString()} field mismatches`);
process.exit(1);
}
console.log('OK: every documented request body and query table matches the live schema');