mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 15:06:32 +00:00
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.
This commit is contained in:
@@ -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<string, number>, listed: Record<string, string[]>, 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;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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') })
|
||||
);
|
||||
Reference in New Issue
Block a user