172 lines
5.6 KiB
JavaScript
172 lines
5.6 KiB
JavaScript
#!/usr/bin/env node
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
export const DEFAULT_DB_LEGACY_DIRECTORY = "apps/web/app/utils/db";
|
|
|
|
const REACT_IMPORT_PATTERN = /(?:^|[\n;])\s*import(?:\s+type)?(?:[\s\S]*?\s+from\s*)?["']react(?:\/[^"']*)?["']/g;
|
|
const ATTRIBUTE_LIKE_MARKUP_TAG_PATTERN = /<\s*\/?\s*[A-Za-z][A-Za-z0-9.:-]*(?:\s+[A-Za-z_:][\w:.-]*(?:\s*=\s*(?:"[^"]*"|'[^']*'|\{[^}]*\}))?)+\s*\/?>/g;
|
|
const LOWERCASE_HTML_LIKE_TAG_PATTERN = /<\s*\/?\s*[a-z][A-Za-z0-9.:-]*\s*\/?>/g;
|
|
const STRING_LITERAL_PATTERN = /`(?:\\[\s\S]|[^`\\])*`|"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'/g;
|
|
const TYPE_CAST_TAG_PATTERN = /<\s*(?:const|keyof|infer|readonly|unique|abstract|new|typeof|[A-Z][A-Za-z0-9_$]*(?:\s+extends\s+[^=>]+)?)(?:\s*,[^>]*)?>\s*(?=[A-Za-z_$({[])/g;
|
|
const TYPE_RETURN_TAG_PATTERN = /(?<=\bPromise\s*)<[^>]+>|(?<=\b(?:Partial|Pick|Omit|Record|Required|Readonly|Exclude|Extract|NonNullable|Awaited|Array|Set|Map)\s*)<[^>]+>/g;
|
|
const FORBIDDEN_SOURCE_PATTERNS = [
|
|
{
|
|
reason: "forbidden dangerouslySetInnerHTML marker",
|
|
pattern: /\bdangerouslySetInnerHTML\b/g,
|
|
},
|
|
{
|
|
reason: "forbidden JSX.Element type",
|
|
pattern: /\bJSX\.Element\b/g,
|
|
},
|
|
{
|
|
reason: "forbidden ReactNode type",
|
|
pattern: /\bReactNode\b/g,
|
|
},
|
|
{
|
|
reason: "forbidden ReactElement type",
|
|
pattern: /\bReactElement\b/g,
|
|
},
|
|
{
|
|
reason: "forbidden JSXFragment type",
|
|
pattern: /\bJSXFragment\b/g,
|
|
},
|
|
];
|
|
|
|
function stripStringLiterals(source) {
|
|
return source.replace(STRING_LITERAL_PATTERN, (literal) => " ".repeat(literal.length));
|
|
}
|
|
|
|
function stripTypeOnlyTags(source) {
|
|
return source
|
|
.replace(TYPE_CAST_TAG_PATTERN, (tag) => " ".repeat(tag.length))
|
|
.replace(TYPE_RETURN_TAG_PATTERN, (tag) => " ".repeat(tag.length));
|
|
}
|
|
|
|
function hasJsxLikeMarkupTag(source) {
|
|
ATTRIBUTE_LIKE_MARKUP_TAG_PATTERN.lastIndex = 0;
|
|
if (ATTRIBUTE_LIKE_MARKUP_TAG_PATTERN.test(source)) {
|
|
return true;
|
|
}
|
|
|
|
LOWERCASE_HTML_LIKE_TAG_PATTERN.lastIndex = 0;
|
|
let match;
|
|
while ((match = LOWERCASE_HTML_LIKE_TAG_PATTERN.exec(source))) {
|
|
const afterTag = source.slice(LOWERCASE_HTML_LIKE_TAG_PATTERN.lastIndex).trimStart();
|
|
if (match[0].includes("/") || afterTag.startsWith("</")) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
function normalizeRelativePath(relativePath) {
|
|
return relativePath.split(path.sep).join("/");
|
|
}
|
|
|
|
function hasReadableDirectory(rootDir, relativeDirectory) {
|
|
return fs.existsSync(path.join(rootDir, relativeDirectory));
|
|
}
|
|
|
|
export function listDbLegacyFiles(rootDir, relativeDirectory = DEFAULT_DB_LEGACY_DIRECTORY) {
|
|
const absoluteDirectory = path.join(rootDir, relativeDirectory);
|
|
if (!fs.existsSync(absoluteDirectory)) {
|
|
return [];
|
|
}
|
|
|
|
const entries = fs.readdirSync(absoluteDirectory, { withFileTypes: true });
|
|
const files = [];
|
|
|
|
for (const entry of entries) {
|
|
const relativePath = path.join(relativeDirectory, entry.name);
|
|
if (entry.isDirectory()) {
|
|
files.push(...listDbLegacyFiles(rootDir, relativePath));
|
|
continue;
|
|
}
|
|
|
|
if (entry.isFile() && [".ts", ".tsx"].includes(path.extname(entry.name))) {
|
|
files.push(normalizeRelativePath(relativePath));
|
|
}
|
|
}
|
|
|
|
return files.sort();
|
|
}
|
|
|
|
export function findDbLegacyMarkupFailures(rootDir = process.cwd(), relativeDirectory = DEFAULT_DB_LEGACY_DIRECTORY) {
|
|
const failures = [];
|
|
|
|
if (!hasReadableDirectory(rootDir, relativeDirectory)) {
|
|
failures.push(`${normalizeRelativePath(relativeDirectory)}: missing DB legacy directory`);
|
|
return failures;
|
|
}
|
|
|
|
for (const relativePath of listDbLegacyFiles(rootDir, relativeDirectory)) {
|
|
if (relativePath.endsWith(".tsx")) {
|
|
failures.push(`${relativePath}: forbidden .tsx file in server-only DB legacy boundary`);
|
|
}
|
|
|
|
if (!relativePath.endsWith(".ts")) {
|
|
continue;
|
|
}
|
|
|
|
const source = fs.readFileSync(path.join(rootDir, relativePath), "utf8");
|
|
const sourceWithoutStrings = stripTypeOnlyTags(stripStringLiterals(source));
|
|
REACT_IMPORT_PATTERN.lastIndex = 0;
|
|
if (REACT_IMPORT_PATTERN.test(source)) {
|
|
failures.push(`${relativePath}: forbidden React import in server-only DB legacy boundary`);
|
|
}
|
|
|
|
for (const { reason, pattern } of FORBIDDEN_SOURCE_PATTERNS) {
|
|
pattern.lastIndex = 0;
|
|
if (pattern.test(source)) {
|
|
failures.push(`${relativePath}: ${reason}`);
|
|
}
|
|
}
|
|
|
|
if (hasJsxLikeMarkupTag(sourceWithoutStrings)) {
|
|
failures.push(`${relativePath}: forbidden JSX-like markup tag`);
|
|
}
|
|
}
|
|
|
|
return failures.sort();
|
|
}
|
|
|
|
export function verifyDbLegacyMarkup(rootDir = process.cwd(), relativeDirectory = DEFAULT_DB_LEGACY_DIRECTORY) {
|
|
const files = listDbLegacyFiles(rootDir, relativeDirectory);
|
|
const failures = findDbLegacyMarkupFailures(rootDir, relativeDirectory);
|
|
return { filesScanned: files.length, failures };
|
|
}
|
|
|
|
export function formatDbLegacyMarkupResult(result) {
|
|
if (result.failures.length > 0) {
|
|
return [
|
|
"FAIL DB legacy markup verification",
|
|
...result.failures.map((failure) => `- ${failure}`),
|
|
`Summary: ${result.filesScanned} files scanned, ${result.failures.length} failed`,
|
|
"",
|
|
].join("\n");
|
|
}
|
|
|
|
return [
|
|
"PASS DB legacy markup verification",
|
|
`Summary: ${result.filesScanned} files scanned, 0 failed`,
|
|
"",
|
|
].join("\n");
|
|
}
|
|
|
|
const isCli = process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1]);
|
|
|
|
if (isCli) {
|
|
const result = verifyDbLegacyMarkup(process.cwd());
|
|
const output = formatDbLegacyMarkupResult(result);
|
|
|
|
if (result.failures.length > 0) {
|
|
process.stderr.write(output);
|
|
process.exitCode = 1;
|
|
} else {
|
|
process.stdout.write(output);
|
|
}
|
|
}
|