Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
151 changes: 151 additions & 0 deletions .github/scripts/knip-classify.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
#!/usr/bin/env node
// Tag each unused exported symbol in a Knip JSON report with whether it's still
// referenced privately within its own file — beyond its own declaration. This
// separates an *unnecessary export* (used privately — just drop `export`, a
// clean diff) from a *dead exported symbol* (used nowhere — the export was only
// hiding it from the unused-var linter, so delete it).
//
// We parse each file with the TypeScript compiler and ask whether any reference
// to the symbol falls outside its own declaration's subtree. A name-count regex
// can't answer that: it reads a recursive call or a self-referencing type as a
// "use" and mislabels genuinely dead code as merely unnecessary.
//
// Must run while the report's ref is checked out, since it reads source files.
// Rewrites the report in place. Usage: node knip-classify.mjs <report.json>

import { readFileSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { pathToFileURL } from 'node:url';

const REPO = process.env.GITHUB_WORKSPACE || process.cwd();

// TypeScript ships as CommonJS; import it by absolute path so this script
// resolves it even when run from outside the repo (CI copies it to a temp dir,
// which has no node_modules to walk up into). If TS can't load, leave the report
// unclassified — knip-delta detects that and posts its own alert.
let ts;
try {
const url = pathToFileURL(
path.join(REPO, 'node_modules/typescript/lib/typescript.js')
).href;
ts = (await import(url)).default;
} catch {
process.exit(0);
}

const jsonPath = process.argv[2];
if (!jsonPath) {
console.error('usage: knip-classify.mjs <report.json>');
process.exit(1);
}

const SYM = ['exports', 'types', 'nsExports', 'nsTypes'];

let data;
try {
data = JSON.parse(readFileSync(jsonPath, 'utf8'));
} catch {
process.exit(0); // nothing to classify; delta script degrades gracefully
}
if (!data || !Array.isArray(data.issues)) process.exit(0);

// Parse each source file once. Returns the SourceFile, or null if unreadable.
const cache = new Map();
const parse = (file) => {
if (!cache.has(file)) {
try {
const text = readFileSync(path.join(REPO, file), 'utf8');
// Pass the real filename so .tsx is parsed as JSX.
cache.set(
file,
ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true)
);
} catch {
cache.set(file, null);
}
}
return cache.get(file);
};

// An identifier that names a member/binding rather than *referencing* the
// symbol: the `b` in `a.b`, the key in `{ b: … }`, a class/interface member
// name, an enum member, an import/export specifier. Shorthand `{ b }` is a real
// reference, so it is intentionally not excluded here.
const isNonReference = (id) => {
const p = id.parent;
if (!p) return false;
if (ts.isPropertyAccessExpression(p)) return p.name === id;
if (ts.isQualifiedName(p)) return p.right === id;
if (
ts.isPropertyAssignment(p) ||
ts.isPropertySignature(p) ||
ts.isPropertyDeclaration(p) ||
ts.isMethodSignature(p) ||
ts.isMethodDeclaration(p) ||
ts.isGetAccessorDeclaration(p) ||
ts.isSetAccessorDeclaration(p) ||
ts.isEnumMember(p) ||
ts.isImportSpecifier(p) ||
ts.isExportSpecifier(p)
)
return p.name === id;
return false;
};

// The top-level statement enclosing a node — the declaration's own subtree.
const topLevelStatement = (node, sf) => {
let n = node;
while (n.parent && n.parent !== sf) n = n.parent;
return n;
};

// privateUse: is the symbol referenced anywhere outside its own declaration?
// We find the declaration by the identifier nearest knip's reported `pos`, then
// look for any other reference to the same name outside that declaration's range.
const isPrivatelyUsed = (sf, name, pos) => {
const refs = [];
let decl = null;
let bestDist = Infinity;
const visit = (node) => {
if (
ts.isIdentifier(node) &&
node.text === name &&
!isNonReference(node)
) {
const start = node.getStart(sf);
refs.push(start);
if (typeof pos === 'number') {
const dist = Math.abs(start - pos);
if (dist < bestDist) {
bestDist = dist;
decl = node;
}
}
}
ts.forEachChild(node, visit);
};
visit(sf);

// Couldn't anchor to a declaration (missing pos, re-export, generated name):
// fall back to "more than one reference exists" — imperfect, but no worse
// than the old count heuristic.
if (!decl) return refs.length >= 2;

const stmt = topLevelStatement(decl, sf);
const lo = stmt.getStart(sf);
const hi = stmt.getEnd();
return refs.some((start) => start < lo || start >= hi);
};

