← back to Govarbitrage

apps/mobile/scripts/lint-financial-format.mjs

129 lines

#!/usr/bin/env node
/**
 * lint-financial-format — regression guard-rail (TK-10279, yoloforever Cycle 4).
 *
 * Financial + score values (roi, netProfit, annualizedReturn, probabilityOfSale,
 * currentBid, *Score sub-scores, …) MUST reach the UI only through the guarded
 * helpers — fmtUSD / fmtPct / fmtScore (lib/format.ts) and pnlColor / scoreColor
 * (lib/pnl.ts, ScoreBadge) — which return "—" / neutral for null|non-finite and
 * clamp implausible ratios. A backend garbage value (e.g. annualizedReturn
 * 23002764 → "2,300,276,400.0%"), null, or NaN must never render as an absurd
 * number, literal "NaN", or a misleading green/red.
 *
 * This script fails (exit 1) if a UI file under app/** or components/** does an
 * AD-HOC render/color of such a value that bypasses those helpers. Zero-dep,
 * read-only.
 *
 * Escape hatch: append  // lint-financial-format-ok: <reason>  to a reviewed
 * exception line (e.g. a boolean success color, not a raw number).
 *
 * KNOWN LIMITATIONS (line-regex scanner, no AST — do not mistake a pass for proof):
 *   - Aliasing / cross-line data flow is invisible: `const r = row.roi;` then
 *     `<Text>{r}</Text>` on the next line is NOT caught (the alias isn't a field
 *     name). Reviewers must still catch indirection.
 *   - Only the Colors.profit/Colors.loss token pair is checked for sign-color;
 *     a new color family or a styles.profitText indirection driven by a raw sign
 *     is not caught.
 *   - Field matching is name-based (suffix Score/Bid/Profit/Return/Price + an
 *     explicit list); a financial field that fits none of those is not tracked.
 *
 * Run: npm run lint:fin   (or: node scripts/lint-financial-format.mjs [rootDir])
 */
import { readFileSync, readdirSync, statSync } from "node:fs";
import { join, relative } from "node:path";

const APP_ROOT = process.argv[2] || join(import.meta.dirname, "..");
const SCAN_DIRS = ["app", "components"];
const EXT = /\.(tsx|ts)$/;

// Name-based field detection: common financial/score suffixes (catches
// arbitrageScore, recommendedMaxBid, expectedNetProfit, expectedSalePrice, …)
// plus explicit fields that fit no suffix.
const FIELD_SRC =
  "\\b(?:\\w*(?:Score|Bid|Profit|Return|Price)|roi|annualizedReturn|probabilityOfSale|daysUntilSold|liquidationValue|sellTodayValue|marketplaceFees|totalInvestment|expectedReturns)\\b";
const FIELD_RE = new RegExp(FIELD_SRC);
const GUARD_CALL = /\b(?:fmtUSD|fmtPct|fmtScore|pnlColor|scoreColor)\s*\([^)]*\)/g;
const FINITE = /Number\.isFinite\s*\(/;
const OK_MARK = /lint-financial-format-ok/;

// Strip guarded-helper call spans + Number.isFinite spans so we only inspect the
// UNGUARDED remainder of a line for a raw field.
function residual(line) {
  return line.replace(GUARD_CALL, " ").replace(/Number\.isFinite\s*\([^)]*\)/g, " ");
}

const RULES = [
  { id: "adhoc-round",
    test: (l) => new RegExp(`Math\\.round\\s*\\([^)]*${FIELD_SRC}`).test(l),
    msg: "Math.round() on a financial/score field — use fmtScore()/fmtPct()" },
  { id: "adhoc-format",
    test: (l) => (/(\.toFixed|\.toLocaleString)\s*\(/.test(l) && FIELD_RE.test(l))
                 || (/Intl\.NumberFormat/.test(l) && FIELD_RE.test(l)),
    msg: ".toFixed()/.toLocaleString()/Intl.NumberFormat on a field — use fmtUSD()/fmtPct()" },
  { id: "adhoc-pct100",
    test: (l) => /\*\s*100\b/.test(l) && (FIELD_RE.test(l) || /%/.test(l)),
    msg: "ad-hoc *100 percent conversion — use fmtPct()" },
  { id: "raw-sign-color",
    test: (l) => /\?\s*Colors\.(profit|loss)\s*:\s*Colors\.(profit|loss)/.test(l)
                 && (/(>=|<=|<|>)\s*0/.test(l) || FIELD_RE.test(l)),
    msg: "profit/loss color from a raw number — use pnlColor(value)" },
  // A raw field reaching output as a BARE expression — `{row.currentBid}` or
  // `${row.currentBid}` — with no guarded helper wrapping it and no
  // Number.isFinite guard on the line. Only flags a bare field access (no
  // call/operator inside the braces), so conditional guards like
  // `{row.roi != null && (…)}` are not flagged. Catches the "delete the wrapper"
  // regression, the whole reason this guard exists.
  { id: "raw-field-render",
    test: (l) => {
      if (FINITE.test(l)) return false;
      if (/^\s*import\b/.test(l)) return false;
      const r = residual(l);
      // {obj.field} or `${obj.field}` where the FIELD is the last member segment
      // (a real data access), the object is not a style/theme namespace, and the
      // braces hold only that access (no call/operator). Catches the
      // "delete the fmt wrapper" regression; ignores style refs + component names.
      return /\$?\{\s*(?!(?:styles|Colors|Typography|Spacing|Radius|StyleSheet)\b)[\w.]*?\.(?:\w*(?:Score|Bid|Profit|Return|Price)|roi|annualizedReturn|probabilityOfSale|daysUntilSold|liquidationValue|sellTodayValue|marketplaceFees|totalInvestment|expectedReturns)\b\s*\}/.test(r);
    },
    msg: "raw {field} reaching output — wrap in fmtUSD()/fmtPct()/fmtScore() (or guard with Number.isFinite() for a plain count)" },
];

function walk(dir, out = []) {
  for (const name of readdirSync(dir)) {
    if (name === "node_modules" || name.startsWith(".")) continue;
    const p = join(dir, name);
    const st = statSync(p);
    if (st.isDirectory()) walk(p, out);
    else if (EXT.test(name)) out.push(p);
  }
  return out;
}

const violations = [];
for (const sub of SCAN_DIRS) {
  const base = join(APP_ROOT, sub);
  let files;
  try { files = walk(base); } catch { continue; }
  for (const file of files) {
    const lines = readFileSync(file, "utf8").split("\n");
    lines.forEach((line, i) => {
      if (OK_MARK.test(line)) return;
      for (const rule of RULES) {
        if (rule.test(line)) {
          violations.push({ file: relative(APP_ROOT, file), line: i + 1, rule: rule.id, msg: rule.msg, snippet: line.trim().slice(0, 120) });
        }
      }
    });
  }
}

if (violations.length === 0) {
  console.log("✓ lint-financial-format: no ad-hoc financial/score rendering found.");
  process.exit(0);
}
console.error(`✗ lint-financial-format: ${violations.length} violation(s) — route through the guarded helpers (or add // lint-financial-format-ok: <reason>):\n`);
for (const v of violations) {
  console.error(`  ${v.file}:${v.line}  [${v.rule}] ${v.msg}`);
  console.error(`      ${v.snippet}`);
}
process.exit(1);