Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
56da980
Stop the runaway pending-work test from outliving its own assertion
claude Aug 18, 2026
652d230
Bound the superuser password draw so starved randomness fails loudly
claude Aug 18, 2026
4be75e5
Close the crypto and redirect survivors
claude Aug 18, 2026
6914c9d
Pin what the attendees browser offers and what it ignores
claude Aug 18, 2026
2de7390
Pin the flush round cap and the single-listing price sync
claude Aug 18, 2026
8532039
Pin the route tie-break against declaration order
claude Aug 18, 2026
8cf4045
Close the superuser survivors, and fix an off-by-one in the draw cap
claude Aug 18, 2026
7ee684c
Move the missing-member test to the file it belongs to
claude Aug 18, 2026
e5c1011
Name every word a page may not take
claude Aug 18, 2026
fbf7de5
Cover the listing-attendee reads nobody was checking
claude Aug 18, 2026
c15cafb
Refuse a malformed round count instead of throwing, and tighten two t…
claude Aug 18, 2026
570e08c
Settle the flush boundary test on ports instead of timers
claude Aug 18, 2026
16568a5
Invalidate on what a write assigns, not on which word it starts with
claude Aug 18, 2026
3c2761c
Check an imported listing's parent links in one place, not two
claude Aug 18, 2026
adc2419
Say the new words in the edge guard's own test too
claude Aug 18, 2026
52b6d50
Pin what happens when Logistics is turned off under pressure
claude Aug 18, 2026
f66171b
Read the import refusals from the message catalog in the wider tests too
claude Aug 18, 2026
94c354f
Write down the parent-link survivors this branch surfaced but does no…
claude Aug 18, 2026
45f4ee6
Keep one copy of the two import refusals, not two
claude Aug 18, 2026
22e8c2e
Merge remote-tracking branch 'origin/main' into followup/sweep-survivors
claude Aug 18, 2026
53dd996
Write the new comments and notes in Simplified Technical English
claude Aug 18, 2026
ec38167
Test the counting edges of the pricing engine
claude Aug 18, 2026
0309729
Record the early drop of an untriggered modifier as unkillable
claude Aug 18, 2026
9864661
Name the three shapes the parent-link survivors take
claude Aug 18, 2026
ce66899
Merge branch 'main' into followup/sweep-survivors
stefan-burke Aug 18, 2026
1f801e4
Merge remote-tracking branch 'origin/main' into followup/sweep-survivors
claude Aug 19, 2026
56848c0
Ask again when a released port is taken before the test can use it
claude Aug 19, 2026
9e592cf
Merge remote-tracking branch 'origin/main' into followup/sweep-survivors
claude Aug 20, 2026
31c25a3
Merge remote-tracking branch 'origin/main' into followup/sweep-survivors
claude Aug 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -2558,6 +2558,55 @@ on the last column via the `alsoAbout` pattern in

---

## Close the 14 mutation survivors in `src/shared/db/listing-parents.ts` (from PR #2110)

PR #2110 mutated `src/shared/db/listing-edge-write.ts` to a 100% score. The same
run also covered `src/shared/db/listing-parents.ts`, because the assertion that
the PR changed lives in that file's mirror tests. The run found 14 survivors in
`listing-parents.ts`. The PR does not change that file, so the survivors sit
outside its own gate. The branch-level `precommit:mutation` step covers only the
sources that a branch changes.

The survivors fall into three shapes:

- Eleven are "did this list come back empty?" branches. No test tells the empty
arm from the full one.
- One is the sort comparator inside `listingsForLinks`, where a divide replaces
the subtraction.
- Two are fallbacks in `edgeIncompatibilityAfterChange`, where `||` replaces
`??`. Check first whether either left side can hold a falsy-but-present value.
If it cannot, the entry belongs in `equivalent-mutants/` with that proof
rather than in a test.