for (const issue of data.issues) {
for (const cat of SYM) {
for (const s of issue[cat] ?? []) {
const sf = parse(issue.file);
s.privateUse = sf
? isPrivatelyUsed(sf, String(s.name), s.pos)
: false;
}
}
}

writeFileSync(jsonPath, JSON.stringify(data));
228 changes: 228 additions & 0 deletions .github/scripts/knip-delta.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
#!/usr/bin/env node
// Diff two Knip JSON reports (base vs head) into a PR "code-health" delta: which
// dead-code issues this PR introduces vs. removes, across the categories Knip
// reports (unused files, exports, dependencies, …).
//
// The comparison is against the PR's base, so the bar ratchets forward on its
// own as main improves — no committed baseline to maintain.
//
// Usage: node knip-delta.mjs <base.json> <head.json> <comment-out.md>
// Always exits 0 (informational). Emits `introduced` / `fixed` as step outputs
// so a future version can gate on `introduced > 0`.
import { appendFileSync, readFileSync, writeFileSync } from 'node:fs';

const [, , basePath, headPath, outPath] = process.argv;
const MARKER = '<!-- knip-health -->';
const CAP = 10; // max offenders listed per category before "…and N more"

// Raw Knip issue arrays we read from each file. `files` is the file itself being
// unused; the rest are arrays of symbols/names on a file. (unresolved imports
// and duplicate exports are intentionally omitted for now — add back here.)
const RAW_CATS = [
'files',
'dependencies',
'devDependencies',
'optionalPeerDependencies',
'unlisted',
'binaries',
'exports',
'types',
'nsExports',
'nsTypes',
'enumMembers',
'classMembers',
];
const EXPORTED = new Set(['exports', 'types', 'nsExports', 'nsTypes']);

// Fold raw categories into the display categories used in the report:
// - deps / devDeps / optional peers collapse into one "unused dependencies" row;
// - an unused export splits by whether the symbol is still used privately in its
// own file — an *unnecessary export* (just drop `export`, clean diff) — or
// unused everywhere — a *dead exported symbol* the export was hiding from the
// linter (delete it). `privateUse` is tagged per-symbol by knip-classify.mjs
// while each ref is checked out; if absent, fall back to one "unused exports".
function displayCat(cat, entry) {
if (EXPORTED.has(cat)) return entry.privateUse ? 'unnecessary' : 'dead';
if (cat === 'devDependencies' || cat === 'optionalPeerDependencies')
return 'dependencies';
return cat;
}

// Display order + labels.
const LABELS = {
files: 'Dead files',
dead: 'Dead symbols',
enumMembers: 'Dead enum members',
unnecessary: 'Unnecessary exports',
dependencies: 'Unused dependencies',
unlisted: 'Unlisted dependencies',
classMembers: 'Unused class members',
binaries: 'Unused binaries',
};

// One-line explainer per display category, shown when its section is expanded.
const EXPLAIN = {
files: 'File imported nowhere — delete (or import) it.',
dead: 'Symbol used nowhere — `export` hides it from the linter; delete it',
enumMembers: 'An enum member referenced nowhere',
unnecessary:
'Exported but only used within its own file — just drop `export`',
dependencies: 'In package.json but never imported',
unlisted: 'Imported but missing from package.json',
classMembers: 'A class member referenced nowhere',
binaries: 'A referenced binary that is not installed',
};

function load(path) {
try {
const data = JSON.parse(readFileSync(path, 'utf8'));
if (!data || !Array.isArray(data.issues)) return null;
return data;
} catch {
return null;
}
}

// Map every issue to a stable key so we can diff the *sets* (not just counts):
// a PR that fixes one issue and adds another nets zero but still introduced one.
function keyset(report) {
const map = new Map(); // key -> {cat, file, name} (cat = display category)
for (const issue of report.issues) {
const file = issue.file ?? '(root)';
for (const cat of RAW_CATS) {
for (const entry of issue[cat] ?? []) {
const name =
typeof entry === 'string'
? entry
: (entry?.name ?? JSON.stringify(entry));
const dcat = displayCat(cat, entry || {});
const key =
dcat === 'files'
? `files ${name}`
: `${dcat} ${file} ${name}`;
map.set(key, { cat: dcat, file, name });
}
}
}
return map;
}

// Inner text for an offender; wrapped in backticks (monospace) by the caller.
function fmt(entry) {
return entry.cat === 'files' ? entry.name : `${entry.file} : ${entry.name}`;
}

// A report is "classified" if every exported symbol carries a privateUse tag
// (added by knip-classify.mjs); without it the unnecessary/dead split is a lie,
// so we fail loudly rather than mislabel.
function isClassified(report) {
for (const issue of report.issues)
for (const cat of EXPORTED)
for (const entry of issue[cat] ?? [])
if (entry && entry.privateUse === undefined) return false;
return true;
}

