173 lines
6.1 KiB
JavaScript
173 lines
6.1 KiB
JavaScript
#!/usr/bin/env node
|
|
import fs from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const FILENAME = fileURLToPath(import.meta.url);
|
|
const REPO_ROOT = path.resolve(FILENAME, "..", "..");
|
|
const ROUTES_DIR = path.join(REPO_ROOT, "apps", "web", "app", "routes");
|
|
|
|
// WF-P8 adds three reviewed tenant routes: the S04 board settings
|
|
// (admin.pipelines.$pipelineId._index), the S05 field template
|
|
// (admin.pipelines.$pipelineId.fields) and the S02 contact-search resource
|
|
// (projects.$id.contact-search). BILL-13 adds the reviewed tenant-bound product
|
|
// acquisition return route. Tenant 178 → 181, public/system 76 → 77 and total
|
|
// 563 → 567.
|
|
//
|
|
// P4 adds one reviewed tenant resource route: the link-target search behind the
|
|
// file detail's "Datei verknüpfen" dialog (files.link-targets), which replaced
|
|
// the raw-id field. Tenant 181 → 182 and total 567 → 568.
|
|
// BILL-14 adds one public, read-only registration plan-advice resource. Public
|
|
// system 77 → 78 and total 568 → 569.
|
|
// The appearance settings page adds one reviewed tenant route. Tenant 182 →
|
|
// 183 and total 569 → 570.
|
|
export const ROUTE_INVENTORY_BUDGETS = Object.freeze({
|
|
admin: 159,
|
|
tenant: 184,
|
|
public_help_docs_internal: 78,
|
|
portal: 14,
|
|
optional: 136,
|
|
total: 571,
|
|
});
|
|
|
|
const OPTIONAL_ROUTE_PATTERNS = Object.freeze([
|
|
/^help(\/|$)/,
|
|
/^mcp(\/|$)/,
|
|
/^api\.embed\.forms\./,
|
|
/^b\.\$tenant\.\$slug(\.|$)/,
|
|
/^e\.[co]\.\$outboundEmailId(\.|$)/,
|
|
/^embed\.forms\./,
|
|
/^f\.\$tenant\.\$slug(\.|$)/,
|
|
/^integrations\.aircall\.oauth\./,
|
|
/^integrations\.kwp\./,
|
|
/^sign\.\$token(\.|$)/,
|
|
/^webhooks\.telephony\.aircall(\.|$)/,
|
|
/^internal\/api\/__workflows(\/|$)/,
|
|
/^admin\/?__workflows(\/|$)/,
|
|
/^admin\/ai(\/|$)/,
|
|
/^admin\/chat(\/|$)/,
|
|
/^admin\/crm(\.|\/|$)/,
|
|
/^admin\/email-marketing(\.|\/|$)/,
|
|
/^admin\/help-desk(\.|\/|$)/,
|
|
/^admin\/knowledge-base(\.|\/|$)/,
|
|
/^admin\/prompts(\.|\/|$)/,
|
|
/^app\.\$tenant\/bookings(\.|\/|$)/,
|
|
/^app\.\$tenant\/calendar(\.|\/|$)/,
|
|
/^app\.\$tenant\/calls(\.|\/|$)/,
|
|
/^app\.\$tenant\/forms(\.|\/|$)/,
|
|
/^app\.\$tenant\/hr(\.|\/|$)/,
|
|
/^app\.\$tenant\/settings\/integrations\.calendar(\.|\/|$)/,
|
|
/^app\.\$tenant\/settings\/chat(\.|\/|$)/,
|
|
/^app\.\$tenant\/settings\.integrations\.telephony(\.|\/|$)/,
|
|
/^app\.\$tenant\/telephony(\.|\/|$)/,
|
|
/^app\.\$tenant\/__widgets(\/|$)/,
|
|
/^app\.\$tenant\/__workflows(\/|$)/,
|
|
/^app\.\$tenant\/chat(\.|\/|$)/,
|
|
/^app\.\$tenant\/contacts(\.|\/|$)/,
|
|
/^app\.\$tenant\/crm(\.|\/|$)/,
|
|
/^app\.\$tenant\/email-marketing(\.|\/|$)/,
|
|
/^app\.\$tenant\/internal\/ai(\/|$)/,
|
|
/^app\.\$tenant\/__portals(\/|$)/,
|
|
/^app\.\$tenant\/sites(\/|$)/,
|
|
]);
|
|
|
|
function isRouteFile(fileName) {
|
|
return /\.(ts|tsx)$/.test(fileName) && !/\.test\.(ts|tsx)$/.test(fileName) && !/\.spec\.(ts|tsx)$/.test(fileName) && !/\.server\.(ts|tsx)$/.test(fileName);
|
|
}
|
|
|
|
async function walkRoutes(dir = ROUTES_DIR) {
|
|
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
const files = [];
|
|
for (const entry of entries) {
|
|
const fullPath = path.join(dir, entry.name);
|
|
if (entry.isDirectory()) {
|
|
files.push(...(await walkRoutes(fullPath)));
|
|
} else if (entry.isFile() && isRouteFile(entry.name)) {
|
|
files.push(path.relative(ROUTES_DIR, fullPath).split(path.sep).join("/"));
|
|
}
|
|
}
|
|
return files.sort((left, right) => left.localeCompare(right));
|
|
}
|
|
|
|
export function classifyRouteSurface(relativePath) {
|
|
if (/^customer-portal(\/|$)/.test(relativePath)) {
|
|
return "portal";
|
|
}
|
|
if (OPTIONAL_ROUTE_PATTERNS.some((pattern) => pattern.test(relativePath))) {
|
|
return "optional";
|
|
}
|
|
if (relativePath.startsWith("admin") || relativePath.startsWith("admin/")) {
|
|
return "admin";
|
|
}
|
|
if (relativePath.startsWith("app.$tenant")) {
|
|
return "tenant";
|
|
}
|
|
return "public_help_docs_internal";
|
|
}
|
|
|
|
export function classifyRouteModule(relativePath) {
|
|
const surface = classifyRouteSurface(relativePath);
|
|
if (surface === "portal") {
|
|
return "portal";
|
|
}
|
|
if (surface === "optional") {
|
|
return "optional";
|
|
}
|
|
if (surface === "admin" || surface === "tenant") {
|
|
return "default-enabled";
|
|
}
|
|
return "public-system";
|
|
}
|
|
|
|
export async function scanRouteInventory() {
|
|
const files = await walkRoutes();
|
|
const surfaces = { admin: 0, tenant: 0, public_help_docs_internal: 0, portal: 0, optional: 0, total: files.length };
|
|
const classifications = { "public-system": 0, "default-enabled": 0, portal: 0, optional: 0 };
|
|
|
|
for (const file of files) {
|
|
surfaces[classifyRouteSurface(file)] += 1;
|
|
classifications[classifyRouteModule(file)] += 1;
|
|
}
|
|
|
|
return { files, surfaces, classifications };
|
|
}
|
|
|
|
export function evaluateRouteInventory(inventory, budgets = ROUTE_INVENTORY_BUDGETS) {
|
|
return Object.entries(budgets).map(([surface, budget]) => {
|
|
const count = inventory.surfaces[surface] ?? 0;
|
|
return { surface, count, budget, pass: count <= budget };
|
|
});
|
|
}
|
|
|
|
export async function runRouteInventoryGate({ logger = console } = {}) {
|
|
const inventory = await scanRouteInventory();
|
|
const results = evaluateRouteInventory(inventory);
|
|
const failures = results.filter((result) => !result.pass);
|
|
|
|
for (const result of results) {
|
|
const writer = result.pass ? logger.log : logger.error;
|
|
writer(`${result.pass ? "PASS" : "FAIL"} route_inventory surface=${result.surface} count=${result.count} budget=${result.budget}`);
|
|
}
|
|
logger.log(
|
|
`Physical classifications: public-system=${inventory.classifications["public-system"]} default-enabled=${inventory.classifications["default-enabled"]} portal=${inventory.classifications.portal} optional=${inventory.classifications.optional}`
|
|
);
|
|
logger.log(`Summary: ${results.length - failures.length} passed, ${failures.length} failed`);
|
|
return { inventory, results, failures };
|
|
}
|
|
|
|
async function main() {
|
|
const outcome = await runRouteInventoryGate();
|
|
if (outcome.failures.length > 0) {
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
const invokedDirectly = process.argv[1] && path.resolve(process.argv[1]) === FILENAME;
|
|
if (invokedDirectly) {
|
|
main().catch((error) => {
|
|
// eslint-disable-next-line no-console -- CLI gate must surface fatal errors on stderr.
|
|
console.error(error);
|
|
process.exit(1);
|
|
});
|
|
}
|