```
listingIdsWithLinks~1dqzuig ?: → arms swapped
listingIdsWithLinks~0zl9wvu > → <=, 0 → 1
getNonStandaloneChildIds~1vmop13 ?: → arms swapped
getNonStandaloneChildIds~00bh4s4 0 → 1
anyNonStandaloneChild~0v88xt2 > → <=, 0 → 1
listingsForLinks~1gjwt45 - → /
listingsForLinks~14c1k8g ?: → arms swapped
listingsForLinks~1v5jl2k > → <=, 0 → 1
edgeIncompatibilityAfterChange.children~0cip5re ?? → ||
edgeIncompatibilityAfterChange.parents~1cm1r1e ?? → ||
edgeIncompatibilityAfterChange~02ardat ?: → arms swapped
```

Starting point: `listingIdsWithLinks` is exported and pure. A table of maps — no
links, some links, all links — kills its three survivors on its own.
`getNonStandaloneChildIds` and `anyNonStandaloneChild` need a listing that is a
child, a listing that is `bookable_alone`, and the empty-input short circuit.
`listingsForLinks` is private, so reach it through the readers that hydrate the
links. Note that its `-` → `/` survivor sits in a sort comparator. That one
needs two keys whose order a divide changes. Reproduce with:

```bash
deno task mutation --source src/shared/db/listing-parents.ts \
--test 'test/shared/db/listing-parents/*.test.ts' --harness
```

---

## Square treats a malformed payment link as "provider not configured"

_Origin: the 2026-08 refactor survey (ADMIN_SURFACE_PLAN.md)._
Expand Down
6 changes: 6 additions & 0 deletions scripts/mutation/equivalent-mutants/features.txt
Original file line number Diff line number Diff line change
Expand Up @@ -196,3 +196,9 @@ src/features/admin/users.ts::toDisplayUser.inviteExpired~1fwmcbe false → true
src/features/admin/entity-write-tab.ts::defineEditEntityPage.extraTabs~1333wen ?? → || # configuredExtraTabs is an array of tab definitions or undefined, and an array is always truthy, so || keeps exactly what ?? keeps
src/features/admin/attendee-page.ts::loadEditPanel.data.returnUrl~0i4v0pb ?? → || # URLSearchParams.get gives back a string or null, and the only falsy string is the empty fallback itself
src/features/admin/route-tables.ts::idParamOf.name~0rg3o3r → "mutated" # the stand-in name for a path with no "/:" is only ever fed to the guard below, which throws for every name that is not "id" and does not end with "Id", and the message it throws names the pattern rather than the name

# Route ordering (router.ts) — two array fallbacks, and a replacement string
# whose length cancels out of the only comparison that reads it.
src/features/router.ts::routeSpecificity.paramCount~1806jrr ?? → || # String.match returns an array or null, and an array is always truthy
src/features/router.ts::compileRoutes.methodRoutes~02urywu ?? → || # Map.get returns an array or undefined, and an array is always truthy
src/features/router.ts::routeSpecificity.literalLength~0au8fma → "mutated" # literal length is only ever compared between two routes with the SAME parameter count, so replacing each parameter with a fixed word adds the same amount to both sides and the comparison is unchanged
29 changes: 29 additions & 0 deletions scripts/mutation/equivalent-mutants/shared-a-l.txt
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ src/shared/site-assignment.ts::renewalDeadlineBaseMs~00cywnq ?? → || # pars
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/db/listing-prices.ts::sourceRowStatements~0fyy4cr ?? → || # unit_price is number|null, so the only falsy-non-null value is 0 and 0 ?? 0 === 0 || 0
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
src/shared/ledger/project.ts::balanceOf~128hnl9 ?? → || # balanceOf: same Map<string,number>.get fallback; 0 ?? 0 === 0 || 0
src/shared/ledger/reconcile.ts::IDENTITY_FIELDS~0rfpt43 ?? → || # IDENTITY_FIELDS kind: string|undefined, only falsy-non-null is "" and "" ?? "" === "" || ""
Expand Down Expand Up @@ -181,6 +182,14 @@ 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/utils.ts — the out-of-range code readers. constantTimeCodesEqual seeds
# its XOR fold with lengthA ^ lengthB and walks the longer sequence, so a reader
# is only asked past the end of one string when the lengths differ — and that
# seed already forces "not equal". Whatever the short side reads there cannot
# change the answer.
src/shared/crypto/utils.ts::constantTimeEqual~1sr6g7q 0 → 1 # only read past the end of the shorter string, where the length XOR has already decided the result
src/shared/crypto/utils.ts::constantTimeEqual~0nflsgj 0 → 1 # same reader for the other side, reached only when the lengths already differ

