Skip to content

ci: per-check status badges + react-doctor workflow#236

Open
Kelbie wants to merge 38 commits into
mainfrom
ci/per-check-badges
Open

ci: per-check status badges + react-doctor workflow#236
Kelbie wants to merge 38 commits into
mainfrom
ci/per-check-badges

Conversation

@Kelbie

@Kelbie Kelbie commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

What

Adds a status badge to the top of the README for each CI check — Lint, Type Check, Knip, React Compiler, and React Doctor — and the workflows needed to back them.

GitHub Actions badges are workflow-scoped (a badge URL resolves to one workflow file's status, not a job or step), so a single bundled ci.yml can only produce one "CI" badge. To get a badge per check, each check now lives in its own workflow file.

Changes

  • .github/actions/setup-sovran — new composite action holding the shared Bun setup + secret-boundary install (deps:sovran-latestbun install --ignore-scripts with the registry token in scope → bun pm trust + postinstall with no secret in scope), plus a Bun-store cache so the per-check split doesn't pay the install five times over. Reused by every gate workflow.
  • lint.yml / type-check.yml / knip.yml / react-compiler.yml — each runs one gate → one badge. React Compiler coverage was previously only a step inside code-quality; it now also has a dedicated badge.
  • react-doctor.yml — runs bunx react-doctor@latest --verbose, non-blocking (continue-on-error), and uploads the verbose report as an artifact. It is informational signal, deliberately kept off the merge gate given its false-positive rate on this codebase; the badge stays green to mean "it ran".
  • ci.ymlcode-quality and structural-health now use the composite action; code-quality keeps only the gates that don't need a dedicated badge (Prettier, Test). No gate runs twice.
  • package.json — adds the react-doctor script for local parity.
  • README.md — badge row under the title.

Notes

  • Branched on top of chore/eslint-suppressions-and-rules; until that merges, this PR's diff includes its commits. The composite action intentionally matches that branch's two-phase secret-boundary install.
  • Badges render live only once these workflows exist on the default branch.
  • Cost: more parallel runners per push; the Bun-store cache mitigates the repeated installs.

Kelbie added 30 commits June 23, 2026 14:26
Make `npx knip` run clean by acting on its real findings and silencing
its false positives, verified individually rather than trusting the
raw report.

Removed (truly unreferenced):
- functions: getReservedProofs, useNostrTierConfig, useNote, useNoteStats,
  selfIdentity
- types: ComposerMode, UploadProgress

Internalized (used only within their own file — dropped the `export`):
- symbols: useMediaServerStore, DEFAULT_BLOSSOM_SERVER, fetchRelayInformation,
  buildSenderSelfCopyWrap, useTransactionAnnotationStore
- 22 exported types across nostr/media, nostr/outbox, composer/publish,
  transactions, nip11, useEntityCache, transactionDistributionStore

knip.json:
- ignore `files` issue for *.ios / *.blur Metro platform variants (matches
  the existing *.android / *.liquid handling) — these are build-time entry
  points knip cannot resolve
- add @sovranbitcoin/colada, nagg-ts, schemas to ignoreDependencies — linked
  siblings (scripts/link-local-siblings.mjs) that knip's resolver misreads as
  unused despite 80+ import sites

Gates: knip clean, tsc --noEmit clean, eslint clean on touched files.
analyze-structure reported 2 import cycles; both were type definitions
co-located with one side of a two-file pair. Extract each shared shape into
its own module so neither file imports the other.

- dmEnvelopeTypes.ts: DmEnvelope / DmEnvelopePage (was in dmEnvelopeClient,
  imported back by facadeDmAdapter). Client and adapter now both depend on
  the type module; client still imports the adapter's converters one-way.
- recentContactTypes.ts: RecentContact (was in useNip17RecentContacts,
  imported back by mockDataStore). Hook and store now both depend on the
  type module.

All importers repointed directly (ContactsScreen, dmDecryptPipeline,
dmPagination, useDmConversations, useDmThread, mockDataStore, a test) — no
re-export shims. Cycle count 2 -> 0; tsc and eslint clean.
loggerCore.ts was the repo's #3 complexity hotspot (cognitive 588, 898 LOC),
mixing the emit/transport/ring-buffer core with two self-contained pure
concerns. Move them out into deep modules behind small function seams:

- loggerValueCompact.ts: the secret-redaction + value-summarization walk
  (classifyString, summarizeString, compactValue, field-name redaction, the
  SECRET/EMBEDDED/LONG string pattern tables). This is the redaction boundary;
  behavior is unchanged (pure move).
- loggerSourceLocation.ts: stack-frame parsing (getCallerLocation, simplifyPath).

loggerCore now imports compactValue/redactKnownSecretSubstrings/getCallerLocation
and drops ~310 lines. Added __tests__/loggerValueCompact.test.ts unit-testing the
extracted module directly (structural compaction + field-name redaction paths),
complementing the existing end-to-end loggerRedaction suite.

Gates: 41 logger tests pass, tsc clean, eslint clean (one pre-existing
lazy-require warning unchanged).
contentShiftLog.ts (cognitive 579, 1401 LOC) mixed rectangle math with the
registry/measurement/snapshot orchestration. Pull the pure primitives into
contentShiftGeometry.ts (LayoutRect, validRect, rectBottom/Right, vertical/
horizontal/rectOverlap, viewportFlags) so the remaining file reads as
orchestration over them.

Registry/snapshot extraction deliberately deferred: VisualRecord ties the
registry to the VisualLayoutConfig type web, so moving it would risk a new
import cycle for marginal gain (the registry is a Map plus two helpers).

Gates: tsc clean, 26 contentShift tests pass, eslint clean.
…ngine

parseMethodParams was a nested function that never touched engine closure
state (state/deps/generation/dedupe) — a pure pre-gate parse that validates
per-method params and builds the approval preview, making no authorization
decision. Lift it (and its ParsedSignParams result type) into
nip46RequestParse.ts.

The security-critical inbound pipeline (the 13-gate processEvent) is left
fully intact and source-ordered in nip46Engine; only the pre-gate shape parse
moved. Dropped the now-unused UnsignedEventSchema import from the engine.

Gates: tsc clean, 783 nip46 tests pass, eslint clean.
The commit-3 extractions exported a few symbols that only their own module
uses (verticalOverlap/horizontalOverlap in contentShiftGeometry, CompactOpts,
SourceLocation, ParsedSignParams). knip flags them as unused exports; drop the
`export` so the new modules expose only their real surface.

Gates: knip clean, eslint clean.
…tConfig

sovranPaymentConfig.ts was the repo's #2 complexity hotspot (cognitive 631,
1868 LOC) bundling six unrelated factory concerns. Split out the funds-
execution core first:

- sovranPaymentOperations.ts: createSovranExecuteReceive +
  createSovranExecuteMintQuote and their private helpers (receive-history
  polling, pending/fallback entry builders, ReceiveOperationLike). These hold
  the snapshot->poll->fallback race-window guards; moved verbatim,
  behavior-identical.
- sovranPaymentLog.ts: the shared mintUrlLogFields redaction helper (used by
  operations, notifications, and handlers).

Colada.tsx repointed to import the two operation factories from their new
home (no re-export shim); dropped the now-unused imports from the config.
sovranPaymentConfig drops ~480 lines. Notifications/handlers/scanSources/
screenActions decomposition follows in subsequent commits.

Gates: tsc clean, payment-config tests pass, eslint clean.
Move createSovranScanSources (clipboard / gallery / NFC input sources) into
sovranScanSources.ts — a self-contained concern with no shared payment-config
state. Colada.tsx imports it from the new module.

Also dedup: Colada.tsx had its own copy of mintUrlLogFields; point it at the
shared sovranPaymentLog helper instead.

Gates: tsc clean, payment-config tests pass, eslint clean.
…onfig

Move createSovranNotifications (the ~460-line notification-code -> UI/state map)
into sovranNotifications.ts. Pure relocation; imports pruned to the subset the
notifications actually use. Colada.tsx and the profile test repointed to the
new module (no re-export shim).

Gates: tsc clean, payment-config tests pass, knip + eslint clean.
…Config

Move createSovranScreenActionHandlers (post-terminal NFC/emoji/copy/memo
actions) and its Ctx narrowing helpers (sendCtx, mintQuoteCtx) into
sovranScreenActions.ts. injectRecipientPubkey stays in the config file — it is
a handlers helper that merely lived in the screen-actions section. Colada.tsx
repointed to the new module.

After this, sovranPaymentConfig.ts holds only createSovranHandlers + its
helpers (down from 1868 to ~570 LOC).

Gates: tsc clean, payment-config tests pass, knip + eslint clean.
With operations, notifications, scan sources, and screen actions extracted, the
file holds only createSovranHandlers (+ its helpers), so the
"PaymentConfig" name no longer fits. Rename to sovranHandlers.ts, repoint the
two importers (Colada.tsx, profile test), and rewrite the file header.

The 1868-LOC #2 complexity hotspot is now six focused modules:
sovranPaymentOperations, sovranNotifications, sovranScanSources,
sovranScreenActions, sovranHandlers, and the shared sovranPaymentLog.

Gates: tsc clean, payment tests pass, knip + eslint clean.
useMintRebalanceOrchestrator's pure decision logic was already extracted
(rebalanceRunState + rebalance helpers, both tested); the remaining inline
logic is closure-coupled orchestration. computeRouteSuggestion was the one
exception — a pure-ish failure-recovery computation (gather candidates ->
audit -> build graph -> pick path) that only depended on its inputs plus an
auditor lookup.

Move it to rebalanceRouting.ts behind an injected `fetchAudit` port (and
passed-in groups), so it's unit-testable without network/store access. The
hook keeps a thin closure that gathers values and delegates. Added
__tests__/rebalanceRouting.test.ts (5 cases: null path, name mapping,
candidate dedup, audit filtering, 12-candidate cap).

This is on the reroute path, NOT the live melt-execution path — the
funds-critical executeStep orchestration is untouched.

Gates: tsc clean, rebalance tests pass (15), knip + eslint clean.
mintUrlLogFields — the mint-URL log redaction helper ({ hasMintUrl,
mintUrlLength }) — was copy-pasted byte-identically into 37 files across
app/, features/, and shared/ with no owner. Classic latent duplication.

Hoist it to shared/lib/mintUrlLog.ts as the single owner, migrate all 37
sites to import it, and delete every local copy. Also retire the interim
features/send/lib/sovranPaymentLog.ts (introduced earlier this branch) and
point its consumers at the canonical module — no re-export shim.

The body is unchanged everywhere, so redaction behavior is identical; this
just removes the duplication and gives the helper a home.

Gates: tsc clean, knip clean, eslint clean, full suite green (1664 tests,
150 suites).
describeImageLoadError — the <Image> onError-event-to-string normalizer —
was copy-pasted byte-identically in WallpaperThumbnail, UnitPreviewCard, and
SpriteView. Hoist to shared/lib/imageLoadError.ts and migrate all three to
import it; delete the local copies. Behavior identical.

