From 7a7903a799ef8a47dae20e2680e3d13f14a5f53a Mon Sep 17 00:00:00 2001 From: Maurice Date: Thu, 8 Oct 2026 11:43:38 +0200 Subject: [PATCH] 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. --- client/CLAUDE.md | 3 +- client/package.json | 1 + client/scripts/lib/offline.mjs | 77 +++++++ client/scripts/offline-baseline.json | 215 ++++++++++++++++++ client/scripts/offline-lint.mjs | 23 ++ .../tests/unit/scripts/offline-lint.test.ts | 71 ++++++ 6 files changed, 389 insertions(+), 1 deletion(-) create mode 100644 client/scripts/lib/offline.mjs create mode 100644 client/scripts/offline-baseline.json create mode 100644 client/scripts/offline-lint.mjs create mode 100644 client/tests/unit/scripts/offline-lint.test.ts diff --git a/client/CLAUDE.md b/client/CLAUDE.md index 04159bb08..9849cce40 100644 --- a/client/CLAUDE.md +++ b/client/CLAUDE.md @@ -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 , 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. diff --git a/client/package.json b/client/package.json index e96c829f0..74f5ac217 100644 --- a/client/package.json +++ b/client/package.json @@ -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", diff --git a/client/scripts/lib/offline.mjs b/client/scripts/lib/offline.mjs new file mode 100644 index 000000000..ec24d1660 --- /dev/null +++ b/client/scripts/lib/offline.mjs @@ -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 +} diff --git a/client/scripts/offline-baseline.json b/client/scripts/offline-baseline.json new file mode 100644 index 000000000..91cbfddd0 --- /dev/null +++ b/client/scripts/offline-baseline.json @@ -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" +] diff --git a/client/scripts/offline-lint.mjs b/client/scripts/offline-lint.mjs new file mode 100644 index 000000000..635dda592 --- /dev/null +++ b/client/scripts/offline-lint.mjs @@ -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') })) diff --git a/client/tests/unit/scripts/offline-lint.test.ts b/client/tests/unit/scripts/offline-lint.test.ts new file mode 100644 index 000000000..85f74f0dd --- /dev/null +++ b/client/tests/unit/scripts/offline-lint.test.ts @@ -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, 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); + }); +});