# 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.
Expand Down Expand Up @@ -220,3 +229,23 @@ src/shared/listings-actions.ts::listingInputToEdge.listing_type~1lqe3go ?? →
src/shared/listings-actions.ts::listingInputToEdge.months_per_unit~0ruet3x ?? → || # input.monthsPerUnit is a validated whole number or absent; its only falsy value 0 equals the fallback
src/shared/listings-actions.ts::validateListingEdges.orphanError~1juqee2 ?? → || # input.groupIds is an array when present, and arrays are always truthy
src/shared/admin-surface.ts::adminRecordPath~11evcma ?? → || # `pattern.match(/:\w+/g) ?? []`: a global match returns null when nothing matches and a non-empty array otherwise, so the fallback is reached on exactly the same input either way

# db/listings/attendees.ts — fallbacks whose left side can never be a
# falsy-but-present value that differs from the fallback itself.
src/shared/db/listings/attendees.ts::listingAttendeeFilter.activeOnly~1bv0ikc ?? → || # activeOnly is boolean|undefined and the fallback is false, so false ?? false === false || false
src/shared/db/listings/attendees.ts::listingAttendeeFilter.kindScope~0taxexu ?? → || # kindScope is one of two non-empty words or undefined, so it is never falsy-but-present
src/shared/db/listings/attendees.ts::getListingWithAttendeeRaw.attendeeRaw~0x3k1f1 ?? → || # the row is an Attendee object or undefined, and an object is always truthy

# db/client.ts — fallbacks whose left side can never be a falsy-but-present
# value, and a label no code ever reads.
src/shared/db/client.ts::extractUpdateColumns.addAssignment~17cdcum 0 → 1 # an "=" at the very start leaves an empty column name, which the `if (col)` guard drops, so returning early adds the same nothing
src/shared/db/client.ts::writeSqlOf~1begzdu ?? → || # the captured tail has to start with INSERT, UPDATE, DELETE, REPLACE or SELECT, so the group is never the empty string
src/shared/db/client.ts::GUARDED_CLIENT~1cajdnv guarded-db-client → "" # a symbol's description only names it in a debugger; the guard compares symbol identity, which the description cannot change
src/shared/db/client.ts::executeRead.args~0hs5u5i ?? → || # args is an array or a named-args object when present, and both are always truthy
src/shared/db/client.ts::queryOne~1hu7rck ?? → || # a result row is always an object, so the only falsy first element is the missing one the fallback is there for

# crypto/hashing.ts — the explicit radix on the stored round count. The line
# above it now refuses any count that is not pure decimal digits, and for such a
# string an inferred radix is decimal too, so naming the radix can no longer
# change what is read. It stays because the reader should say what base it means.
src/shared/crypto/hashing.ts::verifyPassword.iterations~10pid9h 10 → 0 # the digit-only guard above rules out every string an inferred radix would read differently
27 changes: 27 additions & 0 deletions scripts/mutation/equivalent-mutants/shared-m-z.txt
Original file line number Diff line number Diff line change
Expand Up @@ -265,3 +265,30 @@ src/shared/payment/row-state.ts::SortedAttendeeIdsSchema~1d17kt9 Refund claim a
# 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

# superuser.ts — the cache generation's starting number. Only a CHANGE in the
# generation matters: a lookup snapshots it before its await and compares that
# snapshot with the current value, so every comparison is between two readings
# of the same counter. Starting the count anywhere gives the same answers.
src/shared/superuser.ts::%3cfile%3e~0ljtci5 0 → 1 # the generation is only ever compared against itself across an await, never against a fixed number

