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:
Codeman maintainer committed 2026-11-13 20:39:05 +01:00
1 parent 75ea908f29
commit 574eae72f3
3 files changed
+272

No files matched your search

+1
View File
@@ -77,6 +77,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
| Build the docker agent image | `node scripts/build-agent-image.mjs --no-cache` (builds `codeman/agent:base` from `docker/agent.Dockerfile`; prerequisite for Docker cases; `--engine`/`--image`). ⚠ **Always `--no-cache`** — a plain rebuild re-uses the cached `npm install -g` layer and silently keeps the CLIs frozen at their original versions, which once shipped a BROKEN codex while reporting success. See `docs/docker-cases.md` |
| Browser-test exclusion check | `npm run check:browser-excludes` (`scripts/check-browser-test-excludes.mjs`; runs in CI, <1s). Fails if a test importing playwright/puppeteer is still collected by `config/vitest.ci.config.ts`; add it to `BROWSER_TEST_GLOBS` in `config/test-suites.ts` |
| Dead-code report | `npm run knip` (`config/knip.json`; fetched with `npx`, so it needs the network and is not a CI step). It reports nothing on a clean tree: an export, file or dependency it lists is new dead code, or a manual script that needs an `entry`. Tests count as users, and `src/web/public/` is outside it (plain scripts with no imports). A deliberate keep carries `@knipignore` in its JSDoc |
| Frontend dead-code check | `npm run check:frontend-dead` (`scripts/check-frontend-dead-code.mjs`, a few seconds, not a CI step). `src/web/public/` is plain scripts that knip cannot follow, so this is its counterpart there: it fails on a function, method or top-level binding that nothing references (identifiers, strings, `index.html`, the gesture overlay's source, tests) and on a CSS class or id that no markup or script can produce. `--production` also lists what only tests reference. A deliberate keep goes in the script's `ALLOW` map with its reason |
| Pre-push hook | Installed by `npm install` (`scripts/git-hooks.mjs`, via postinstall): runs the static CI checks (~10-40s) before `git push`. Skip once: `CODEMAN_SKIP_PREPUSH=1 git push`. Skips itself with a notice when HEAD is not the pushed commit or the tree has uncommitted changes the checks would read. Marker-owned, so a hand-written `pre-push` is never overwritten; installs ONLY into the repo's own `<git-common-dir>/hooks` (worktree-safe; a `core.hooksPath` elsewhere, e.g. a global one, is left alone) |
| Production logs | `journalctl --user -u codeman-web -f` |
| Detached server | `codeman web -d` (`--status`, `--stop`; pidfile+log at `dataPath('web.pid'/'web.log')`). ⚠ Refuses to start a 2nd server on one data dir — see Instance isolation |
+1
View File
@@ -29,6 +29,7 @@
"pretest:mobile": "node scripts/prepare-test-vendor.mjs",
"test:mobile": "vitest run --config test/mobile/vitest.config.ts",
"check:frontend-syntax": "node scripts/check-frontend-syntax.mjs",
"check:frontend-dead": "node scripts/check-frontend-dead-code.mjs",
"check:browser-excludes": "node scripts/check-browser-test-excludes.mjs",
"check:local-model-catalog": "tsx scripts/check-local-model-catalog.mjs",
"fix:node-pty": "node scripts/fix-node-pty.mjs",
+270
View File
@@ -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);