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