#!/usr/bin/env node // Contract-coverage gate (docs/adr/ADR-029-v2-r5-final-hardening.md, brief Phase 1): // fails when a route actually registered by an ASP.NET Minimal API endpoint file under // src/backend/ItWorx.KnowledgeHub.Api/Endpoints does not appear in // docs/api/openapi.yaml. This is what makes "the API must be reliably usable by a // future separate MCP server" (CLAUDE.md) an enforced property, not an aspiration — // a route can no longer silently ship undocumented. // // Deliberately a source-scan, not a live-server introspection: it runs with no // database, no built application and no network, so it is cheap enough to run on every // `node scripts/verify.mjs` (fast and full), not only the release gate. import fs from "node:fs"; import path from "node:path"; import process from "node:process"; import yaml from "js-yaml"; import { extractRoutesFromSource } from "./lib/openapi-routes.mjs"; const endpointsDir = "src/backend/ItWorx.KnowledgeHub.Api/Endpoints"; const openApiPath = "docs/api/openapi.yaml"; // Routes intentionally excluded from OpenAPI coverage: not `/api/v1/*` REST endpoints // at all (health probes, the raw attachment byte stream already documented once under // its real path, etc.) — see docs/09-api-contract.md "Base path: /api/v1". const EXCLUDED_ROUTE_PREFIXES = ["/health"]; function listEndpointFiles(dir) { return fs .readdirSync(dir) .filter((file) => file.endsWith(".cs")) .map((file) => path.join(dir, file)); } function extractRoutesFromFile(filePath) { return extractRoutesFromSource(fs.readFileSync(filePath, "utf8"), filePath); } function isExcluded(routePath) { return EXCLUDED_ROUTE_PREFIXES.some((prefix) => routePath.startsWith(prefix)); } const registeredRoutes = listEndpointFiles(endpointsDir).flatMap(extractRoutesFromFile).filter((route) => !isExcluded(route.path)); const document = yaml.load(fs.readFileSync(openApiPath, "utf8")); const documentedPaths = document?.paths ?? {}; const missing = []; for (const route of registeredRoutes) { const pathEntry = documentedPaths[route.path]; const documentedMethods = pathEntry ? Object.keys(pathEntry).map((key) => key.toUpperCase()) : []; if (!documentedMethods.includes(route.method)) { missing.push(route); } } if (missing.length > 0) { console.error(`OpenAPI coverage gate failed: ${missing.length} registered route(s) not documented in ${openApiPath}:`); for (const route of missing.sort((a, b) => a.path.localeCompare(b.path))) { console.error(`- ${route.method} /api/v1${route.path === "/" ? "" : route.path} (${route.file})`); } process.exit(1); } console.log(`OpenAPI coverage gate passed: all ${registeredRoutes.length} registered /api/v1 routes are documented.`);