mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 10:39:32 +01:00
chore: add a dead-code check for the frontend scripts
src/web/public is plain <script> files with no imports, so knip cannot follow it and ESLint does not cover it; leftovers there went unnoticed until this cleanup. `npm run check:frontend-dead` is the counterpart of `npm run knip` for that tree: - JavaScript: every function, method and top-level binding, counted against the identifiers and string literals of the frontend files, index.html (inline handlers), the gesture overlay's source and the tests. A name with no reference but its own definition fails the check. - CSS: every class and id of styles.css and mobile.css against the same text. A class built at runtime is recognised by its prefix or suffix. - `--production` also lists what only tests reference. It parses with the TypeScript compiler and PostCSS, both already dev dependencies. Clean on this tree; run against the tree before the cleanup it reports the 28 functions and 43 selectors that were removed. On demand for now, not a CI step. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
This commit is contained in:
1 parent
75ea908f29
commit
574eae72f3
3 files changed
+272
No files matched your search
@@ -0,0 +1,270 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Frontend dead-code check.
|
||||
*
|
||||
* `src/web/public/*.js` is loaded as plain <script> files: no imports, so knip cannot
|
||||
* follow it, and it is outside ESLint. A method that lost its last caller therefore
|
||||
* stays in the tree unnoticed (the November 2026 cleanup removed about 2,000 such
|
||||
* lines of JS and 1,500 lines of CSS). This script finds them.
|
||||
*
|
||||
* JavaScript: every function, method and top-level binding the frontend files
|
||||
* define, checked against every identifier and every string or template literal in
|
||||
* those files, `index.html` (inline handlers), the gesture overlay's source and the
|
||||
* tests. A name that appears nowhere but at its own definition is reported.
|
||||
*
|
||||
* CSS: every class and id selector in `styles.css` and `mobile.css`, checked against
|
||||
* the same text. A class built at runtime (`tab-state-${state}`) is recognised by its
|
||||
* prefix and skipped, so the report holds only names no code can produce.
|
||||
*
|
||||
* Text matching is deliberately conservative: a mention in a comment does not count,
|
||||
* a mention in any string does. So a clean run does not prove everything is used,
|
||||
* but a reported name really has no reference. To keep one on purpose, add it to
|
||||
* ALLOW below with the reason.
|
||||
*
|
||||
* Usage: node scripts/check-frontend-dead-code.mjs [--production]
|
||||
* --production also report what only tests reference (informational, exit 0)
|
||||
*
|
||||
* Parsed with the TypeScript compiler (already a dev dependency), so this needs no
|
||||
* package of its own.
|
||||
*/
|
||||
import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs';
|
||||
import { join, dirname, relative } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { createRequire } from 'node:module';
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const PUBLIC_DIR = join(ROOT, 'src', 'web', 'public');
|
||||
const require = createRequire(join(ROOT, 'package.json'));
|
||||
const ts = require('typescript');
|
||||
const postcss = require('postcss');
|
||||
|
||||
/**
|
||||
* Names kept on purpose although nothing references them. Key: the name as reported
|
||||
* (a JS name, or a selector with its `.` or `#`). Value: why it stays.
|
||||
*/
|
||||
const ALLOW = new Map([
|
||||
// ['exampleMethod', 'called from the browser console when debugging X'],
|
||||
]);
|
||||
|
||||
/** Class prefixes that belong to vendored code, which the scan does not read. */
|
||||
const EXTERNAL_CLASS_PREFIXES = ['xterm'];
|
||||
|
||||
const productionMode = process.argv.includes('--production');
|
||||
|
||||
function walk(dir, filter, skipDirs = []) {
|
||||
const out = [];
|
||||
if (!existsSync(dir)) return out;
|
||||
for (const entry of readdirSync(dir)) {
|
||||
if (entry === 'node_modules' || skipDirs.includes(entry)) continue;
|
||||
const path = join(dir, entry);
|
||||
if (statSync(path).isDirectory()) out.push(...walk(path, filter, skipDirs));
|
||||
else if (filter.test(entry)) out.push(path);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const rel = (path) => relative(ROOT, path);
|
||||
const read = (path) => readFileSync(path, 'utf8');
|
||||
|
||||
// ── What counts as a reference ────────────────────────────────────────────────
|
||||
|
||||
// Shipped scripts. The vendored bundles and the generated gesture bundle are not ours
|
||||
// to judge; the gesture overlay is read from its source instead.
|
||||
const frontendFiles = walk(PUBLIC_DIR, /\.js$/, ['vendor', 'gesture', 'fonts']);
|
||||
const htmlText = walk(PUBLIC_DIR, /\.html$/, ['vendor', 'gesture']).map(read).join('\n');
|
||||
const gestureText = walk(join(ROOT, 'packages', 'gesture-control', 'src'), /\.(ts|js)$/)
|
||||
.map(read)
|
||||
.join('\n');
|
||||
const serverText = walk(join(ROOT, 'src'), /\.ts$/, ['public']).map(read).join('\n');
|
||||
const overlayText = walk(join(ROOT, 'packages', 'xterm-zerolag-input', 'src'), /\.(ts|js)$/)
|
||||
.map(read)
|
||||
.join('\n');
|
||||
const testText = walk(join(ROOT, 'test'), /\.(ts|mjs|js)$/).map(read).join('\n');
|
||||
|
||||
const identifierCount = new Map();
|
||||
const literalParts = [];
|
||||
const definitions = [];
|
||||
|
||||
function bump(name) {
|
||||
identifierCount.set(name, (identifierCount.get(name) ?? 0) + 1);
|
||||
}
|
||||
|
||||
function isFunctionLike(node) {
|
||||
return !!node && (ts.isFunctionExpression(node) || ts.isArrowFunction(node));
|
||||
}
|
||||
|
||||
function propertyName(name) {
|
||||
if (!name) return null;
|
||||
if (ts.isIdentifier(name) || ts.isStringLiteral(name)) return name.text;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Top-level statements, looking one level into an IIFE wrapper. */
|
||||
function topLevelStatements(sourceFile) {
|
||||
const out = [...sourceFile.statements];
|
||||
for (const statement of sourceFile.statements) {
|
||||
if (!ts.isExpressionStatement(statement)) continue;
|
||||
let expr = statement.expression;
|
||||
while (ts.isParenthesizedExpression(expr) || ts.isPrefixUnaryExpression(expr)) {
|
||||
expr = ts.isParenthesizedExpression(expr) ? expr.expression : expr.operand;
|
||||
}
|
||||
if (!ts.isCallExpression(expr)) continue;
|
||||
let callee = expr.expression;
|
||||
while (ts.isParenthesizedExpression(callee)) callee = callee.expression;
|
||||
if (ts.isPropertyAccessExpression(callee)) callee = callee.expression;
|
||||
while (ts.isParenthesizedExpression(callee)) callee = callee.expression;
|
||||
if ((ts.isFunctionExpression(callee) || ts.isArrowFunction(callee)) && ts.isBlock(callee.body)) {
|
||||
out.push(...callee.body.statements);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
for (const file of frontendFiles) {
|
||||
const text = read(file);
|
||||
const sourceFile = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.JS);
|
||||
const lineOf = (node) => sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1;
|
||||
const define = (name, node, kind) => {
|
||||
if (name) definitions.push({ name, file: rel(file), line: lineOf(node), kind });
|
||||
};
|
||||
|
||||
for (const statement of topLevelStatements(sourceFile)) {
|
||||
if (ts.isVariableStatement(statement)) {
|
||||
for (const decl of statement.declarationList.declarations) {
|
||||
if (ts.isIdentifier(decl.name)) define(decl.name.text, decl, 'binding');
|
||||
}
|
||||
} else if (ts.isClassDeclaration(statement) && statement.name) {
|
||||
define(statement.name.text, statement, 'binding');
|
||||
}
|
||||
}
|
||||
|
||||
const visit = (node) => {
|
||||
if (ts.isIdentifier(node) || ts.isPrivateIdentifier(node)) bump(node.text);
|
||||
else if (
|
||||
ts.isStringLiteral(node) ||
|
||||
ts.isNoSubstitutionTemplateLiteral(node) ||
|
||||
node.kind === ts.SyntaxKind.TemplateHead ||
|
||||
node.kind === ts.SyntaxKind.TemplateMiddle ||
|
||||
node.kind === ts.SyntaxKind.TemplateTail
|
||||
) {
|
||||
literalParts.push(node.text);
|
||||
}
|
||||
|
||||
if (ts.isFunctionDeclaration(node) && node.name) define(node.name.text, node, 'function');
|
||||
else if (ts.isMethodDeclaration(node) || ts.isGetAccessor(node) || ts.isSetAccessor(node)) {
|
||||
define(propertyName(node.name), node, 'method');
|
||||
} else if (ts.isPropertyAssignment(node) && isFunctionLike(node.initializer)) {
|
||||
define(propertyName(node.name), node, 'method');
|
||||
} else if (ts.isPropertyDeclaration(node) && isFunctionLike(node.initializer)) {
|
||||
define(propertyName(node.name), node, 'method');
|
||||
} else if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && isFunctionLike(node.initializer)) {
|
||||
define(node.name.text, node, 'function');
|
||||
}
|
||||
ts.forEachChild(node, visit);
|
||||
};
|
||||
visit(sourceFile);
|
||||
}
|
||||
|
||||
const literalText = literalParts.join('\n');
|
||||
const wordPattern = (name) => new RegExp(`(?<![\\w$])${name.replace(/[$]/g, '\\$&')}(?![\\w$])`, 'g');
|
||||
const count = (text, pattern) => (text.match(pattern) ?? []).length;
|
||||
|
||||
// ── JavaScript ────────────────────────────────────────────────────────────────
|
||||
|
||||
const byName = new Map();
|
||||
for (const def of definitions) {
|
||||
// A binding and a function for the same declaration are one definition.
|
||||
const list = byName.get(def.name) ?? [];
|
||||
if (!list.some((d) => d.file === def.file && d.line === def.line)) list.push(def);
|
||||
byName.set(def.name, list);
|
||||
}
|
||||
|
||||
const deadJs = [];
|
||||
const testOnlyJs = [];
|
||||
for (const [name, defs] of byName) {
|
||||
if (name.length < 3 || name === 'constructor' || ALLOW.has(name)) continue;
|
||||
const pattern = wordPattern(name);
|
||||
const shipped =
|
||||
(identifierCount.get(name) ?? 0) -
|
||||
defs.length +
|
||||
count(literalText, pattern) +
|
||||
count(htmlText, pattern) +
|
||||
count(gestureText, pattern);
|
||||
if (shipped > 0) continue;
|
||||
const inTests = count(testText, pattern);
|
||||
(inTests > 0 ? testOnlyJs : deadJs).push({ name, defs, inTests });
|
||||
}
|
||||
|
||||
// ── CSS ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
const cssCorpus = [
|
||||
...frontendFiles.map(read),
|
||||
htmlText,
|
||||
gestureText,
|
||||
overlayText,
|
||||
serverText,
|
||||
...walk(PUBLIC_DIR, /\.json$/, ['vendor', 'gesture']).map(read),
|
||||
].join('\n');
|
||||
const corpusWords = new Set(cssCorpus.match(/[A-Za-z_][\w-]*/g) ?? []);
|
||||
// `foo-${x}` and 'foo-' + x build a class at runtime: everything with that prefix may exist.
|
||||
const dynamicPrefixes = new Set();
|
||||
for (const m of cssCorpus.matchAll(/([A-Za-z_][\w-]*-)(?:\$\{|['"`]\s*\+)/g)) dynamicPrefixes.add(m[1]);
|
||||
// `${x}-suffix` and x + '-suffix' likewise, when the stem itself is a known word.
|
||||
const dynamicSuffixes = new Set();
|
||||
for (const m of cssCorpus.matchAll(/(?:\}|\+\s*['"`])(-[\w-]+)/g)) dynamicSuffixes.add(m[1]);
|
||||
|
||||
const deadCss = [];
|
||||
for (const cssFile of [join(PUBLIC_DIR, 'styles.css'), join(PUBLIC_DIR, 'mobile.css')]) {
|
||||
const seen = new Map();
|
||||
postcss.parse(read(cssFile), { from: cssFile }).walkRules((rule) => {
|
||||
const selector = rule.selector.replace(/\[[^\]]*\]/g, ' ').replace(/"[^"]*"|'[^']*'/g, ' ');
|
||||
for (const m of selector.matchAll(/([.#])(-?[A-Za-z_][\w-]*)/g)) {
|
||||
const key = m[1] + m[2];
|
||||
if (!seen.has(key)) seen.set(key, rule.source.start.line);
|
||||
}
|
||||
});
|
||||
for (const [key, line] of seen) {
|
||||
const name = key.slice(1);
|
||||
if (ALLOW.has(key) || corpusWords.has(name)) continue;
|
||||
if (EXTERNAL_CLASS_PREFIXES.some((prefix) => name === prefix || name.startsWith(prefix + '-'))) continue;
|
||||
if ([...dynamicPrefixes].some((prefix) => name.startsWith(prefix))) continue;
|
||||
if ([...dynamicSuffixes].some((suffix) => name.endsWith(suffix) && corpusWords.has(name.slice(0, -suffix.length)))) {
|
||||
continue;
|
||||
}
|
||||
if (deadCss.some((d) => d.key === key)) continue;
|
||||
deadCss.push({ key, file: rel(cssFile), line });
|
||||
}
|
||||
}
|
||||
|
||||
// ── Report ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const where = (defs) => defs.map((d) => `${d.file}:${d.line}`).join(', ');
|
||||
const sortByPlace = (a, b) => where(a.defs).localeCompare(where(b.defs), undefined, { numeric: true });
|
||||
|
||||
if (productionMode && testOnlyJs.length > 0) {
|
||||
console.log(`Referenced by tests only (${testOnlyJs.length}):`);
|
||||
for (const item of testOnlyJs.sort(sortByPlace)) {
|
||||
console.log(` ${item.name} ${where(item.defs)} (${item.inTests} mention${item.inTests === 1 ? '' : 's'} in test/)`);
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
|
||||
if (deadJs.length === 0 && deadCss.length === 0) {
|
||||
console.log(
|
||||
`✓ no unreferenced frontend code: ${byName.size} names in ${frontendFiles.length} scripts and the selectors of 2 stylesheets checked`
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (deadJs.length > 0) {
|
||||
console.error(`Unreferenced frontend functions and bindings (${deadJs.length}):`);
|
||||
for (const item of deadJs.sort(sortByPlace)) console.error(` ${item.name} ${where(item.defs)}`);
|
||||
}
|
||||
if (deadCss.length > 0) {
|
||||
console.error(`${deadJs.length > 0 ? '\n' : ''}CSS selectors nothing can produce (${deadCss.length}):`);
|
||||
for (const item of deadCss) console.error(` ${item.key} ${item.file}:${item.line}`);
|
||||
}
|
||||
console.error(
|
||||
'\nRemove each one, or add it to ALLOW in scripts/check-frontend-dead-code.mjs with the reason it stays.'
|
||||
);
|
||||
process.exit(1);
|
||||
Reference in new issue
Block a user