ci(client): lint:offline stops new views from calling the API directly

The offline core only works when the views go through it: component,
feature hook, store, repo, then api or Dexie, with writes through the
mutation queue. A view that imports src/api/ skips the cache, the
optimistic write and the idempotent replay. client/CLAUDE.md said so
and noted that no ratchet held the line, and the number of view files
importing api/client grew from 161 to 190 in two months.

lint:offline lists the 213 files under components/, mobile/, pages/
and hooks/ that import anything from src/api/ for more than types
(api/client, the websocket, the per-domain modules) in
scripts/offline-baseline.json. No other view file may, and a listed
file that stops leaves the list through --update, so the next file at
that path does not inherit the exemption. Type-only imports do not
count: they carry no request.

client/CLAUDE.md describes the rule next to the data-flow section,
including that an online-only domain puts its calls in a store or repo.
This commit is contained in:
Maurice
2026-10-08 11:43:38 +02:00
parent 798553b13b
commit 7a7903a799
6 changed files with 389 additions and 1 deletions
+77
View File
@@ -0,0 +1,77 @@
/*
* The comparison behind lint:offline (scripts/offline-lint.mjs).
*/
import { join } from 'node:path'
import { fileList, lowerList, readBaseline, writeBaseline } from './ratchet.mjs'
import { sourceGraph } from './imports.mjs'
/** The layers that render or drive a view, and so must not hold a network call. */
export const VIEW_LAYERS = ['components', 'mobile', 'pages', 'hooks']
/**
* The view files that import from src/api/ for anything but types, each with
* the imports that do it. A type-only import is erased from the bundle and
* carries no request, so it does not count.
*/
export function bypasses(root) {
const found = {}
for (const file of sourceGraph(root)) {
if (!VIEW_LAYERS.includes(file.layer)) continue
const direct = file.imports.filter((imp) => imp.layer === 'api' && !imp.typeOnly)
if (direct.length) found[file.key] = direct.map((imp) => `${imp.line}: '${imp.specifier}'`)
}
return found
}
/**
* Runs the check against the list at baselinePath. A view file outside the
* list may not import src/api/. A listed file that no longer does fails the
* check as well until --update takes it out, so the list only shrinks and
* the next file at that path does not inherit the exemption. Returns the exit
* code.
*/
export function check({
root,
baselinePath = join(root, 'scripts/offline-baseline.json'),
update = false,
list = false,
log = console.log,
error = console.error,
}) {
const found = bypasses(root)
const offenders = Object.keys(found)
let baseline = readBaseline(baselinePath, fileList)
if (update) {
baseline = lowerList(baseline, offenders)
writeBaseline(baselinePath, baseline)
}
const allowed = new Set(baseline)
const fresh = offenders.filter((key) => !allowed.has(key))
const stale = baseline.filter((key) => !found[key])
if (list) {
for (const [key, direct] of Object.entries(found)) {
log(`${allowed.has(key) ? 'listed' : 'new '} ${key}`)
for (const line of direct) log(` ${line}`)
}
}
for (const key of fresh) {
error(`FAIL ${key} imports src/api/ directly:`)
for (const line of found[key]) error(` ${line}`)
}
if (fresh.length) {
error(
'A view reads through a repo (src/repo/) or a store and writes through the mutation queue, ' +
'so it keeps working offline. A domain that is online-only on purpose still gets its calls ' +
'in a store or a repo, not in the view. See client/CLAUDE.md, "Data flow".',
)
}
for (const key of stale) {
error(`FAIL ${key} is listed in scripts/offline-baseline.json but no longer imports src/api/ (or is gone).`)
}
if (stale.length) error('Run npm run lint:offline -- --update to take it off the list.')
log(`offline: ${offenders.length} view file(s) import src/api/ directly, ${baseline.length} listed in the baseline`)
return fresh.length || stale.length ? 1 : 0
}
+215
View File
@@ -0,0 +1,215 @@
[
"src/components/Admin/AddonManager.tsx",
"src/components/Admin/AdminMcpTokensPanel.tsx",
"src/components/Admin/AdminPluginsPanel.tsx",
"src/components/Admin/AuditLogPanel.tsx",
"src/components/Admin/BackupPanel.tsx",
"src/components/Admin/CategoryManager.tsx",
"src/components/Admin/DefaultUserSettingsTab.tsx",
"src/components/Admin/DevNotificationsPanel.tsx",
"src/components/Admin/GitHubPanel.tsx",
"src/components/Admin/PackingTemplateManager.tsx",
"src/components/Admin/PermissionsPanel.tsx",
"src/components/Admin/storage/useStorageAdmin.ts",
"src/components/Admin/useGoogleQuota.ts",
"src/components/Admin/useInstanceSettings.ts",
"src/components/Admin/useNotificationChannels.ts",
"src/components/BackgroundTasks/BackgroundTasksWidget.tsx",
"src/components/Budget/BudgetPanelPerPersonInline.tsx",
"src/components/Budget/CostsPanel.tsx",
"src/components/Budget/ReceiptPreviewModal.tsx",
"src/components/Budget/receiptUploads.ts",
"src/components/Budget/useBudgetPanel.ts",
"src/components/Collab/CollabChatLinkPreview.tsx",
"src/components/Collab/CollabLinks.tsx",
"src/components/Collab/CollabNotes.tsx",
"src/components/Collab/CollabNotesAuthedImg.tsx",
"src/components/Collab/CollabNotesFilePreviewPortal.tsx",
"src/components/Collab/CollabNotesWebsiteThumbnail.tsx",
"src/components/Collab/CollabPolls.tsx",
"src/components/Collab/useCollabChat.ts",
"src/components/Collections/AddPlaceToCollectionModal.tsx",
"src/components/Collections/CollectionPicker.tsx",
"src/components/Collections/CollectionPlaceDetail.tsx",
"src/components/Collections/CopyToTripModal.tsx",
"src/components/Collections/ImportFromTripModal.tsx",
"src/components/Collections/ListEditorModal.tsx",
"src/components/Collections/MSaveToCollectionSheet.tsx",
"src/components/Collections/SaveToCollectionModal.tsx",
"src/components/Collections/SaveTripPlacesToListModal.tsx",
"src/components/Collections/ShareCollectionModal.tsx",
"src/components/Dashboard/CollectionsWidget.tsx",
"src/components/Dawarich/DawarichAtlasDialog.tsx",
"src/components/Files/FileManagerAssignModal.tsx",
"src/components/Files/FileManagerAuthedImg.tsx",
"src/components/Files/FileManagerImageLightbox.tsx",
"src/components/Files/docsync/useConflicts.ts",
"src/components/Files/docsync/useDocSync.ts",
"src/components/Files/useFileManager.ts",
"src/components/Journey/ContributorInviteDialog.tsx",
"src/components/Journey/JourneyDetailPageAddTripDialog.tsx",
"src/components/Journey/JourneyDetailPageEntryCard.tsx",
"src/components/Journey/JourneyDetailPageEntryEditor.tsx",
"src/components/Journey/JourneyDetailPageGalleryView.tsx",
"src/components/Journey/JourneyDetailPageProviderPicker.tsx",
"src/components/Journey/JourneyDetailPageSettingsDialog.tsx",
"src/components/Journey/JourneyShareSection.tsx",
"src/components/Journey/useConnectedPhotoProviders.ts",
"src/components/Journey/useEntryPhotoOrder.ts",
"src/components/Journey/useJourneyTripSuggestion.ts",
"src/components/Map/MapPluginLayers.tsx",
"src/components/Map/MapPluginMarkers.tsx",
"src/components/Map/MapView.tsx",
"src/components/Map/MapViewGL.tsx",
"src/components/Map/RouteCalculator.ts",
"src/components/Map/routeUsageCounter.ts",
"src/components/Map/usePoiExplore.ts",
"src/components/OAuth/ScopeGroupPicker.tsx",
"src/components/PDF/TripPDF.tsx",
"src/components/Packing/ApplyTemplateButton.tsx",
"src/components/Packing/usePackingExport.ts",
"src/components/Packing/usePackingListPanel.ts",
"src/components/Planner/AddressInput.tsx",
"src/components/Planner/AirTrailImportModal.tsx",
"src/components/Planner/AirportSelect.tsx",
"src/components/Planner/BookingImportModal.tsx",
"src/components/Planner/DayPlanSidebar.tsx",
"src/components/Planner/FileImportModal.tsx",
"src/components/Planner/LocationSelect.tsx",
"src/components/Planner/PlaceDetailsColumn.tsx",
"src/components/Planner/PlaceFormModal.tsx",
"src/components/Planner/PlaceInspector.tsx",
"src/components/Planner/TransitSearchPanel.tsx",
"src/components/Planner/TripWarningsBanner.tsx",
"src/components/Planner/useDayDetail.ts",
"src/components/Planner/usePlacesSidebar.ts",
"src/components/Plugins/PluginContributions.tsx",
"src/components/Plugins/PluginDaySchedule.tsx",
"src/components/Plugins/PluginFrame.tsx",
"src/components/Plugins/TripCardBadges.tsx",
"src/components/Roadtrip/useDayBoundaries.ts",
"src/components/Roadtrip/useRefuelSearch.ts",
"src/components/Roadtrip/useRoadtripVias.ts",
"src/components/Settings/AccountTab.tsx",
"src/components/Settings/AirTrailConnectionSection.tsx",
"src/components/Settings/ApiKeysSection.tsx",
"src/components/Settings/IntegrationsTab.tsx",
"src/components/Settings/NotificationsTab.tsx",
"src/components/Settings/PasskeysSection.tsx",
"src/components/Settings/PhotoProvidersSection.tsx",
"src/components/Settings/PluginActivityPanel.tsx",
"src/components/Settings/PluginSettingsTab.tsx",
"src/components/Settings/useOfflineSettings.ts",
"src/components/Studio/useBookPresence.ts",
"src/components/Studio/useBookStore.ts",
"src/components/Todo/useTodoList.ts",
"src/components/Trips/TripFormModal.tsx",
"src/components/Trips/useTripShare.ts",
"src/components/Vacay/VacayCalendar.tsx",
"src/components/Vacay/VacayPersons.tsx",
"src/components/Vacay/VacaySettings.tsx",
"src/components/Vacay/VacaySharedCalendars.tsx",
"src/components/Vacay/holidayRegions.ts",
"src/components/shared/PlaceAvatarUpload.tsx",
"src/hooks/useAirtrailConnection.ts",
"src/hooks/useDawarichConnection.ts",
"src/hooks/useDawarichSuggestions.ts",
"src/hooks/useInAppNotificationListener.ts",
"src/hooks/usePlaceSuggestions.ts",
"src/hooks/useRoadtripPreferencesSync.ts",
"src/hooks/useTripWebSocket.ts",
"src/hooks/useWebPush.ts",
"src/mobile/screens/admin/MAdmin.tsx",
"src/mobile/screens/admin/MAdminAddonManager.tsx",
"src/mobile/screens/admin/MAdminAuditLogPanel.tsx",
"src/mobile/screens/admin/MAdminBackupPanel.tsx",
"src/mobile/screens/admin/MAdminCategoryManager.tsx",
"src/mobile/screens/admin/MAdminDefaultUserSettings.tsx",
"src/mobile/screens/admin/MAdminDevNotificationsPanel.tsx",
"src/mobile/screens/admin/MAdminGitHubPanel.tsx",
"src/mobile/screens/admin/MAdminMcpTokensPanel.tsx",
"src/mobile/screens/admin/MAdminNotificationsSection.tsx",
"src/mobile/screens/admin/MAdminNotifyMatrix.tsx",
"src/mobile/screens/admin/MAdminPackingTemplateManager.tsx",
"src/mobile/screens/admin/MAdminPermissionsPanel.tsx",
"src/mobile/screens/admin/MAdminPluginsPanel.tsx",
"src/mobile/screens/admin/MAdminSettingsSection.tsx",
"src/mobile/screens/admin/MAdminSheets.tsx",
"src/mobile/screens/atlas/MAtlasCountryPopup.tsx",
"src/mobile/screens/collections/MCollAddSheet.tsx",
"src/mobile/screens/collections/MCollEditSheet.tsx",
"src/mobile/screens/collections/MCollPlaceSheet.tsx",
"src/mobile/screens/collections/MCollShareSheet.tsx",
"src/mobile/screens/collections/MCollTripPickerSheet.tsx",
"src/mobile/screens/dashboard/MDashWidgets.tsx",
"src/mobile/screens/dashboard/MNewTripSheet.tsx",
"src/mobile/screens/journey/MJourneyDetail.tsx",
"src/mobile/screens/journey/MJourneyEntrySheet.tsx",
"src/mobile/screens/journey/MJourneySettingsSheet.tsx",
"src/mobile/screens/settings/MAirTrailConnectionSection.tsx",
"src/mobile/screens/settings/MPhotoProvidersSection.tsx",
"src/mobile/screens/settings/MScopeGroupPicker.tsx",
"src/mobile/screens/settings/MSettingsAccount.tsx",
"src/mobile/screens/settings/MSettingsMcp.tsx",
"src/mobile/screens/settings/MSettingsNotifications.tsx",
"src/mobile/screens/settings/MSettingsPlugins.tsx",
"src/mobile/screens/trip/places/MPlacesBrowser.tsx",
"src/mobile/screens/trip/places/MPlacesSaveToCollectionSheet.tsx",
"src/mobile/screens/trip/plan/useMPlanTimeline.ts",
"src/mobile/screens/trip/sheets/ImpFileStep.tsx",
"src/mobile/screens/trip/sheets/ImpListStep.tsx",
"src/mobile/screens/trip/sheets/MAccommodationSheet.tsx",
"src/mobile/screens/trip/sheets/MDaySheet.tsx",
"src/mobile/screens/trip/sheets/MPlaceSheet.tsx",
"src/mobile/screens/trip/sheets/PlPlaceSearch.tsx",
"src/mobile/screens/trip/tabs/MCollabChat.tsx",
"src/mobile/screens/trip/tabs/MCollabNotes.tsx",
"src/mobile/screens/trip/tabs/MCollabPolls.tsx",
"src/mobile/screens/trip/tabs/MCostsTab.tsx",
"src/mobile/screens/trip/tabs/MFileLightbox.tsx",
"src/mobile/screens/trip/tabs/MFileLinkSheet.tsx",
"src/mobile/screens/trip/tabs/MFileMenuSheet.tsx",
"src/mobile/screens/trip/tabs/MFileTrashSheet.tsx",
"src/mobile/screens/trip/tabs/MFilesTab.tsx",
"src/mobile/screens/trip/tabs/MPackingImportSheet.tsx",
"src/mobile/screens/trip/tabs/MPackingListTab.tsx",
"src/mobile/screens/vacay/MVacayInviteSheet.tsx",
"src/mobile/screens/vacay/MVacaySettingsSheet.tsx",
"src/mobile/screens/vacay/MVacayShareSheet.tsx",
"src/mobile/screens/vacay/useMVacay.ts",
"src/pages/AdminPage.tsx",
"src/pages/AtlasPage.tsx",
"src/pages/JourneyDetailPage.tsx",
"src/pages/OAuthAuthorizePage.tsx",
"src/pages/TripPlannerPage.tsx",
"src/pages/admin/AdminNotificationsPanel.tsx",
"src/pages/admin/AdminNotificationsTab.tsx",
"src/pages/admin/AdminSettingsTab.tsx",
"src/pages/admin/AdminUserModals.tsx",
"src/pages/admin/useAdmin.ts",
"src/pages/admin/useNotificationDefaults.ts",
"src/pages/atlas/useAtlas.ts",
"src/pages/collections/useCollections.ts",
"src/pages/dashboard/useDashboard.ts",
"src/pages/dashboard/useTripSearch.ts",
"src/pages/forgotPassword/useForgotPassword.ts",
"src/pages/help/useHelp.ts",
"src/pages/join/useJoinTrip.ts",
"src/pages/journey/useJourney.ts",
"src/pages/journeyDetail/useJourneyDetail.ts",
"src/pages/journeyDetail/useProviderPhotoAdds.ts",
"src/pages/journeyPublic/useJourneyPublic.ts",
"src/pages/journeyStudio/useJourneyStudio.ts",
"src/pages/login/useLogin.ts",
"src/pages/oauthAuthorize/useOAuthAuthorize.ts",
"src/pages/resetPassword/useResetPassword.ts",
"src/pages/settings/useSettings.ts",
"src/pages/sharedTrip/useSharedTrip.ts",
"src/pages/tripPlanner/useBookingEdits.ts",
"src/pages/tripPlanner/usePlaceEdits.ts",
"src/pages/tripPlanner/usePlaceFormOpeners.ts",
"src/pages/tripPlanner/usePlannerDialogs.ts",
"src/pages/tripPlanner/useRoadtripStopEdits.ts",
"src/pages/tripPlanner/useTripPlanner.ts",
"src/pages/vacay/useVacay.ts"
]
+23
View File
@@ -0,0 +1,23 @@
#!/usr/bin/env node
/*
* lint:offline: views go through the offline core, not around it.
*
* The data path is component, feature hook, store, repo, then api or Dexie,
* and writes go through the mutation queue. A view that imports src/api/
* skips the cache, the optimistic write and the idempotent replay, so the
* feature breaks offline. Such files kept growing, by about fifteen a month.
* The view files (components/, mobile/, pages/, hooks/) that already import
* src/api/ for more than types are listed in scripts/offline-baseline.json;
* no other view file may, and the list only shrinks.
*
* npm run lint:offline check against the baseline (CI)
* npm run lint:offline -- --list print every view file that imports src/api/
* npm run lint:offline -- --update take the files that stopped off the list;
* it never adds one
*/
import { fileURLToPath } from 'node:url'
import { check } from './lib/offline.mjs'
import { runCli } from './lib/ratchet.mjs'
const root = fileURLToPath(new URL('..', import.meta.url))
await runCli('lint:offline', (args) => check({ root, update: args.includes('--update'), list: args.includes('--list') }))