162 lines
5.4 KiB
TypeScript
162 lines
5.4 KiB
TypeScript
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
import { dirname, join, relative, resolve } from "node:path";
|
|
|
|
export const codeQueueSourceSubdir = "src/components/microservices/code-queue/src";
|
|
const tsExtensions = [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs", ".json"] as const;
|
|
|
|
export interface MissingRelativeImport {
|
|
importer: string;
|
|
specifier: string;
|
|
expected: string[];
|
|
}
|
|
|
|
export interface CodeQueueSourceImportPreflightResult {
|
|
ok: boolean;
|
|
guard: "code-queue-hostpath-source-imports";
|
|
root: string;
|
|
sourceRoot: string;
|
|
checkedFiles: number;
|
|
checkedImports: number;
|
|
missing: MissingRelativeImport[];
|
|
degradedReason: "none" | "source-root-missing" | "missing-relative-import-target";
|
|
message: string;
|
|
}
|
|
|
|
function normalizedRelative(from: string, to: string): string {
|
|
return relative(from, to).split("\\").join("/");
|
|
}
|
|
|
|
function walkTsFiles(dir: string): string[] {
|
|
const result: string[] = [];
|
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
if (entry.name === "node_modules" || entry.name === "dist" || entry.name === ".git" || entry.name === ".state") continue;
|
|
const path = join(dir, entry.name);
|
|
if (entry.isDirectory()) {
|
|
result.push(...walkTsFiles(path));
|
|
continue;
|
|
}
|
|
if (entry.isFile() && /\.tsx?$/u.test(entry.name)) result.push(path);
|
|
}
|
|
return result.sort();
|
|
}
|
|
|
|
function stripComments(text: string): string {
|
|
return text
|
|
.replace(/\/\*[\s\S]*?\*\//gu, "")
|
|
.replace(/(^|[^:])\/\/.*$/gmu, "$1");
|
|
}
|
|
|
|
function relativeImportSpecifiers(text: string): string[] {
|
|
const cleaned = stripComments(text);
|
|
const specifiers = new Set<string>();
|
|
const patterns = [
|
|
/\b(?:import|export)\s+(?:type\s+)?(?:[\s\S]*?\s+from\s+)?["'](\.{1,2}\/[^"']+)["']/gu,
|
|
/\bimport\s*\(\s*["'](\.{1,2}\/[^"']+)["']\s*\)/gu,
|
|
];
|
|
for (const pattern of patterns) {
|
|
for (const match of cleaned.matchAll(pattern)) {
|
|
const specifier = match[1];
|
|
if (specifier !== undefined) specifiers.add(specifier);
|
|
}
|
|
}
|
|
return [...specifiers].sort();
|
|
}
|
|
|
|
function importCandidates(importer: string, specifier: string): string[] {
|
|
const base = resolve(dirname(importer), specifier);
|
|
if (tsExtensions.some((extension) => base.endsWith(extension))) return [base];
|
|
return [
|
|
...tsExtensions.map((extension) => `${base}${extension}`),
|
|
...tsExtensions.map((extension) => join(base, `index${extension}`)),
|
|
];
|
|
}
|
|
|
|
function importTargetExists(candidates: string[]): boolean {
|
|
return candidates.some((candidate) => {
|
|
if (!existsSync(candidate)) return false;
|
|
const stats = statSync(candidate);
|
|
return stats.isFile();
|
|
});
|
|
}
|
|
|
|
export function codeQueueSourceImportPreflight(rootDir: string): CodeQueueSourceImportPreflightResult {
|
|
const root = resolve(rootDir);
|
|
const sourceRoot = resolve(root, codeQueueSourceSubdir);
|
|
if (!existsSync(sourceRoot) || !statSync(sourceRoot).isDirectory()) {
|
|
return {
|
|
ok: false,
|
|
guard: "code-queue-hostpath-source-imports",
|
|
root,
|
|
sourceRoot,
|
|
checkedFiles: 0,
|
|
checkedImports: 0,
|
|
missing: [],
|
|
degradedReason: "source-root-missing",
|
|
message: `Code Queue source root is missing: ${codeQueueSourceSubdir}`,
|
|
};
|
|
}
|
|
|
|
const files = walkTsFiles(sourceRoot);
|
|
const missing: MissingRelativeImport[] = [];
|
|
let checkedImports = 0;
|
|
for (const file of files) {
|
|
const text = readFileSync(file, "utf8");
|
|
for (const specifier of relativeImportSpecifiers(text)) {
|
|
checkedImports += 1;
|
|
const candidates = importCandidates(file, specifier);
|
|
if (importTargetExists(candidates)) continue;
|
|
missing.push({
|
|
importer: normalizedRelative(root, file),
|
|
specifier,
|
|
expected: candidates.map((candidate) => normalizedRelative(root, candidate)),
|
|
});
|
|
}
|
|
}
|
|
|
|
return {
|
|
ok: missing.length === 0,
|
|
guard: "code-queue-hostpath-source-imports",
|
|
root,
|
|
sourceRoot,
|
|
checkedFiles: files.length,
|
|
checkedImports,
|
|
missing,
|
|
degradedReason: missing.length === 0 ? "none" : "missing-relative-import-target",
|
|
message: missing.length === 0
|
|
? `Code Queue hostPath source import preflight passed (${files.length} files, ${checkedImports} relative imports).`
|
|
: `Code Queue hostPath source import preflight failed: ${missing.length} relative import target(s) are missing.`,
|
|
};
|
|
}
|
|
|
|
function optionValue(args: string[], name: string): string | null {
|
|
const index = args.indexOf(name);
|
|
if (index === -1) return null;
|
|
const value = args[index + 1];
|
|
if (value === undefined || value.length === 0) throw new Error(`${name} requires a value`);
|
|
return value;
|
|
}
|
|
|
|
export function runCodeQueueSourceGuardCli(args: string[]): CodeQueueSourceImportPreflightResult {
|
|
if (args.includes("--help") || args.includes("-h")) {
|
|
return {
|
|
ok: true,
|
|
guard: "code-queue-hostpath-source-imports",
|
|
root: process.cwd(),
|
|
sourceRoot: resolve(process.cwd(), codeQueueSourceSubdir),
|
|
checkedFiles: 0,
|
|
checkedImports: 0,
|
|
missing: [],
|
|
degradedReason: "none",
|
|
message: "usage: bun scripts/code-queue-source-guard.ts --root <repo-root>",
|
|
};
|
|
}
|
|
const root = optionValue(args, "--root") ?? process.cwd();
|
|
return codeQueueSourceImportPreflight(root);
|
|
}
|
|
|
|
export function emitCodeQueueSourceGuardCli(args: string[]): void {
|
|
const result = runCodeQueueSourceGuardCli(args);
|
|
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
if (!result.ok) process.exitCode = 1;
|
|
}
|