Ctrl K
ComunidadGuías…

scripts/compareKnipReports.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import type {TupleToUnion} from 'type-fest';

import CLI from 'expensify-common/CLI';
import fs from 'fs';

/**
 * Knip (https://knip.dev) is a static analyzer that flags unused files, exports,
 * types, dependencies, and unlisted imports across the codebase. Project-specific
 * scope and ignores live in `knip.json`.
 *
 * This script compares two knip JSON reports (main vs PR) and exits 1 if the PR
 * introduces any new finding that isn't present on main — even when the PR also
 * resolves others. Findings are matched per `<file>::<name>`, so a single file
 * with multiple unused items counts as one finding per item.
 *
 * Usage: bun scripts/compareKnipReports.ts --mainPath=<main.json> --prPath=<pr.json>
 */

const CATEGORIES = [
    'files',
    'dependencies',
    'devDependencies',
    'optionalPeerDependencies',
    'unlisted',
    'binaries',
    'unresolved',
    'exports',
    'types',
    'nsExports',
    'nsTypes',
    'enumMembers',
    'classMembers',
    'duplicates',
] as const;

type Category = TupleToUnion<typeof CATEGORIES>;

type IssueItem = string | {name?: string; symbol?: string};

type Entry = {
    file?: string;
} & Partial<Record<Category, IssueItem[]>>;

type Report = {
    issues?: Entry[];
};

function parseReport(filepath: string): Report {
    if (!fs.existsSync(filepath)) {
        throw new Error(`Knip report not found: ${filepath}`);
    }
    const raw = fs.readFileSync(filepath, 'utf8');
    if (!raw.trim()) {
        throw new Error(`Knip report is empty: ${filepath}`);
    }

    // knip writes a single top-level JSON object to stdout, but tooling around it
    // can prepend noise (npm-run script header, babel.config.js debug logs,
    // rspack-plugin warnings, etc.). The object can also be pretty-printed, so
    // we can't rely on a fixed token like `{"issues"`. Locate every `{` and try
    // to parse from there; accept the first slice that parses AND has an
    // `issues` array. Anything else is a hard failure — the CI should not
    // silently treat a malformed report as "no findings".
    let searchFrom = 0;
    let lastParseError: Error | undefined;
    while (true) {
        const braceIdx = raw.indexOf('{', searchFrom);
        if (braceIdx < 0) {
            break;
        }
        try {
            const parsed = JSON.parse(raw.slice(braceIdx)) as unknown;
            if (parsed && typeof parsed === 'object' && Array.isArray((parsed as Report).issues)) {
                return parsed as Report;
            }
        } catch (e) {
            lastParseError = e as Error;
        }
        searchFrom = braceIdx + 1;
    }
    const detail = lastParseError ? `: ${lastParseError.message}` : '';
    throw new Error(`Failed to parse knip JSON report at ${filepath}${detail}`);
}

function flatten(report: Report): Map<Category, Set<string>> {
    const out = new Map<Category, Set<string>>();
    for (const cat of CATEGORIES) {
        out.set(cat, new Set<string>());
    }

    for (const entry of report.issues ?? []) {
        const file = entry.file ?? '';
        for (const cat of CATEGORIES) {
            const items = entry[cat];
            if (!Array.isArray(items)) {
                continue;
            }
            for (const item of items) {
                const name = typeof item === 'string' ? item : (item.symbol ?? item.name ?? JSON.stringify(item));
                out.get(cat)?.add(`${file}::${name}`);
            }
        }
    }
    return out;
}

function diff(mainMap: Map<Category, Set<string>>, prMap: Map<Category, Set<string>>): {added: Map<Category, string[]>; resolved: Map<Category, string[]>} {
    const added = new Map<Category, string[]>();
    const resolved = new Map<Category, string[]>();
    for (const cat of CATEGORIES) {
        const m = mainMap.get(cat) ?? new Set<string>();
        const p = prMap.get(cat) ?? new Set<string>();
        const a: string[] = [];
        const r: string[] = [];
        for (const x of p) {
            if (!m.has(x)) {
                a.push(x);
            }
        }
        for (const x of m) {
            if (!p.has(x)) {
                r.push(x);
            }
        }
        if (a.length) {
            added.set(cat, a.sort());
        }
        if (r.length) {
            resolved.set(cat, r.sort());
        }
    }
    return {added, resolved};
}

function totalCount(map: Map<Category, Set<string>>): number {
    let n = 0;
    for (const set of map.values()) {
        n += set.size;
    }
    return n;
}

function printSection(title: string, byCategory: Map<Category, string[]>): void {
    if (byCategory.size === 0) {
        return;
    }
    console.log(`\n${title}`);
    for (const [cat, items] of byCategory) {
        console.log(`  ${cat} (${items.length}):`);
        for (const it of items) {
            console.log(`    ${it}`);
        }
    }
}

const cli = new CLI({
    namedArgs: {
        mainPath: {
            description: 'Path to the main knip report JSON file',
            required: true,
        },
        prPath: {
            description: 'Path to the PR knip report JSON file',
            required: true,
        },
    },
});

const {mainPath, prPath} = cli.namedArgs;

let mainFlat: Map<Category, Set<string>>;
let prFlat: Map<Category, Set<string>>;
try {
    mainFlat = flatten(parseReport(mainPath));
    prFlat = flatten(parseReport(prPath));
} catch (e) {
    console.log(`::error::Knip comparator could not read a report: ${(e as Error).message}`);
    process.exit(2);
}

const mainTotal = totalCount(mainFlat);
const prTotal = totalCount(prFlat);
const delta = prTotal - mainTotal;
const {added, resolved} = diff(mainFlat, prFlat);
const addedTotal = [...added.values()].reduce((n, a) => n + a.length, 0);
const resolvedTotal = [...resolved.values()].reduce((n, a) => n + a.length, 0);

console.log('Knip comparison:');
console.log(`  main : ${mainTotal}`);
console.log(`  PR   : ${prTotal} (delta ${delta >= 0 ? '+' : ''}${delta})`);
console.log(`  added by PR    : ${addedTotal}`);
console.log(`  resolved by PR : ${resolvedTotal}`);

printSection('New issues introduced:', added);
printSection('Issues resolved:', resolved);

if (addedTotal > 0) {
    // Emit one annotation per new finding so GitHub surfaces them on the PR's Checks tab.
    // GitHub renders up to 10 annotations per step; remaining entries stay in the full log above.
    for (const [cat, items] of added) {
        for (const id of items) {
            const sepIdx = id.indexOf('::');
            const file = sepIdx > 0 ? id.slice(0, sepIdx) : '';
            const name = sepIdx > 0 ? id.slice(sepIdx + 2) : id;
            const attrs = file ? `file=${file},title=Knip` : 'title=Knip';
            console.log(`::error ${attrs}::[${cat}] ${name}`);
        }
    }
    console.log(`\n::error::PR introduces ${addedTotal} new knip finding(s) (resolved ${resolvedTotal}, delta ${delta >= 0 ? '+' : ''}${delta}).`);
    process.exit(1);
}

console.log('\nPR introduces no new knip findings.');