const base = load(basePath);
const head = load(headPath);

let body;
if (!base || !head) {
body = `${MARKER}\n## ⚠️ Code Health\n\nCouldn't compute the delta — Knip failed to produce a report on ${!base ? 'the base' : 'this branch'}. (Check the job logs.)`;
writeFileSync(outPath, body);
console.log(body);
process.exit(1);
}

if (!isClassified(head) || !isClassified(base)) {
body = `${MARKER}\n## ⚠️ Code Health\n\nThe classify step (knip-classify) didn't run, so unnecessary vs. dead exports can't be split. Failing the check — see the job logs.`;
writeFileSync(outPath, body);
console.log(body);
process.exit(1);
}

const baseKeys = keyset(base);
const headKeys = keyset(head);
const introduced = [...headKeys]
.filter(([k]) => !baseKeys.has(k))
.map(([, v]) => v);
const fixed = [...baseKeys].filter(([k]) => !headKeys.has(k)).map(([, v]) => v);
const carried = [...headKeys] // "old": present in both base and head (total − new)
.filter(([k]) => baseKeys.has(k))
.map(([, v]) => v);

const groupByCat = (items) => {
const m = {};
for (const i of items) (m[i.cat] ??= []).push(i);
return m;
};
const newG = groupByCat(introduced);
const fixedG = groupByCat(fixed);
const oldG = groupByCat(carried);

// Offenders go in a blockquote (normal markdown, so both LaTeX math and inline
// code render). A math-colored ± marker gives new = red `+` / gone = green `-`,
// matching the header on BOTH sign and color — which a ```diff block couldn't,
// since its color is tied to the sign. Paths stay monospace via backticks (the
// marker is colored; the path keeps the default code color).
const MARK = {
new: '$\\textcolor{red}{+}$ ',
fixed: '$\\textcolor{green}{-}$ ',
old: '&nbsp;'.repeat(5), // non-breaking spaces: won't collapse in the HTML
};
const blocks = [];
for (const cat of Object.keys(LABELS)) {
const nw = newG[cat] ?? [];
const fx = fixedG[cat] ?? [];
const od = oldG[cat] ?? [];
const total = nw.length + od.length;
if (!total && !fx.length) continue;

const deltas = [];
if (nw.length) deltas.push(`$\\textcolor{red}{+${nw.length}}$`);
if (fx.length) deltas.push(`$\\textcolor{green}{-${fx.length}}$`);
const summary =
`${total} ${LABELS[cat]}` +
(deltas.length ? ` ${deltas.join(' ')}` : '');

const items = [
...nw.map((i) => `${MARK.new}\`${fmt(i)}\``),
...fx.map((i) => `${MARK.fixed}\`${fmt(i)}\``),
...od.map((i) => `${MARK.old}\`${fmt(i)}\``),
];
const lines = items.slice(0, CAP);
if (items.length > CAP) lines.push(`…and ${items.length - CAP} more`);
const list = lines.join('\n');

blocks.push(
`<details${nw.length ? ' open' : ''}><summary>${summary}</summary>\n\n> ${EXPLAIN[cat]}\n\n${list}\n\n</details>`
);
}

// ❌ any dead code added or newly orphaned (even if the PR also cleaned some up);
// ✅ only removals; ⚪ no change. (⚠️ is reserved for a failed check, above.)
let emoji, headline;
if (introduced.length > 0) {
emoji = '❌';
headline = `Introduces $\\textcolor{red}{${introduced.length}}$ dead-code issue${introduced.length > 1 ? 's' : ''}${fixed.length ? ` (removes $\\textcolor{green}{${fixed.length}}$)` : ''}.`;
} else if (fixed.length > 0) {
emoji = '✅';
headline = `Removes $\\textcolor{green}{${fixed.length}}$ dead-code issue${fixed.length > 1 ? 's' : ''}, introduces none.`;
} else {
emoji = '⚪';
headline = 'No change to the dead-code surface.';
}

body =
`${MARKER}\n## ${emoji} Code Health\n\n${headline}\n\n` + blocks.join('\n');

writeFileSync(outPath, body);
console.log(body);

if (process.env.GITHUB_STEP_SUMMARY)
appendFileSync(process.env.GITHUB_STEP_SUMMARY, body + '\n');
if (process.env.GITHUB_OUTPUT)
appendFileSync(
process.env.GITHUB_OUTPUT,
`introduced=${introduced.length}\nfixed=${fixed.length}\n`
);
Loading
Loading