mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 02:29:33 +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
@@ -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 |
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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