Erste Produktivversion
This commit is contained in:
243
tools/check-js.mjs
Normal file
243
tools/check-js.mjs
Normal file
@@ -0,0 +1,243 @@
|
||||
#!/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);
|
||||
Reference in New Issue
Block a user