From 83d807781e727c42206bdc0fc5aed81bbc1f96c7 Mon Sep 17 00:00:00 2001 From: Maurice Date: Sat, 10 Oct 2026 13:31:35 +0200 Subject: [PATCH] ci(client): hold strict null check errors per file to a baseline that only shrinks The client compiles with strict: false, so nullable contract fields arrive as if always present and implicit anys stay invisible. tsconfig.strict.json is the same program with strictNullChecks and noImplicitAny, and lint:strict (scripts/strict-lint.mjs) counts its errors per file against scripts/strict-baseline.json: a file may not gain one, a new file has to be clean, and an entry above its count fails until --update lowers it. Nothing is converted here; today's 534 errors in 165 files are frozen. allowJs stays: the tests import the .mjs scripts and read their JSDoc types through it. The todo MSW handler maps a cleared priority to its default so the test helper written in this branch starts at zero. The check runs after the typecheck in client-quality. --- .github/workflows/test.yml | 7 + CLAUDE.md | 3 +- client/CLAUDE.md | 1 + client/package.json | 1 + client/scripts/lib/strict.mjs | 118 +++++++++++++ client/scripts/strict-baseline.json | 167 ++++++++++++++++++ client/scripts/strict-lint.mjs | 30 ++++ client/tests/helpers/msw/handlers/todo.ts | 17 +- client/tests/unit/scripts/strict-lint.test.ts | 77 ++++++++ client/tsconfig.strict.json | 11 ++ 10 files changed, 428 insertions(+), 4 deletions(-) create mode 100644 client/scripts/lib/strict.mjs create mode 100644 client/scripts/strict-baseline.json create mode 100644 client/scripts/strict-lint.mjs create mode 100644 client/tests/unit/scripts/strict-lint.test.ts create mode 100644 client/tsconfig.strict.json diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index bc3d69793..6249de341 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -380,6 +380,13 @@ jobs: - name: Typecheck run: cd client && npm run typecheck + - name: Strict type check + # tsconfig.strict.json is the same program with strictNullChecks and + # noImplicitAny; its errors per file may not rise above + # scripts/strict-baseline.json, and a file without an entry, every new + # one included, has to be clean (scripts/strict-lint.mjs). + run: cd client && npm run lint:strict + - name: Lint # Fails on any ESLint error, like lint:check did, and on any rule # whose warning count rises above scripts/eslint-baseline.json. diff --git a/CLAUDE.md b/CLAUDE.md index 6f22572ba..67e55294d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,6 +38,7 @@ cd client && npm run lint:rtl # physical left/right sides m cd client && npm run lint:dup # lines in copied code may only shrink per file, at SonarCloud's thresholds (CI gate) cd client && npm run lint:pairs # desktop and phone views of a feature import its shared hook; every phone screen is listed (CI gate) cd client && npm run lint:size # no source file past 1000 lines, longer ones only shrink (CI gate; server has the same) +cd client && npm run lint:strict # strictNullChecks + noImplicitAny errors may only shrink per file; new files start at zero (CI gate) cd client && npm run lint:warnings # eslint (typed) errors fail, warnings per rule may only shrink, new rules start frozen (CI gate) cd server && npm run lint:warnings # the same for the server, against server/scripts/eslint-baseline.json (CI gate) cd server && npm run lint:format # Prettier outside the baseline list (CI gate; shared has the same in lint-prettier.yml) @@ -132,7 +133,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`), 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`, `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). diff --git a/client/CLAUDE.md b/client/CLAUDE.md index 563473024..8db441b4b 100644 --- a/client/CLAUDE.md +++ b/client/CLAUDE.md @@ -8,6 +8,7 @@ Scope: the **`@trek/client`** workspace (React 19 + Vite + Zustand + Tailwind PW npm run dev # Vite dev server; proxies the API/ws/uploads/MCP/OAuth paths → http://localhost:3001 npm run build # prebuild generates PWA icons, then vite build npm run typecheck # tsc --noEmit (CI) +npm run lint:strict # tsc over tsconfig.strict.json (strictNullChecks + noImplicitAny): errors per file may only shrink against scripts/strict-baseline.json, a new file starts at zero (CI gate; --list shows them, --update lowers it) npm run lint # eslint . (CI runs lint:warnings instead, which fails on the same errors) npm run lint:warnings # eslint (typed), failing on any error and on any warning count per rule above scripts/eslint-baseline.json and on any entry above its count (app code, tests and eslint-disable'd messages counted apart; a `/* eslint : ... */` config comment fails outright; --update lowers it). Frozen as warnings: floating/misused promises, `.catch(() => {})` (trek/no-swallowed-catch), react-hooks v7 recommended, complexity/max-depth/max-params/max-lines-per-function (120 per .ts function, 300 per .tsx), `use*Store()` without a selector, `window.__*` and window.dispatchEvent buses (trek/no-window-globals), no-console, no-non-null-assertion; the local rules live in scripts/lib/eslint-rules.mjs npm run lint:pages # enforce the Page pattern (CI gate) diff --git a/client/package.json b/client/package.json index 18f3e2f69..de5e0283a 100644 --- a/client/package.json +++ b/client/package.json @@ -30,6 +30,7 @@ "lint:dup": "node scripts/dup-lint.mjs", "lint:pairs": "node scripts/pairs-lint.mjs", "lint:skips": "node scripts/skip-lint.mjs", + "lint:strict": "node scripts/strict-lint.mjs", "lint:i18n-keys": "node scripts/i18n-keys.mjs", "check:gl-split": "node scripts/check-gl-split.mjs", "theme:lint": "node scripts/theme-lint.mjs", diff --git a/client/scripts/lib/strict.mjs b/client/scripts/lib/strict.mjs new file mode 100644 index 000000000..13ab2d3bc --- /dev/null +++ b/client/scripts/lib/strict.mjs @@ -0,0 +1,118 @@ +/* + * The tally and the comparison behind lint:strict (scripts/strict-lint.mjs). + */ +import { spawnSync } from 'node:child_process'; +import { createRequire } from 'node:module'; +import { join } from 'node:path'; +import { + countMap, + lowerCounts, + RatchetError, + readBaseline, + reportStale, + staleCounts, + writeBaseline, +} from './ratchet.mjs'; + +// `src/a.ts(12,5): error TS2531: Object is possibly 'null'.` as tsc prints it with --pretty false. +const FILE_ERROR = /^(.+?)\((\d+),(\d+)\): error (TS\d+): (.*)$/; +// An error tied to no file, such as a broken tsconfig, starts the same way without the position. +const OTHER_ERROR = /^(?:.+?: )?error (TS\d+): (.*)$/; + +/** + * The strict errors per file in tsc's output, with each error's line, and the + * errors tied to no file (a broken config), which always fail. A file key is + * the path tsc printed, relative to the client root, with forward slashes; the + * indented lines that continue a message belong to the error above them. + + * + * @param {string} output + * @returns {{ counts: Record, listed: Record, general: string[] }} + */ +export function parseTscOutput(output) { + const counts = {}; + const listed = {}; + const general = []; + for (const line of output.replace(/\r\n?/g, '\n').split('\n')) { + const hit = FILE_ERROR.exec(line); + if (hit) { + const key = hit[1].split('\\').join('/'); + counts[key] = (counts[key] ?? 0) + 1; + (listed[key] ??= []).push(`${hit[2]}:${hit[3]} ${hit[4]} ${hit[5]}`); + continue; + } + if (!/^\s/.test(line) && OTHER_ERROR.test(line)) general.push(line.trim()); + } + return { counts, listed, general }; +} + +/** tsc over tsconfig.strict.json, as its plain-text output. */ +export function runTsc(root) { + const tsc = createRequire(join(root, 'package.json')).resolve('typescript/bin/tsc'); + const result = spawnSync(process.execPath, [tsc, '-p', 'tsconfig.strict.json', '--noEmit', '--pretty', 'false'], { + cwd: root, + encoding: 'utf8', + maxBuffer: 256 * 1024 * 1024, + }); + if (result.error) throw new RatchetError(`tsc did not run: ${result.error.message}`); + // tsc exits 0 when clean and 1 or 2 when it reports errors; anything else is a crash. + if (![0, 1, 2].includes(result.status ?? -1)) + throw new RatchetError( + `tsc stopped with exit code ${result.status}: ${(result.stderr || result.stdout).slice(0, 500)}` + ); + return `${result.stdout}\n${result.stderr}`; +} + +const sum = (counts) => Object.values(counts).reduce((a, b) => a + b, 0); + +/** + * Runs tsc (or tsc, in a test) and compares the strict errors per file with + * the baseline at baselinePath. Returns the exit code. + */ +export function check({ + root, + baselinePath = join(root, 'scripts/strict-baseline.json'), + update = false, + list = false, + tsc = runTsc, + log = console.log, + error = console.error, +}) { + let baseline = readBaseline(baselinePath, countMap); + const { counts, listed, general } = parseTscOutput(tsc(root)); + + if (list) { + for (const [file, errors] of Object.entries(listed).sort()) { + log(`${file} (${errors.length}, baseline ${baseline[file] ?? 0})`); + for (const line of errors) log(` ${line}`); + } + } + + if (update) { + baseline = lowerCounts(baseline, counts); + writeBaseline(baselinePath, baseline); + } + + for (const line of general) error(`FAIL ${line}`); + const grown = Object.entries(counts).filter(([file, n]) => n > (baseline[file] ?? 0)); + for (const [file, n] of grown) { + const first = listed[file].slice(0, 3).join('; '); + error( + `FAIL ${file}: ${n} error(s) under strictNullChecks and noImplicitAny, baseline ${baseline[file] ?? 0}. ` + + `First: ${first}` + ); + } + if (grown.length) { + error( + 'Handle the null or type the value rather than asserting it away with `!` or `any`: both are counted by ' + + 'lint:warnings. Run npm run lint:strict -- --list for every error.' + ); + } + const stale = staleCounts(baseline, counts); + reportStale(stale, { file: 'strict-baseline.json', command: 'lint:strict', root, error }); + log( + `strict: ${sum(counts)} error(s) in ${Object.keys(counts).length} file(s) under strictNullChecks and ` + + `noImplicitAny, baseline allows ${sum(baseline)}` + ); + return general.length || grown.length || stale.length ? 1 : 0; +} diff --git a/client/scripts/strict-baseline.json b/client/scripts/strict-baseline.json new file mode 100644 index 000000000..4ca9d0d28 --- /dev/null +++ b/client/scripts/strict-baseline.json @@ -0,0 +1,167 @@ +{ + "src/components/Admin/AddonManager.tsx": 2, + "src/components/Admin/AdminPluginsPanel.test.tsx": 2, + "src/components/Admin/AdminPluginsPanel.tsx": 3, + "src/components/Admin/storage/useStorageAdmin.test.tsx": 1, + "src/components/Admin/useBackupAdmin.ts": 1, + "src/components/Budget/BudgetPanelMemberChips.tsx": 1, + "src/components/Budget/CostsPanel.test.tsx": 1, + "src/components/Collab/CollabChat.helpers.ts": 1, + "src/components/Collab/CollabChat.tsx": 3, + "src/components/Collab/CollabChatEmojiPicker.tsx": 5, + "src/components/Collab/CollabChatMessageText.tsx": 2, + "src/components/Collab/CollabChatMessages.tsx": 4, + "src/components/Collab/CollabChatReactionBadge.tsx": 1, + "src/components/Collab/CollabChatReactionMenu.tsx": 2, + "src/components/Collab/CollabChatTwemojiImg.tsx": 1, + "src/components/Collab/CollabNotes.helpers.ts": 3, + "src/components/Collab/CollabNotes.test.tsx": 2, + "src/components/Collab/CollabNotes.tsx": 23, + "src/components/Collab/CollabNotesCard.tsx": 1, + "src/components/Collab/CollabNotesCategorySettingsModal.tsx": 6, + "src/components/Collab/CollabNotesEditableCatName.tsx": 2, + "src/components/Collab/CollabNotesFormModal.tsx": 8, + "src/components/Collab/CollabPanel.tsx": 6, + "src/components/Collab/CollabPolls.test.tsx": 1, + "src/components/Collab/CollabPolls.tsx": 4, + "src/components/Collab/WhatsNextWidget.test.tsx": 16, + "src/components/Collab/WhatsNextWidget.tsx": 5, + "src/components/Collab/useCollabChat.ts": 6, + "src/components/Files/FileManager.tsx": 2, + "src/components/Files/FileManagerAssignModal.tsx": 2, + "src/components/Files/FileManagerMarkdownPreviewModal.tsx": 4, + "src/components/Files/FileManagerPdfPreviewModal.tsx": 5, + "src/components/Files/FileManagerPreviewModals.test.tsx": 3, + "src/components/Files/useFileManager.ts": 5, + "src/components/Journey/JourneyDayDawarich.test.tsx": 1, + "src/components/Journey/JourneyDetailPageSettingsDialog.tsx": 1, + "src/components/Journey/JourneyMap.test.tsx": 6, + "src/components/Journey/journeyGeo.ts": 4, + "src/components/Layout/Navbar.test.tsx": 1, + "src/components/Map/MapView.test.tsx": 2, + "src/components/Map/MapView.tsx": 8, + "src/components/Map/MapViewGL.tsx": 1, + "src/components/Map/ReservationOverlay.test.tsx": 1, + "src/components/Map/transitGeometry.ts": 1, + "src/components/Map/useDawarichTrailGL.test.ts": 4, + "src/components/Map/useMarkerThumbs.ts": 1, + "src/components/PDF/TripPDF.tsx": 44, + "src/components/Packing/PackingListPanelItemRow.test.tsx": 2, + "src/components/Planner/DayPlanSidebar.constants.ts": 1, + "src/components/Planner/DayPlanSidebar.tsx": 61, + "src/components/Planner/DayPlanSidebarNoteModal.test.tsx": 1, + "src/components/Planner/PlaceFormModal.test.tsx": 3, + "src/components/Planner/PlaceFormModal.tsx": 1, + "src/components/Planner/PlaceInspector.tsx": 19, + "src/components/Planner/PlacesBulkCategoryModal.test.tsx": 1, + "src/components/Planner/PlacesSidebar.tsx": 1, + "src/components/Planner/PlacesSidebarMobileDayPicker.tsx": 8, + "src/components/Planner/ReservationModal.tsx": 4, + "src/components/Planner/TransitSearchPanel.tsx": 2, + "src/components/Planner/assignmentOptions.ts": 1, + "src/components/Planner/bookings/bookingFacts.ts": 1, + "src/components/Planner/bookings/bookingsModel.ts": 2, + "src/components/Planner/bookings/timelineModel.ts": 4, + "src/components/Planner/reservationFormModel.test.ts": 1, + "src/components/Planner/transportEndpoints.ts": 2, + "src/components/Planner/useDayDetail.ts": 2, + "src/components/Planner/usePlacesSidebar.ts": 1, + "src/components/Planner/useTripExport.ts": 1, + "src/components/Plugins/PluginFrame.tsx": 2, + "src/components/Roadtrip/RoadtripAlternativesBar.tsx": 1, + "src/components/Roadtrip/RoadtripCorridorPanel.tsx": 4, + "src/components/Roadtrip/RoadtripSidebarBadges.tsx": 4, + "src/components/Roadtrip/RoadtripSidebarDriveBands.tsx": 2, + "src/components/Roadtrip/RoadtripSidebarHeaders.tsx": 2, + "src/components/Roadtrip/RoadtripSidebarRefuelBand.tsx": 5, + "src/components/Roadtrip/RoadtripTrackModal.tsx": 4, + "src/components/Roadtrip/ServiceStopSection.tsx": 2, + "src/components/Roadtrip/useRoadtripRoutes.test.ts": 2, + "src/components/Roadtrip/useRoadtripRoutes.ts": 4, + "src/components/Settings/DisplaySettingsTab.tsx": 2, + "src/components/Settings/MapboxPreview.tsx": 1, + "src/components/Settings/NotificationsTab.test.tsx": 1, + "src/components/Settings/PasskeysSection.tsx": 1, + "src/components/Settings/PhotoProvidersSection.test.tsx": 2, + "src/components/Studio/StudioCanvas.tsx": 1, + "src/components/Studio/StudioInspector.tsx": 1, + "src/components/Studio/autoLayout.ts": 1, + "src/components/Todo/todoListModel.ts": 1, + "src/components/Tours/planner/TourPlannerPanels.tsx": 2, + "src/components/Tours/planner/useTourPlanner.ts": 2, + "src/components/Tours/tourParts.tsx": 11, + "src/components/Trips/TripFormModal.tsx": 3, + "src/components/Trips/TripMembersModal.tsx": 1, + "src/components/Vacay/VacayCalendar.tsx": 1, + "src/components/Vacay/VacayPersons.tsx": 4, + "src/components/Vacay/VacaySettings.tsx": 2, + "src/components/Vacay/VacaySharedCalendars.tsx": 1, + "src/components/Vacay/holidayRegions.test.ts": 2, + "src/components/Vacay/holidayRegions.ts": 4, + "src/components/Weather/WeatherWidget.tsx": 14, + "src/components/shared/CustomSelect.test.tsx": 1, + "src/components/shared/PlaceAvatar.test.tsx": 2, + "src/components/shared/PlaceAvatar.tsx": 1, + "src/components/shared/PlaceAvatarUpload.test.tsx": 1, + "src/hooks/useDawarichSuggestions.test.ts": 1, + "src/mobile/screens/admin/MAdminAddonManager.tsx": 2, + "src/mobile/screens/admin/MAdminPluginsPanel.tsx": 6, + "src/mobile/screens/journey/MJourneySettingsSheet.tsx": 1, + "src/mobile/screens/settings/MSettingsAccount.tsx": 1, + "src/mobile/screens/trip/MTripShell.tsx": 1, + "src/mobile/screens/trip/map/MMapArea.tsx": 1, + "src/mobile/screens/trip/places/MPlacesBrowser.tsx": 2, + "src/mobile/screens/trip/places/MToursSelectionList.tsx": 6, + "src/mobile/screens/trip/plan/planTimelineModel.ts": 1, + "src/mobile/screens/trip/roadtrip/MRoadtripTab.tsx": 2, + "src/mobile/screens/trip/roadtrip/MRtAlternativesBar.tsx": 1, + "src/mobile/screens/trip/roadtrip/MRtCorridorBar.tsx": 1, + "src/mobile/screens/trip/roadtrip/MRtCorridorSheet.tsx": 2, + "src/mobile/screens/trip/sheets/MReservationSheet.tsx": 1, + "src/mobile/screens/trip/sheets/MTripSheets.tsx": 1, + "src/mobile/screens/trip/tabs/transportsModel.ts": 1, + "src/mobile/screens/vacay/MVacaySettingsSheet.tsx": 1, + "src/pages/AtlasPage.tsx": 6, + "src/pages/FilesPage.tsx": 1, + "src/pages/JourneyDetailPage.tsx": 1, + "src/pages/JourneyPublicPage.test.tsx": 1, + "src/pages/LoginPage.tsx": 1, + "src/pages/VacayPage.tsx": 1, + "src/pages/admin/useAdmin.ts": 4, + "src/pages/atlas/atlasModel.ts": 2, + "src/pages/atlas/useAtlas.ts": 9, + "src/pages/collections/collectionsModel.ts": 1, + "src/pages/journeyDetail/useJourneyDetail.ts": 3, + "src/pages/sharedTrip/SharedPlanView.tsx": 1, + "src/pages/tripPlanner/PlanMapStage.tsx": 1, + "src/pages/tripPlanner/b4a-wiring.test.ts": 1, + "src/pages/tripPlanner/useBookingEdits.ts": 1, + "src/pages/tripPlanner/useDayPlanEdits.ts": 2, + "src/pages/tripPlanner/useMapPlaces.ts": 3, + "src/pages/tripPlanner/usePlaceEdits.ts": 2, + "src/pages/tripPlanner/usePlaceFormOpeners.test.ts": 2, + "src/pages/tripPlanner/usePlaceFormOpeners.ts": 1, + "src/pages/tripPlanner/useTripPlanner.ts": 2, + "src/repo/tripRepo.ts": 2, + "src/store/slices/budgetSlice.test.ts": 1, + "src/utils/formatters.test.ts": 1, + "src/utils/touchDragPolyfill.ts": 1, + "tests/integration/api/client.test.ts": 1, + "tests/integration/hooks/useRouteCalculation.test.ts": 1, + "tests/unit/i18n/i18nKeysScript.test.ts": 3, + "tests/unit/mobile/admin/MAdminAddonManager.test.tsx": 2, + "tests/unit/mobile/admin/MAdminGitHubPanel.test.tsx": 1, + "tests/unit/mobile/admin/MAdminNotifyMatrix.test.tsx": 1, + "tests/unit/mobile/admin/MAdminStoragePanel.test.tsx": 1, + "tests/unit/mobile/dashboard/MDashWidgets.test.tsx": 1, + "tests/unit/mobile/settings/MSettingsNotifications.test.tsx": 3, + "tests/unit/mobile/settings/MSettingsPlugins.test.tsx": 2, + "tests/unit/mobile/trip/MCostsTab.test.tsx": 1, + "tests/unit/mobile/trip/filesModel.test.ts": 3, + "tests/unit/remoteEventHandler/budget.test.ts": 1, + "tests/unit/remoteEventHandler/trip.test.ts": 1, + "tests/unit/scripts/eslint-ratchet.test.ts": 1, + "tests/unit/slices/budgetSlice.test.ts": 1, + "tests/unit/studio/autoLayout.travel.test.ts": 3, + "tests/unit/studio/mapFraming.test.tsx": 1 +} diff --git a/client/scripts/strict-lint.mjs b/client/scripts/strict-lint.mjs new file mode 100644 index 000000000..65dda2253 --- /dev/null +++ b/client/scripts/strict-lint.mjs @@ -0,0 +1,30 @@ +#!/usr/bin/env node +/* + * lint:strict: the errors strict null checks would raise may only go down. + * + * The client compiles with strict: false, so a `.nullable()` field from + * @trek/shared arrives as if it were always there and an untyped parameter is + * an `any` nobody sees. Turning strict on at once would mean fixing hundreds + * of files in one go. Instead tsconfig.strict.json is the same program with + * strictNullChecks and noImplicitAny on, and this counts its errors per file + * against scripts/strict-baseline.json: a file may not gain one, and a file + * without an entry, which every new file is, has to be clean. An entry above + * its count, or for a file that is gone, fails until --update lowers it, so a + * file brought down stays down. When a whole directory reaches zero, it can + * move into the main tsconfig's strict settings for good. + * + * npm run lint:strict check against the baseline (CI) + * npm run lint:strict -- --list print every error, file by file + * npm run lint:strict -- --update lower the baseline to today's counts; + * it never raises an entry + * + * The parsing and the comparison live in scripts/lib/strict.mjs. + */ +import { fileURLToPath } from 'node:url'; +import { runCli } from './lib/ratchet.mjs'; +import { check } from './lib/strict.mjs'; + +const root = fileURLToPath(new URL('..', import.meta.url)); +await runCli('lint:strict', (args) => + check({ root, update: args.includes('--update'), list: args.includes('--list') }) +); diff --git a/client/tests/helpers/msw/handlers/todo.ts b/client/tests/helpers/msw/handlers/todo.ts index 6340f2526..b716c3e45 100644 --- a/client/tests/helpers/msw/handlers/todo.ts +++ b/client/tests/helpers/msw/handlers/todo.ts @@ -6,9 +6,20 @@ import { contractHandler } from '../contract'; // @trek/shared holds the todo requests but no todo item schema yet, so only // the request side is checked here. -/** The server stores checked as 0/1 and answers it that way, whichever form the request used. */ -const asStored = ({ checked, ...rest }: T) => - checked === undefined ? rest : { ...rest, checked: Number(checked) }; +/** + * The row as the server stores it: checked as 0/1 whichever form the request + * used, and a cleared priority as its default, 0. + */ +const asStored = ({ + checked, + priority, + ...rest +}: T) => ({ + ...rest, + ...(checked === undefined ? {} : { checked: Number(checked) }), + ...(priority === undefined ? {} : { priority: priority ?? 0 }), +}); + export const todoHandlers = [ contractHandler('get', '/api/trips/:id/todo', {}, ({ params }) => ({ items: [buildTodoItem({ trip_id: Number(params.id) })], diff --git a/client/tests/unit/scripts/strict-lint.test.ts b/client/tests/unit/scripts/strict-lint.test.ts new file mode 100644 index 000000000..44508d55c --- /dev/null +++ b/client/tests/unit/scripts/strict-lint.test.ts @@ -0,0 +1,77 @@ +import { readFileSync } from 'node:fs'; +import { afterEach, describe, expect, it } from 'vitest'; +import { RatchetError } from '../../../scripts/lib/ratchet.mjs'; +import { check, parseTscOutput } from '../../../scripts/lib/strict.mjs'; +import { ratchetTree, type RatchetTree } from '../../helpers/ratchetFixture'; + +let tree: RatchetTree; +afterEach(() => tree?.remove()); + +const BASELINE = 'scripts/strict-baseline.json'; + +const OUTPUT = [ + "src/a.ts(3,7): error TS18047: 'user' is possibly 'null'.", + "src/a.ts(9,1): error TS7006: Parameter 'x' implicitly has an 'any' type.", + "src\\b.tsx(1,2): error TS2322: Type 'string | null' is not assignable to type 'string'.", + " Type 'null' is not assignable to type 'string'.", +].join('\r\n'); + +function run(baseline: Record, output: string, files: Record = {}, update = false) { + tree = ratchetTree({ [BASELINE]: JSON.stringify(baseline), ...files }); + return check({ root: tree.root, baselinePath: tree.path(BASELINE), update, tsc: () => output, ...tree.out }); +} + +describe('lint:strict', () => { + it('STRICT-001: counts errors per file, joins continuation lines and reads Windows paths', () => { + const { counts, listed, general } = parseTscOutput(OUTPUT); + expect(counts).toEqual({ 'src/a.ts': 2, 'src/b.tsx': 1 }); + expect(listed['src/a.ts'][0]).toBe("3:7 TS18047 'user' is possibly 'null'."); + expect(general).toEqual([]); + }); + + it('STRICT-002: passes counts within the baseline', () => { + const files = { 'src/a.ts': '', 'src/b.tsx': '' }; + expect(run({ 'src/a.ts': 2, 'src/b.tsx': 1 }, OUTPUT, files)).toBe(0); + expect(tree.log.join('\n')).toMatch(/strict: 3 error\(s\) in 2 file\(s\).*baseline allows 3/); + }); + + it('STRICT-003: a new file starts at zero, and a file that gains an error fails', () => { + expect(run({ 'src/a.ts': 2 }, OUTPUT, { 'src/a.ts': '', 'src/b.tsx': '' })).toBe(1); + expect(tree.error.join('\n')).toMatch( + /src\/b\.tsx: 1 error\(s\) under strictNullChecks and noImplicitAny, baseline 0/ + ); + }); + + it('STRICT-004: an error tied to no file fails the check', () => { + expect( + run({}, "tsconfig.strict.json(3,5): error TS5023: Unknown compiler option 'x'.\nerror TS6053: File not found.") + ).toBe(1); + expect(tree.error.join('\n')).toMatch(/FAIL {2}error TS6053: File not found\./); + }); + + it('STRICT-005: --update lowers the baseline and never raises it', () => { + run({ 'src/a.ts': 5, 'src/b.tsx': 1, 'src/fixed.ts': 2 }, OUTPUT, { 'src/a.ts': '', 'src/b.tsx': '' }, true); + expect(JSON.parse(readFileSync(tree.path(BASELINE), 'utf8'))).toEqual({ 'src/a.ts': 2, 'src/b.tsx': 1 }); + }); + + it('STRICT-006: fails an entry above its count, or for a file that is gone, until --update', () => { + expect(run({ 'src/a.ts': 3, 'src/b.tsx': 1 }, OUTPUT, { 'src/a.ts': '', 'src/b.tsx': '' })).toBe(1); + expect(tree.error.join('\n')).toMatch( + /src\/a\.ts is held at 3 in scripts\/strict-baseline\.json, but there are 2 now/ + ); + tree.remove(); + expect(run({ 'src/a.ts': 2, 'src/b.tsx': 1, 'src/gone.ts': 1 }, OUTPUT, { 'src/a.ts': '', 'src/b.tsx': '' })).toBe( + 1 + ); + expect(tree.error.join('\n')).toMatch( + /src\/gone\.ts is held at 1 in scripts\/strict-baseline\.json, but the file is gone/ + ); + }); + + it('STRICT-007: a missing baseline stops the check instead of reading as empty', () => { + tree = ratchetTree({}); + expect(() => check({ root: tree.root, baselinePath: tree.path(BASELINE), tsc: () => '', ...tree.out })).toThrow( + RatchetError + ); + }); +}); diff --git a/client/tsconfig.strict.json b/client/tsconfig.strict.json new file mode 100644 index 000000000..0627b319a --- /dev/null +++ b/client/tsconfig.strict.json @@ -0,0 +1,11 @@ +{ + // The strict-ratchet view of the client (npm run lint:strict, scripts/strict-lint.mjs): + // the same program as tsconfig.json with strictNullChecks and noImplicitAny on. + // Errors per file may only go down against scripts/strict-baseline.json; a file + // without an entry, every new one included, has to compile cleanly here. + "extends": "./tsconfig.json", + "compilerOptions": { + "strictNullChecks": true, + "noImplicitAny": true + } +}