# db/modifier-resolve.ts — fallbacks whose left side can never be a
# falsy-but-present value. Each map holds whole counts, arrays, or objects,
# and each option is an object or absent.
src/shared/db/modifier-resolve.ts::stockedQuantity.remaining~06sda3h ?? → || # the used-stock map holds whole counts, so its only falsy value is the 0 the fallback supplies
src/shared/db/modifier-resolve.ts::triggerQuantity~04te607 ?? → || # the add-on map holds requested whole quantities, so its only falsy value is the 0 the fallback supplies
src/shared/db/modifier-resolve.ts::triggerQuantity~18fvmyz ?? → || # the answer-quantity map holds whole counts, so its only falsy value is the 0 the fallback supplies
src/shared/db/modifier-resolve.ts::answerModifierQuantities.entries~15ao8g3 ?? → || # the argument is a record of answer ids or absent, and every record is truthy, the empty one included
src/shared/db/modifier-resolve.ts::answerModifierQuantities.modifierIds~1y1b3am ?? → || # the map holds arrays of modifier ids, and every array is truthy, the empty one included
src/shared/db/modifier-resolve.ts::answerModifierQuantities~1ju3gsl ?? → || # the running total is a whole count, so its only falsy value is the 0 the fallback supplies
src/shared/db/modifier-resolve.ts::eligibleCandidates.addOns~0b50snz ?? → || # the option is a Map or absent, and every Map is truthy
src/shared/db/modifier-resolve.ts::eligibleCandidates.answerQuantities~1uu272f ?? → || # the option is a Map or absent, and every Map is truthy
src/shared/db/modifier-resolve.ts::eligibleCandidates.ctx~0kgxcim ?? → || # the option is a pricing-context object or absent, and every object is truthy
src/shared/db/modifier-resolve.ts::oversubscribedAnswerTiers~0f5w5hu ?? → || # the used-stock map holds whole counts, so its only falsy value is the 0 the fallback supplies
src/shared/db/modifier-resolve.ts::childOnlyAddOnNameWithScopes~0vgtyif ?? → || # a modifier name is a required non-empty field, so the only falsy result is the missing one the fallback is for

# db/modifier-resolve.ts — the early drop of an untriggered modifier. It saves
# real work on every checkout, because an unmatched promo code yields 0 for
# every code modifier. It changes no answer, because both readers drop a 0
# again, and no negative quantity ever reaches it.
src/shared/db/modifier-resolve.ts::eligibleCandidates~1oaufcv 1 → 0 # resolveModifiers keeps only a stocked quantity of 1 or more, and an oversubscribed tier needs a quantity above the stock left, which 0 never is
119 changes: 2 additions & 117 deletions src/features/admin/catalog-transfer/import-listing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,12 @@ import {
packageGroupIdsTx,
validateListingGroupMembershipsTx,
} from "#db/groups/membership.ts";
import { getGroupsById, groups, listingGroups } from "#db/groups.ts";
import {
addParentEdgesWithPackageCheckTx,
listingParents,
} from "#db/listing-parents.ts";
import { addParentEdgesWithPackageCheckTx } from "#db/listing-parents.ts";
import {
syncListingPrices,
writeListingDayCounts,
} from "#db/listing-prices.ts";
import { getListingsById, listingsTable } from "#db/listings/records.ts";
import {
childOnlyAddOnCheckerForListings,
type ListingGroupMembership,
toListingGroupMembership,
} from "#db/modifier-resolve.ts";
import { listingsTable } from "#db/listings/records.ts";
import {
isNameTakenAnywhere,
loadCatalogNameIndex,
Expand All @@ -33,26 +24,17 @@ import { TransactionValidationError } from "#db/transaction.ts";
import { t } from "#i18n";
import type { ListingInput } from "#shared/catalog-fields/fields.ts";
import { isBuilderEnabled } from "#shared/config.ts";
import {
childAddOnError,
type EdgeListing,
edgeFieldError,
} from "#shared/listing-parents-rules.ts";
import {
dayPriceFieldsFromInput,
generateUniqueListingSlug,
listingInputToEdge,
validateListingInput,
} from "#shared/listings-actions.ts";
import { errorResult, okResult, type Result } from "#shared/result.ts";
import { seenBefore } from "#shared/seen-before.ts";
import {
type AdminLevel,
availableDayCounts,
clampDurationDays,
type DayPricedListing,
type Group,
type Listing,
parseDayPrices,
} from "#types";
import { type ImportedMembership, writeMembershipsTx } from "./membership.ts";
Expand Down Expand Up @@ -206,97 +188,6 @@ const listingDataToInput = (
} as ListingInput;
};

