Skip to content
14 changes: 3 additions & 11 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,17 +128,9 @@ decision, not a patch. Starting points: the dates.ts restructure in this PR;
`scripts/mutation/execution.ts` for the test stage the decision would live in.

The follow-up sweep bounded the freeze-capable loops across the rest of `src/`
(`range`/`entries`/`chunk`/`reduce` shapes; see the `#fp` `range` helper). Two
files were left out because they have **no mirror direct test suite at all**, so
the mutation gate cannot even start on them — each needs its suite built first,
then the same bounded-loop treatment:

- `src/features/admin/listings-form.ts` — `parseDayPricesFromForm`'s
`for (let n = 1; n <= maxDays; n++)` spins forever under `n++ → n--` (blank
day-price reads skip without ever ending the loop).
- `src/shared/seeds.ts` — the unique-slug count loop and the chunked attendee
loop both spin or grow without bound under a step-neutralising mutant
(`i++ → i--`, `offset += CHUNK_SIZE → /=`).
(`range`/`entries`/`chunk`/`reduce` shapes; see the `#fp` `range` helper),
including the two files that first needed mirror direct suites built
(`src/features/admin/listings-form.ts`, `src/shared/seeds.ts`).

---

Expand Down
10 changes: 10 additions & 0 deletions scripts/mutation/equivalent-mutants/features.txt
Original file line number Diff line number Diff line change
Expand Up @@ -171,3 +171,13 @@ src/features/public/order.ts::loadOrderPools~15mruqs 1 → 0 # every listing'
src/features/public/order.ts::bookingUrlFor.chosen.kind~17n3p4g → "mutated" # a selection key is always built as kind:id by listingOptionKey/packageOptionKey, so the kind is never missing
src/features/public/order.ts::bookingUrlFor.chosen.rawId~1xqr8m1 → "mutated" # the same key always carries its id, so the id half is never missing
src/features/public/order.ts::bookingUrlFor.chosen.prefill~1vgmybj 1 → 0 # buildTicketListing sets maxPurchasable to 0 for a sold-out or closed listing and to at least 1 otherwise (max_quantity is at least 1), so the two thresholds never disagree once the checks beside them have passed

# Listing form (listings-form.ts) — a redundant day-price row the parser drops,
# a default the field's own bounds make unreachable, error text no surface can
# show, and a fallback whose left side is never falsy-but-present.
src/features/admin/listings-form.ts::parseDayPricesFromForm~11szepb 1 → 0 # reading day_price_0 as well changes nothing: parseDayPrices keeps only whole day counts of 1 or more
src/features/admin/listings-form.ts::extractCommonFields.durationDays~1nsjfgg ?? → || # duration_days is a number field with min 1 whose validate rejects anything below 1, so the value here is a positive number or absent
src/features/admin/listings-form.ts::extractCommonFields.unitPrice~0fxriin ?? → || # the only falsy non-null price is 0, and unit_price is a withDefault(() => 0) column, so the undefined the mutant produces is stored as that same 0
src/features/admin/listings-form.ts::extractCommonFields.closesAt~1ox85dk closes_at → "" # the label only appears in normalizeDatetime's throw, and the field's validateDatetime already refused every value localToUtc would reject
src/features/admin/listings-form.ts::extractCommonFields.date~1ogjyd0 date → "" # same: the date field is validated by the same parser before toInput runs, so the throw naming it is unreachable
src/features/admin/listings-form.ts::listingValidate~19dmftv ?? → || # validateDayPricesFromForm returns null or one fixed non-empty message, so it is never falsy-but-present
8 changes: 3 additions & 5 deletions scripts/mutation/equivalent-mutants/shared-a-l.txt
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ src/shared/qr-token.ts::buildQrBookPayload.d~1ywzxp9 ?? → || # input.date?:
src/shared/qr-token.ts::buildQrBookPayload.n~1tn44vm ?? → || # input.name?: string, only falsy string "" === fallback ""
src/shared/app-forms.ts::createAuthedHandler~1q900po ?? → || # config.auth is an AuthPolicy object when present, so it is always truthy
src/shared/site-assignment.ts::renewalDeadlineBaseMs~00cywnq ?? → || # parseReadOnlyFromMs(): number|null, 0 ?? 0 === 0 || 0
src/shared/site-assignment.ts::assignSitesForEntries.site~101ljxt ?? → || # available[idx]: BuiltSite|undefined, and a BuiltSite object is always truthy
src/shared/site-assignment.ts::assignSitesForEntries.site~0c03jq6 ?? → || # available.pop(): BuiltSite|undefined, and a BuiltSite object is always truthy
src/shared/site-assignment.ts::sendSiteAssignmentEmail.config~11vz7t9 ?? → || # getEmailConfig(): EmailConfig|null
src/shared/site-assignment.ts::sendSiteAssignmentEmail.replyTo~195nl86 ?? → || # parseEmail(): ValidEmail|null, always truthy or null
src/shared/ledger/project.ts::allBalances.add~0gr4ng6 ?? → || # allBalances: Map<string,number>.get; the only falsy-non-null number is 0, and 0 ?? 0 === 0 || 0
Expand Down Expand Up @@ -178,12 +178,10 @@ src/shared/band-name-generator.ts::fixArticles~1rw9fca A $1 → "A $1 mutated"
# parsePositiveIntId's strict decimal-digit schema.
src/shared/logistics-filter.ts::parseAgentFilter.n~03f157n → "mutated" # parsePositiveIntId's schema rejects any non-digit string identically; the "" fallback and any mutated string both parse to null

