← 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);