#!/usr/bin/env node /* eslint-disable no-console */ import { createHash } from "node:crypto"; import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { spawn } from "node:child_process"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(__dirname, ".."); export const reportPath = path.join(repoRoot, ".tc/baselines/host-aware-url/baseline-health.md"); const ignoredDirectoryNames = new Set([ ".git", "node_modules", "build", "dist", "coverage", ".react-router", ".turbo", ".next", ".cache", ".vite", "generated", "llm-docs", "docs", ".tc", ]); export const legacyUrlInventoryScanRoots = ["apps/web/app/routes", "apps/web/app/modules", "apps/web/app/components", "apps/web/app/utils", "apps/api/src"]; export const legacyUrlInventoryPatterns = [ { id: "legacy-url-utils-import", pattern: /(?:from\s+["']~\/utils\/app\/UrlUtils["']|from\s+["']\.\.?\/.*UrlUtils["']|import\(["'][^"']*UrlUtils["']\)|\bUrlUtils\b)/g }, { id: "window-location-reference", pattern: /\bwindow\.location\b/g }, { id: "root-data-server-url", pattern: /\brootData\.serverUrl\b/g }, { id: "canonical-url-utils-reference", pattern: /\bCanonicalUrlUtils\b|\b(?:appPath|appUrl|adminPath|adminUrl|apiPath|apiUrl)\b/g }, { id: "legacy-app-route-reference", pattern: /(?:["'`]\/app\/[A-Za-z0-9_$:{}`/?#&=.%+-]*|\bapp\.\$tenant\b|\/app\/:tenant\b)/g }, ]; export const gates = [ { id: "typecheck", command: "pnpm", args: ["run", "typecheck"] }, { id: "lint", command: "pnpm", args: ["run", "lint"] }, { id: "vitest-host-aware-canonical-url", command: "pnpm", args: [ "exec", "vitest", "run", "apps/web/app/utils/app/CanonicalUrlUtils.test.ts", "apps/web/app/utils/app/HostRouting.server.test.ts", "apps/web/app/routes/admin.host-aware.test.ts", "apps/web/app/routes/app.$tenant.host-aware.test.ts", "apps/web/app/routes/app..host-aware.test.ts", ], }, { id: "legacy-url-inventory", type: "legacy-url-inventory" }, ]; function shellCommand(gate) { if (gate.type === "legacy-url-inventory") return "read-only legacy URL inventory"; return [gate.command, ...gate.args].join(" "); } function outputFingerprint(output) { const normalized = output .split(/\r?\n/) .map((line) => line.trim()) .filter(Boolean) .slice(-40) .join("\n"); return { hash: createHash("sha256").update(output).digest("hex").slice(0, 12), excerpt: normalized.slice(0, 1200) || "(no output)" }; } function parseNumber(value) { const parsed = Number.parseInt(value, 10); return Number.isFinite(parsed) ? parsed : 0; } function conservativeFailureCount(count, output) { return count > 0 ? count : output.trim() ? 1 : 0; } function countVitestFailures(output) { const failedTests = output.match(/Tests\s+(\d+)\s+failed/i); if (failedTests) return parseNumber(failedTests[1]); const failedFiles = output.match(/Test Files\s+(\d+)\s+failed/i); return failedFiles ? parseNumber(failedFiles[1]) : 0; } export function failureCount(gateId, stdout, stderr, exitCode = 1) { if (exitCode === 0) return 0; const output = `${stdout}\n${stderr}`; if (gateId === "typecheck") return conservativeFailureCount((output.match(/error TS\d+/g) ?? []).length, output); if (gateId === "lint") { const problemsMatch = output.match(/✖\s+(\d+)\s+problems?\s+\((\d+)\s+errors?/i); const compactMatch = output.match(/(\d+)\s+errors?/i); return conservativeFailureCount(problemsMatch ? parseNumber(problemsMatch[2]) : compactMatch ? parseNumber(compactMatch[1]) : 0, output); } if (gateId === "vitest-host-aware-canonical-url") return conservativeFailureCount(countVitestFailures(output), output); return output.trim() ? 1 : 0; } export function hasFailedResults(results) { return results.some((result) => result.exitCode !== 0 || result.failureCount > 0); } function runShellGate(gate) { return new Promise((resolve) => { const startedAt = Date.now(); const child = spawn(gate.command, gate.args, { cwd: repoRoot, env: { ...process.env, CI: "1" }, shell: false }); let stdout = ""; let stderr = ""; child.stdout.on("data", (chunk) => { stdout += chunk; }); child.stderr.on("data", (chunk) => { stderr += chunk; }); child.on("error", (error) => { stderr += `\n${error.message}`; }); child.on("close", (code) => { const output = `${stdout}\n${stderr}`; resolve({ id: gate.id, command: shellCommand(gate), exitCode: code ?? 1, durationMs: Date.now() - startedAt, failureCount: failureCount(gate.id, stdout, stderr, code ?? 1), fingerprint: outputFingerprint(output), }); }); }); } function isIgnoredDirectory(name, relativePath) { return ignoredDirectoryNames.has(name) || ignoredDirectoryNames.has(relativePath) || name.endsWith(".cache"); } async function rootExists(root, rootRelativePath) { try { await stat(path.join(root, rootRelativePath)); return true; } catch (error) { if (error?.code === "ENOENT") return false; throw error; } } async function collectFiles(root, rootRelativePath) { const absoluteRoot = path.join(root, rootRelativePath); const files = []; async function walk(directory) { const entries = await readdir(directory, { withFileTypes: true }); for (const entry of entries) { const absolutePath = path.join(directory, entry.name); const relativePath = path.relative(root, absolutePath).split(path.sep).join("/"); if (entry.isDirectory()) { if (isIgnoredDirectory(entry.name, relativePath)) continue; await walk(absolutePath); } else if (entry.isFile() && /\.(?:ts|tsx|js|jsx|mjs|cjs)$/.test(entry.name) && !entry.name.endsWith(".generated.ts") && !entry.name.endsWith(".generated.tsx")) { files.push(absolutePath); } } } await walk(absoluteRoot); return files; } // Coarse, case-insensitive literal tokens. Every precise legacyUrlInventoryPattern // requires at least one of these substrings to be present in a matching line, so a // file that contains none of them cannot possibly match. This lets us run a ripgrep // `--files-with-matches` PREFILTER and skip reading files that can't match, while the // exact per-line check below stays unchanged. The token set is a guaranteed superset // of the precise patterns, so the reported violation set is identical. const legacyUrlInventoryPrefilterTokens = [ "UrlUtils", "window.location", "rootData.serverUrl", "CanonicalUrlUtils", "appPath", "appUrl", "adminPath", "adminUrl", "apiPath", "apiUrl", "/app/", "app.$tenant", ]; const maxPrefilterArgBytes = 100_000; function chunkFilesForPrefilter(files) { const chunks = []; let chunk = []; let chunkBytes = 0; for (const file of files) { const fileBytes = Buffer.byteLength(file) + 1; if (chunk.length > 0 && chunkBytes + fileBytes > maxPrefilterArgBytes) { chunks.push(chunk); chunk = []; chunkBytes = 0; } chunk.push(file); chunkBytes += fileBytes; } if (chunk.length > 0) chunks.push(chunk); return chunks; } // Narrow the candidate file set with ripgrep. On ANY failure (rg missing/ENOENT, // unexpected exit code, spawn error) we fall back to the full file list so behavior // is identical to the unprefiltered scan. rg exit code 1 means "no matches" (empty set). async function prefilterFiles(files) { if (files.length === 0) return files; try { const matching = new Set(); for (const chunk of chunkFilesForPrefilter(files)) { const args = [ "--files-with-matches", "--fixed-strings", "--ignore-case", "--no-ignore", "--hidden", "--text", "--no-messages", ...legacyUrlInventoryPrefilterTokens.flatMap((token) => ["--regexp", token]), "--", ...chunk, ]; const result = await new Promise((resolve, reject) => { const child = spawn("rg", args, { cwd: repoRoot }); let stdout = ""; let stderr = ""; child.stdout.on("data", (data) => { stdout += data; }); child.stderr.on("data", (data) => { stderr += data; }); child.on("error", reject); child.on("close", (code) => { if (code !== 0 && code !== 1) { reject(new Error(`ripgrep prefilter exited with code ${code}: ${stderr.trim()}`)); return; } resolve(stdout); }); }); for (const line of result.split("\n")) { const trimmed = line.trim(); if (trimmed) matching.add(path.resolve(repoRoot, trimmed)); } } // Preserve original ordering for deterministic output. return files.filter((file) => matching.has(path.resolve(file))); } catch { return files; } } export async function runLegacyUrlInventory(root = repoRoot) { const existingRoots = []; const missingRoots = []; for (const rootRelativePath of legacyUrlInventoryScanRoots) { if (await rootExists(root, rootRelativePath)) existingRoots.push(rootRelativePath); else missingRoots.push(rootRelativePath); } const collectedFiles = (await Promise.all(existingRoots.map((rootRelativePath) => collectFiles(root, rootRelativePath)))).flat(); // filesScanned reflects the full collected set so the report's scanned-files count is // unchanged; the prefilter only narrows which files we actually open and read. const files = root === repoRoot ? await prefilterFiles(collectedFiles) : collectedFiles; const counts = new Map(legacyUrlInventoryPatterns.map(({ id }) => [id, 0])); const matches = []; for (const file of files) { const relativeFile = path.relative(root, file).split(path.sep).join("/"); const lines = (await readFile(file, "utf8")).split(/\r?\n/); lines.forEach((line, index) => { for (const { id, pattern } of legacyUrlInventoryPatterns) { pattern.lastIndex = 0; const found = line.match(pattern); if (!found) continue; counts.set(id, (counts.get(id) ?? 0) + found.length); matches.push(`${relativeFile}:${index + 1}: [${id}] ${line.trim()}`); } }); } return { roots: existingRoots, missingRoots, filesScanned: collectedFiles.length, counts: Object.fromEntries(counts.entries()), totalMatches: matches.length, matches }; } async function runLegacyUrlInventoryGate(gate) { const startedAt = Date.now(); const inventory = await runLegacyUrlInventory(repoRoot); const integrityFailures = inventory.missingRoots.length + (inventory.filesScanned === 0 ? 1 : 0); const outputLines = [ `Legacy URL inventory scanned roots: ${inventory.roots.join(", ")}`, `Legacy URL inventory missing roots: ${inventory.missingRoots.join(", ") || "(none)"}`, `files scanned: ${inventory.filesScanned}`, ...Object.entries(inventory.counts).map(([id, count]) => `${id}: ${count}`), `total matches: ${inventory.totalMatches}`, ...inventory.matches.slice(0, 240), ]; if (inventory.matches.length > 240) outputLines.push(`... ${inventory.matches.length - 240} additional matches omitted`); const output = outputLines.join("\n"); return { id: gate.id, command: shellCommand(gate), exitCode: integrityFailures > 0 ? 1 : 0, durationMs: Date.now() - startedAt, failureCount: integrityFailures, inventory, fingerprint: outputFingerprint(output), }; } async function runGate(gate) { if (gate.type === "legacy-url-inventory") return runLegacyUrlInventoryGate(gate); return runShellGate(gate); } export function renderReport(results) { const lines = [ "# Host Aware URL Baseline Health", "", `Generated: ${new Date().toISOString()}`, "", "This report captures non-E2E baseline gates for Host Aware URL baseline. The runner exits non-zero when any executable gate fails.", "The legacy URL inventory gate fingerprints UrlUtils imports/references, window.location, rootData.serverUrl, CanonicalUrlUtils helper usage, and legacy /app/:tenant route references while skipping generated, dependency, build, cache, documentation, and TC artifact folders. Missing scan roots fail the run.", "", ]; for (const result of results) lines.push( `## ${result.id}`, "", `- Command: \`${result.command}\``, `- Exitcode: ${result.exitCode}`, `- Failure Count: ${result.failureCount}`, `- Duration: ${result.durationMs}ms`, `- Output Fingerprint: ${result.fingerprint.hash}`, "", "```text", result.fingerprint.excerpt, "```", "" ); return `${lines.join("\n")}\n`; } async function writeFailureReport(error) { await mkdir(path.dirname(reportPath), { recursive: true }); const output = error?.stack ?? String(error); await writeFile( reportPath, renderReport([{ id: "runner-error", command: "node scripts/verify-host-aware-url-baseline.mjs", exitCode: 1, durationMs: 0, failureCount: 1, fingerprint: outputFingerprint(output) }]), "utf8" ); } async function main() { const results = []; for (const gate of gates) { console.log(`Running ${gate.id}: ${shellCommand(gate)}`); results.push(await runGate(gate)); } await mkdir(path.dirname(reportPath), { recursive: true }); await writeFile(reportPath, renderReport(results), "utf8"); console.log(`Wrote ${path.relative(repoRoot, reportPath)}`); if (hasFailedResults(results)) { process.exitCode = 1; } } if (process.argv[1] === fileURLToPath(import.meta.url)) { main().catch(async (error) => { console.error(error); try { await writeFailureReport(error); } finally { process.exit(1); } }); }