Gates: tsc clean, knip clean, eslint clean, full suite green.
The useAuditedMint(s) and use{Nostr,Sovran}DiscoveredMints hooks were
carried since the shared/features migration but never wired into any
screen and had no tests — they survived only as mint-barrel re-exports.
Removing them orphans their sole dependency, the NIP-87 recommendation
helpers in shared/lib/nostr/client.ts (parseRecommendation,
isCashuRecommendationEvent, extractMintUrlFromEvent, NostrEvent), so
those go too; npubToPubkey stays. Also prunes the dead rebalance/lib
re-exports from the mint barrels and un-exports types used only within
their own module.
Prunes re-export lines that nothing imports from the feed, transactions,
wallet, user, camera, settings, image-overlay, popup, MintSelector,
GlassSearchBar and QRButton barrels, and removes the dead
`export default LoadingIndicator` (every consumer uses the named export)
— clearing the default+named clash and a duplicate export. Interfaces and
types used only within their own file are un-exported rather than deleted;
a handful of genuinely dead functions (getFirstTagValue, feedRowsAreEqual,
getEmbeddedRepostEvent, normalizeFeedEvent, readThreadSeed, the exported
useTransactionSource duplicate, setPopupDuration, the unused
liquidGlassModifiers helpers, the unused PullToAiRefreshControl component)
are removed.
`__tests__/**` and colocated `*.test.*` are now entry points, so
test-only-used code is no longer mis-flagged as dead. With the
preceding cleanup, the ~20 broad per-tree `ignoreIssues` masks
(features/feed, features/mint, shared/lib/popup, shared/styles/tokens,
…) are gone — knip is green on its own. What remains is structurally
justified: the four platform-variant `files` patterns (Metro resolves
them by extension), the codereview tool API, the dynamic icon registry,
the vendored p2pk plugin, and three narrow file-scoped suppressions for
surfaces knip can't statically see (naggSchemas + recentPeopleProfiles,
in-flight graphql-strip consumed via dynamic import; sendMemoSheet
helpers consumed by a require()-based test).
The structural analyzer parsed only static `import … from` / `export … from`
edges, so a module reached ONLY via `await import('…')`, `void import('…')`,
or `require('…')` was reported as a dead orphan, and a symbol pulled only via
`const { x } = await import('…')` was reported as an unused export. Both are
false positives — the code is live, the bundler resolves it, and knip already
counts it as used.

extractImports now also records dynamic-import and require edges (module plus
destructured binding names). They are tagged `isDynamic` and feed ONLY the
usage/reachability views — fan-in *presence* (orphans) and imported-names
(unused exports) — and are deliberately excluded from every static-coupling
metric: cycle detection (a lazy import is exactly how a static cycle is
broken, so counting it would forge cycles), fanout / importer-reach, and the
fan-in *magnitude* used by hub-spoke and pass-through. The change is therefore
surgical: nothing that measures static structure moves.

Verified on this tree (analyze-structure --history --reach --leakage
--vocab-drift): dead-orphan files 5→4 (e.g. shared/lib/profile/ownProfileSync,
loaded via `void import()` in NostrKeysProvider; the two color scripts
require()d by build-background-themes.js); unused-export files 26→23
(purgeFixtureMetadata via require in settingsStore, clearAllSecureData via
dynamic import); cycles still clean; hub-spoke (141), pass-through (43) and all
other metrics unchanged; Overall 61→62.
Address the highest-signal React Doctor findings and seed representative
samples for the migration-scale rules (full sweeps deferred to follow-up).

Full fixes:
- Security/build-pipeline-secret-boundary: install with --ignore-scripts
  while the GitHub Packages token is in scope (it is only needed to auth the
  registry fetch), then run trusted dependency scripts (skia) and the
  first-party postinstall in a separate token-free step. Applied to both CI
  jobs.
- Perf/no-json-parse-stringify-clone: structuredClone in the loggerChild test.

Representative samples (recipe confirmed; remaining sites need sign-off):
- react-compiler-no-manual-memoization: drop redundant useCallback in the
  home + notifications layouts, hoisting the closure-free handler to module
  scope. drawer/_layout.tsx:131 left intact: its React.memo is a load-bearing
  render-bailout for drawer animation timing (rule false positive).
- set-state-in-effect: derive isChecking from permission in the camera hook.
- js-combine-iterations: single-pass for...of in useAllSearchResults and
  pollParse.
- no-giant-component: split PostComposer (405->300 lines) into Header,
  MediaTray and Toolbar sub-components, preserving the VisualLayoutProbe
  log taxonomy.

Deferred: refs (timing-sensitive), plus the full sweeps of the above rules.

Verified: react-doctor 2857->2847 with no new findings; type-check, knip,
loggerChild test, and prettier (changed files) all pass.
React Compiler is enabled (app.json experiments.reactCompiler), so manual
memo/useMemo/useCallback the compiler already covers is dead weight. An AST
codemod unwraps ONLY React's memo/useMemo/useCallback (and removes the
now-unused imports), across 125 files — 360 wrappers removed.

Kept as load-bearing (verified against a fresh react-doctor run + cross-model
review, NOT swept):
- files where removal introduced a new compiler bailout (preserve-manual-
  memoization / refs / immutability / purity / no-effect-with-fresh-deps)
- a side-effecting useMemo factory (NDKCacheAdapterSqlite in NostrNDKProvider)
- memos with explicit human justification (stable prop identity, list-row
  render bailouts)

The rn-no-non-native-navigator (x3) and js-length-check-first (x2) findings
from the same react-doctor pass are confirmed false positives for this Expo
Router setup and were intentionally left untouched: no native drawer exists in
react-navigation v7, and the flagged array compares are an intentional prefix
match and an already-guarded equality check.

react-doctor total 2847 -> 2507; react-compiler-no-manual-memoization 1320 ->
960 (-360); all compiler-health rules flat. type-check, eslint (0 errors), and
1664 tests pass.
Audit of the repo-root config/dotfiles, cross-checked with a second model.
Remove five files that no longer have a load path:

- app-env.d.ts / nativewind-env.d.ts: redundant `uniwind/types`
  references (nativewind is no longer a dependency; the app migrated to
  uniwind). The reference is already provided by the uniwind-generated
  uniwind-types.d.ts, and both were byte-identical leftovers.
- components.json / tailwind.config.js: shadcn CLI config plus the
  Tailwind JS config it pointed at. shadcn is not a dependency, Tailwind
  v4 does not auto-load a JS config without an `@config` directive (and
  global.css has none), and uniwind never reads it. The colors it
  defined already live in global.css `@theme`.
- .DS_Store: macOS cruft (already gitignored, was untracked).

