384 lines
14 KiB
JavaScript
384 lines
14 KiB
JavaScript
#!/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);
|
|
}
|
|
});
|
|
}
|