140 lines
5.4 KiB
TypeScript
140 lines
5.4 KiB
TypeScript
import { spawnSync } from "node:child_process";
|
|
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
const explicitAnyLabel = "explicit " + "any";
|
|
const sourceFiles = [
|
|
...collectSourceFiles(path.join(rootDir, "src")),
|
|
...collectSourceFiles(path.join(rootDir, "scripts"))
|
|
];
|
|
let explicitAnyCount = 0;
|
|
for (const file of sourceFiles) {
|
|
const text = readFileSync(file, "utf8");
|
|
const stripped = stripCommentsAndStrings(text);
|
|
const count = countExplicitAny(stripped);
|
|
if (count > 0) {
|
|
explicitAnyCount += count;
|
|
console.error(` ${path.relative(rootDir, file)}: ${count} ${explicitAnyLabel} usage(s)`);
|
|
}
|
|
}
|
|
if (explicitAnyCount > 0) {
|
|
console.error(`hwlab-cloud-web tsc-check: ${explicitAnyCount} ${explicitAnyLabel} usage(s) in Web source`);
|
|
} else {
|
|
console.log(`hwlab-cloud-web tsc-check: ${explicitAnyLabel} scan passed (0 usages)`);
|
|
}
|
|
|
|
const sourceShapeStatus = runBunScript("source-shape", path.join(rootDir, "scripts/check.ts"));
|
|
|
|
const vueTscBin = resolveVueTscBin(rootDir);
|
|
let vueTscStatus = 0;
|
|
if (!vueTscBin) {
|
|
console.error("hwlab-cloud-web tsc-check: vue-tsc not found in ancestor node_modules/.bin or PATH");
|
|
vueTscStatus = 2;
|
|
} else {
|
|
const result = spawnSync(vueTscBin, ["--noEmit", "--project", path.join(rootDir, "tsconfig.json")], { cwd: rootDir, encoding: "utf8" });
|
|
if (result.stdout) process.stdout.write(result.stdout);
|
|
if (result.stderr) process.stderr.write(result.stderr);
|
|
if (result.error) console.error(`hwlab-cloud-web tsc-check: failed to start ${vueTscBin}: ${result.error.message}`);
|
|
vueTscStatus = result.status ?? (result.error ? 2 : 0);
|
|
if (vueTscStatus === 0) {
|
|
console.log("hwlab-cloud-web tsc-check: vue-tsc passed");
|
|
} else {
|
|
console.error(`hwlab-cloud-web tsc-check: vue-tsc failed with exit ${result.status ?? "unknown"}`);
|
|
}
|
|
}
|
|
|
|
if (explicitAnyCount > 0 || sourceShapeStatus !== 0 || vueTscStatus !== 0) {
|
|
console.error(`hwlab-cloud-web tsc-check: failed (source-shape=${sourceShapeStatus}, vue-tsc=${vueTscStatus}, ${explicitAnyLabel}=${explicitAnyCount})`);
|
|
process.exit(firstFailureStatus(sourceShapeStatus, vueTscStatus, explicitAnyCount > 0 ? 2 : 0));
|
|
}
|
|
console.log(`hwlab-cloud-web tsc-check: passed (source-shape, vue-tsc, 0 ${explicitAnyLabel})`);
|
|
|
|
function runBunScript(label: string, scriptPath: string): number {
|
|
const result = spawnSync(process.execPath, ["run", scriptPath], { cwd: rootDir, encoding: "utf8" });
|
|
if (result.stdout) process.stdout.write(result.stdout);
|
|
if (result.stderr) process.stderr.write(result.stderr);
|
|
if (result.error) console.error(`hwlab-cloud-web tsc-check: failed to start ${label}: ${result.error.message}`);
|
|
const status = result.status ?? (result.error ? 2 : 0);
|
|
if (status === 0) console.log(`hwlab-cloud-web tsc-check: ${label} passed`);
|
|
else console.error(`hwlab-cloud-web tsc-check: ${label} failed with exit ${result.status ?? "unknown"}`);
|
|
return status;
|
|
}
|
|
|
|
function firstFailureStatus(...statuses: number[]): number {
|
|
return statuses.find((status) => status !== 0) ?? 2;
|
|
}
|
|
|
|
function explicitAnyPatterns(): RegExp[] {
|
|
const token = "a" + "ny";
|
|
return [
|
|
new RegExp(`:\\s*${token}\\b`, "gu"),
|
|
new RegExp(`\\bas\\s+${token}\\b`, "gu"),
|
|
new RegExp(`<[^>\\n]*\\b${token}\\b[^>\\n]*>`, "gu"),
|
|
new RegExp(`\\b${token}\\s*\\[\\s*\\]`, "gu"),
|
|
new RegExp(`\\b${token}\\s*(?:[|&;,)=]|$)`, "gu")
|
|
];
|
|
}
|
|
|
|
function countExplicitAny(strippedSource: string): number {
|
|
const token = "a" + "ny";
|
|
const tokenPattern = new RegExp(`\\b${token}\\b`, "gu");
|
|
const positions = new Set<number>();
|
|
for (const pattern of explicitAnyPatterns()) {
|
|
for (const match of strippedSource.matchAll(pattern)) {
|
|
const index = match.index;
|
|
if (index === undefined) continue;
|
|
for (const tokenMatch of match[0].matchAll(tokenPattern)) {
|
|
if (tokenMatch.index !== undefined) positions.add(index + tokenMatch.index);
|
|
}
|
|
}
|
|
}
|
|
return positions.size;
|
|
}
|
|
|
|
function resolveVueTscBin(startDir: string): string | null {
|
|
let current = startDir;
|
|
while (true) {
|
|
const candidate = path.join(current, "node_modules/.bin/vue-tsc");
|
|
if (isRunnableFile(candidate)) return candidate;
|
|
const parent = path.dirname(current);
|
|
if (parent === current) break;
|
|
current = parent;
|
|
}
|
|
for (const dir of (process.env.PATH ?? "").split(path.delimiter).filter(Boolean)) {
|
|
const candidate = path.join(dir, process.platform === "win32" ? "vue-tsc.cmd" : "vue-tsc");
|
|
if (isRunnableFile(candidate)) return candidate;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function isRunnableFile(candidate: string): boolean {
|
|
try {
|
|
return statSync(candidate).isFile();
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function collectSourceFiles(dir: string): string[] {
|
|
const out: string[] = [];
|
|
for (const entry of readdirSync(dir)) {
|
|
if (entry === "node_modules" || entry === "dist" || entry.startsWith(".")) continue;
|
|
const full = path.join(dir, entry);
|
|
const stat = statSync(full);
|
|
if (stat.isDirectory()) out.push(...collectSourceFiles(full));
|
|
else if (/\.(ts|vue)$/u.test(full)) out.push(full);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function stripCommentsAndStrings(text: string): string {
|
|
return text
|
|
.replace(/\/\*[\s\S]*?\*\//gu, "")
|
|
.replace(/(^|[^:])\/\/.*$/gmu, "$1")
|
|
.replace(/`(?:\\[\s\S]|[^`\\])*`/gu, "``")
|
|
.replace(/"(?:\\.|[^"\\])*"/gu, '""')
|
|
.replace(/'(?:\\.|[^'\\])*'/gu, "''");
|
|
}
|