244 lines
8.5 KiB
JavaScript
244 lines
8.5 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Sucht Bezeichner, die verwendet, aber nirgends deklariert oder
|
|
* importiert werden.
|
|
*
|
|
* node tools/check-js.mjs
|
|
*
|
|
* Hintergrund: Ohne Build-Schritt gibt es keinen Linter, und der Browser
|
|
* meldet "x is not defined" erst, wenn die betroffene Zeile tatsächlich
|
|
* ausgeführt wird. Eine Funktion, die nur im Menü eines Eintrags
|
|
* gebraucht wird, kann so lange fehlen, ohne dass es auffällt.
|
|
*
|
|
* Das ist bewusst eine grobe Prüfung ohne echten Parser: Sie kennt keine
|
|
* Blockgeltungsbereiche und meldet daher nichts, was irgendwo in der
|
|
* Datei deklariert ist. Für den Zweck - "ganz vergessen" statt "am
|
|
* falschen Ort" - reicht das.
|
|
*/
|
|
|
|
import { readFileSync, readdirSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
|
|
const ROOT = process.argv[2] || "web/html/js";
|
|
|
|
/** Was der Browser mitbringt. Bewusst knapp gehalten - je kürzer, desto
|
|
* mehr findet die Prüfung. */
|
|
const GLOBALS = new Set([
|
|
// Sprache
|
|
"Array", "Boolean", "Date", "Error", "Infinity", "Intl", "JSON", "Map",
|
|
"Math", "NaN", "Number", "Object", "Promise", "RegExp", "Set", "String",
|
|
"Symbol", "TypeError", "Uint8Array", "Uint8ClampedArray", "WeakMap",
|
|
"globalThis", "isNaN", "parseFloat", "parseInt", "structuredClone",
|
|
"undefined", "queueMicrotask", "console",
|
|
// Browser
|
|
"AbortController", "Blob", "CustomEvent", "DOMException", "Event",
|
|
"EventSource", "FileReader", "FormData", "Headers", "IDBKeyRange",
|
|
"Image", "ImageData", "Node", "Request", "Response", "URL",
|
|
"URLSearchParams", "alert", "clearInterval", "clearTimeout", "confirm",
|
|
"crypto", "document", "fetch", "history", "indexedDB", "localStorage",
|
|
"location", "navigator", "performance", "prompt", "requestAnimationFrame",
|
|
"Notification", "PushManager", "ServiceWorkerRegistration",
|
|
"self", "sessionStorage", "setInterval", "setTimeout", "window",
|
|
"BarcodeDetector", "Element", "HTMLElement", "MediaStream", "TextDecoder",
|
|
"TextEncoder", "atob", "btoa", "decodeURIComponent", "encodeURIComponent",
|
|
// Service Worker
|
|
"caches", "clients", "registration", "skipWaiting",
|
|
// Schlüsselwörter, die als Wort auftauchen
|
|
"arguments", "as", "async", "await", "break", "case", "catch", "class", "const",
|
|
"continue", "default", "delete", "do", "else", "export", "extends",
|
|
"false", "finally", "for", "from", "function", "get", "if", "import",
|
|
"in", "instanceof", "let", "new", "null", "of", "return", "set", "static",
|
|
"super", "switch", "this", "throw", "true", "try", "typeof", "var", "void",
|
|
"while", "yield",
|
|
]);
|
|
|
|
/** Entfernt Kommentare und Zeichenkettenliterale. Der Inhalt von
|
|
* Template-Ausdrücken ${...} bleibt erhalten - dort steht echter Code. */
|
|
function stripNoise(source) {
|
|
let out = "";
|
|
let i = 0;
|
|
const n = source.length;
|
|
|
|
while (i < n) {
|
|
const two = source.slice(i, i + 2);
|
|
|
|
if (two === "//") {
|
|
while (i < n && source[i] !== "\n") i++;
|
|
continue;
|
|
}
|
|
if (two === "/*") {
|
|
i += 2;
|
|
while (i < n && source.slice(i, i + 2) !== "*/") i++;
|
|
i += 2;
|
|
continue;
|
|
}
|
|
if (source[i] === '"' || source[i] === "'") {
|
|
const quote = source[i++];
|
|
while (i < n && source[i] !== quote) {
|
|
if (source[i] === "\\") i++;
|
|
i++;
|
|
}
|
|
i++;
|
|
out += '""';
|
|
continue;
|
|
}
|
|
if (source[i] === "`") {
|
|
i++;
|
|
while (i < n && source[i] !== "`") {
|
|
if (source[i] === "\\") { i += 2; continue; }
|
|
if (source.slice(i, i + 2) === "${") {
|
|
// Ausdruck übernehmen, Klammern zählen - und rekursiv säubern,
|
|
// denn darin können wieder Zeichenketten stehen.
|
|
i += 2;
|
|
let depth = 1;
|
|
let inner = "";
|
|
while (i < n && depth > 0) {
|
|
if (source[i] === "{") depth++;
|
|
else if (source[i] === "}") depth--;
|
|
if (depth > 0) inner += source[i];
|
|
i++;
|
|
}
|
|
out += " " + stripNoise(inner) + " ";
|
|
continue;
|
|
}
|
|
i++;
|
|
}
|
|
i++;
|
|
out += '""';
|
|
continue;
|
|
}
|
|
// Regulärer Ausdruck? Ein / ist nur dann Beginn eines Literals, wenn
|
|
// davor ein Operator oder Klammeranfang steht - sonst ist es eine
|
|
// Division. Ohne diese Unterscheidung landen die Flags (g, i, s) und
|
|
// Zeichenklassen (\D, \w) in der Bezeichnerliste.
|
|
if (source[i] === "/") {
|
|
const head = out.replace(/\s+$/, "");
|
|
const before = head.slice(-1);
|
|
// Nach einem Schlüsselwort steht ebenfalls ein Literal, keine
|
|
// Division: `return /x/.test(s)` ist gültiges JavaScript.
|
|
const keyword = /(?:^|[^\w$])(return|typeof|instanceof|in|of|case|do|else|void|delete|await|yield|new|throw)$/.test(head);
|
|
if (before === "" || keyword || "(,=:[!&|?{};+-*%<>~^".includes(before)) {
|
|
i++;
|
|
let inClass = false;
|
|
while (i < n) {
|
|
if (source[i] === "\\") { i += 2; continue; }
|
|
if (source[i] === "[") inClass = true;
|
|
else if (source[i] === "]") inClass = false;
|
|
else if (source[i] === "/" && !inClass) break;
|
|
else if (source[i] === "\n") break;
|
|
i++;
|
|
}
|
|
i++;
|
|
while (i < n && /[a-z]/.test(source[i])) i++; // Flags
|
|
out += "0";
|
|
continue;
|
|
}
|
|
}
|
|
|
|
out += source[i++];
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function declaredNames(code) {
|
|
const names = new Set();
|
|
const add = (raw) => {
|
|
// Destrukturierung entfernen, dann jeden Teil einzeln betrachten.
|
|
for (const part of raw.replace(/[{}[\]]/g, "").split(",")) {
|
|
// "a = 1" -> a, "a: b" -> b (der zweite Name ist die Bindung)
|
|
const pieces = part.split("=")[0].split(":");
|
|
const name = pieces[pieces.length - 1].trim().replace(/^\.\.\./, "");
|
|
if (/^[A-Za-z_$][\w$]*$/.test(name)) names.add(name);
|
|
}
|
|
};
|
|
|
|
// Importe
|
|
for (const m of code.matchAll(/import\s+\*\s+as\s+([\w$]+)/g)) names.add(m[1]);
|
|
for (const m of code.matchAll(/import\s+\{([^}]*)\}/g)) {
|
|
for (const part of m[1].split(",")) {
|
|
const name = part.trim().split(/\s+as\s+/).pop().trim();
|
|
if (name) names.add(name);
|
|
}
|
|
}
|
|
for (const m of code.matchAll(/import\s+([\w$]+)\s+from/g)) names.add(m[1]);
|
|
|
|
// Funktionen und Klassen
|
|
for (const m of code.matchAll(/(?:function|class)\s+([\w$]+)/g)) names.add(m[1]);
|
|
|
|
// Variablen, auch destrukturiert
|
|
for (const m of code.matchAll(/(?:const|let|var)\s+(\{[^}]*\}|\[[^\]]*\]|[\w$]+)/g)) {
|
|
add(m[1]);
|
|
}
|
|
|
|
// Parameterlisten: alles zwischen ( ) vor einem => oder {
|
|
for (const m of code.matchAll(/\(([^()]*)\)\s*(?:=>|\{)/g)) add(m[1]);
|
|
// Einzelner Pfeilparameter ohne Klammern
|
|
for (const m of code.matchAll(/(?:^|[^\w$.])([\w$]+)\s*=>/gm)) names.add(m[1]);
|
|
// catch (e)
|
|
for (const m of code.matchAll(/catch\s*\(\s*([\w$]+)/g)) names.add(m[1]);
|
|
// for (const x of ...) ist oben abgedeckt; benannte Objektmethoden
|
|
for (const m of code.matchAll(/([\w$]+)\s*\([^()]*\)\s*\{/g)) names.add(m[1]);
|
|
|
|
return names;
|
|
}
|
|
|
|
function usedNames(code) {
|
|
const names = new Set();
|
|
// Kein Punkt davor (sonst wäre es ein Eigenschaftszugriff), kein
|
|
// Doppelpunkt danach (sonst wäre es ein Objektschlüssel oder Label).
|
|
for (const m of code.matchAll(/(?<![\w$.])([A-Za-z_$][\w$]*)/g)) {
|
|
const name = m[1];
|
|
const after = code.slice(m.index + name.length, m.index + name.length + 40);
|
|
if (/^\s*:/.test(after)) continue;
|
|
names.add(name);
|
|
}
|
|
return names;
|
|
}
|
|
|
|
function walk(dir, files = []) {
|
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
const path = join(dir, entry.name);
|
|
if (entry.isDirectory()) walk(path, files);
|
|
else if (entry.name.endsWith(".js")) files.push(path);
|
|
}
|
|
return files;
|
|
}
|
|
|
|
let problems = 0;
|
|
const targets = walk(ROOT);
|
|
targets.push("web/html/sw.js");
|
|
|
|
for (const file of targets) {
|
|
let source;
|
|
try {
|
|
source = readFileSync(file, "utf8");
|
|
} catch {
|
|
continue;
|
|
}
|
|
const code = stripNoise(source);
|
|
const declared = declaredNames(code);
|
|
const used = usedNames(code);
|
|
|
|
const unknown = [...used].filter(
|
|
(name) => !declared.has(name) && !GLOBALS.has(name)
|
|
).sort();
|
|
|
|
if (unknown.length) {
|
|
console.log(`\n ${file}`);
|
|
for (const name of unknown) {
|
|
// Zeilennummer des ersten Vorkommens für die Fehlersuche
|
|
const line = source.split("\n").findIndex((l) =>
|
|
new RegExp(`(^|[^\\w$.])${name}\\b`).test(l)) + 1;
|
|
console.log(` Zeile ${line}: ${name} wird verwendet, aber nirgends deklariert`);
|
|
problems++;
|
|
}
|
|
}
|
|
}
|
|
|
|
console.log(
|
|
problems
|
|
? `\n${problems} möglicherweise undefinierte(r) Bezeichner`
|
|
: `${targets.length} Dateien geprüft, keine undefinierten Bezeichner`
|
|
);
|
|
process.exit(problems ? 1 : 0);
|