Skip to content

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:316calculateElo, @deprecated in favor of calculateEloV2.
  • 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 calculateEloV2 silently does not apply to the delta-preview screen.
  • Fix: migrate use-elo-delta-preview.ts to calculateEloV2, delete calculateElo. Prevention: an ESLint rule banning imports of names tagged @deprecated in domain/rules/** (or a no-restricted-imports entry 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:132 getEloTier — 13+ live callers across packages/features/records/*, packages/features/clubs/*, use-match-history.ts, leaderboard.ts, season.rules.ts.
  • packages/app/src/domain/entities/profile.entity.ts:122,124 BEGINNER_MAX/INTERMEDIATE_MAX — re-exported live via packages/app/src/domain/index.ts:28-29.
  • packages/app/src/domain/entities/club/role-helpers.ts:30 getRoleTitle — live caller packages/app/src/presentation/hooks/use-club-role.ts:22.
  • packages/app/src/adapters/supabase/client.ts:81 / client.web.ts:66 supabase proxy (use getSupabase()) — 30+ live importers.
  • packages/app/src/ports/services/venue-search.service.port.ts:17,25,36 naverMapUrl/kakaoPlaceId/naverPlaceId — dozens of live consumers across mappers/entities/UI.
  • packages/app/src/presentation/components/sessions/session-strip-ui.tsx:239 SessionStripView — live caller in packages/features/sessions/src/session-detail-screen.tsx.
  • supabase/functions/ingest-venues/util.ts:33 haversineM re-export — live caller supabase/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.ts barrel.
  • Verified only ~7 symbols are actually consumed externally through the @/domain/rules/matchmaking barrel path: resolveTournamentStrategy, deriveTournamentFormat, flattenTeamIds, strategyIdForFormat, isTournamentComplete, RoundResult, TournamentState (consumed by use-create-tournament.ts and use-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.ts imports compassStrategy from ./strategies/compass.strategy directly; rotation.rules.test.ts imports resolveStrategy/STRATEGY_REGISTRY from ./matchmaking/registry directly). It's the re-export through this specific barrel that nobody uses.
  • TEAM_ID_DELIMITER "duplicate" (flagged at both index.ts:86 and tournament-output.ts:58) is not two definitions — index.ts:86 is a re-export of the single real const at tournament-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: knip in 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.t and _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 in docs/architecture/dead-and-stale-register.md:27 as "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 bulk get_home_snapshot RPC (shipped today, commit ad2cc22d "home lag via one bulk admin snapshot rpc") replaced — none of them actually import the hook. homeSignalsKeys (the query-key builder, a separate symbol in query-keys.ts) is still legitimately used for cache seeding in bundles.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 the homeSignals.get(...) port/adapter binding in registry.ts this hook was the sole caller of is now itself orphaned.
  • packages/features/sessions/src/directory-venue-detail/index.ts — a barrel that re-exports DirectoryVenueDetailScreen. Verified nobody imports the bare directory-venue-detail path — 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, and packages/features/sessions/src/index.ts:16 re-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-108 installs a custom Metro resolveRequest that 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 in docs/history/autonomous-run-ledger.md:175 as 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.ts self-documents ("Legacy single-blob query cache cleanup... superseded by the lazy per-key persister in query-lazy-persister.ts") and now only exports dropLegacyBlobCache(), a one-time migration cleanup still imported live from packages/app/src/index.tsthe 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-persister and @tanstack/react-query-persist-client (both declared in packages/app/package.json, both flagged "unused" by knip) — are genuinely orphaned now that the migration completed. Safe to remove both from packages/app/package.json.
  • Prevention: yarn check:supabase-types-style drift gates don't cover this; adding knip (corrected config) to yarn check with 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 in supabase/functions/, no comment explaining retention. This is the example the task named — confirmed real. Delete.
  • supabase/functions/seed-scenario/scenarios/baseline.ts:205async function _generateAchievements(...), ~35-line body, never invoked anywhere.
  • supabase/functions/seed-scenario/lib/match-scoring.ts:219function _eloDelta(...), never invoked.
  • supabase/functions/simulate-activity/config.ts:95export const _ACHIEVEMENT_TYPES = [...], exported, zero importers.
  • packages/app/src/domain/rules/tournament.rules.test.ts:14,18_allCompetitiveMatches/_allFriendlyMatches, declared but never referenced by any it() 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:177CompletedRowProps.onPress, deprecated in favor of profileMap. Its only consumer, rounds-tab.tsx, has migrated to profileMap at both call sites and no longer passes onPress anywhere. 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-97signInWithGoogleNative() is called live but its body is entirely commented-out GoogleSignin SDK 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.
  • tamagui as a plain dependencies entry in packages/features/activity/package.json and packages/features/onboarding/package.json — already listed as peerDependencies in both, and neither package's src/ imports tamagui directly (components come pre-styled from @twomore/ui). The dependencies copy is redundant; keep the peerDependencies entry.
  • @tamagui/animations-react-native — genuinely unused in apps/mobile specifically: apps/mobile/tamagui.config.ts uses @tamagui/animations-moti, not this package. (It IS genuinely needed in apps/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 both apps/mobile/package.json and apps/web/package.json; real consumer is packages/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 in apps/mobile/package.json; every real import site is in packages/features/clubs/*, packages/features/messaging/*, packages/features/profile/*, packages/features/sessions/*, packages/features/home/*, or packages/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's node_modules, so removing it from apps/mobile/package.json risks breaking the native build even though no JS file there imports it by name.

False positives — do not touch:

  • react-native-mmkv — real consumer packages/app/src/lib/mmkv.ts (correctly declared there); the apps/mobile copy 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.ts webpack-aliases react-nativereact-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 direct from 'moti' import anywhere, but Tamagui's Moti animation driver (@tamagui/animations-moti, actually imported in apps/mobile/tamagui.config.ts:9) very likely requires moti present as a peer dependency. Recommend yarn why moti before 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.ts isValidDate — rejects past dates (d >= today required).
  • packages/features/sessions/src/session-detail-helpers.ts isValidDateInput — 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. isValidFutureDateInput vs isValidDateInput) and add a one-line comment on each cross-referencing the other, OR collapse into one isValidDateInput(value, { allowPast }) with the call sites stating their own requirement. Collapsing into one home in presentation/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:65 and packages/features/clubs/src/leaderboard-club-tab.tsx:54 both implement .sort((a, b) => b.eloRating - a.eloRating).
  • The leaderboard-club-tab.tsx copy 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[] in presentation/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:52 hand-writes a local admin/match_director/else rank ordering.
  • The centralized ROLE_ORDER array already exists at packages/features/clubs/src/member-modals.tsx:24 and is presumably the canon source for role ordering elsewhere.
  • Fix: import and use ROLE_ORDER in transfer-ownership-modal.tsx instead of the local rank. Risk if left alone: a future role added to ROLE_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 both ClubGroup and RegularMeet entities.
  • Fix: one generic sortActiveBySortOrder<T extends { isActive: boolean; sortOrder: number }>(items: T[]): T[] in presentation/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-sortOrder handling) 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-135 and packages/features/clubs/src/club-members/use-club-members-data.ts:84-108 independently build the identical Map<id, {displayName, avatarUrl, trustTier}> profile-stub map and the identical Map<groupId, name> group-name map.
  • Both are the canonical buildProfileStubMap shape (in packages/app/src/presentation/utils/profile-map.ts) plus one extra field (trustTier) — but neither imports buildProfileStubMap; both re-derive it from scratch.
  • Fix: add a trustTier-inclusive variant (or an options param) to buildProfileStubMap in presentation/utils/profile-map.ts, and a buildGroupNameMap helper 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 migration 00493.
  • 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) in presentation/utils/ or domain/rules/, referencing migration 00493 in 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) and packages/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's isValidDate/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.ts is the single real home for formatDate/formatRelativeTime/etc.; feature packages only consume t().common.weekdays for 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, and sessionPaymentGateState/buildSessionInfoModel are consistently imported everywhere checked. Match/tournament status === '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:21 and presentation/utils/date-utils.ts:14): not a second implementation. Both are re-export shims of the single canonical packages/app/src/config/date-utils.ts implementation (presentation/utils/date-utils.ts is explicitly documented as a "backward compatibility" re-export layer, and date-format.ts re-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 redundant getDayOfWeek re-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-level partialize fed into persist()" 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 the export on 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).

Markdown remains the source of truth. Run yarn docs:check before handoff.