Drop the now-dead "nativewind-env.d.ts" entry from tsconfig `include`;
the `**/*.ts` glob still covers the remaining uniwind types.

type-check passes; lint shows only pre-existing prettier warnings in
unrelated files.
…mbine-iterations)

Three genuine app-runtime list transforms over medium/large arrays — thread
replies, a notifications page, and the swap-group timeline — rewritten from
.map().filter() / .filter().map() to a single .flatMap() pass (the rule's own
canonical fix). Identical semantics, comparable readability, one fewer traversal
and intermediate allocation each.

Scoped, cross-model-validated sample (me + Codex agreed): the remaining
js-combine-iterations sites and ALL js-set-map-lookups sites are
false-positive-dominated in this RN/Expo codebase and were left as-is —
offline codereview/scripts tooling (not in the app bundle), string-receiver
misfires (msg.includes / field.indexOf), and sub-10-item or already-memoized
arrays where a second pass is negligible.
Adds devDependencies for the next-tier lint rules: neverthrow
must-consume-result (@okee-tech), no-secrets, security, unicorn,
sonarjs, and a direct dep on eslint-plugin-import (previously only
transitive via expo). No rules enabled yet.
Replace the three object-literal `{...} as RoutstrError` throws with a
`makeRoutstrError` factory (satisfies consistent-type-assertions without
a cast) and narrow `per_request_limits: any` to `unknown` (the field is
never read).
Replaces opaque eslint-suppressions.json counts with documented, scoped
exceptions for genuinely-legitimate cases:
- redux/**/*.deprecated.ts: no-explicit-any off (live migration scaffolding
  reading arbitrary old persisted Redux blobs; slated for deletion).
- routstr/api.ts: no-restricted-globals off (every endpoint needs the raw
  Response for its status-aware error pipeline + SSE streaming; fetchJson
  cannot carry that contract).
- Button/SpriteView/AmountFormatter: no-restricted-imports off with a
  TODO(reanimated-migration) note (legacy Animated, deferred to its own PR).
- easeGradient.ts: inline disable (MIT adaptation driving RN's internal
  color-interpolation API; no Reanimated equivalent).
Clears the no-restricted-syntax (hex literal) suppressions:
- White-on-dark icons/text -> INVARIANT_WHITE; immersive/story/shadow
  blacks -> INVARIANT_BLACK.
