From 52d1a0720fff383462e22b43bf851d055d4835dc Mon Sep 17 00:00:00 2001 From: Maurice Date: Tue, 25 Aug 2026 16:03:01 +0200 Subject: [PATCH] fix: say out loud what every sort was already doing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sonar's S2871 flags a bare .sort(), which orders by UTF-16 code unit rather than by value. All 30 remaining sites turned out to sort ASCII strings — ISO dates, currency and country codes, setting keys, route names, OAuth scopes, zip entry names — where that order is already the intended one. Each says so explicitly now, with a comparator that reproduces it exactly rather than a localeCompare that would quietly reorder things. That mattered more than it sounds. Several of these feed something other than a display: useAtlas joins the sorted codes into the signature that decides whether to rebuild the region layer, daysSlice re-pins sorted dates positionally onto reordered days, and three timeline views pick a day's colour by indexOf into the sorted array. Switching any of them to a locale collator would have been a silent regression. Two smaller ones came with it. The .map(placeBtn) calls in FileManagerAssignModal pass the index as a second argument, which placeBtn declares and uses for its React key — correct already, now written out as .map((p, i) => placeBtn(p, i)). And budgetSlice's optimistic paid-toggle stored the raw boolean behind `paid as unknown as number`, while the schema declares z.number() and both the REST response and the WebSocket event carry 0/1. So the optimistic state and the confirmed state disagreed on type until a refresh. It normalises now, and the two tests that asserted `true` assert the contract. Four `| 0` truncations are listed in sonar-project.properties instead of changed. Two are a djb2 string hash where the 32-bit wrap is the algorithm. The other two look like plain truncation of a row id, but `| 0` is also coercing undefined to 0 there — the settlement flows the API returns carry no user_id, so that is the normal path, and Math.trunc returns NaN and crashes the lookup. --- .../Files/FileManagerAssignModal.tsx | 4 +-- .../Journey/JourneyDetailPageMapView.tsx | 2 +- .../components/Journey/MobileMapTimeline.tsx | 2 +- .../components/Planner/ReservationModal.tsx | 2 +- client/src/components/Studio/autoLayout.ts | 2 +- client/src/hooks/useRouteCalculation.ts | 2 +- .../mobile/screens/dashboard/MDashWidgets.tsx | 2 +- .../screens/trip/sheets/MReservationSheet.tsx | 2 +- client/src/pages/DashboardPage.tsx | 2 +- client/src/pages/JourneyDetailPage.tsx | 2 +- client/src/pages/atlas/useAtlas.ts | 2 +- .../pages/journeyDetail/useJourneyDetail.ts | 2 +- .../pages/journeyPublic/useJourneyPublic.ts | 2 +- client/src/store/slices/budgetSlice.test.ts | 2 +- client/src/store/slices/budgetSlice.ts | 8 ++--- client/src/store/slices/daysSlice.ts | 2 +- client/src/utils/formatters.ts | 2 +- client/tests/unit/slices/budgetSlice.test.ts | 2 +- plugin-sdk/src/cli/checks/network.ts | 4 +-- plugin-sdk/src/cli/checks/offline.ts | 4 +-- plugin-sdk/src/cli/pack.ts | 2 +- server/src/nest/atlas/atlas.service.ts | 2 +- server/src/nest/common/managed.ts | 2 +- .../nest/common/validate-body-contracts.ts | 4 +-- server/src/nest/days/days.service.ts | 4 +-- server/src/nest/journey/journey-stats.ts | 6 ++-- .../mcp-transport/mcp-transport.service.ts | 4 +-- sonar-project.properties | 33 ++++++++++++++++++- 28 files changed, 70 insertions(+), 39 deletions(-) diff --git a/client/src/components/Files/FileManagerAssignModal.tsx b/client/src/components/Files/FileManagerAssignModal.tsx index e47dfc299..4263ef351 100644 --- a/client/src/components/Files/FileManagerAssignModal.tsx +++ b/client/src/components/Files/FileManagerAssignModal.tsx @@ -123,13 +123,13 @@ export function AssignModal(S: FileManagerState) { ) : null })()} - {dayPlaces.map(placeBtn)} + {dayPlaces.map((p, i) => placeBtn(p, i))} ))} {unassigned.length > 0 && (
{dayGroups.length > 0 &&
{t('files.unassigned')}
} - {unassigned.map(placeBtn)} + {unassigned.map((p, i) => placeBtn(p, i))}
)} diff --git a/client/src/components/Journey/JourneyDetailPageMapView.tsx b/client/src/components/Journey/JourneyDetailPageMapView.tsx index a6baefb87..c52204747 100644 --- a/client/src/components/Journey/JourneyDetailPageMapView.tsx +++ b/client/src/components/Journey/JourneyDetailPageMapView.tsx @@ -23,7 +23,7 @@ export function MapView({ entries, mapEntries, sortedDates, activeLocationId, fu if (!byDate.has(d)) byDate.set(d, []) byDate.get(d)!.push({ entry: e, globalIdx: i }) }) - const dates = [...byDate.keys()].sort() + const dates = [...byDate.keys()].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)) const mapItems = useMemo(() => mapEntries.map(e => ({ id: String(e.id), diff --git a/client/src/components/Journey/MobileMapTimeline.tsx b/client/src/components/Journey/MobileMapTimeline.tsx index 84326088f..c118c97cf 100644 --- a/client/src/components/Journey/MobileMapTimeline.tsx +++ b/client/src/components/Journey/MobileMapTimeline.tsx @@ -46,7 +46,7 @@ export default function MobileMapTimeline({ const [activeIndex, setActiveIndex] = useState(0) const entryDayMeta = useMemo(() => { - const uniqueDates = [...new Set(entries.map((e: any) => e.entry_date).sort())] + const uniqueDates = [...new Set(entries.map((e: any) => e.entry_date).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)))] const counters = new Map() return entries.map((e: any) => { const dayIdx = uniqueDates.indexOf(e.entry_date) diff --git a/client/src/components/Planner/ReservationModal.tsx b/client/src/components/Planner/ReservationModal.tsx index 47c29bd56..c6cab1753 100644 --- a/client/src/components/Planner/ReservationModal.tsx +++ b/client/src/components/Planner/ReservationModal.tsx @@ -118,7 +118,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p // constrain to trip days via their day dropdowns. Falls back to no limit when // the trip has no dated days. const tripDateRange = useMemo(() => { - const dates = (days || []).map(d => d.date).filter((d): d is string => !!d).sort() + const dates = (days || []).map(d => d.date).filter((d): d is string => !!d).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)) return { min: dates[0], max: dates[dates.length - 1] } }, [days]) diff --git a/client/src/components/Studio/autoLayout.ts b/client/src/components/Studio/autoLayout.ts index 15c45fc84..6c0214d98 100644 --- a/client/src/components/Studio/autoLayout.ts +++ b/client/src/components/Studio/autoLayout.ts @@ -1450,7 +1450,7 @@ function backSpread(input: AutoInput): BookSpread { const dates = input.entries .map(e => e.date) .filter((d): d is string => !!d) - .sort() + .sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)) const span = dates.length ? [formatBookDate(dates[0], input.locale), formatBookDate(dates[dates.length - 1], input.locale)].filter(Boolean).join(' — ') : '' diff --git a/client/src/hooks/useRouteCalculation.ts b/client/src/hooks/useRouteCalculation.ts index 55c32438b..f66df2349 100644 --- a/client/src/hooks/useRouteCalculation.ts +++ b/client/src/hooks/useRouteCalculation.ts @@ -249,7 +249,7 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu const eps = (r.endpoints || []).map(e => `${e.role}@${e.lat ?? ''},${e.lng ?? ''}`).join(';') return `${r.id}:${r.day_id ?? ''}:${r.end_day_id ?? ''}:${r.reservation_time ?? ''}:${pos ?? ''}:${eps}` }) - .sort() + .sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)) .join('|') }, [reservationsForSignature, selectedDayId]) diff --git a/client/src/mobile/screens/dashboard/MDashWidgets.tsx b/client/src/mobile/screens/dashboard/MDashWidgets.tsx index e385b5142..c845472fb 100644 --- a/client/src/mobile/screens/dashboard/MDashWidgets.tsx +++ b/client/src/mobile/screens/dashboard/MDashWidgets.tsx @@ -151,7 +151,7 @@ function MCurrencyWidget(): React.ReactElement { }).catch(() => { /* keep localStorage; retry on next load */ }) }, [isLoaded, updateSetting]) - const currencies = rates ? Object.keys(rates).sort() : [...CURRENCIES] + const currencies = rates ? Object.keys(rates).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)) : [...CURRENCIES] const rate = rates?.[to] ?? null const converted = rate != null ? (Number.parseFloat(amount.replace(',', '.')) || 0) * rate : null const swap = () => { setFrom(to); setTo(from) } diff --git a/client/src/mobile/screens/trip/sheets/MReservationSheet.tsx b/client/src/mobile/screens/trip/sheets/MReservationSheet.tsx index 90a85de11..81b037b88 100644 --- a/client/src/mobile/screens/trip/sheets/MReservationSheet.tsx +++ b/client/src/mobile/screens/trip/sheets/MReservationSheet.tsx @@ -196,7 +196,7 @@ export default function MReservationSheet({ planner, onOpenExpense }: MReservati // Restrict non-hotel booking dates to the trip's span (#1662); hotels already // constrain to trip days via their day dropdowns. - const tripDates = days.map(d => d.date).filter((d): d is string => !!d).sort() + const tripDates = days.map(d => d.date).filter((d): d is string => !!d).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)) const tripMinDate = tripDates[0] const tripMaxDate = tripDates[tripDates.length - 1] diff --git a/client/src/pages/DashboardPage.tsx b/client/src/pages/DashboardPage.tsx index c48f516e6..70b6cccf0 100644 --- a/client/src/pages/DashboardPage.tsx +++ b/client/src/pages/DashboardPage.tsx @@ -633,7 +633,7 @@ function CurrencyTool(): React.ReactElement { }).catch(() => { /* keep localStorage; retry on next load */ }) }, [isLoaded, updateSetting]) - const currencies = rates ? Object.keys(rates).sort() : CURRENCIES + const currencies = rates ? Object.keys(rates).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)) : CURRENCIES const ccyOptions = currencies.map(c => ({ value: c, label: c })) const rate = rates?.[to] ?? null const converted = rate != null ? (Number.parseFloat(amount.replace(',', '.')) || 0) * rate : null diff --git a/client/src/pages/JourneyDetailPage.tsx b/client/src/pages/JourneyDetailPage.tsx index f326b02f0..14a0d1a0c 100644 --- a/client/src/pages/JourneyDetailPage.tsx +++ b/client/src/pages/JourneyDetailPage.tsx @@ -63,7 +63,7 @@ function JourneyDetailPageDesktop() { const timelineEntries = current.entries.filter(e => (!hideSkeletons || e.type !== 'skeleton')) const dayGroups = groupByDate(timelineEntries) - const sortedDates = [...dayGroups.keys()].sort() + const sortedDates = [...dayGroups.keys()].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)) const tripDateMin = current.trips.length ? current.trips.reduce((min: string, t: any) => t.start_date && (!min || t.start_date < min) ? t.start_date : min, '') diff --git a/client/src/pages/atlas/useAtlas.ts b/client/src/pages/atlas/useAtlas.ts index 0e886545a..937b2f1e8 100644 --- a/client/src/pages/atlas/useAtlas.ts +++ b/client/src/pages/atlas/useAtlas.ts @@ -649,7 +649,7 @@ export function useAtlas() { // layer meant a continent's worth of admin-1 polygons stayed in the DOM, and every // newly loaded country tore the whole thing down and built it again (#1950). const bounds = viewportBounds() - const inViewCodes = Object.keys(regionGeoCache.current).filter(code => countryInView(code, bounds)).sort() + const inViewCodes = Object.keys(regionGeoCache.current).filter(code => countryInView(code, bounds)).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)) const sig = inViewCodes.join('|') if (!force && sig === renderedRegionSigRef.current) return diff --git a/client/src/pages/journeyDetail/useJourneyDetail.ts b/client/src/pages/journeyDetail/useJourneyDetail.ts index d8c9edef9..a41d0f6c4 100644 --- a/client/src/pages/journeyDetail/useJourneyDetail.ts +++ b/client/src/pages/journeyDetail/useJourneyDetail.ts @@ -300,7 +300,7 @@ export function useJourneyDetail() { (current?.entries || []) .filter(e => e.title !== 'Gallery' && e.title !== '[Trip Photos]') .map(e => e.entry_date) - .sort() + .sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)) )] const sorted = [...mapEntries].sort((a, b) => a.entry_date.localeCompare(b.entry_date)) const dayCounters = new Map() diff --git a/client/src/pages/journeyPublic/useJourneyPublic.ts b/client/src/pages/journeyPublic/useJourneyPublic.ts index d4ae76fe8..91e7a07a7 100644 --- a/client/src/pages/journeyPublic/useJourneyPublic.ts +++ b/client/src/pages/journeyPublic/useJourneyPublic.ts @@ -55,7 +55,7 @@ export function useJourneyPublic() { const groupedEntries = useMemo(() => groupByDate(timelineEntries), [timelineEntries]) // Chronological throughout: this is what the day colours and the stop numbers are // derived from, so flipping the reading order must not renumber the trip. - const sortedDates = useMemo(() => [...groupedEntries.keys()].sort(), [groupedEntries]) + const sortedDates = useMemo(() => [...groupedEntries.keys()].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)), [groupedEntries]) const mapEntries = useMemo( () => timelineEntries.filter(e => e.location_lat && e.location_lng), [timelineEntries], diff --git a/client/src/store/slices/budgetSlice.test.ts b/client/src/store/slices/budgetSlice.test.ts index c30a6cc7d..a3eade30b 100644 --- a/client/src/store/slices/budgetSlice.test.ts +++ b/client/src/store/slices/budgetSlice.test.ts @@ -144,7 +144,7 @@ describe('budgetSlice', () => { ); await useTripStore.getState().toggleBudgetMemberPaid(1, 8, 3, true); const stored = useTripStore.getState().budgetItems.find(i => i.id === 8); - expect(stored?.members?.[0]?.paid).toBe(true); + expect(stored?.members?.[0]?.paid).toBe(1); }); it('FE-STORE-BUDGET-010: reorderBudgetItems reorders optimistically and reloads on error', async () => { diff --git a/client/src/store/slices/budgetSlice.ts b/client/src/store/slices/budgetSlice.ts index dac827ddb..dd237ac66 100644 --- a/client/src/store/slices/budgetSlice.ts +++ b/client/src/store/slices/budgetSlice.ts @@ -82,10 +82,10 @@ export const createBudgetSlice = (set: SetState, get: GetState): BudgetSlice => set(state => ({ budgetItems: state.budgetItems.map(item => item.id === itemId - // The server persists `paid` as 0/1; the optimistic update stores the - // boolean toggle value (truthy-compatible) — narrow it to the member's - // numeric type without changing the stored runtime value. - ? { ...item, members: (item.members || []).map(m => m.user_id === userId ? { ...m, paid: paid as unknown as number } : m) } + // The server persists `paid` as 0/1 and broadcasts the same 0/1 over + // WebSocket, so the optimistic write normalises the boolean toggle to + // that numeric contract instead of parking a boolean under a cast. + ? { ...item, members: (item.members || []).map(m => m.user_id === userId ? { ...m, paid: paid ? 1 : 0 } : m) } : item ) })); diff --git a/client/src/store/slices/daysSlice.ts b/client/src/store/slices/daysSlice.ts index 8d67dd3ef..48cd5e205 100644 --- a/client/src/store/slices/daysSlice.ts +++ b/client/src/store/slices/daysSlice.ts @@ -20,7 +20,7 @@ export const createDaysSlice = (set: SetState, get: GetState): DaysSlice => ({ reorderDays: async (tripId, orderedIds) => { const prevDays = get().days const byId = new Map(prevDays.map(d => [d.id, d])) - const sortedDates = prevDays.map(d => d.date).filter((d): d is string => !!d).sort() + const sortedDates = prevDays.map(d => d.date).filter((d): d is string => !!d).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)) const optimistic = orderedIds .map((id, i) => { const d = byId.get(id) diff --git a/client/src/utils/formatters.ts b/client/src/utils/formatters.ts index feb045396..018b674f4 100644 --- a/client/src/utils/formatters.ts +++ b/client/src/utils/formatters.ts @@ -285,7 +285,7 @@ export function formatMoneySum( // Breakdown: base first, then the rest in stable code order. const parts = [ ...(groups.has(baseCur) ? [baseCur] : []), - ...foreign.sort(), + ...foreign.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)), ] return parts.map(cur => formatMoney(groups.get(cur)!, cur, locale, opts)).join(' + ') } diff --git a/client/tests/unit/slices/budgetSlice.test.ts b/client/tests/unit/slices/budgetSlice.test.ts index 3b428d1b4..2b1a7d2cb 100644 --- a/client/tests/unit/slices/budgetSlice.test.ts +++ b/client/tests/unit/slices/budgetSlice.test.ts @@ -170,7 +170,7 @@ describe('budgetSlice', () => { const updatedItem = useTripStore.getState().budgetItems.find(i => i.id === 10); const updatedMember = updatedItem?.members.find(m => m.user_id === 5); - expect(updatedMember?.paid).toBe(true); + expect(updatedMember?.paid).toBe(1); }); }); }); diff --git a/plugin-sdk/src/cli/checks/network.ts b/plugin-sdk/src/cli/checks/network.ts index 2de2356df..08e8a2a66 100644 --- a/plugin-sdk/src/cli/checks/network.ts +++ b/plugin-sdk/src/cli/checks/network.ts @@ -141,12 +141,12 @@ const manifestAtCommit: NetworkCheck = { if ((m.operatorEgress === true) !== (v.operatorEgress === true)) { p(`manifest operatorEgress ${m.operatorEgress === true} != entry ${v.operatorEgress === true}`); } - const normAddons = (a: unknown) => (Array.isArray(a) ? [...a].map(String).sort() : []); + const normAddons = (a: unknown) => (Array.isArray(a) ? [...a].map(String).sort((x, y) => (x < y ? -1 : x > y ? 1 : 0)) : []); if (JSON.stringify(normAddons(m.requiredAddons)) !== JSON.stringify(normAddons(v.requiredAddons))) { p('manifest requiredAddons != entry requiredAddons'); } const normDeps = (d: unknown) => - (Array.isArray(d) ? d.map((x) => `${(x as { id?: string })?.id}@${(x as { version?: string })?.version}`).sort() : []); + (Array.isArray(d) ? d.map((x) => `${(x as { id?: string })?.id}@${(x as { version?: string })?.version}`).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)) : []); if (JSON.stringify(normDeps(m.pluginDependencies)) !== JSON.stringify(normDeps(v.pluginDependencies))) { p('manifest pluginDependencies != entry pluginDependencies'); } diff --git a/plugin-sdk/src/cli/checks/offline.ts b/plugin-sdk/src/cli/checks/offline.ts index f3c160402..411fc069d 100644 --- a/plugin-sdk/src/cli/checks/offline.ts +++ b/plugin-sdk/src/cli/checks/offline.ts @@ -530,12 +530,12 @@ const releaseEntryParity: OfflineCheck = { problems.push(`• manifest operatorEgress ${m.operatorEgress === true} != entry ${v.operatorEgress === true}`); } - const normAddons = (a: unknown) => [...arr(a)].sort(); + const normAddons = (a: unknown) => [...arr(a)].sort((x, y) => (x < y ? -1 : x > y ? 1 : 0)); if (JSON.stringify(normAddons(m.requiredAddons)) !== JSON.stringify(normAddons(v.requiredAddons))) { problems.push(`• manifest requiredAddons [${normAddons(m.requiredAddons)}] != entry [${normAddons(v.requiredAddons)}]`); } const normDeps = (d: unknown) => - (Array.isArray(d) ? d.map((x) => `${(x as { id?: string })?.id}@${(x as { version?: string })?.version}`).sort() : []); + (Array.isArray(d) ? d.map((x) => `${(x as { id?: string })?.id}@${(x as { version?: string })?.version}`).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)) : []); if (JSON.stringify(normDeps(m.pluginDependencies)) !== JSON.stringify(normDeps(v.pluginDependencies))) { problems.push(`• manifest pluginDependencies [${normDeps(m.pluginDependencies)}] != entry [${normDeps(v.pluginDependencies)}]`); } diff --git a/plugin-sdk/src/cli/pack.ts b/plugin-sdk/src/cli/pack.ts index 7585ddee2..8dff1f86e 100644 --- a/plugin-sdk/src/cli/pack.ts +++ b/plugin-sdk/src/cli/pack.ts @@ -100,6 +100,6 @@ export function packPluginDir(dir: string, outPath: string): PackResult { artifact: outAbs, sha256: createHash('sha256').update(buf).digest('hex'), size: buf.length, - files: filtered.map((f) => f.name).sort(), + files: filtered.map((f) => f.name).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)), }; } diff --git a/server/src/nest/atlas/atlas.service.ts b/server/src/nest/atlas/atlas.service.ts index 6d5d567d7..c740d6969 100644 --- a/server/src/nest/atlas/atlas.service.ts +++ b/server/src/nest/atlas/atlas.service.ts @@ -254,7 +254,7 @@ export class AtlasService { const dates = countryTrips .map((t) => t.start_date) .filter(Boolean) - .sort(); + .sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)); return { code: c.code, placeCount: c.places.length, diff --git a/server/src/nest/common/managed.ts b/server/src/nest/common/managed.ts index f39ab61a8..fd99c0198 100644 --- a/server/src/nest/common/managed.ts +++ b/server/src/nest/common/managed.ts @@ -166,5 +166,5 @@ export function splitManagedKeys>( if (LOCKED.has(key)) blocked.push(key); else (allowed as Record)[key] = value; } - return { allowed, blocked: blocked.sort() }; + return { allowed, blocked: blocked.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)) }; } diff --git a/server/src/nest/common/validate-body-contracts.ts b/server/src/nest/common/validate-body-contracts.ts index 5e093948f..b03255d9c 100644 --- a/server/src/nest/common/validate-body-contracts.ts +++ b/server/src/nest/common/validate-body-contracts.ts @@ -86,14 +86,14 @@ export function validateBodyContracts( problems.push( `${violations.length} mutation handler(s) read @Body() without a createZodDto class ` + `(add a .dto.ts wrapper over the @trek/shared schema — do NOT extend the allow-list): ` + - violations.sort().join(', '), + violations.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)).join(', '), ); } if (stale.length) { problems.push( `stale body-contract allow-list entr${stale.length === 1 ? 'y' : 'ies'} ` + `(route removed or now validated — delete from body-contract-allow-list.ts): ` + - stale.sort().join(', '), + stale.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)).join(', '), ); } if (problems.length) { diff --git a/server/src/nest/days/days.service.ts b/server/src/nest/days/days.service.ts index 0b0942136..ce00d1b28 100644 --- a/server/src/nest/days/days.service.ts +++ b/server/src/nest/days/days.service.ts @@ -398,7 +398,7 @@ export class DaysService { const oldDateById = new Map(rows.map(r => [r.id, r.date])); // Dates stay pinned to slots: position i keeps the i-th date (ascending). - const sortedDates = rows.map(r => r.date).filter((d): d is string => !!d).sort(); + const sortedDates = rows.map(r => r.date).filter((d): d is string => !!d).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)); const isDated = sortedDates.length > 0; const setDayNumber = this.db.prepare('UPDATE days SET day_number = ? WHERE id = ?'); @@ -452,7 +452,7 @@ export class DaysService { } // Dated trip: rebuild N+1 contiguous dates from the earliest date. - const start = datedRows.map(r => r.date).sort()[0]; + const start = datedRows.map(r => r.date).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))[0]; const dates = Array.from({ length: n + 1 }, (_, i) => addDays(start, i)); const oldDateById = new Map(rows.map(r => [r.id, r.date])); const setDayNumberAndDate = this.db.prepare('UPDATE days SET day_number = ?, date = ? WHERE id = ?'); diff --git a/server/src/nest/journey/journey-stats.ts b/server/src/nest/journey/journey-stats.ts index 1a66feb72..98d5ac9d4 100644 --- a/server/src/nest/journey/journey-stats.ts +++ b/server/src/nest/journey/journey-stats.ts @@ -179,10 +179,10 @@ export interface StatsInput { */ export function computeJourneyStats(input: StatsInput): JourneyStats { const points = thinRoute(input.points); - const dated = points.map(p => isoDay(p.date)).filter((d): d is string => !!d).sort(); + const dated = points.map(p => isoDay(p.date)).filter((d): d is string => !!d).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)); - const tripStarts = input.tripDates.map(t => isoDay(t.start)).filter((d): d is string => !!d).sort(); - const tripEnds = input.tripDates.map(t => isoDay(t.end)).filter((d): d is string => !!d).sort(); + const tripStarts = input.tripDates.map(t => isoDay(t.start)).filter((d): d is string => !!d).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)); + const tripEnds = input.tripDates.map(t => isoDay(t.end)).filter((d): d is string => !!d).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)); const start = dated[0] ?? tripStarts[0] ?? null; const end = dated[dated.length - 1] ?? tripEnds[tripEnds.length - 1] ?? null; diff --git a/server/src/nest/mcp-transport/mcp-transport.service.ts b/server/src/nest/mcp-transport/mcp-transport.service.ts index 8a71d062b..9d521d475 100644 --- a/server/src/nest/mcp-transport/mcp-transport.service.ts +++ b/server/src/nest/mcp-transport/mcp-transport.service.ts @@ -82,8 +82,8 @@ export function jsonRpcError(message: string, code = -32000): object { export function sameScopes(a: string[] | null, b: string[] | null): boolean { if (a === null || b === null) return a === b; if (a.length !== b.length) return false; - const left = [...a].sort(); - const right = [...b].sort(); + const left = [...a].sort((x, y) => (x < y ? -1 : x > y ? 1 : 0)); + const right = [...b].sort((x, y) => (x < y ? -1 : x > y ? 1 : 0)); return left.every((scope, i) => scope === right[i]); } diff --git a/sonar-project.properties b/sonar-project.properties index 226053367..de8af18c0 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -62,7 +62,7 @@ sonar.cpd.exclusions=shared/src/i18n/** # Locale files are UI copy — strings like "Password", "API key", "token" are labels, # not credentials, and the secret detectors false-positive on them constantly. # Ignore the secrets analyzer and the hard-coded-credential rules on i18n only. -sonar.issue.ignore.multicriteria=i18nsec1,i18nsec2,i18nsec3,rand1,rand2,rand3,rand4,rand5,cli1,hash1,hash2,hash3,hash4,ns1,ns2,frame1 +sonar.issue.ignore.multicriteria=i18nsec1,i18nsec2,i18nsec3,rand1,rand2,rand3,rand4,rand5,rand6,cli1,hash1,hash2,hash3,hash4,hash5,hash6,hash7,hash8,ns1,ns2,frame1 sonar.issue.ignore.multicriteria.i18nsec1.ruleKey=secrets:* sonar.issue.ignore.multicriteria.i18nsec1.resourceKey=shared/src/i18n/** sonar.issue.ignore.multicriteria.i18nsec2.ruleKey=typescript:S2068 @@ -145,3 +145,34 @@ sonar.issue.ignore.multicriteria.ns2.resourceKey=server/src/nest/plugins/host/rp # event.source instead. sonar.issue.ignore.multicriteria.frame1.ruleKey=typescript:S2819 sonar.issue.ignore.multicriteria.frame1.resourceKey=client/src/components/Plugins/PluginFrame.tsx +# randomId is the module that MINTS idempotency keys, and its first two rungs are +# crypto.randomUUID and crypto.getRandomValues. The Math.random line is the third, +# reached only by a runtime with no Web Crypto at all, and the value it produces is +# a deduplication key the server scopes per (user, method, path) — not a token, not +# a secret. Replacing it is not possible: there is no stronger primitive left at +# that point, and deleting the rung would leave such a runtime with no key. +sonar.issue.ignore.multicriteria.rand6.ruleKey=typescript:S2245 +sonar.issue.ignore.multicriteria.rand6.resourceKey=client/src/utils/randomId.ts + +# S7767 (bitwise truncation) — Math.trunc is NOT equivalent to `| 0`, and all four +# sites depend on a difference the rule does not model. +# +# packingListPanel.helpers and todoListModel hold the same djb2-variant string +# hash, `h = ((h << 5) - h + chr) | 0`, whose only job is to stay bounded while it +# folds a category name into a colour index. Math.trunc would let h grow past 2^53, +# lose precision, and hand out different colours for the same names. +# +# BudgetPanel.helpers and TravelerPicker look like plain truncation of a user row +# id, but `| 0` is also coercing there: it turns undefined and NaN into 0, while +# Math.trunc returns NaN and the array lookup then yields undefined. That is not +# theoretical — the settlement `flows` the API returns carry a username and an +# avatar but no user_id, so colorForUserId(undefined) is the normal path for that +# section. Swapping it crashed two BudgetPanel tests on `.gradient` of undefined. +sonar.issue.ignore.multicriteria.hash5.ruleKey=typescript:S7767 +sonar.issue.ignore.multicriteria.hash5.resourceKey=client/src/components/Packing/packingListPanel.helpers.ts +sonar.issue.ignore.multicriteria.hash6.ruleKey=typescript:S7767 +sonar.issue.ignore.multicriteria.hash6.resourceKey=client/src/components/Todo/todoListModel.ts +sonar.issue.ignore.multicriteria.hash7.ruleKey=typescript:S7767 +sonar.issue.ignore.multicriteria.hash7.resourceKey=client/src/components/Budget/BudgetPanel.helpers.ts +sonar.issue.ignore.multicriteria.hash8.ruleKey=typescript:S7767 +sonar.issue.ignore.multicriteria.hash8.resourceKey=client/src/components/Planner/TravelerPicker.tsx