const listingToEdge = (listing: Listing): EdgeListing => ({
customisable_days: listing.customisable_days,
day_prices: listing.day_prices,
duration_days: clampDurationDays(listing.duration_days),
id: listing.id,
listing_type: listing.listing_type,
months_per_unit: listing.months_per_unit,
name: listing.name,
});

const firstPackageGroup = async (
groupIds: readonly number[],
): Promise<Group | null> => {
if (groupIds.length === 0) return null;
const byId = await getGroupsById();
return (
groupIds.map((id) => byId.get(id)).find((group) => group?.is_package) ||
null
);
};

const loadChildAddOnChecker = async (
input: ListingInput,
groupIds: readonly number[],
byId: Awaited<ReturnType<typeof getListingsById>>,
): Promise<Awaited<
ReturnType<typeof childOnlyAddOnCheckerForListings>
> | null> => {
if (groupIds.length === 0 || input.bookableAlone) return null;
const allMembership = await listingGroups.getIdsByKeys([...byId.keys()]);
const wouldBe: ListingGroupMembership[] = [
...[...byId.values()].map((listing) =>
toListingGroupMembership(listing, allMembership),
),
{ active: true, groupIds: [...groupIds], id: 0 },
];
return childOnlyAddOnCheckerForListings(wouldBe);
};

type ParentEdges = {
groupIds: readonly number[];
input: ListingInput;
parentIds: readonly number[];
};

const validateParentEdges = async ({
groupIds,
input,
parentIds,
}: ParentEdges): Promise<string | null> => {
if (parentIds.length === 0) return null;
const pkg = await firstPackageGroup(groupIds);
if (pkg) {
return `"${input.name}" is a member of the package "${pkg.name}", so it cannot also be an add-on child of another listing.`;
}
const [byId, nestedParentLinks, parentGroupIds, allGroups] =
await Promise.all([
getListingsById(),
listingParents.getIdsByKeys(parentIds),
listingGroups.getIdsByKeys([...parentIds]),
groups.cache.getAll(),
]);
const addOnChecker = await loadChildAddOnChecker(input, groupIds, byId);
const hiddenPackageIds = new Set(
allGroups
.filter((group) => group.is_package && group.hide_package_listings)
.map((group) => group.id),
);
const childEdge = listingInputToEdge(input, 0);
for (const parentId of parentIds) {
const parent = byId.get(parentId)!;
if (nestedParentLinks.get(parentId)!.length > 0) {
return t("listings_table.children_err_parent_is_child", {
name: parent.name,
});
}
if (
listingGroups
.idsFor(parentGroupIds, parentId)
.some((groupId) => hiddenPackageIds.has(groupId))
) {
return `"${parent.name}" is a member of a hidden package, so it cannot offer add-on children.`;
}
const fieldError = edgeFieldError(listingToEdge(parent), childEdge);
if (fieldError) return fieldError;
const addOn = addOnChecker?.(0, [parentId]);
if (addOn) return childAddOnError(addOn, input.name);
}
return null;
};

const applyImportPolicy = (
input: ListingInput,
adminLevel: AdminLevel | undefined,
Expand Down Expand Up @@ -339,12 +230,6 @@ export const importListing = async (
);
const validationError = await validateListingInput(input);
if (validationError) return fail(validationError);
const edgeError = await validateParentEdges({
groupIds: groupResolve.ids,
input,
parentIds: parentResolve.ids,
});
if (edgeError) return fail(edgeError);

const newMember: DayPricedListing = dayPriceFieldsFromInput(input);
const id = await writeRowInTransaction(
Expand Down
1 change: 1 addition & 0 deletions src/locales/en/listings-table.json
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,7 @@
"error.child_listing_nested": "A selected child now has its own children. Please reload and try again.",
"error.listing_deleted": "This listing was deleted. Please go back and try again.",
"error.parent_listing_nested": "This listing is now a child of another listing. Please reload and try again.",
"error.parent_is_already_a_child": "A listing you named as a parent is itself a child of another listing. A listing cannot be both.",
"error.selected_group_deleted": "The selected group was deleted. Please try again.",
"error.selected_listing_deleted": "A selected listing was deleted. Please try again.",
"listings_table.duplicate_children_dropped": "Listing duplicated, but its required children weren't copied: {reason}",
Expand Down
Loading