# crypto/der.ts — these values are written into Uint8Array elements. Adding or
# crypto/der.ts — this value is written into a Uint8Array element. Adding or
# subtracting 128 produces the same low eight bits, and Uint8Array discards all
# higher bits. The base-128 continuation byte is therefore identical either way.
# higher bits.
src/shared/crypto/der.ts::encodeLength~0ikcwcz 128 → -128 # DER long-length marker: 128+n and -128+n have identical low eight bits
src/shared/crypto/der.ts::encodeBase128~1em6brp + → - # base-128 continuation: low7+128 and low7-128 have identical low eight bits
src/shared/crypto/der.ts::encodeBase128~1em6brp 128 → -128 # base-128 continuation marker is unchanged after Uint8Array conversion

# dates.ts — provably-equivalent survivors from the whole-file run over the
# dates suites. Each is unobservable through every export that reaches the line.
Expand Down
4 changes: 4 additions & 0 deletions scripts/mutation/equivalent-mutants/shared-m-z.txt
Original file line number Diff line number Diff line change
Expand Up @@ -261,3 +261,7 @@ src/shared/schema-atlas/types.ts::atlasState.facts~0j1zyry ?? → || # extra.f
# data), so no caller, page, or log can ever see this string — no input
# distinguishes it from "".
src/shared/payment/row-state.ts::SortedAttendeeIdsSchema~1d17kt9 Refund claim attendee ids must be sorted and unique → "" # issue text is discarded by the stored-JSON wrapper; unobservable through any surface

# seeds.ts — two thresholds no seeded value can fall between.
src/shared/seeds.ts::prepareAttendee.paymentId~01ncynu 0 → 1 # a seeded listing's unit price is 0 or one of the demo prices (500 and up), so no listing sits between the two thresholds
src/shared/seeds.ts::createSeeds~0ghuowu 0 → 1 # the customisable listing always yields a delete plus one multi-row insert, so the list holds 0 or 2 statements, never 1
5 changes: 3 additions & 2 deletions src/features/admin/listings-form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
*/

