knip-guard
Version:
Baseline / no-new-issues guard for Knip
264 lines • 9.66 kB
JavaScript
;
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.runKnipJson = runKnipJson;
exports.loadKnipReportFromFile = loadKnipReportFromFile;
exports.extractIssueKeys = extractIssueKeys;
exports.loadBaseline = loadBaseline;
exports.saveBaseline = saveBaseline;
exports.diffBaseline = diffBaseline;
const promises_1 = require("node:fs/promises");
const node_path_1 = __importDefault(require("node:path"));
const node_child_process_1 = require("node:child_process");
const node_util_1 = require("node:util");
const exec = (0, node_util_1.promisify)(node_child_process_1.exec);
/**
* Run knip with JSON reporter and parse the output
* Executes the given command with `--reporter json` flag and returns the parsed report
* @param command - Full command to run knip (default: "npx knip")
* @returns Parsed Knip JSON report
* @throws Error if command fails or output is invalid JSON
* @example
* const report = await runKnipJson('npm run knip');
*/
async function runKnipJson(command = 'npx knip') {
const fullCommand = `${command} --reporter json`;
const { stdout } = await exec(fullCommand, { maxBuffer: 20 * 1024 * 1024 });
return JSON.parse(stdout);
}
/**
* Load a Knip JSON report from a file
* @param reportPath - Path to the Knip JSON report file
* @returns Parsed Knip JSON report
* @throws Error if file cannot be read or JSON is invalid
* @example
* const report = await loadKnipReportFromFile('./knip-report.json');
*/
async function loadKnipReportFromFile(reportPath) {
const abs = node_path_1.default.resolve(reportPath);
const content = await (0, promises_1.readFile)(abs, 'utf8');
return JSON.parse(content);
}
/**
* Extract the name from a Knip issue entry
* Handles both structured entries with a `name` property and fallback to JSON string
* @param entry - The issue entry to extract a name from
* @returns The name as a string
* @internal
*/
function getEntryName(entry) {
if (entry &&
typeof entry === 'object' &&
'name' in entry &&
typeof entry.name === 'string') {
return entry.name;
}
return JSON.stringify(entry);
}
/**
* Add deterministic keys for simple issue fields (arrays of entries with names)
* Creates keys in format: `fieldName::filePath::entryName`
* @param keys - Set to accumulate the generated keys
* @param issue - The Knip issue object
* @param file - The file path for the issue
* @param fields - List of field names to process
* @internal
*/
function addSimpleFieldKeys(keys, issue, file, fields) {
for (const field of fields) {
const arr = issue[field];
if (!Array.isArray(arr))
continue;
for (const item of arr) {
const name = getEntryName(item);
keys.add(`${String(field)}::${file}::${name}`);
}
}
}
/**
* Add deterministic keys for enum member issues
* Creates keys in format: `enumMembers::filePath::enumName.memberName`
* @param keys - Set to accumulate the generated keys
* @param issue - The Knip issue object
* @param file - The file path for the issue
* @internal
*/
function addEnumMemberKeys(keys, issue, file) {
const enumMembers = issue.enumMembers;
if (!enumMembers || typeof enumMembers !== 'object')
return;
for (const enumName of Object.keys(enumMembers)) {
const members = enumMembers[enumName];
if (!Array.isArray(members))
continue;
for (const member of members) {
const memberName = getEntryName(member);
keys.add(`enumMembers::${file}::${enumName}.${memberName}`);
}
}
}
/**
* Add deterministic keys for class member issues
* Creates keys in format: `classMembers::filePath::className.memberName`
* @param keys - Set to accumulate the generated keys
* @param issue - The Knip issue object
* @param file - The file path for the issue
* @internal
*/
function addClassMemberKeys(keys, issue, file) {
const classMembers = issue.classMembers;
if (!classMembers || typeof classMembers !== 'object')
return;
for (const className of Object.keys(classMembers)) {
const members = classMembers[className];
if (!Array.isArray(members))
continue;
for (const member of members) {
const memberName = getEntryName(member);
keys.add(`classMembers::${file}::${className}.${memberName}`);
}
}
}
/**
* Add deterministic keys for duplicate export issues
* Creates keys in format: `duplicates::filePath::exportName`
* @param keys - Set to accumulate the generated keys
* @param issue - The Knip issue object
* @param file - The file path for the issue
* @internal
*/
function addDuplicateKeys(keys, issue, file) {
if (!Array.isArray(issue.duplicates))
return;
for (const name of issue.duplicates) {
if (typeof name !== 'string')
continue;
keys.add(`duplicates::${file}::${name}`);
}
}
/**
* Extract deterministic issue keys from a Knip report
* Keys exclude line/column/position information, making them stable across formatting changes
* This allows baseline comparisons to work correctly even when code is reformatted
* @param report - The Knip JSON report to extract keys from
* @returns Set of deterministic issue keys
* @example
* const report = await runKnipJson();
* const keys = extractIssueKeys(report);
* // keys contains: "files::src/unused.ts", "exports::src/foo.ts::unusedFn"
*/
function extractIssueKeys(report) {
const keys = new Set();
// Unused files
if (Array.isArray(report.files)) {
for (const file of report.files) {
if (typeof file === 'string') {
keys.add(`files::${file}`);
}
}
}
if (!Array.isArray(report.issues)) {
return keys;
}
const simpleFields = [
'dependencies',
'devDependencies',
'optionalPeerDependencies',
'unlisted',
'unresolved',
'binaries',
'exports',
'nsExports',
'types',
'nsTypes'
];
for (const issue of report.issues) {
const file = typeof issue.file === 'string' ? issue.file : '<unknown-file>';
addSimpleFieldKeys(keys, issue, file, simpleFields);
addEnumMemberKeys(keys, issue, file);
addClassMemberKeys(keys, issue, file);
addDuplicateKeys(keys, issue, file);
}
return keys;
}
/**
* Load baseline data from disk
* Returns null if the baseline file does not exist
* @param baselinePath - Path to the baseline JSON file
* @returns Baseline data or null if file not found
* @throws Error if file exists but cannot be read or parsed
* @example
* const baseline = await loadBaseline('.knip-baseline.json');
*/
async function loadBaseline(baselinePath) {
try {
const abs = node_path_1.default.resolve(baselinePath);
const content = await (0, promises_1.readFile)(abs, 'utf8');
return JSON.parse(content);
}
catch (err) {
if (err && (err.code === 'ENOENT' || err.code === 'ENOTDIR')) {
return null;
}
throw err;
}
}
/**
* Save baseline data to disk
* Creates a new baseline or updates an existing one with metadata timestamps
* Issues are automatically sorted for deterministic output
* @param baselinePath - Path where the baseline JSON file should be saved
* @param issueKeys - Set of deterministic issue keys to include in baseline
* @param existing - Existing baseline data (to preserve createdAt timestamp)
* @param sourceCommand - Optional command used to generate the baseline
* @returns The saved baseline data
* @throws Error if file cannot be written
* @example
* const keys = extractIssueKeys(report);
* const baseline = await saveBaseline('.knip-baseline.json', keys, null, 'npm run knip');
*/
async function saveBaseline(baselinePath, issueKeys, existing, sourceCommand) {
const abs = node_path_1.default.resolve(baselinePath);
const now = new Date().toISOString();
const sortedIssues = Array.from(issueKeys).sort();
const data = {
createdAt: existing?.createdAt ?? now,
updatedAt: now,
issues: sortedIssues,
sourceCommand: sourceCommand ?? existing?.sourceCommand
};
await (0, promises_1.writeFile)(abs, JSON.stringify(data, null, 2) + '\n', 'utf8');
return data;
}
/**
* Compare current issues against a baseline
* Identifies new issues that weren't in the baseline and resolved issues that no longer exist
* @param baseline - The baseline data to compare against (null is treated as empty)
* @param currentIssueKeys - Set of current deterministic issue keys
* @returns Object containing arrays of new and resolved issues, both sorted
* @example
* const baseline = await loadBaseline('.knip-baseline.json');
* const current = extractIssueKeys(report);
* const { newIssues, resolvedIssues } = diffBaseline(baseline, current);
*/
function diffBaseline(baseline, currentIssueKeys) {
const baseSet = new Set(baseline?.issues ?? []);
const newIssues = [];
const resolvedIssues = [];
for (const key of currentIssueKeys) {
if (!baseSet.has(key)) {
newIssues.push(key);
}
}
for (const key of baseSet) {
if (!currentIssueKeys.has(key)) {
resolvedIssues.push(key);
}
}
newIssues.sort();
resolvedIssues.sort();
return { newIssues, resolvedIssues };
}
//# sourceMappingURL=index.js.map