Files
einkaufsapp/tools/check-js.mjs

255 lines
9.1 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.
*
* NICHT gefunden wird deshalb der Zugriff auf ein const, das erst
* WEITER UNTEN im selben Geltungsbereich angelegt wird ("Cannot access
* 'x' before initialization"). Ein Versuch, das hier mitzuprüfen, hat
* auf sauberem Quelltext rund achtzig Fehlalarme erzeugt: Aufrufe in
* Ereignisbehandlern stehen im Text vor der Deklaration, laufen aber
* später. Zuverlässig ginge das nur mit einem echten Parser.
*
* Als Regel im Quelltext statt als Prüfung: In einem Funktionsrumpf
* erst ALLE const/let anlegen, dann die Hilfsfunktionen, dann der Code,
* der etwas tut. Dann kann der Fall nicht auftreten.
*/
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);