/* jscpd:ignore-start */
import { range } from "#fp";
import { projectCatalogFields } from "#shared/catalog-fields/definition.ts";
import {
type ListingInput,
Expand Down Expand Up @@ -94,7 +95,7 @@ export const parseGroupIds = (form: FormParams): number[] =>
form
.getAll("group_ids")
.map(Number)
.filter((n) => n > 0);
.filter((n) => Number.isSafeInteger(n) && n > 0);

/**
* Read the per-day-count price inputs (`day_price_1`, `day_price_2`, …) from
Expand All @@ -107,7 +108,7 @@ const parseDayPricesFromForm = (
maxDays: number,
): DayPrices => {
const result: DayPrices = {};
for (let n = 1; n <= maxDays; n++) {
for (const n of range(1, maxDays + 1)) {
// Optional per-day price: blank ⇒ skip (that day isn't offered). A non-blank
// value that fails to parse is caught by validateDayPricesFromForm before
// the save, so here a null result is only ever a blank.
Expand Down
8 changes: 3 additions & 5 deletions src/shared/bunny-cdn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
* The pull zone is discovered via the Edge Script API, not request hostname.
*/

import { range } from "#fp";
import {
getBunnyApiKey,
getBunnyDnsSubdomainSuffix,
Expand Down Expand Up @@ -373,11 +374,8 @@ const registerBunnySubdomainImpl = async (
// 3. Register hostname with pull zone (add hostname + SSL)
// Retry to allow DNS propagation after CNAME record creation.
let cdnResult = await bunnyCdnApi.validateCustomDomain(fullDomain);
for (
let attempt = 0;
attempt < CERT_RETRY_COUNT && !cdnResult.ok;
attempt++
) {
for (const attempt of range(0, CERT_RETRY_COUNT)) {
if (cdnResult.ok) break;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
await bunnyCdnApi.delay(certRetryDelay(attempt));
cdnResult = await bunnyCdnApi.validateCustomDomain(fullDomain);
}
Expand Down
15 changes: 4 additions & 11 deletions src/shared/crypto/der.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,17 +23,10 @@ export const bytesEqual = (left: Uint8Array, right: Uint8Array): boolean =>
left.length === right.length &&
left.every((byte, index) => byte === right[index]);

const unsignedBytes = (value: number): number[] => {
const bytes: number[] = [];
for (
let remaining = value;
remaining > 0;
remaining = Math.floor(remaining / 256)
) {
bytes.unshift(remaining & 0xff);
}
return bytes;
};
/** Big-endian bytes of a non-negative whole number; zero is no bytes. Each
* step shrinks the value 256-fold, so the recursion always bottoms out. */
const unsignedBytes = (value: number): number[] =>
value <= 0 ? [] : [...unsignedBytes(Math.floor(value / 256)), value & 0xff];

const encodeLength = (length: number): Uint8Array => {
// Values below 128 use one byte. Otherwise bit 7 marks how many
Expand Down
27 changes: 14 additions & 13 deletions src/shared/seeds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
* Uses batch writes for efficient database operations.
*/

import { map, sum } from "#fp";
import { chunk, map, range, sum } from "#fp";
import { encrypt } from "#shared/crypto/encryption.ts";
import { hmacHash } from "#shared/crypto/hashing.ts";
import { generateTicketToken } from "#shared/crypto/utils.ts";
Expand Down Expand Up @@ -40,16 +40,16 @@ const randomQuantity = (): number => 1 + Math.floor(Math.random() * 4);
/** Sample unit prices in minor units (e.g. pence/cents) for paid listings */
const DEMO_UNIT_PRICES = [500, 1000, 1500, 2000, 2500, 3000, 5000];

/** Generate slugs that are unique within the batch */
/** Generate slugs that are unique within the batch. The slugs made so far are
* the only "already taken" list — nothing is in the database yet. */
const generateUniqueSlugs = async (count: number): Promise<SlugWithIndex[]> => {
const usedSlugs = new Set<string>();
const results: SlugWithIndex[] = [];
for (let i = 0; i < count; i++) {
const result = await generateUniqueSlug(hmacHash, (slug) =>
Promise.resolve(usedSlugs.has(slug)),
for (const _slot of range(0, count)) {
results.push(
await generateUniqueSlug(hmacHash, (slug) =>
Promise.resolve(results.some((made) => made.slug === slug)),
),
);
usedSlugs.add(result.slug);
results.push(result);
}
return results;
};
Expand Down Expand Up @@ -187,7 +187,10 @@ export const createSeeds = async (
index: i,
quantities,
slug: slugs[i]!,
unitPrice: i % 2 === 0 ? randomChoice(DEMO_UNIT_PRICES) : 0,
// Paid listings walk the sample prices in order, so a big enough demo
// set shows every tier and a test can name each one.
unitPrice:
i % 2 === 0 ? DEMO_UNIT_PRICES[(i / 2) % DEMO_UNIT_PRICES.length]! : 0,
};
});

Expand Down Expand Up @@ -232,17 +235,15 @@ export const createSeeds = async (
for (const [e, listingId] of listingIds.entries()) {
const { quantities, unitPrice } = listingData[e]!;

for (let offset = 0; offset < attendeesPerListing; offset += CHUNK_SIZE) {
const batchSize = Math.min(CHUNK_SIZE, attendeesPerListing - offset);
const chunkQuantities = quantities.slice(offset, offset + batchSize);
for (const chunkQuantities of chunk(CHUNK_SIZE)(quantities)) {
const statementPairs = await Promise.all(
map((q: number) => prepareAttendee(listingId, q, unitPrice))(
chunkQuantities,
),
);
// Each booking locates its attendee by the caller-supplied stable token.
await executeBatch(statementPairs.flat());
totalAttendees += batchSize;
totalAttendees += chunkQuantities.length;
}
}

Expand Down
Loading