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
+2 -1
View File
@@ -14,6 +14,7 @@ npm run lint:rtl # physical left/right sides and rtl-lint-disable comme
npm run lint:size # no source file or stylesheet past 1000 lines, no test past 2000, counted wrapped at 120 columns; longer ones may only shrink (CI gate; --update lowers the baseline)
npm run lint:format # Prettier: every file outside scripts/format-baseline.json must be formatted, the list only shrinks (CI gate; npx prettier --write <file>, then --update)
npm run lint:layers # imports go downwards: components never import pages/ or mobile/, nothing under the views imports a view; per-file counts may only shrink (CI gate; --list, --update)
npm run lint:offline # no view file (components/mobile/pages/hooks) imports src/api/ beyond scripts/offline-baseline.json, which only shrinks (CI gate; --list, --update)
npm run test # vitest run (tests/** + co-located src/**/*.test.{ts,tsx}); also test:unit / test:integration / test:coverage
npm run e2e # Playwright (CI runs --project=public --project=app; e2e:report opens the last report)
npm run shots # Playwright screenshot project (shots:promote to accept)
@@ -43,7 +44,7 @@ The layering is **component → feature hook → store/slice → `repo/` → `ap
The offline core is flagship work surrounded by a periphery that ignores it. New code extends the core's discipline, not the periphery's shortcuts:
- **One data path, no layer skips.** Never import `api/client` (or raw fetch/axios) from a component or modal — especially never *write* from one. A long tail of existing files bypasses the layering and no lint ratchet holds the line yet; that is debt, not precedent.
- **One data path, no layer skips.** Never import `api/client` (or raw fetch/axios) from a component or modal — especially never *write* from one. `lint:offline` enforces it: a file under `components/`, `mobile/`, `pages/` or `hooks/` may not import anything from `src/api/` except types. The long tail that already does is listed in `scripts/offline-baseline.json`; the list only shrinks (a file that stops leaves it via `--update`) and is debt, not precedent. A domain that is online-only on purpose (admin, auth) still puts its calls in a store or repo, not in the view.
- **Offline-first is the product promise.** New domains get a repo with read-through and offline writes (temp id → optimistic Dexie write → idempotent queue entry). If a domain is deliberately online-only, say so explicitly.
- **Imports go downwards** (`lint:layers`, rules in `scripts/lib/layers.mjs`). A component never imports `pages/` or `mobile/`, a shared hook no view, and stores, repos, sync, api, db and the helpers no view at all. A model or helper both sides need moves down (`utils/`, `hooks/`, `types/`); the counts in `scripts/layers-baseline.json` are debt, not precedent.
- **No god components or god hooks.** The page pattern is a floor, not a ceiling. Decompose hooks by concern, split renders into memoizable sections, and don't pass huge prop bags with inline-arrow callbacks.
+1
View File
@@ -22,6 +22,7 @@
"lint:size": "node scripts/size-lint.mjs",
"lint:format": "node scripts/format-lint.mjs",
"lint:layers": "node scripts/layer-lint.mjs",
"lint:offline": "node scripts/offline-lint.mjs",
"check:gl-split": "node scripts/check-gl-split.mjs",
"theme:lint": "node scripts/theme-lint.mjs",
"theme:lint:strict": "node scripts/theme-lint.mjs --strict",
+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') }))
@@ -0,0 +1,71 @@
import { readFileSync } from 'node:fs';
import { afterEach, describe, expect, it } from 'vitest';
import { bypasses, check } from '../../../scripts/lib/offline.mjs';
import { RatchetError } from '../../../scripts/lib/ratchet.mjs';
import { ratchetTree, type RatchetTree } from '../../helpers/ratchetFixture';
let tree: RatchetTree;
afterEach(() => tree?.remove());
const BASELINE = 'scripts/offline-baseline.json';
const DIRECT = "import { tripsApi } from '../../api/client'\n";
function run(baseline: string[], files: Record<string, string>, update = false) {
tree = ratchetTree({
[BASELINE]: JSON.stringify(baseline),
'src/api/client.ts': 'export const tripsApi = {}\n',
...files,
});
return check({ root: tree.root, baselinePath: tree.path(BASELINE), update, ...tree.out });
}
describe('lint:offline', () => {
it('OFFLINE-001: finds view files that import src/api/ for more than types', () => {
tree = ratchetTree({
'src/components/A/A.tsx': DIRECT,
'src/mobile/M.tsx': "import { connect } from '../api/websocket'\n",
'src/pages/p/usePage.ts': "const load = () => import('../../api/client')\n",
'src/hooks/useTypes.ts': "import type { Trip } from '../api/client'\n",
'src/hooks/useMixed.ts': "import { type Trip, tripsApi } from '../api/client'\n",
'src/store/s.ts': "import { tripsApi } from '../api/client'\n",
'src/repo/r.ts': "import { tripsApi } from '../api/client'\n",
'src/components/A/A.test.tsx': DIRECT,
'src/components/B.tsx': "import { tripRepo } from '../repo/tripRepo'\n",
});
expect(bypasses(tree.root)).toEqual({
'src/components/A/A.tsx': ["1: '../../api/client'"],
'src/hooks/useMixed.ts': ["1: '../api/client'"],
'src/mobile/M.tsx': ["1: '../api/websocket'"],
'src/pages/p/usePage.ts': ["1: '../../api/client'"],
});
});
it('OFFLINE-002: passes the listed files and fails a new one', () => {
expect(run(['src/components/A/A.tsx'], { 'src/components/A/A.tsx': DIRECT })).toBe(0);
tree.remove();
expect(
run(['src/components/A/A.tsx'], { 'src/components/A/A.tsx': DIRECT, 'src/components/B/B.tsx': DIRECT })
).toBe(1);
expect(tree.error.join('\n')).toMatch(/src\/components\/B\/B\.tsx imports src\/api\/ directly/);
expect(tree.error.join('\n')).not.toMatch(/A\.tsx imports/);
});
it('OFFLINE-003: fails a listed file that stopped, until --update takes it off', () => {
const files = { 'src/components/A/A.tsx': "import { tripRepo } from '../../repo/tripRepo'\n" };
expect(run(['src/components/A/A.tsx'], files)).toBe(1);
expect(tree.error.join('\n')).toMatch(/A\.tsx is listed .* no longer imports src\/api\//);
tree.remove();
expect(run(['src/components/A/A.tsx'], files, true)).toBe(0);
expect(JSON.parse(readFileSync(tree.path(BASELINE), 'utf8'))).toEqual([]);
});
it('OFFLINE-004: --update never puts a file on the list', () => {
expect(run([], { 'src/hooks/useX.ts': "import { tripsApi } from '../api/client'\n" }, true)).toBe(1);
expect(JSON.parse(readFileSync(tree.path(BASELINE), 'utf8'))).toEqual([]);
});
it('OFFLINE-005: a broken baseline stops the check', () => {
tree = ratchetTree({ [BASELINE]: '{"src/a.ts": 1}', 'src/a.ts': '' });
expect(() => check({ root: tree.root, baselinePath: tree.path(BASELINE), ...tree.out })).toThrow(RatchetError);
});
});