- New shared brand constants for genuinely cross-site values: LIKE_ACCENT
  (#ff5a7a), AMBER_ACCENT (#f59e0b), WALLPAPER_PLACEHOLDER gradient.
- Color-definition homes (map/categories marker palette, useThemeColor
  resolver fallback, RowStatsAccent STAT_COLOR_* exports) added to the
  no-restricted-syntax exempt list alongside themes.ts/brandColors.ts.
- Localized intentional invariants (notification-type palette, native-tab
  theme colors, default decorative gradient, avatar placeholder fill) get
  documented inline disables instead of opaque bulk-suppression counts.

No rendered colors change: every swap maps a literal to a constant of the
same value or an existing token of the same value.
The eslint-disable-next-line directive sat above the explanatory comment
block, so it targeted a comment line instead of the restricted import.
Move the directive to the line immediately before the import.
Clears the no-explicit-any suppressions for live (non-deprecated) code:
- Concrete types where derivable: forwardRef<ElementRef<typeof View>>,
  StyleProp<ViewStyle>, a structural Measurable ref type (hero-transition),
  typed routstr stream via Awaited<ReturnType<...>>, Href for the router
  pathname, the reanimated scroll-handler type, typed mint-info/theme-registry
  records, and narrowed object casts.
- Redundant casts dropped: the (delta as any) chat-stream casts are covered
  by the zod chunk spine; Supercluster <any,any> generics fall back to the
  library's AnyProps default.
- Documented inline disables only where any is genuinely load-bearing:
  typedUpdate's implementation signature (behind two typed overloads),
  migrateSettings reading a legacy Redux blob, gorhom/bottom-sheet render-prop
  props, react-native-image-colors' platform union, the compose() provider
  util, and the Animated.FlatList ref typing gap.
Clears the consistent-type-assertions suppressions (object-literal casts):
- Typed seams instead of casts: reduce<Record<string,number>>, typed .catch
  arrow returns (BalanceMap / byMint result), satisfies for the NDK->Applesauce
  event and the custom-sheet payloads, drop the redundant useMemo<T> inner cast.
- Drop unneeded spread-copy cast in PaymentRequestScreen.
- Assert the merged variable (not the literal) in createMergeWithSchema where TS
  can't prove a generic spread is complete.
- Documented inline disables for the two genuinely-load-bearing cases: the
  generic TActiveChildren empty default and gorhom's undocumented useDirectView
  contentContainerProp.
…file

Enables the researched high-signal rules, drives their backlog to zero (or to
warn), and removes eslint-suppressions.json ENTIRELY (193 suppressed violations
-> 0; the file is deleted, not emptied). Also includes the mechanical --fix
output the autofixable rules produced (prettier, optional-chain collapses,
import dedupe, dead-import removal) — no behavioural changes there.

error tier (backlog driven to zero):
- await-thenable, prefer-optional-chain
- unicorn: no-thenable, no-instanceof-array, error-message, throw-new-error
- sonarjs: no-identical-expressions, no-all-duplicated-branches,
  no-element-overwrite, no-collection-size-mischeck, non-existent-operator
- import/no-duplicates, import/no-self-import
- security: detect-pseudoRandomBytes, detect-eval-with-expression,
  detect-bidi-characters
- (the 4 remaining optional-chain + 2 Error-message sites fixed by hand)

warn tier (visible, never suppressed):
- neverthrow/must-consume-result
- no-secrets (tuned tolerance + ignore Nostr/Cashu encodings; off in fixtures)
- import/no-cycle (depth-gated)
- switch-exhaustiveness-check (~18 genuine gaps -> focused follow-up)
- prefer-nullish-coalescing (||->?? needs a falsy-0/'' audit before promotion)

eslint-plugin-import promoted to a direct dependency. Per the no-new-suppressions
rule, every adopted rule either finished at zero or landed at warn.
Kelbie added 8 commits June 24, 2026 04:15
tsc --noEmit surfaced 15 errors where a removed `any`/cast was load-bearing:
- Colada: the useMemo<MachineOperations> object needed both the assertion AND
  contextual param typing — extract to a variable, assert that, and type the one
  inline operation's params explicitly.
- useAiSend catch, list.tsx router pathname, Supercluster Options generics:
  the value is genuinely untyped/3rd-party — restore `any` with a documented
  inline disable rather than a wrong type.
- PaymentRequestScreen: double-assert through unknown (types don't overlap).
- compose(): guard the reduce accumulator off undefined instead of `as any`.

type-check and lint both clean (0 errors).
…port

- eslint-plugin-import is provided transitively by eslint-config-expo and is
  referenced via import/* rule names (not require()d), so the direct
  devDependency is redundant — knip flagged it. The import/no-duplicates,
  no-self-import and no-cycle rules still resolve through expo.
- Drop the unused `export` on the RelayHealth type (used only internally;
  pre-existing knip finding, fixed here so the gate stays green).
React Compiler is enabled, so manual useMemo wrapping a cheap pure value
is dead weight it already covers. Commit 584186b removed the codemod-safe
360 (1320->960); this pass clears 70 of the residue it left behind — value
memos stranded in files a coarser per-file filter preserved.

Method: unwrap only single-line `useMemo(() => expr, [deps])` value memos
(no React.memo wrappers, no useCallback, excluding animation/gesture/Skia/
shared-value expressions), then verify each batch against a fresh
`react-doctor --scope changed` run. Reverted every removal that introduced
a new compiler-health finding rather than force it through:
- targetItem (ThreadView) fed a kept useCallback -> whole-component bailout.
- PollCard / NearPayScreen / LightningStrike / SkeletonExitShimmer memos
  fed effects -> no-effect-with-fresh-deps (effect re-runs every render).
- SettingsDesignSystemTimelineScreen -> purity.

react-doctor's per-removal scan catches these value/callback bailouts
(preserve-manual-memoization / no-effect-with-fresh-deps / purity); it does
NOT catch React.memo render-bailouts (verified: removing the load-bearing
MenuButton memo produced no finding), so component wrappers are left alone.

The rn-no-non-native-navigator (x3) and js-length-check-first (x2) findings
from the same pass remain confirmed false positives and untouched: RN v7 has
no native drawer, two of three nav sites are type/hook imports, and the
flagged compares are an intentional prefix match and an already-guarded
equality check.

react-compiler-no-manual-memoization 960 -> 890. type-check 0 errors,
eslint 0 errors / 0 new warnings, 1664 tests pass (one source-assertion
test updated to match the unwrapped contactRows line).
React Compiler (app.json experiments.reactCompiler) auto-memoizes values,
callbacks, and component output, so most manual useMemo/useCallback is
redundant. The react-perf/jsx-no-new-* eslint rules were deliberately paired
with the compiler to flag prop instability — which directly contradicts
deleting those memos. Make the compiler the source of truth: remove the three
react-perf rules + plugin + devDependency, then sweep the safe subset of
react-compiler-no-manual-memoization findings (890 -> 580).

Bounded to compiler-covered cases only. Kept every React.memo wrapper,
preserve-manual-memoization site, commented memo, and generic
useMemo<T>/useCallback<T> (dropping the type arg breaks inner-param
inference). Skipped any binding consumed by a hook dependency array (removing
it re-fires the effect every render) and multi-statement block useMemo.

Verified per-file with `react-doctor --scope changed` and a full
before/after per-(file,rule) count delta against the original report: total
issues 2434 -> 2121, zero net-new issues of any rule. tsc, eslint (0 errors),
and knip all clean. 16 files with genuine new findings were reverted wholesale.

Left two false-positive clusters untouched (see ADR 0006):
rn-no-non-native-navigator (fix target @react-navigation/native-drawer does
not exist; sites are a deliberate expo-router/drawer surface, a
useDrawerProgress hook, and a type-only BottomTabBarProps import) and
js-length-check-first (both are the rule's own documented exceptions: an
existing length guard, and an intentional prefix-match).
Move render-stable helper functions out of component bodies to module
scope. None captured component state/props — only their own params,
module imports, and module consts — so React rebuilt them on every
render for nothing, and a fresh function identity defeated memoized
children. Pure relocation; zero behaviour change (the compiler already
stabilised these, so their location never affected runtime).

Clears all 30 react-doctor prefer-module-scope-pure-function findings.
MerchantDetailScreen's handleCall/handleEmail/handleContactPress and
SearchResultRows' renderItem were hoisted as cascade follow-ups once
their sole dependency (handleOpenURL / renderSearchResult) moved out.

Verified against the real tool: prefer-module-scope 30 -> 0, total
react-doctor 2121 -> 2012, zero net-new findings of any rule; tsc,
eslint (0 errors), and knip all clean.
The first React-Compiler memo sweep (584186b) removed ~20 React.memo/memo()
wrappers and never restored them — including the app's most render-sensitive
components (charts, animated backgrounds, gradient overlays, feed media,
transfer/transaction list rows). React.memo is a props-comparison RENDER
bailout, a different mechanism from value memoization; the compiler does not
reliably replace it (React Compiler 1.0 guidance, point #2). The "keep all
wrappers" rule was only adopted in pass 2 — pass 1 had already stripped them.

Runtime logs confirmed the regression: a log-doctor renders pass showed the
three instrumented stripped components re-rendering repeatedly
(tx.monthly_chart 6x, bg.view 7x, ScrollableGradientOverlay 4x) — the
user-reported lag.

Changes:
- Re-wrap all 20 stripped components in React.memo/memo().
- Restore getMintInfo's useCallback([manager]) in useMintManagement — a
  callback consumed as a useEffect dependency in CONSUMER hooks
  (useMintInfo.ts, useMintContacts.ts); the sweep's "skip dep-array bindings"
  guard only checked the same file, so this cross-file escape hatch slipped.
- Add a React Compiler coverage gate: scripts/check-react-compiler.mjs +
  check:react-compiler npm script + CI step. It runs react-compiler-healthcheck
  and fails when compiled < total. The eslint-plugin-react-compiler rule that
  would normally catch a bailout is dead under the repo's zod@4 override, so
  this restores the signal using the compiler itself as source of truth.
- ADR 0007 documents the finding + gate; corrects ADR 0006's "kept every
  wrapper" claim.

Verified: react-compiler-healthcheck 596/596 (zero bailouts), tsc 0,
eslint 0 errors, knip 0, jest pass, prettier clean.
Second audit pass over the React-Compiler memo sweep diff.

- Restore 3 gesture useMemos the bulk pass (8bd4f74) stripped despite the
  earlier passes' explicit policy to exclude gesture/animation memos:
  ThreadEmbedSheet/DistributionSlider/NearPayScreen panGesture+gesture. A fresh
  Gesture.Pan() each render re-registers the GestureDetector handler — a footgun
  the render-count log wouldn't surface. Re-wrapped with their original deps.

- Cut the two over-logging events that were 61% of a sample session's log volume
  (the user-reported "repeated logs"), neither a memo bug:
  * mint.info.fetch.success (31%): the hook re-logged success on every getMintInfo
    call including instant SWR cache hits, duplicating the cache layer's own
    store.mint_info.* hit/miss/fetch logs. Removed the redundant hook-level
    start/success debug logs (kept the error log).
  * nostr.client.npub_to_pubkey (30%): a debug log on every call of a pure decode
    invoked per-row across follower/contact lists. Removed (kept decode-failure).

- Restore addQuery's useCallback (useRecentSearches) — consumed as a cross-file
  useEffect dep; deps [addSearch, surface] are stable.

Context-provider value memos were investigated and deliberately left compiler-owned:
restoring them introduced 7 net-new react-hooks/exhaustive-deps warnings (their
callback deps aren't manually memoized), and the compiler already stabilizes them
(596/596 compile) under the new coverage gate. See ADR 0007 "Second pass".

Verified: tsc 0, eslint 0 errors, knip 0, jest 1664/1664, prettier clean,
react-compiler-healthcheck 596/596.
Split lint, type-check, knip, and React Compiler coverage out of the single
code-quality job into dedicated workflows so the README can show a status
badge per check (GitHub Actions badges are workflow-scoped, not per-step).
Add a non-blocking React Doctor workflow that runs
`bunx react-doctor@latest --verbose` and uploads the report as an artifact —
informational signal, kept off the merge gate given its false-positive rate
on this codebase.

Extract the shared Bun setup + secret-boundary install (deps:sovran-latest,
--ignore-scripts auth fetch, then trust + postinstall with no secret in
scope) into a composite action (.github/actions/setup-sovran) reused by every
gate workflow, with a Bun-store cache so the per-check split doesn't pay the
install cost five times over. ci.yml keeps Prettier, Test, and the
structural-health signal job.

Add the `react-doctor` npm script for local parity and a badge row to the top
of the README.
@github-actions

Copy link
Copy Markdown

Structural health

# Structural Health Score

Overall: **65/100**
- Architecture: 70/100 (weight 20)
- Module Design: 60/100 (weight 15)
- Code Complexity: 42/100 (weight 15)
- Type Safety: 87/100 (weight 10)
- Component Health: 89/100 (weight 10)
- Hygiene: 87/100 (weight 15)
- Testability: 8/100 (weight 10)
- Conceptual Cohesion: 92/100 (weight 5)

# Repo Analysis: .

Files: 1135  Code: 147172  Cycles: 0  Orphans: 310  Shallow: 44  Pass-through: 43  Complexity hotspots: 313  Component smells: 83  Type-safety hotspots: 334  Hub-spoke: 141  Test gaps: 723  Unused-export files: 23

## Top complexity hotspots
- features/mint/hooks/useMintRebalanceOrchestrator.ts | cognitive=684 cyclomatic=172 nesting=12 code=1111
- shared/lib/contentShiftLog.ts | cognitive=566 cyclomatic=392 nesting=6 code=1326
- features/nostrSigner/lib/nip46Engine.ts | cognitive=358 cyclomatic=171 nesting=6 code=793
- shared/lib/loggerCore.ts | cognitive=354 cyclomatic=181 nesting=6 code=643
- features/nearPay/screens/NearPayScreen.tsx | cognitive=352 cyclomatic=207 nesting=5 code=2068
- features/feed/components/nostr/image-overlay/AnimatedImageOverlay.tsx | cognitive=345 cyclomatic=184 nesting=7 code=1352
- features/feed/components/HomeFeed.tsx | cognitive=335 cyclomatic=134 nesting=8 code=1026
- shared/ui/composed/ContactRow.tsx | cognitive=313 cyclomatic=247 nesting=6 code=828
- shared/lib/cashu/manager.ts | cognitive=296 cyclomatic=80 nesting=9 code=683
- features/feed/screens/NotificationsScreen.tsx | cognitive=293 cyclomatic=183 nesting=5 code=1252
- …and 303 more

## Top shallow modules
- features/feed/components/nostr/image-overlay/config.ts | depth=1.2 exports=52 code=62
- features/feed/components/thread-embed/embedConstants.ts | depth=1.2 exports=9 code=11
- shared/lib/brandColors.ts | depth=1.2 exports=17 code=21
- scripts/release.config.mjs | depth=1.8 exports=25 code=46
- shared/lib/nav/routeSchemas.ts | depth=1.9 exports=7 code=13
- features/nostrSigner/lib/boundedDisplay.ts | depth=2.2 exports=5 code=11
- features/nostrSigner/lib/nip46Types.ts | depth=2.5 exports=44 code=109
- shared/ui/composed/ScreenFooterContext.tsx | depth=2.5 exports=4 code=10
- shared/ui/composed/CircleActionButton/CircleActionButton.types.ts | depth=3 exports=6 code=18
- shared/lib/cashu/npc.ts | depth=4.2 exports=6 code=25
- …and 34 more

## Pass-through suspects
- features/feed/components/nostr/image-overlay/config.ts | ratio=1 exports=52 fanin=5 fanout=0
- features/feed/components/thread-embed/ThreadEmbedProvider.tsx | ratio=1 exports=2 fanin=2 fanout=6
- features/feed/components/HomeFeed.tsx | ratio=1 exports=4 fanin=1 fanout=25
- features/nostrSigner/components/display.tsx | ratio=1 exports=2 fanin=5 fanout=2
- features/nostrSigner/components/SummaryPreviewCards.tsx | ratio=1 exports=7 fanin=2 fanout=18
- features/nostrSigner/hooks/signerApprovalCoordination.ts | ratio=1 exports=2 fanin=3 fanout=0
- features/nostrSigner/lib/boundedDisplay.ts | ratio=1 exports=5 fanin=10 fanout=0
- features/nostrSigner/lib/pairingPreset.ts | ratio=1 exports=2 fanin=4 fanout=1
- features/nostrSigner/lib/rateLimiter.ts | ratio=1 exports=7 fanin=2 fanout=1
- features/send/screens/AmountFlowScreen.tsx | ratio=1 exports=2 fanin=3 fanout=13
- …and 33 more

## Hub-spoke coordinators
- shared/ui/primitives/Avatar.tsx | fanin=39 fanout=11 ×=429
- shared/ui/composed/Screen.tsx | fanin=52 fanout=6 ×=312
- shared/ui/primitives/Pressable.tsx | fanin=87 fanout=3 ×=261
- shared/providers/NostrKeysProvider.tsx | fanin=37 fanout=6 ×=222
- features/nostrSigner/lib/nip46Engine.ts | fanin=9 fanout=18 ×=162
- features/nostrSigner/data/nip46ConnectionsStore.ts | fanin=26 fanout=6 ×=156
- shared/ui/composed/ButtonHandler.tsx | fanin=22 fanout=7 ×=154
- shared/ui/composed/AmountFormatter.tsx | fanin=17 fanout=9 ×=153
- shared/ui/composed/ContactRow.tsx | fanin=9 fanout=17 ×=153
- shared/stores/global/settingsStore.ts | fanin=45 fanout=3 ×=135
- …and 131 more

## Type-safety hotspots
- redux/store/store.deprecated.ts | any=29 !=0 as=16 ts-ignore=0
- __tests__/nip46Engine.test.ts | any=0 !=31 as=1 ts-ignore=0
- __tests__/avatarFallback.test.tsx | any=0 !=16 as=0 ts-ignore=0
- __tests__/sovranPaymentConfig.profile.test.ts | any=0 !=0 as=6 ts-ignore=6
- shared/blocks/popup/PopupHost.tsx | any=3 !=1 as=18 ts-ignore=0
- __tests__/receiveScreenLayout.test.tsx | any=0 !=13 as=0 ts-ignore=0
- __tests__/loggerChild.test.ts | any=0 !=10 as=3 ts-ignore=0
- __tests__/androidUiRegressions.test.tsx | any=0 !=10 as=0 ts-ignore=0
- redux/nostr/reducer.deprecated.ts | any=6 !=0 as=2 ts-ignore=0
- shared/lib/cashu/onchainMint.ts | any=0 !=0 as=20 ts-ignore=0
- …and 324 more

## Component smells
- features/nearPay/screens/NearPayScreen.tsx:NearPayScreen | large(763L) hooks(54) effect-deps(19)
- features/mint/screens/MintAddScreen.tsx:MintAddScreen | large(496L) hooks(32) effect-deps(7)
- features/feed/screens/NotificationsScreen.tsx:NotificationsScreen | large(563L) hooks(22) bool-state(3)
- features/mint/screens/MintRebalancePlanScreen.tsx:MintRebalancePlanScreen | large(531L) hooks(19) effect-deps(16)
- features/contacts/screens/ContactsScreen.tsx:ContactsScreen | large(449L) hooks(27) inline-subcomp(2)
- features/feed/screens/NotificationFollowersScreen.tsx:NotificationFollowersScreen | large(441L) hooks(28)
- features/onboarding/screens/ClaimUsernameScreen.tsx:ClaimUsernameScreen | large(398L) hooks(22) inline-subcomp(1)
- shared/blocks/popup/PopupHost.tsx:SheetPopup | large(429L) hooks(27)
- features/mint/screens/MintDistributionScreen.tsx:MintDistributionScreen | large(346L) hooks(35)
- shared/blocks/popup/ActionMenuHost.tsx:ActionMenuHost | large(542L) hooks(13)
- …and 73 more

## Duplicate export names
- ShareRoute in 2 files: app/share.tsx, features/user/screens/ShareRoute.tsx
- NOTE_CONTENT_LINE_HEIGHT in 2 files: features/feed/components/nostr/NoteContent.tsx, features/feed/lib/threadListLayout.ts
- PUBLISH_TIMEOUT_MS in 2 files: features/nostrSigner/lib/nip46Transport.ts, shared/lib/nostr/publish/constants.ts
- isSupported in 2 files: modules/liquid-glass-menu/src/LiquidGlassMenu.tsx, modules/liquid-glass-text/src/LiquidGlassText.tsx
- Avatar in 2 files: shared/ui/primitives/Avatar.tsx, mealection-react-native-boring-avatars.d.ts

## Unused export sets
- features/feed/data/naggSchemas.ts | unused: NaggNoteMetrics, NaggFeedEvent, NaggProfileInfo, NaggFeedItem, …
- features/nostrSigner/data/nip46ConnectionsStore.ts | unused: ConnectionOrigin, ConnectionEncryption, Nip46Grant, PeerDecryptGrant
- features/feed/data/recentPeopleProfiles.ts | unused: buildRecentPeopleProfilesGraphqlBody, mapRecentPeopleProfileEvents, RECENT_PEOPLE_PROFILES_QUERY
- features/nostrSigner/lib/nip46Engine.ts | unused: OnUserVerdictNeeded, Nip46EngineStartConfig, Nip46EngineTransport
- features/nostrSigner/lib/permissionPolicy.ts | unused: PolicyAllowReason, PolicyDenyReason, PolicyAskReason
- features/nostrSigner/lib/requestSummary.ts | unused: SummaryReaction, SummaryCopySegment, SummaryCopyContext
- features/nostrSigner/lib/nip46Types.ts | unused: ENCRYPTION_GRANT_METHODS, SignEventGrantKey
- shared/ui/primitives/SelectableCheck/types.ts | unused: SelectableCheckStyle, SelectableCheckVariant
- features/bitchat/hooks/useBluetoothState.ts | unused: BluetoothStatus
- features/bitchat/stores/bitchatDmMessages.ts | unused: BleDmDeliveryStatus
- …and 13 more

## Test gaps
- features/nearPay/screens/NearPayScreen.tsx | exports=1 code=2068
- shared/lib/popup/popups/emojiData.ts | exports=4 code=1440
- features/feed/components/nostr/image-overlay/AnimatedImageOverlay.tsx | exports=1 code=1352
- features/feed/screens/NotificationsScreen.tsx | exports=1 code=1252
- features/user/screens/UserProfileScreen.tsx | exports=1 code=1166
- features/mint/hooks/useMintRebalanceOrchestrator.ts | exports=1 code=1111
- features/feed/components/HomeFeed.tsx | exports=4 code=1026
- features/feed/components/nostr/image-overlay/provider.tsx | exports=5 code=983
- features/feed/components/UserFeed.tsx | exports=2 code=983
- features/nostrSigner/components/SignerConnectSheetContent.tsx | exports=2 code=939
- …and 713 more

## Instability per folder
- __tests__ | I=1.00 Ce=250 Ca=0
- scripts | I=1.00 Ce=1 Ca=0
- app | I=1.00 Ce=292 Ca=1
- features | I=0.86 Ce=1814 Ca=290
- navigation | I=0.54 Ce=7 Ca=6
- redux | I=0.42 Ce=5 Ca=7
- assets | I=0.27 Ce=3 Ca=8
- config | I=0.19 Ce=4 Ca=17
- shared | I=0.03 Ce=55 Ca=2095
- codereview | I=0.00 Ce=0 Ca=1
- …and 2 more

## Re-export depth (barrel hops)
- __tests__/nip46PairingEntry.test.ts | depth=2 exports=0
- __tests__/nip46SignerService.test.ts | depth=2 exports=0
- __tests__/nip46SwitchProfileAndPair.test.ts | depth=2 exports=0
- __tests__/sovranPaymentConfig.profile.test.ts | depth=2 exports=0
- app/(drawer)/(tabs)/index/index.tsx | depth=2 exports=1
- __tests__/contactRowLoadingReservation.test.tsx | depth=1 exports=0
- __tests__/loggerChild.test.ts | depth=1 exports=0
- __tests__/loggerFile.test.ts | depth=1 exports=0
- __tests__/loggerRedaction.test.ts | depth=1 exports=0
- __tests__/mintRebalanceRunState.test.ts | depth=1 exports=0
- …and 17 more

## Concept locality
- size | folders=8 files=254
- data | folders=7 files=114
- not | folders=4 files=69
- seed source | folders=4 files=61
- raw relays | folders=4 files=51
- pending | folders=4 files=39
- tier | folders=3 files=20
- resolveOn modes | folders=3 files=8
- resolveOn | folders=3 files=8
- confirmed | folders=3 files=6
- …and 64 more

## Churn × cognitive (history)
- features/nearPay/screens/NearPayScreen.tsx | commits=43 cognitive=352 ×=15136
- features/mint/hooks/useMintRebalanceOrchestrator.ts | commits=13 cognitive=684 ×=8892
- app/_layout.tsx | commits=75 cognitive=104 ×=7800
- features/feed/components/HomeFeed.tsx | commits=22 cognitive=335 ×=7370
- shared/ui/composed/ContactRow.tsx | commits=17 cognitive=313 ×=5321
- features/transactions/components/Transactions.tsx | commits=27 cognitive=176 ×=4752
- shared/lib/cashu/manager.ts | commits=16 cognitive=296 ×=4736
- features/feed/data/naggFeedClient.ts | commits=19 cognitive=247 ×=4693
- features/feed/screens/NotificationsScreen.tsx | commits=15 cognitive=293 ×=4395
- features/feed/components/ThreadView.tsx | commits=32 cognitive=126 ×=4032
- …and 821 more

## Temporal coupling (history)
- package.json ↔ yarn.lock | co=38
- package-lock.json ↔ package.json | co=35
- bun.lock ↔ package.json | co=25
- package-lock.json ↔ yarn.lock | co=16
- metro.config.js ↔ package.json | co=15
- app/currency.tsx ↔ helper/payment-handler/handlers.ts | co=11
- app/ecashSendConfirmation.tsx ↔ app/lightningReceiveConfirmation.tsx | co=11
- docs/adr/0004-thread-list-anchoring.md ↔ features/feed/components/ThreadView.tsx | co=10
- features/nearPay/lib/peerLayout.ts ↔ features/nearPay/screens/NearPayScreen.tsx | co=10
- __tests__/nearPayPeerLayout.test.ts ↔ features/nearPay/screens/NearPayScreen.tsx | co=10
- …and 227 more

## Stale files (history)
- prettier.config.js | last=2025-04-16 code=9

14 |     "moduleSuffixes": [".ios", ".android", ""],
         ^
warn: moduleSuffixes is not supported yet
   at /home/runner/work/Sovran/Sovran/sovran-app/tsconfig.json:14:5

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant