ci: hold unused files, exports and dependencies to a knip baseline that only shrinks

Nothing looked for dead code: no knip, depcheck or ts-prune, noUnusedLocals
off. knip.jsonc configures knip over the client, server and shared workspaces
with their real entry points (main.tsx, index.ts and bootstrap, the MikroORM
config, the forked plugin host and its SDK surface, migrations and seeders
found by glob, the shared build entries with one per locale, the scripts,
the tests and the Playwright launcher). lint:knip (scripts/ci/knip-ratchet.mjs)
counts its findings per category and file against scripts/ci/knip-baseline.json,
which only shrinks and is built on the client's ratchet helpers. Today's 427
findings are frozen, among them unused server dependencies (nodemon,
@types/uuid, eslint-plugin-prettier, @mikro-orm/cli), the unused root unrun,
and imports of packages no package.json lists (leaflet.markercluster, geojson).
False positives are fixed in knip.jsonc with the reason. knip is a root
devDependency; the check runs in server-quality, the job that installs every
workspace, and knip.jsonc joins the code area of the change classifier.
This commit is contained in:
Maurice
2026-10-10 13:39:59 +02:00
parent 83d807781e
commit c83fa66c67
11 changed files with 1556 additions and 33 deletions
+2
View File
@@ -49,6 +49,8 @@ export const AREAS = {
'package-lock.json',
'.nvmrc',
'sonar-project.properties',
// lint:knip in server-quality reads it.
'knip.jsonc',
'Dockerfile',
...READ_BY_TESTS,
...ALWAYS,
+4
View File
@@ -52,6 +52,10 @@ describe('classify', () => {
assert.deepEqual(classify(['scripts/lib/ratchet.mjs']), { code: true, image: false, deploy: false });
});
it('runs the tests but not the image for a knip config change', () => {
assert.deepEqual(classify(['knip.jsonc']), { code: true, image: false, deploy: false });
});
it('runs the image but not the tests for a .dockerignore change', () => {
assert.deepEqual(classify(['.dockerignore']), { code: false, image: true, deploy: false });
});
+249
View File
@@ -0,0 +1,249 @@
{
"devDependencies": {
"server/package.json": 3
},
"duplicates": {
"client/src/api/client.ts": 1,
"client/src/components/Map/VectorBasemap.tsx": 1,
"client/src/components/shared/Skeleton.tsx": 1,
"client/src/components/shared/SlidingTabs.tsx": 1,
"client/src/components/shared/Toast.tsx": 1,
"client/src/components/shared/Tooltip.tsx": 1,
"shared/src/docsync/docsync.schema.ts": 1,
"shared/src/tours/tours.schema.ts": 1
},
"exports": {
"client/scripts/lib/dup.mjs": 6,
"client/scripts/lib/eslint-warnings.mjs": 1,
"client/scripts/lib/format.mjs": 3,
"client/scripts/lib/offline.mjs": 1,
"client/scripts/lib/pairs.mjs": 1,
"client/scripts/lib/rtl.mjs": 2,
"client/scripts/lib/size.mjs": 3,
"client/scripts/lib/skips.mjs": 2,
"client/scripts/lib/strict.mjs": 1,
"client/scripts/lib/theme.mjs": 4,
"client/src/api/pluginPois.ts": 1,
"client/src/components/Admin/storage/storageModel.ts": 1,
"client/src/components/Admin/storage/useStorageAdmin.ts": 1,
"client/src/components/Budget/BudgetPanel.constants.ts": 1,
"client/src/components/Budget/BudgetPanel.helpers.ts": 5,
"client/src/components/Collab/useChatImages.ts": 2,
"client/src/components/Files/docsync/useConnectForm.ts": 2,
"client/src/components/Journey/journeyPhotoLayer.ts": 1,
"client/src/components/Journey/moodConfig.ts": 1,
"client/src/components/Journey/photoZoom.ts": 2,
"client/src/components/Layout/navItems.ts": 1,
"client/src/components/Map/MapViewGL.tsx": 1,
"client/src/components/Map/glProviders.ts": 2,
"client/src/components/Map/poiClusters.ts": 2,
"client/src/components/Packing/packingListPanel.helpers.ts": 1,
"client/src/components/Planner/DayPlanSidebar.constants.ts": 2,
"client/src/components/Planner/PlaceFormModal.helpers.ts": 1,
"client/src/components/Planner/transportEndpoints.ts": 1,
"client/src/components/Roadtrip/corridor.ts": 2,
"client/src/components/Roadtrip/dayWindow.ts": 1,
"client/src/components/Roadtrip/followTrack.ts": 1,
"client/src/components/Roadtrip/roadtripModel.ts": 6,
"client/src/components/Roadtrip/stopKinds.ts": 1,
"client/src/components/Roadtrip/useFollowTrack.ts": 2,
"client/src/components/Roadtrip/useHotelBookends.ts": 1,
"client/src/components/Studio/countryShapes.ts": 1,
"client/src/components/Tours/planner/useTourPlanner.ts": 3,
"client/src/components/shared/ErrorBoundary.tsx": 1,
"client/src/components/shared/Toast.tsx": 1,
"client/src/constants/mapDefaults.ts": 4,
"client/src/help/registry.ts": 1,
"client/src/i18n/supportedLanguages.ts": 1,
"client/src/mobile/screens/trip/roadtrip/useMRoadtrip.ts": 2,
"client/src/pages/dashboard/useTripSearch.ts": 1,
"client/src/pages/journeyDetail/JourneyDetailPage.helpers.ts": 3,
"client/src/pages/journeyStudio/useJourneyStudio.ts": 2,
"client/src/pages/sharedTrip/SharedPlaceDetails.tsx": 1,
"client/src/push/webPush.ts": 2,
"client/src/repo/docSyncRepo.ts": 1,
"client/src/utils/dayImpactLines.ts": 1,
"client/src/utils/dayMerge.ts": 1,
"client/src/utils/flightLegs.ts": 3,
"client/src/utils/formatters.ts": 1,
"client/src/utils/geo.ts": 1,
"client/src/utils/placeSource.ts": 1,
"client/src/utils/placesSession.ts": 1,
"client/src/vacay/yearWindow.ts": 1,
"server/src/app-config/boot-derive.ts": 1,
"server/src/app-config/derive.ts": 2,
"server/src/app-config/index.ts": 24,
"server/src/app-config/parsers.ts": 1,
"server/src/db/dialect/platform.ts": 1,
"server/src/mcp/index.ts": 2,
"server/src/mcp/nest-mcp-policy.ts": 1,
"server/src/mcp/scopes.ts": 3,
"server/src/mcp/tools/_shared.ts": 8,
"server/src/nest-mcp/index.ts": 1,
"server/src/nest/accommodations/night-seat.ts": 1,
"server/src/nest/app-config/index.ts": 6,
"server/src/nest/auth/auth.helpers.ts": 1,
"server/src/nest/auth/public.decorator.ts": 1,
"server/src/nest/backup/backup-archive.ts": 1,
"server/src/nest/backup/backup.impl.ts": 3,
"server/src/nest/budget/budget.service.ts": 1,
"server/src/nest/common/validate-managed-routes.ts": 1,
"server/src/nest/day-notes/day-notes.service.ts": 1,
"server/src/nest/doc-sync/providers/paperless.client.ts": 7,
"server/src/nest/doc-sync/providers/paperless.provider.ts": 1,
"server/src/nest/doc-sync/providers/papra.client.ts": 2,
"server/src/nest/doc-sync/providers/synology-drive.client.ts": 1,
"server/src/nest/doc-sync/providers/webdav.client.ts": 2,
"server/src/nest/doc-sync/providers/webdav.provider.ts": 1,
"server/src/nest/files/files.constants.ts": 1,
"server/src/nest/integrations/airtrail.mapper.ts": 1,
"server/src/nest/llm-parse/image-input.ts": 2,
"server/src/nest/llm-parse/llm-config.ts": 1,
"server/src/nest/llm-parse/llm-prompt.ts": 1,
"server/src/nest/maps/maps.service.ts": 2,
"server/src/nest/maps/providers/amap.provider.ts": 2,
"server/src/nest/maps/providers/places-provider.ts": 1,
"server/src/nest/notifications/channel-registry.ts": 1,
"server/src/nest/notifications/push/push-subscription.helpers.ts": 2,
"server/src/nest/notifications/transports/ntfy.service.ts": 1,
"server/src/nest/oauth/oauth.helpers.ts": 1,
"server/src/nest/place-enrichment/place-enrichment.service.ts": 1,
"server/src/nest/place-shadow/place-shadow.service.ts": 1,
"server/src/nest/places/kml-import.helpers.ts": 1,
"server/src/nest/places/places.helpers.ts": 1,
"server/src/nest/plugins/contributions/plugin-search.helpers.ts": 2,
"server/src/nest/plugins/host/plugin-data.service.ts": 1,
"server/src/nest/plugins/host/plugin-jobs.ts": 1,
"server/src/nest/plugins/mcp-tool-schema.ts": 5,
"server/src/nest/plugins/protocol/envelope.ts": 1,
"server/src/nest/public-api/public-api-request.ts": 2,
"server/src/nest/roadtrip/charging.helpers.ts": 1,
"server/src/nest/route-usage/route-usage.service.ts": 1,
"server/src/nest/share/share.service.ts": 1,
"server/src/nest/storage/storage-stats.service.ts": 1,
"server/src/nest/transit/transit-provider.ts": 2,
"shared/src/roadtrip/accessSpur.ts": 3
},
"types": {
"client/src/components/Admin/storage/BackendForm.tsx": 1,
"client/src/components/Admin/storage/storageModel.ts": 1,
"client/src/components/Admin/storage/useStorageAdmin.ts": 1,
"client/src/components/Budget/CostsToolbar.tsx": 1,
"client/src/components/Budget/costsModel.ts": 2,
"client/src/components/Collab/CollabChat.types.ts": 1,
"client/src/components/Collab/collabModel.ts": 2,
"client/src/components/Collections/useCollectionPlaceForm.ts": 1,
"client/src/components/Collections/useTripCopyPicker.ts": 1,
"client/src/components/Files/docsync/useDocSync.ts": 1,
"client/src/components/Journey/JourneyEntryCover.tsx": 1,
"client/src/components/Journey/JourneyMap.tsx": 2,
"client/src/components/Map/mapViewContract.ts": 1,
"client/src/components/Map/useTripRouteOverview.ts": 2,
"client/src/components/Planner/PlaceFormModal.tsx": 1,
"client/src/components/Planner/placeOpenState.ts": 1,
"client/src/components/Planner/transportEndpoints.ts": 1,
"client/src/components/Planner/useTripExport.ts": 1,
"client/src/components/Plugins/PluginFrame.tsx": 1,
"client/src/components/Roadtrip/alternativePins.ts": 1,
"client/src/components/Roadtrip/carrierRide.ts": 1,
"client/src/components/Roadtrip/corridor.ts": 2,
"client/src/components/Roadtrip/dayWindow.ts": 3,
"client/src/components/Roadtrip/followTrack.ts": 2,
"client/src/components/Roadtrip/manualStop.ts": 2,
"client/src/components/Roadtrip/nightSpill.ts": 1,
"client/src/components/Roadtrip/refuelSuggestion.ts": 1,
"client/src/components/Roadtrip/roadtripModel.ts": 4,
"client/src/components/Roadtrip/useFollowTrack.ts": 1,
"client/src/components/Roadtrip/useRoadtripRoutes.ts": 3,
"client/src/components/Studio/StudioCanvas.tsx": 1,
"client/src/components/Studio/grids.ts": 1,
"client/src/components/Studio/mapTiles.ts": 1,
"client/src/components/Studio/templates.ts": 1,
"client/src/components/Trips/useTripShare.ts": 3,
"client/src/components/shared/DialogShell.tsx": 1,
"client/src/components/shared/SlidingTabs.tsx": 1,
"client/src/components/shared/elevationProfileModel.ts": 1,
"client/src/db/offlineDb.ts": 1,
"client/src/help/defineScreen.ts": 1,
"client/src/help/types.ts": 1,
"client/src/hooks/useDawarichConnection.ts": 1,
"client/src/hooks/useDawarichSuggestions.ts": 1,
"client/src/i18n/supportedLanguages.ts": 1,
"client/src/mobile/components/MSegmented.tsx": 1,
"client/src/mobile/components/MSheet.tsx": 2,
"client/src/mobile/screens/collections/MCollLabelsSheet.tsx": 1,
"client/src/mobile/screens/journey/MJourneyCreateSheet.tsx": 1,
"client/src/mobile/screens/settings/MSetPickerSheet.tsx": 1,
"client/src/mobile/screens/settings/MSettingsUi.tsx": 1,
"client/src/mobile/screens/trip/MTripShell.tsx": 1,
"client/src/mobile/screens/trip/plan/planTimelineModel.ts": 1,
"client/src/mobile/screens/trip/tabs/transportsModel.ts": 1,
"client/src/mobile/screens/vacay/useMVacay.ts": 1,
"client/src/pages/dashboard/dashboardModel.ts": 2,
"client/src/pages/journeyPublic/journeyPublicModel.ts": 1,
"client/src/pages/tripPlanner/plannerTypes.ts": 1,
"client/src/pages/tripPlanner/useBookingEdits.ts": 1,
"client/src/pages/tripPlanner/useDayPlanEdits.ts": 1,
"client/src/pages/tripPlanner/usePlaceFormOpeners.ts": 1,
"client/src/pages/tripPlanner/useRoadtripAlternatives.ts": 1,
"client/src/pages/tripPlanner/useRoadtripStopEdits.ts": 1,
"client/src/push/webPush.ts": 1,
"client/src/store/backgroundTasksStore.ts": 1,
"client/src/store/helpStore.ts": 2,
"client/src/theme/applyAppearance.ts": 1,
"client/src/types.ts": 7,
"client/src/utils/connectionsVisibility.ts": 1,
"client/src/utils/dayDeleteImpact.ts": 3,
"client/src/utils/journeyLifecycle.ts": 1,
"client/src/utils/tripRangeImpact.ts": 1,
"server/src/app-config/index.ts": 2,
"server/src/db/repositories/AuditLog.repository.ts": 1,
"server/src/db/repositories/BudgetSettlements.repository.ts": 1,
"server/src/db/repositories/DocumentProviderFields.repository.ts": 1,
"server/src/db/repositories/GoogleApiUsage.repository.ts": 1,
"server/src/db/repositories/McpTokens.repository.ts": 1,
"server/src/db/repositories/PasswordResetTokens.repository.ts": 1,
"server/src/db/repositories/Places.repository.ts": 1,
"server/src/db/repositories/UserSessions.repository.ts": 1,
"server/src/db/repositories/_shared/rows.ts": 1,
"server/src/mcp/nest-mcp-policy.ts": 1,
"server/src/nest-mcp/index.ts": 13,
"server/src/nest-mcp/types.ts": 4,
"server/src/nest/booking-import/kitinerary.types.ts": 6,
"server/src/nest/collab/collab.service.ts": 1,
"server/src/nest/files/files.service.ts": 1,
"server/src/nest/integrations/airtrail.mapper.ts": 1,
"server/src/nest/llm-parse/llm-config.ts": 1,
"server/src/nest/maps/maps.helpers.ts": 2,
"server/src/nest/maps/providers/places-provider.ts": 1,
"server/src/nest/maps/trek-places.client.ts": 1,
"server/src/nest/memories/memories.helpers.ts": 1,
"server/src/nest/notifications/notifications.service.ts": 1,
"server/src/nest/places/places.helpers.ts": 1,
"server/src/nest/plugins/contributions/map-layers.controller.ts": 1,
"server/src/nest/plugins/host/rpc-kit/types.ts": 2,
"server/src/nest/plugins/install/manifest.ts": 7,
"server/src/nest/plugins/plugins.service.ts": 1,
"server/src/nest/plugins/protocol/envelope.ts": 2,
"server/src/nest/storage/drivers/mirror.driver.ts": 1,
"server/src/nest/storage/storage-registry.service.ts": 1,
"server/src/nest/tours/tours.helpers.ts": 1,
"server/src/nest/trips/trips.service.ts": 3,
"server/src/nest/unsplash/unsplash.service.ts": 1,
"server/src/nest/vacay/vacay.service.ts": 4,
"server/src/nest/weather/weather.impl.ts": 1,
"server/src/shutdown.ts": 1,
"server/src/systemNotices/types.ts": 7,
"server/src/types.ts": 14
},
"unlisted": {
"client/src/components/Map/MapView.tsx": 1,
"client/src/components/Map/hazardPopup.ts": 1,
"client/src/components/Map/mapZoomCeiling.test.ts": 1,
"client/src/components/Map/markerCluster.test.ts": 1,
"client/src/pages/sharedTrip/SharedMap.tsx": 1,
"server/tests/unit/eslint-rules/no-promise-as-value.test.ts": 1,
"server/tests/unit/eslint-rules/restricted-syntax-selectors.test.ts": 1,
"shared/src/i18n/tours-locales.spec.ts": 1
}
}
+27
View File
@@ -0,0 +1,27 @@
#!/usr/bin/env node
/*
* lint:knip: unused files, exports, types and dependencies may only go down.
*
* knip (configured in knip.jsonc at the repo root) follows the imports of the
* client, server and shared workspaces from their entry points and reports
* what nothing reaches: a file, an export, a type, a dependency in a
* package.json, an import of a package no package.json lists. Dead code
* costs every sweep that edits it and every reader who wonders who calls it.
* The findings per category and file are held in scripts/ci/knip-baseline.json:
* a file may not gain one, and an entry above what knip finds now, or for a
* file that is gone, fails until --update lowers it. A false positive (a
* file loaded by path, a public API surface) is fixed in knip.jsonc, with the
* reason, never by raising the baseline.
*
* npm run lint:knip check against the baseline (CI)
* npm run lint:knip -- --update lower the baseline to today's findings;
* it never raises an entry
*
* The comparison lives in scripts/ci/lib/knip.mjs.
*/
import { fileURLToPath } from 'node:url';
import { runCli } from '../../client/scripts/lib/ratchet.mjs';
import { check } from './lib/knip.mjs';
const root = fileURLToPath(new URL('../..', import.meta.url));
await runCli('lint:knip', (args) => check({ root, update: args.includes('--update') }));
+86
View File
@@ -0,0 +1,86 @@
import assert from 'node:assert/strict';
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { after, describe, it } from 'node:test';
import { RatchetError } from '../../client/scripts/lib/ratchet.mjs';
import { categoryCounts, check, tally } from './lib/knip.mjs';
const scratch = mkdtempSync(join(tmpdir(), 'knip-ratchet-'));
after(() => rmSync(scratch, { recursive: true, force: true }));
const REPORT = {
issues: [
{
file: 'client/src/a.ts',
exports: [{ name: 'unusedA' }, { name: 'unusedB' }],
types: [{ name: 'OldType' }],
files: [],
},
{ file: 'server/package.json', devDependencies: [{ name: 'nodemon' }] },
{ file: 'client/src/dead.ts', files: [{ name: 'client/src/dead.ts' }] },
{ file: 'client/src/toast.tsx', duplicates: [[{ name: 'useToast' }, { name: 'default' }]] },
],
};
let n = 0;
function run(baseline, report = REPORT, update = false) {
const path = join(scratch, `baseline-${n++}.json`);
writeFileSync(path, JSON.stringify(baseline));
const out = { log: [], error: [] };
const code = check({
root: scratch,
baselinePath: path,
update,
knip: () => report,
log: (l) => out.log.push(l),
error: (l) => out.error.push(l),
});
return { code, out, path };
}
const FULL = {
exports: { 'client/src/a.ts': 2 },
types: { 'client/src/a.ts': 1 },
devDependencies: { 'server/package.json': 1 },
files: { 'client/src/dead.ts': 1 },
duplicates: { 'client/src/toast.tsx': 1 },
};
describe('lint:knip', () => {
it('KNIP-001: counts each category per file with the names behind it', () => {
const { counts, names } = tally(REPORT);
assert.deepEqual(counts.exports, { 'client/src/a.ts': 2 });
assert.deepEqual(counts.files, { 'client/src/dead.ts': 1 });
assert.deepEqual(names.duplicates['client/src/toast.tsx'], ['useToast = default']);
assert.deepEqual(counts.dependencies, {});
});
it('KNIP-002: passes findings within the baseline', () => {
const { code, out } = run(FULL);
assert.equal(code, 0, out.error.join('\n'));
assert.match(out.log.join('\n'), /knip: 6 finding\(s\), baseline allows 6/);
});
it('KNIP-003: fails a new finding, by category, and names it', () => {
const { code, out } = run({ ...FULL, exports: { 'client/src/a.ts': 1 } });
assert.equal(code, 1);
assert.match(out.error.join('\n'), /FAIL {2}exports: client\/src\/a\.ts has 2, baseline 1: unusedA, unusedB/);
});
it('KNIP-004: fails an entry above its count until --update lowers it, and never raises one', () => {
const { code, out } = run({ ...FULL, types: { 'client/src/a.ts': 3, 'client/src/gone.ts': 1 } });
assert.equal(code, 1);
assert.match(out.error.join('\n'), /types: client\/src\/a\.ts is held at 3 in scripts\/ci\/knip-baseline\.json/);
const updated = run({ ...FULL, types: { 'client/src/a.ts': 3, 'client/src/gone.ts': 1 }, exports: {} }, REPORT, true);
const written = JSON.parse(readFileSync(updated.path, 'utf8'));
assert.deepEqual(written.types, { 'client/src/a.ts': 1 });
assert.equal(written.exports, undefined);
});
it('KNIP-005: a broken baseline or a report without issues stops the check', () => {
assert.match(categoryCounts({ nonsense: {} }), /not one of/);
assert.throws(() => run({ exports: { a: 0 } }), RatchetError);
assert.throws(() => run(FULL, { files: [] }), /no issues array/);
});
});
+163
View File
@@ -0,0 +1,163 @@
/*
* The tally and the comparison behind lint:knip (scripts/ci/knip-ratchet.mjs).
*
* Built on the client's ratchet helpers, which hold the baseline rules every
* ratchet in the repo shares: fail closed on a broken baseline, lower only,
* fail on an entry above its count.
*/
import { spawnSync } from 'node:child_process';
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import {
countMap,
lowerCounts,
RatchetError,
readBaseline,
reportStale,
staleCounts,
writeBaseline,
} from '../../../client/scripts/lib/ratchet.mjs';
/**
* The kinds of finding knip reports per file, as its JSON reporter names them.
* Each is held apart in the baseline, so an unused export removed does not pay
* for an unused dependency added.
*/
const CATEGORIES = [
'files',
'dependencies',
'devDependencies',
'optionalPeerDependencies',
'unlisted',
'binaries',
'unresolved',
'exports',
'types',
'enumMembers',
'namespaceMembers',
'duplicates',
'catalog',
];
/** A baseline: each category an object of file → positive count. */
export function categoryCounts(value) {
if (!value || typeof value !== 'object' || Array.isArray(value)) return 'it is not an object of category → file counts';
for (const [category, counts] of Object.entries(value)) {
if (!CATEGORIES.includes(category)) return `"${category}" is not one of ${CATEGORIES.join(', ')}`;
const problem = countMap(counts);
if (problem) return `in "${category}", ${problem}`;
}
return null;
}
const entries = (value) => (Array.isArray(value) ? value : value && typeof value === 'object' ? Object.values(value) : []);
const nameOf = (item) =>
Array.isArray(item) ? item.map(nameOf).join(' = ') : typeof item === 'string' ? item : (item?.name ?? '?');
/**
* knip's JSON report as counts per category and file, with the names behind
* each count. A report knip did not produce (no `issues` array) is an error of
* the check, not an empty result.
*/
export function tally(report) {
if (!report || !Array.isArray(report.issues)) throw new RatchetError('knip printed no issues array; see its output above');
const counts = Object.fromEntries(CATEGORIES.map((c) => [c, {}]));
const names = Object.fromEntries(CATEGORIES.map((c) => [c, {}]));
for (const issue of report.issues) {
for (const category of CATEGORIES) {
const found = entries(issue[category]);
if (!found.length) continue;
counts[category][issue.file] = found.length;
names[category][issue.file] = found.map(nameOf);
}
}
return { counts, names };
}
/** knip over the repository, as its parsed JSON report. */
function runKnip(root) {
// knip is a root devDependency, so npm installs it at the top of node_modules.
const bin = join(root, 'node_modules', 'knip', 'bin', 'knip.js');
if (!existsSync(bin)) throw new RatchetError(`knip is not installed at ${bin}; run npm ci at the repo root`);
const result = spawnSync(process.execPath, [bin, '--reporter', 'json', '--no-exit-code', '--no-progress'], {
cwd: root,
encoding: 'utf8',
maxBuffer: 256 * 1024 * 1024,
});
if (result.error) throw new RatchetError(`knip did not run: ${result.error.message}`);
if (result.status !== 0) throw new RatchetError(`knip stopped with exit code ${result.status}: ${result.stderr.slice(0, 800)}`);
try {
return JSON.parse(result.stdout);
} catch {
throw new RatchetError(`knip's output is not JSON: ${result.stdout.slice(0, 300)} ${result.stderr.slice(0, 300)}`);
}
}
/** The baseline with each category's files in order too, so a diff of it shows only what changed. */
const sortedFiles = (baseline) =>
Object.fromEntries(
Object.entries(baseline).map(([category, counts]) => [
category,
Object.fromEntries(Object.entries(counts).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))),
])
);
const sum = (counts) => Object.values(counts).reduce((a, b) => a + b, 0);
/**
* Runs knip (or knip, in a test) and compares its findings with the baseline
* at baselinePath. Returns the exit code.
*/
export function check({
root,
baselinePath = join(root, 'scripts/ci/knip-baseline.json'),
update = false,
knip = runKnip,
log = console.log,
error = console.error,
}) {
let baseline = readBaseline(baselinePath, categoryCounts);
const { counts, names } = tally(knip(root));
if (update) {
baseline = Object.fromEntries(
Object.entries(baseline)
.map(([category, allowed]) => [category, lowerCounts(allowed, counts[category] ?? {})])
.filter(([, allowed]) => Object.keys(allowed).length)
);
writeBaseline(baselinePath, sortedFiles(baseline));
}
let grown = 0;
let stale = 0;
for (const category of CATEGORIES) {
const allowed = baseline[category] ?? {};
for (const [file, n] of Object.entries(counts[category])) {
if (n <= (allowed[file] ?? 0)) continue;
grown++;
error(
`FAIL ${category}: ${file} has ${n}, baseline ${allowed[file] ?? 0}: ${names[category][file].slice(0, 8).join(', ')}`
);
}
const fallen = staleCounts(allowed, counts[category]);
reportStale(fallen, {
file: 'ci/knip-baseline.json',
command: 'lint:knip',
label: (file) => `${category}: ${file}`,
error,
});
stale += fallen.length;
}
if (grown) {
error(
'Delete what nothing uses, or list the dependency it needs. If knip is wrong (a file loaded by path, ' +
'a public API), say so in knip.jsonc with the reason; the baseline is not the place for it.'
);
}
const now = CATEGORIES.map((c) => sum(counts[c])).reduce((a, b) => a + b, 0);
const held = Object.values(baseline)
.map(sum)
.reduce((a, b) => a + b, 0);
log(`knip: ${now} finding(s), baseline allows ${held}`);
return grown || stale ? 1 : 0;
}