Initial curated itworx-knowledge-hub source from 3b1751d0d9dee551aa6994eee0093704dc334817
This commit is contained in:
commit
ad8ad252a9
825 files changed
+124216
No files matched your search
@@ -0,0 +1,99 @@
|
||||
#!/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";
|
||||
|
||||
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) {
|
||||
const source = fs.readFileSync(filePath, "utf8");
|
||||
const routes = [];
|
||||
|
||||
// MapGroup("/api/v1/xyz") establishes a prefix for every Map* call chained off the
|
||||
// returned variable within the same file — every endpoint file in this codebase uses
|
||||
// exactly this one-group-per-file pattern (verified by inspection), so a single
|
||||
// "most recent MapGroup literal in this file" tracking variable is sufficient and
|
||||
// avoids needing a real C# parser.
|
||||
const groupMatch = source.match(/MapGroup\(\s*"([^"]+)"\s*\)/);
|
||||
const groupPrefix = groupMatch ? groupMatch[1] : "";
|
||||
|
||||
const mapCallPattern = /\.Map(Get|Post|Put|Delete|Patch)\(\s*"([^"]*)"/g;
|
||||
let match;
|
||||
while ((match = mapCallPattern.exec(source)) !== null) {
|
||||
const method = match[1].toUpperCase();
|
||||
const routeLiteral = match[2];
|
||||
const fullPath = groupPrefix + routeLiteral;
|
||||
routes.push({ method, path: normalizeRoute(fullPath), file: filePath });
|
||||
}
|
||||
|
||||
return routes;
|
||||
}
|
||||
|
||||
// Normalizes an ASP.NET route template's typed route constraints
|
||||
// (`{attachmentId:guid}`) to OpenAPI's untyped `{attachmentId}` style, and strips the
|
||||
// leading `/api/v1` prefix to match how docs/api/openapi.yaml keys its paths (relative
|
||||
// to the documented `servers[0].url`).
|
||||
function normalizeRoute(route) {
|
||||
let normalized = route.replace(/\{([a-zA-Z0-9_]+)(:[a-zA-Z]+)?\}/g, "{$1}");
|
||||
if (normalized.startsWith("/api/v1")) {
|
||||
normalized = normalized.slice("/api/v1".length);
|
||||
}
|
||||
if (normalized === "") {
|
||||
normalized = "/";
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
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.`);
|
||||
Reference in new issue
Block a user