Skip to content

adaptReputation computes completionRate/onTimeDeliveryRate without clamping, despite an unused coerceFraction helper built for exactly this #91

Description

@chonilius

Overview

src/lib/utils.ts already has a fraction-clamping helper built and exported specifically for this purpose:

export function coerceFraction(value: string | null | undefined, fallback = 0): number {
  const n = coerceDecimal(value, fallback);
  if (n < 0) return 0;
  if (n > 1) return 1;
  return n;
}

I grepped the entire src/ tree for coerceFraction and the only match is its own declaration — it is never imported or called anywhere in the codebase. Meanwhile, adaptReputation — the function responsible for turning a raw reputation snapshot into the completionRate/onTimeDeliveryRate fractions that ReputationPage and the contributor dashboard display — reimplements the same "backend percentage string → 0–1 fraction" conversion inline, without the clamp:

export function adaptReputation(
  user: RawUserProfile,
  snapshot: RawReputationSnapshot | null,
): ReputationProfile {
  return {
    handle: user.username,
    avatarUrl: user.avatarUrl ?? `https://api.dicebear.com/9.x/identicon/svg?seed=${user.username}`,
    lifetimeEarnings: snapshot ? coerceNonNegative(snapshot.totalEarnings) : 0,
    mergedPRs: snapshot?.mergedPrCount ?? 0,
    completionRate: snapshot ? coerceDecimal(snapshot.completionRate) / 100 : 0,
    avgReviewTimeHours: snapshot ? coerceNonNegative(snapshot.avgReviewTimeHours) : 0,
    onTimeDeliveryRate: snapshot ? coerceDecimal(snapshot.onTimeDeliveryPercentage) / 100 : 0,
    languages: snapshot ? Object.keys(snapshot.languages) : [],
    organizations: snapshot?.orgsContributedTo ?? [],
    topClients: [],
  };
}

coerceDecimal(snapshot.completionRate) / 100 and coerceDecimal(snapshot.onTimeDeliveryPercentage) / 100 both run the raw string through coerceDecimal (which only guards against non-numeric/non-finite input, falling back to 0 — it does not clamp to any range) and then divide by 100, with nothing after that division ever constraining the result back into [0, 1]. If the backend ever sends a completionRate string like "150" (a corrupted computation, a units mismatch, a backend bug computing a rate as a raw count instead of a percentage), the result is 1.5, not clamped — flowing straight through to ReputationProfile.completionRate: 1.5, and from there into formatPercent(1.5)"150%", displayed with full visual confidence on a public contributor reputation page. The same applies symmetrically to a negative input producing a negative displayed percentage.

This is exactly the shape of value coerceFraction exists to guard — a 0–1 fraction derived from untrusted backend input — and it sits unused three lines above the function that actually needed it.

Requirements

  • Replace adaptReputation's inline coerceDecimal(...) / 100 pattern for both completionRate and onTimeDeliveryRate with coerceFraction, adjusting coerceFraction's signature/call convention as needed to handle the "divide by 100 first, then clamp to [0, 1]" step these two fields require (note coerceFraction as currently written clamps an already-in-[0,1]-range input; it doesn't itself divide by 100, so either extend it to accept a divisor parameter, or clamp after the existing manual division — either is fine as long as the end result is genuinely bounded).
  • Audit the rest of adaptReputation (and adaptBounty/adaptMilestone/adaptMaintenancePool) for any other place a fraction/percentage is derived without a clamp, now that this specific pattern has been found once — confirm whether this is an isolated miss or a broader gap across the adapter layer.
  • Add a regression test proving a corrupted/out-of-range backend value can no longer produce an impossible displayed percentage.

Acceptance Criteria

  • adaptReputation uses coerceFraction (or an equivalent, newly-clamped helper) for both completionRate and onTimeDeliveryRate, rather than the current unclamped inline division.
  • A raw completionRate/onTimeDeliveryPercentage value of "150" produces a ReputationProfile.completionRate/onTimeDeliveryRate of exactly 1 (clamped), not 1.5.
  • A raw negative value produces 0, not a negative fraction.
  • A normal, in-range value (e.g. "94") continues to produce 0.94 exactly as before — no regression to the common case.
  • The PR documents the outcome of the broader adapter-layer audit for the same unclamped pattern, even if no other instance is found.

Additional Notes

Precise references:

  • src/lib/utils.ts:20-25coerceFraction, confirmed unused anywhere else via grep -rn "coerceFraction" src/ (one match: the declaration itself).
  • src/lib/adapters.ts:141-159adaptReputation, the unclamped completionRate/onTimeDeliveryRate computation at lines 152 and 154.
  • src/lib/utils.ts:8-13coerceDecimal, confirmed to only guard non-finite/non-numeric input (falling back to 0), with no upper/lower bound clamp of its own — confirming the clamp genuinely needs to happen at the adaptReputation call site (or inside a fixed coerceFraction), not somewhere further upstream that might already be covering this.
  • src/lib/utils.ts:40-43formatPercent, confirmed to have no clamp either (Math.round(value * 100)}% with no Math.min/Math.max), meaning nothing downstream of adaptReputation would catch an out-of-range fraction before it reaches the screen — the clamp genuinely has to happen at the source.
  • src/app/reputation/[handle]/page.tsx:49-56 — the two StatCards that would display the corrupted value: "Completion rate" and "On-time delivery," both on a page with no authentication gate (public profile).

Relationship to other issues: distinct from the separate HomePage/ReputationPage StatCard-bypass issue in this batch — that issue is about these same two stat cards not using StatCard's format/numeric-value system at all (they're passed pre-formatted strings via formatPercent). This issue is about the correctness of the number going into formatPercent in the first place, upstream in the adapter layer, independent of how StatCard ends up displaying it. Both should be fixed; neither fix makes the other unnecessary.

Test/reproduction plan: unit-test adaptReputation with a fixture RawReputationSnapshot where completionRate: "150" and assert the returned completionRate is 1, not 1.5; repeat with completionRate: "-20" and assert 0; repeat with completionRate: "94" and assert 0.94 (no regression). Mirror the same three cases for onTimeDeliveryPercentage/onTimeDeliveryRate.

Metadata

Metadata

Assignees

Labels

GrantFox OSSIssue tracked in GrantFox OSSMaybe RewardedIssue may be eligible for a GrantFox rewardOfficial Campaign | FWC26Campaign: Official Campaign | FWC26Third CampaignCampaign: Third CampaignbugSomething isn't workingvery hardVery difficult task, expert-level effort required

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions