Adversarial audit — stale code & shareable-logic duplication (2026-09-09)
Status: Active · Owner ask: "remove duplicate codes and turn them into shared codes. No more stale functions." Read-only adversarial audit. Section B (all 7 logic duplications) and most of Section A shipped 2026-09-09; the @deprecated-with-live-caller list (A1/A2) is the main open item.
Read-only audit. No files changed, no git, no yarn check/lint:ratchet, no hosted commands run.
Inputs: knip3.log (corrected entry config — 153 unused exports / 168 unused types, down from the noisy 831/487 in the original knip.log, after the coordinator declared domain/index.ts as an entry), the prior screen/component duplication audit (docs/audits/duplication-audit-2026-09-08.md, not re-reported here), and direct file reads / repo-wide greps to verify every candidate — knip flags a symbol as unreferenced; it does not know whether the reference is a build-time alias, a native-module autolink requirement, or a genuinely dead file.
Section A — Stale / dead code
A1. HIGH-ish (drift risk, ships wrong numbers) — calculateElo still driving live UI, never migrated to calculateEloV2
packages/app/src/domain/rules/elo.rules.ts:316—calculateElo,@deprecatedin favor ofcalculateEloV2.- Live caller:
packages/app/src/presentation/hooks/use-elo-delta-preview.ts:45. - This is duplication wearing a deprecated label: two ELO computations exist, and the rating-preview UI is still wired to the old one. Any correctness fix that landed only in
calculateEloV2silently does not apply to the delta-preview screen. - Fix: migrate
use-elo-delta-preview.tstocalculateEloV2, deletecalculateElo. Prevention: an ESLint rule banning imports of names tagged@deprecatedindomain/rules/**(or ano-restricted-importsentry per symbol) would catch new callers; a periodic@deprecated-with-live-caller grep is otherwise unenforced today.
A2. MEDIUM — six more @deprecated symbols with real, unmigrated callers
All confirmed via direct read + grep for the replacement path:
packages/app/src/domain/entities/profile.entity.ts:132getEloTier— 13+ live callers acrosspackages/features/records/*,packages/features/clubs/*,use-match-history.ts,leaderboard.ts,season.rules.ts.packages/app/src/domain/entities/profile.entity.ts:122,124BEGINNER_MAX/INTERMEDIATE_MAX— re-exported live viapackages/app/src/domain/index.ts:28-29.packages/app/src/domain/entities/club/role-helpers.ts:30getRoleTitle— live callerpackages/app/src/presentation/hooks/use-club-role.ts:22.packages/app/src/adapters/supabase/client.ts:81/client.web.ts:66supabaseproxy (usegetSupabase()) — 30+ live importers.packages/app/src/ports/services/venue-search.service.port.ts:17,25,36naverMapUrl/kakaoPlaceId/naverPlaceId— dozens of live consumers across mappers/entities/UI.packages/app/src/presentation/components/sessions/session-strip-ui.tsx:239SessionStripView— live caller inpackages/features/sessions/src/session-detail-screen.tsx.supabase/functions/ingest-venues/util.ts:33haversineMre-export — live callersupabase/functions/ingest-venues/match.ts:14,40.- Each is a real "no one migrated" case, not a false positive. Lower urgency than A1 because none of these look like they've diverged in behavior from their replacement (they're wrapper/alias deprecations, not two competing implementations) — but they are the exact "duplication left beside its replacement" pattern the owner law targets. Fix: one migration PR per symbol, in order of caller count (biggest blast radius last, so the tests catch regressions early). Prevention: same ESLint
@deprecated-import ban as A1, applied repo-wide.
A3. MEDIUM — domain/rules/matchmaking/index.ts barrel over-exports; ~27 of ~34 flagged exports are genuinely dead re-exports (not dead implementations)
- The file's own header states it is deliberately not re-exported through the public
domain/index.tsbarrel. - Verified only ~7 symbols are actually consumed externally through the
@/domain/rules/matchmakingbarrel path:resolveTournamentStrategy,deriveTournamentFormat,flattenTeamIds,strategyIdForFormat,isTournamentComplete,RoundResult,TournamentState(consumed byuse-create-tournament.tsanduse-advance-tournament.ts). - The other ~27 flagged names (
kingOfCourtStrategy,mexicanoStrategy,compassStrategy,STRATEGY_REGISTRY,resolveStrategy,circleMethodPairings,resolvePool,tournamentRoundsToMatches,tournamentStateToRounds,splitTeamId,TEAM_ID_DELIMITER, etc.) are not dead code — the underlying strategy modules are alive and imported directly by their real consumers (e.g.registry.tsimportscompassStrategyfrom./strategies/compass.strategydirectly;rotation.rules.test.tsimportsresolveStrategy/STRATEGY_REGISTRYfrom./matchmaking/registrydirectly). It's the re-export through this specific barrel that nobody uses. TEAM_ID_DELIMITER"duplicate" (flagged at bothindex.ts:86andtournament-output.ts:58) is not two definitions —index.ts:86is a re-export of the single real const attournament-output.ts:58. No actual duplication.- Fix: trim the barrel to the 7 symbols genuinely consumed via this path (or delete the barrel entirely and have the two tournament hooks import straight from the submodules, matching how every other consumer already does it — more consistent with "single source of truth"). Severity is cosmetic/hygiene, not a runtime risk. Prevention:
knipin CI with the corrected entry config (already fixed by the coordinator) will keep catching this class going forward.
A4. LOW-MEDIUM — genuinely dead files (4)
packages/app/src/types/branded.ts— zero real importers. Only references anywhere are:_templates/store/new/index.ejs.tand_templates/mutation/new/index.ejs.t(codegen scaffold templates that would use it if adopted, but nothing generated from them exists yet), and its own self-referencing doc comment. Already correctly flagged indocs/architecture/dead-and-stale-register.md:27as "DEAD (high)" with a note that CLAUDE.md's enforcement-stack claim that this shipped (P5) is itself a stale claim. Safe to delete (or formally adopt it — that's the owner's call, not mine to make). No live consumer to break.packages/app/src/presentation/hooks/queries/use-home-signals.ts— confirmed dead as of today's work.grep -rn "useHomeSignals("across the whole repo returns only the function's own definition (line 21). The three files that reference the string "use-home-signals"/"useHomeSignals" (use-home-data.ts:99,presentation/cache/bundles.ts:70,ports/repositories/home-snapshot.repository.port.ts:7) do so only in prose comments describing what the new bulkget_home_snapshotRPC (shipped today, commitad2cc22d"home lag via one bulk admin snapshot rpc") replaced — none of them actually import the hook.homeSignalsKeys(the query-key builder, a separate symbol inquery-keys.ts) is still legitimately used for cache seeding inbundles.ts:100— that's unrelated and should stay. Safe to delete the file. Worth a follow-up check (not done here, out of scope for a read-only audit): whether thehomeSignals.get(...)port/adapter binding inregistry.tsthis hook was the sole caller of is now itself orphaned.packages/features/sessions/src/directory-venue-detail/index.ts— a barrel that re-exportsDirectoryVenueDetailScreen. Verified nobody imports the baredirectory-venue-detailpath — the actual public export,packages/features/sessions/src/directory-venue-detail-screen.tsx:10, re-exports the screen directly from./directory-venue-detail/directory-venue-detail-screen, bypassing this barrel entirely, andpackages/features/sessions/src/index.ts:16re-exports from that top-level shim, not from the barrel. Safe to delete — a leftover from the directory-venue-detail god-file split.packages/app/src/config/i18n/mobile.ts— FALSE POSITIVE, do NOT delete.apps/mobile/metro.config.js:36,63-108installs a custom MetroresolveRequestthat swaps@/config/i18n(and./config/i18n/index) for this exact file at bundle time on the mobile platform, specifically to avoid shipping the English string table in every native OTA. Confirmed by reading the metro config directly. Already correctly documented indocs/history/autonomous-run-ledger.md:175as a deliberate knip/grep blind spot. This is the canonical "build-tool-level dynamic resolution" case the task description warned about.
A5. LOW — orphaned dependencies from a completed migration
packages/app/src/presentation/providers/query-persister.tsself-documents ("Legacy single-blob query cache cleanup... superseded by the lazy per-key persister inquery-lazy-persister.ts") and now only exportsdropLegacyBlobCache(), a one-time migration cleanup still imported live frompackages/app/src/index.ts— the file itself is not dead, it did its job of shrinking to a pure migration shim.- But it no longer imports
createSyncStoragePersister/persistQueryClient, and a repo-wide grep for those two functions turns up zero call sites anywhere. The two packages that supplied them —@tanstack/query-sync-storage-persisterand@tanstack/react-query-persist-client(both declared inpackages/app/package.json, both flagged "unused" by knip) — are genuinely orphaned now that the migration completed. Safe to remove both frompackages/app/package.json. - Prevention:
yarn check:supabase-types-style drift gates don't cover this; addingknip(corrected config) toyarn checkwith a baseline would.
A6. LOW — other genuinely-dead underscore "kept for later" functions (knip cannot see these — they're exported+imported-nowhere OR fully local-and-uncalled)
supabase/functions/fetch-weather/playability.ts:30-35—_calculateDewPoint(tempC, rh). Confirmed via full-file read: not one of the 7 weighted playability factors (heat, cold, rain, humidity, air, wind, uv, lines 49-57), zero callers anywhere insupabase/functions/, no comment explaining retention. This is the example the task named — confirmed real. Delete.supabase/functions/seed-scenario/scenarios/baseline.ts:205—async function _generateAchievements(...), ~35-line body, never invoked anywhere.supabase/functions/seed-scenario/lib/match-scoring.ts:219—function _eloDelta(...), never invoked.supabase/functions/simulate-activity/config.ts:95—export const _ACHIEVEMENT_TYPES = [...], exported, zero importers.packages/app/src/domain/rules/tournament.rules.test.ts:14,18—_allCompetitiveMatches/_allFriendlyMatches, declared but never referenced by anyit()in that file.- All five: LOW severity, straightforward deletes, no live behavior depends on them.
- Correctly excluded as false positives (legitimate test-only escape hatches with live test callers, not leftovers):
mmkv.ts/mmkv.web.ts_resetMigrationFlagForTest,mutation-queue.ts_clearMutationExecutorsForTest,replay-mutation-queue.ts_stopReplayManagerForTest,toast.store.ts_resetToastStoreForTest,profile.entity.ts_urlSchema(used two lines later in the same file).
A7. LOW — one dead @deprecated field (no live caller, unlike A1/A2)
packages/features/sessions/src/scorecard/types.ts:177—CompletedRowProps.onPress, deprecated in favor ofprofileMap. Its only consumer,rounds-tab.tsx, has migrated toprofileMapat both call sites and no longer passesonPressanywhere. Safe, isolated delete — the one deprecated symbol in this audit that's actually finished migrating and just needs its scaffold removed.
A8. LOW — commented-out unimplemented scaffolding (not literal duplication, flagged per instructions)
packages/app/src/adapters/supabase/auth.supabase.ts:87-97—signInWithGoogleNative()is called live but its body is entirely commented-outGoogleSigninSDK calls; the live function always throws a Korean "coming soon"DomainError. This is intended future-implementation scaffolding for the Kakao/Google-auth phase, which project memory (project_kakao_auth_oauth,Phase 8) marks as external-credential gated — not accidental dead code. Do not delete; leave a one-line comment pointing at the gating phase if it isn't already there, so a future dead-code sweep doesn't re-flag it without context.
A9. Unused-dependency reclassification (per coordinator's ask — "genuinely unused" vs "declared in the wrong workspace" have different fixes)
Genuinely unused, safe to remove:
@tamagui/config(apps/mobile/package.json) — zero usage anywhere in the repo.@tanstack/query-sync-storage-persister,@tanstack/react-query-persist-client(packages/app/package.json) — see A5.country-flag-icons(packages/ui/package.json) — zero usage anywhere in the repo.tamaguias a plaindependenciesentry inpackages/features/activity/package.jsonandpackages/features/onboarding/package.json— already listed aspeerDependenciesin both, and neither package'ssrc/importstamaguidirectly (components come pre-styled from@twomore/ui). Thedependenciescopy is redundant; keep thepeerDependenciesentry.@tamagui/animations-react-native— genuinely unused inapps/mobilespecifically:apps/mobile/tamagui.config.tsuses@tamagui/animations-moti, not this package. (It IS genuinely needed inapps/web/tamagui.web.config.ts, which imports it directly — leave the web declaration alone.)
Declared in the wrong workspace (real consumer exists downstream; the fix is to add the dependency where it's actually imported, not delete it):
@shopify/flash-list— flagged unused in bothapps/mobile/package.jsonandapps/web/package.json; real consumer ispackages/ui/src/feed-list.tsx(FeedList), which does not declare it as its own dependency.expo-image,expo-image-manipulator,expo-image-picker,react-native-modal-datetime-picker,react-native-spotlight-tour— all flagged unused inapps/mobile/package.json; every real import site is inpackages/features/clubs/*,packages/features/messaging/*,packages/features/profile/*,packages/features/sessions/*,packages/features/home/*, orpackages/ui/*(see knip's own "Unlisted dependencies" section, ~30 file:line hits), none of which declare these packages themselves. Fix: add each as an explicit dependency to every workspace package that imports it. Keep the app-level declaration too — Expo autolinking for native modules scans the top-level app'snode_modules, so removing it fromapps/mobile/package.jsonrisks breaking the native build even though no JS file there imports it by name.
False positives — do not touch:
react-native-mmkv— real consumerpackages/app/src/lib/mmkv.ts(correctly declared there); theapps/mobilecopy is required for Expo native-module autolinking regardless of JS-level import location.react-native-web(apps/web) — real usage is build-config-only:apps/web/next.config.tswebpack-aliasesreact-native→react-native-web. Invisible to knip's import scanner, not dead.react-native-keyboard-controller(apps/mobile) — zero JS usage anywhere in the repo, but this is documented, deliberate dormancy (project memory: "DORMANT in 0.8.0 binary; activation = on-device OTA, per-screen setInputMode, never global pan→resize"). It's a native module pre-installed ahead of a queued activation, not orphaned code. Removing it would undo intentional groundwork.moti(apps/mobile) — no directfrom 'moti'import anywhere, but Tamagui's Moti animation driver (@tamagui/animations-moti, actually imported inapps/mobile/tamagui.config.ts:9) very likely requiresmotipresent as a peer dependency. Recommendyarn why motibefore touching this — did not have hosted/package-manager access in this read-only audit to confirm definitively.
Prevention for all of A: add knip (with the coordinator's corrected entry config) to yarn check with a committed baseline, the same pattern already used for lint:ratchet and check:supabase-types. That converts this entire section from a one-time manual audit into a standing gate.
Section B — Duplication that should be shared code
(Screen/component-level duplication already covered by docs/audits/duplication-audit-2026-09-08.md — not re-reported. Everything below is LOGIC: computation, mapping, validation, guards, comparators.)
B1. HIGH — isValidDate vs isValidDateInput: DIVERGENT, and the divergence is undocumented
packages/features/sessions/src/create-session-helpers.tsisValidDate— rejects past dates (d >= todayrequired).packages/features/sessions/src/session-detail-helpers.tsisValidDateInput— does not reject past dates.- Two names, two files, structurally identical parsing logic, but one extra business rule silently present in only one of them. This is the severe case named in the task: divergent, not just duplicated. It plausibly matches intent (create-wizard should reject past dates; edit-screen shouldn't retroactively invalidate a session that has already started) — but nothing in either file documents that as the reason for the difference, so the next person to touch either one has a 50/50 chance of "fixing" the inconsistency by copying the wrong behavior into the other file.
- Fix: rename to make intent explicit (e.g.
isValidFutureDateInputvsisValidDateInput) and add a one-line comment on each cross-referencing the other, OR collapse into oneisValidDateInput(value, { allowPast })with the call sites stating their own requirement. Collapsing into one home inpresentation/utils/is the "single source of truth" fix; the rename-only fix is the minimum viable one if the owner confirms the behavior split is intentional.
B2. MEDIUM-HIGH — ELO-descending leaderboard sort duplicated, one copy mutates the query cache in place
packages/features/records/src/records-leaderboard-screen.tsx:65andpackages/features/clubs/src/leaderboard-club-tab.tsx:54both implement.sort((a, b) => b.eloRating - a.eloRating).- The
leaderboard-club-tab.tsxcopy sorts the array returned directly from the query cache in place, rather than a copied array — this violates the implicit immutability contract TanStack Query callers should hold, and risks a stale-read/inconsistent-render bug if the same cached array reference is read elsewhere before the next refetch. - Fix: one
sortByEloDescending<T extends { eloRating: number }>(items: T[]): T[]inpresentation/utils/, returning a new array ([...items].sort(...)) — collapses the duplication AND fixes the mutation-in-place risk in the same change.
B3. MEDIUM — role-rank comparator hand-rolled instead of using the centralized ROLE_ORDER
packages/features/clubs/src/transfer-ownership-modal.tsx:52hand-writes a local admin/match_director/else rank ordering.- The centralized
ROLE_ORDERarray already exists atpackages/features/clubs/src/member-modals.tsx:24and is presumably the canon source for role ordering elsewhere. - Fix: import and use
ROLE_ORDERintransfer-ownership-modal.tsxinstead of the local rank. Risk if left alone: a future role added toROLE_ORDER(or a rank change) silently doesn't apply to the transfer-ownership sort, producing a different member order in that one modal than everywhere else.
B4. MEDIUM — "active items sorted by sortOrder" filter+sort duplicated verbatim across 7 sites
- Byte-identical pattern
.filter(x => x.isActive).slice().sort((a, b) => a.sortOrder - b.sortOrder)(or the equivalent) found at:packages/features/clubs/src/regroup-preview.tsx:62,.../ranking-tab.tsx:122,.../club-planning-section.tsx:76,.../club-groups-section.tsx:73,.../club-members/use-club-members-data.ts:102,.../club-detail/members-pane.tsx:126,.../activity-card-section.tsx:47— spanning bothClubGroupandRegularMeetentities. - Fix: one generic
sortActiveBySortOrder<T extends { isActive: boolean; sortOrder: number }>(items: T[]): T[]inpresentation/utils/. No current behavior bug (all 7 copies agree), but 7 independent copies is 7 independent places a future tweak (e.g. secondary sort key, null-sortOrderhandling) has to be remembered and applied.
B5. MEDIUM — profile/avatar-stub map builder duplicated (byte-identical) instead of extending the canonical helper
packages/features/clubs/src/club-detail/members-pane.tsx:113-135andpackages/features/clubs/src/club-members/use-club-members-data.ts:84-108independently build the identicalMap<id, {displayName, avatarUrl, trustTier}>profile-stub map and the identicalMap<groupId, name>group-name map.- Both are the canonical
buildProfileStubMapshape (inpackages/app/src/presentation/utils/profile-map.ts) plus one extra field (trustTier) — but neither importsbuildProfileStubMap; both re-derive it from scratch. - Fix: add a
trustTier-inclusive variant (or an options param) tobuildProfileStubMapinpresentation/utils/profile-map.ts, and abuildGroupNameMaphelper alongside it; have both call sites import instead of re-deriving.
B6. LOW — zero-duration session-time validation copy-pasted 3x, including the same clarifying comment
packages/features/sessions/src/create-session-step1.tsx:298,packages/features/sessions/src/create-session/wizard-copy.ts:36,packages/features/sessions/src/edit-session/config-form.tsx:175— all implement "end === start is invalid, end < start is a legal overnight session" with the identical inline comment referencing migration00493.- Byte-identical rule, not divergent — but 3 copies of a business rule that came from a specific migration means a future rule change (e.g. a minimum session duration) has to be remembered in 3 places.
- Fix: one
isValidSessionTimeRange(start, end): boolean(or similar) inpresentation/utils/ordomain/rules/, referencing migration00493in exactly one place.
B7. LOW — isValidTime/isValidTimeInput: byte-identical body under two names (not divergent, unlike B1's date sibling)
packages/features/sessions/src/create-session-helpers.ts:77(isValidTime) andpackages/features/sessions/src/session-detail-helpers.ts:11(isValidTimeInput) — same body, two names, two files.- Fix: collapse to one name in
presentation/utils/; low urgency since there's no behavioral drift today, but it's the same file pair as B1'sisValidDate/isValidDateInput, so fixing both together (same PR, same target file) is the efficient move.
B8. Checked, no genuine duplication found (reported plainly rather than padded)
- Date/time and Korean-format helpers:
date-format.tsis the single real home forformatDate/formatRelativeTime/etc.; feature packages only consumet().common.weekdaysfor calendar-grid rendering, which is UI, not a re-implemented formatting rule. - Status derivation: no file re-implements
deriveRecruitmentStatus's quorum/almost-full/waitlist precedence, andsessionPaymentGateState/buildSessionInfoModelare consistently imported everywhere checked. Match/tournamentstatus === 'in_progress'checks in feature files are a different enum (match lifecycle, not session recruitment) — not a re-derivation of the canonical session rule. getDayOfWeek(flagged twice by knip,presentation/utils/date-format.ts:21andpresentation/utils/date-utils.ts:14): not a second implementation. Both are re-export shims of the single canonicalpackages/app/src/config/date-utils.tsimplementation (presentation/utils/date-utils.tsis explicitly documented as a "backward compatibility" re-export layer, anddate-format.tsre-exports the same 7 names transitively through it). The real caller (presentation/utils/player-stats.ts:13,63) already imports directly from@/config/date-utils, bypassing both shims — which is exactly why knip flags the shim-level re-exports as unused. LOW housekeeping fix, not a duplication bug: drop the redundantgetDayOfWeekre-export from one of the two shim layers (they don't need to double-hop).partialize(mutation-queue.ts vs auth.store.ts): different field sets/return types for two different Zustand stores (mutation queue vs auth preferences) — a repeated pattern (both are the outlier "named top-levelpartializefed intopersist()" style; every other store in the codebase inlines it as an anonymous lambda), not duplicated logic. No shared home to collapse into; if anything, drop theexporton both since nothing outside either file uses them.
Summary counts
- Section A: 4 dead files (3 real, 1 false positive), 1 dead barrel-hygiene issue (~27 unused re-exports, not unused implementations), 5 genuinely dead underscore functions, 7
@deprecated-with-live-caller cases (1 urgent), 1 finished-migration deprecated field, 1 commented-out scaffold (intentionally kept), 2 orphaned npm dependencies, plus an unused-dependency list reclassified into 5 safe-to-remove / 6 wrong-workspace / 4 false-positive. - Section B: 7 real logic-duplication findings (1 HIGH divergent, 5 MEDIUM, 3 LOW — note some findings pair together), 3 categories checked with no genuine duplication found.
Standing prevention recommendation: add knip (corrected config, already fixed by the coordinator) to yarn check with a committed baseline — mirrors the existing lint:ratchet/check:supabase-types pattern and is the single highest-leverage fix for "no more stale functions," since today's exercise required a human/agent pass to separate knip's real signal from its entry-point blind spots. A no-restricted-imports-style ESLint rule flagging any import of a symbol whose JSDoc carries @deprecated would close the biggest gap knip cannot see at all (A1/A2).