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
+8
View File
@@ -355,6 +355,14 @@ jobs:
# unknown, passthrough, any) in shared's request schemas.
run: cd shared && npm run contracts:open
- name: Unused code and dependencies check
# knip (knip.jsonc) over client, server and shared: unused files,
# exports, types and dependencies per file may not rise above
# scripts/ci/knip-baseline.json (scripts/ci/knip-ratchet.mjs). Here
# rather than in client-quality because it needs every workspace's
# dependencies, which only this job installs.
run: npm run lint:knip
client-quality:
name: Client Types & Lint
needs: changes
+2 -1
View File
@@ -22,6 +22,7 @@ npm run dev # build shared, then watch shared + server + c
npm run build # build shared → server → client (order matters)
npm run lint # lint all three workspaces, check-only (npm run lint:fix applies the fixes in shared and server)
npm run format # prettier --write all three
npm run lint:knip # knip over client/server/shared (knip.jsonc): unused files, exports, types, deps may only shrink against scripts/ci/knip-baseline.json (CI gate; --update lowers it)
```
Tests:
@@ -133,7 +134,7 @@ These principles come out of a full-repo audit and shape all new code:
## Quality gates in CI
`.github/workflows/test.yml` runs, per package: typecheck (server also `typecheck:tests`, client also the strict-mode ratchet `lint:strict`), lint (client and server through `lint:warnings`, which also holds the warning count per rule), `lint:pages`, `lint:rtl`, `lint:size` (client and server), the server's `lint:strict`, `lint:boundaries`, `lint:tx`, `lint:dialect`, `lint:test-sql`, `lint:test-mocks`, `lint:test-new-service`, `lint:query-api`, `lint:mcp-zod`, `lint:service-http` and `lint:response-contracts`, the client's and server's `lint:format`, `lint:layers`, `lint:offline`, `lint:dup`, `lint:pairs`, `lint:skips`, `lint:i18n-keys` and `theme:lint`, shared's `contracts:open`, `check:plugin-facts`, `i18n:parity:strict` (with the untranslated-strings ratchet), the S3 contract suite, the Postgres probe (`postgres-probe`: every dialect helper on SQLite and Postgres, and the statements every repository method reaches with sample arguments against a Postgres service; the SQL errors per method and the list of methods it cannot measure are held to a baseline that only shrinks and fails while it is still unmeasured; see `server/CLAUDE.md`), the browser end-to-end flows (Chromium and WebKit), a client build with `check:gl-split`, coverage for all four packages, then a SonarCloud scan. It also builds the Docker image and boots it until `/api/health/ready` answers (`docker-image`), and lints and renders the Helm chart against the Kubernetes schemas and runs `docker compose config` on every compose file (`deploy-manifests`). `lint-prettier.yml` additionally runs `lint` + `lint:format` on `shared/`, both check-only. `plugin-sdk` has no lint script and is outside the eslint gates. **Not in CI**: the screenshot and help-media Playwright projects; run them locally. There are no git hooks; nothing runs before a commit except you. Other workflows build Docker images, scan them, and publish `trek-plugin-sdk` on `plugin-sdk-v*` tags.
`.github/workflows/test.yml` runs, per package: typecheck (server also `typecheck:tests`, client also the strict-mode ratchet `lint:strict`), lint (client and server through `lint:warnings`, which also holds the warning count per rule), `lint:pages`, `lint:rtl`, `lint:size` (client and server), the server's `lint:strict`, `lint:boundaries`, `lint:tx`, `lint:dialect`, `lint:test-sql`, `lint:test-mocks`, `lint:test-new-service`, `lint:query-api`, `lint:mcp-zod`, `lint:service-http` and `lint:response-contracts`, the client's and server's `lint:format`, `lint:layers`, `lint:offline`, `lint:dup`, `lint:pairs`, `lint:skips`, `lint:i18n-keys` and `theme:lint`, shared's `contracts:open`, the repo-wide `lint:knip` (in server-quality), `check:plugin-facts`, `i18n:parity:strict` (with the untranslated-strings ratchet), the S3 contract suite, the Postgres probe (`postgres-probe`: every dialect helper on SQLite and Postgres, and the statements every repository method reaches with sample arguments against a Postgres service; the SQL errors per method and the list of methods it cannot measure are held to a baseline that only shrinks and fails while it is still unmeasured; see `server/CLAUDE.md`), the browser end-to-end flows (Chromium and WebKit), a client build with `check:gl-split`, coverage for all four packages, then a SonarCloud scan. It also builds the Docker image and boots it until `/api/health/ready` answers (`docker-image`), and lints and renders the Helm chart against the Kubernetes schemas and runs `docker compose config` on every compose file (`deploy-manifests`). `lint-prettier.yml` additionally runs `lint` + `lint:format` on `shared/`, both check-only. `plugin-sdk` has no lint script and is outside the eslint gates. **Not in CI**: the screenshot and help-media Playwright projects; run them locally. There are no git hooks; nothing runs before a commit except you. Other workflows build Docker images, scan them, and publish `trek-plugin-sdk` on `plugin-sdk-v*` tags.
- **`ci-ok` is the required check.** The branch ruleset requires `ci-ok` (plus `lint` and `check-target` from their own workflows), and `ci-ok` needs every job above except the scan. It fails if any of them failed or was cancelled and passes if they passed or were skipped. A new gate job counts only once it is listed in `ci-ok`'s `needs:`.
- **Guides name paths that exist**: the `changes` job runs `scripts/ci/doc-paths.mjs`, which resolves every backtick path in the CLAUDE.md files, `README.md`, `server/src/nest/README.md` and the wiki against the tracked files; the missing ones are listed in `scripts/ci/doc-paths-baseline.json`, which only shrinks (`--update` after a fix).
+70
View File
@@ -0,0 +1,70 @@
{
// knip over the three workspaces: unused files, exports, types and
// dependencies. scripts/ci/knip-ratchet.mjs (npm run lint:knip) holds what it
// finds today in scripts/ci/knip-baseline.json and fails on anything new.
// A false positive is fixed here, with the reason, never in the baseline.
"$schema": "https://unpkg.com/knip@6/schema.json",
// plugin-sdk is published on its own with its own lockfile and is not a
// root workspace; its test files would otherwise read as unlisted vitest.
"ignore": ["plugin-sdk/**"],
// test.yml runs `cd server && node --require tsconfig-paths/register`; knip
// resolves the workflow from the root, where server's dependency is not.
"ignoreUnresolved": ["tsconfig-paths/register"],
"workspaces": {
".": {
"entry": ["scripts/ci/*.mjs", "scripts/*.{cjs,mts}"],
"project": ["scripts/**/*.{mjs,cjs,mts}"],
// An optional peer of tsdown, which loads shared/tsdown.config.ts through
// it; nothing imports it by name.
"ignoreDependencies": ["unrun"]
},
"client": {
// The app, its scripts, the Playwright specs and the server launcher
// playwright.config.ts starts (`node e2e/server-launch.mjs`), and the tests.
"entry": [
"src/main.tsx",
"scripts/*.mjs",
"e2e/**/*.{ts,mjs}",
"src/**/*.test.{ts,tsx}",
"tests/**/*.{ts,tsx}"
],
"project": ["src/**/*.{ts,tsx}", "scripts/**/*.mjs", "tests/**/*.{ts,tsx}", "e2e/**/*.{ts,mjs}"]
},
"server": {
"entry": [
"src/index.ts",
"src/bootstrap.ts",
// Read by the MikroORM CLI and at boot, by path.
"src/mikro-orm.config.ts",
// Started with child_process.fork() by the plugin supervisor (nest/plugins/paths.ts).
"src/nest/plugins/runtime/plugin-host-entry.ts",
// The plugin-author-facing API the forked child exposes; its exported
// types are the contract for plugin code outside this repository.
"src/nest/plugins/runtime/plugin-sdk.ts",
// Found by the migrator and the seeder through a glob, never imported.
"src/db/migrations/*.ts",
"src/db/seeders/*.ts",
"scripts/**/*.{mjs,cjs,ts}",
"reset-admin.js",
"tests/**/*.ts"
],
"project": ["src/**/*.ts", "scripts/**/*.{mjs,cjs,ts}", "tests/**/*.ts"],
// The MikroORM command line (npx mikro-orm), configured by the
// "mikro-orm" block in server/package.json and never imported.
"ignoreDependencies": ["@mikro-orm/cli"]
},
"shared": {
"entry": [
// The package entry points tsdown builds, one per locale for the lazy chunks.
"src/index.ts",
"src/roadtrip/planning.ts",
"src/i18n/index.ts",
"src/i18n/*/index.ts",
// open-request-schemas.d.mts types the .mjs script beside it.
"scripts/*.{mjs,d.mts}",
"src/**/*.spec.ts"
],
"project": ["src/**/*.ts", "scripts/**/*.{mjs,mts}"]
}
}
}
+943 -32
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -25,6 +25,7 @@
"test:e2e": "npm run test:e2e --workspace=server",
"test:ci-scripts": "node --test scripts/ci/*.test.mjs",
"test:lib-scripts": "node --test scripts/lib/*.test.mjs",
"lint:knip": "node scripts/ci/knip-ratchet.mjs",
"lint": "npm run lint --workspace=shared && npm run lint --workspace=server && npm run lint --workspace=client",
"lint:fix": "npm run lint:fix --workspace=shared && npm run lint:fix --workspace=server",
"format": "npm run format --workspace=shared && npm run format --workspace=server && npm run format --workspace=client",
@@ -32,6 +33,7 @@
},
"devDependencies": {
"concurrently": "^10.0.4",
"knip": "^6.38.0",
"unrun": "^0.3.1"
},
"comment:scarfSettings": "swagger-ui-dist ships @scarf/scarf install-time analytics; TREK sends no telemetry, so it stays off.",
+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;
}