66d52a9ce0
The product argued with itself: FAB/nav said "Scan", Free tier "5 scans", ticker "MLB slate scanned" — while the Ledger says "MY READS". Swept every user-visible surface to READ: - BottomTabBar FAB + Nav link: 'Scan' → 'Read' - Pricing free tier: '5 scans to try the model' → '5 reads …' - StatStrip: 'Awaiting next scan' → 'Awaiting next read' - Ticker badge + snapshotService event: tag 'SCAN' → 'READ', 'slate scanned' → 'slate read' (readSportOf parses BOTH old and new so cached ticker items dedupe cleanly through the rollover) - upgradePitch: 'You've scanned N parlays' / 'unlimited scans' → read/reads Internal untouched (not user-visible): /api/scan routes, scan_count column, scanning state, DemoScan/ScanIcon, scanlines CSS, the transitional SCAN color-map key. tests/unit/verbLaw.test.js is the enforcement: it fails on user-visible scan/scanned/scans copy across web/src + src/services (skips comments). Suite 270/3254 green, web build exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
62 lines
2.6 KiB
JavaScript
62 lines
2.6 KiB
JavaScript
'use strict';
|
|
|
|
// VERB LAW (Truth-Everywhere Part 2, item 1): the user-facing verb is READ,
|
|
// never SCAN. This lint fails if user-VISIBLE scan copy reappears anywhere in
|
|
// the web app or the backend strings that reach the UI (ticker text, upgrade
|
|
// pitch). It targets RENDERED patterns, not internal identifiers — route paths
|
|
// (/api/scan), state vars (scanning), DB columns (scan_count), component names
|
|
// (DemoScan/ScanIcon), the CSS 'scanlines' class, and the transitional ticker
|
|
// color-map key are all legitimately internal and NOT matched.
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
const ROOTS = [
|
|
path.join(__dirname, '..', '..', 'web', 'src'),
|
|
path.join(__dirname, '..', '..', 'src', 'services'),
|
|
];
|
|
|
|
// Each pattern is a user-visible scan phrase that must never ship.
|
|
const FORBIDDEN = [
|
|
{ re: /label:\s*['"]Scan['"]/, why: "nav/tab label 'Scan' → 'Read'" },
|
|
{ re: /slate scanned/i, why: "ticker 'slate scanned' → 'slate read'" },
|
|
{ re: /\b\d+\s+scans\b/i, why: "tier copy 'N scans' → 'N reads'" },
|
|
{ re: /unlimited scans/i, why: "'unlimited scans' → 'unlimited reads'" },
|
|
{ re: /you'?ve scanned/i, why: "'You've scanned …' → 'You've read …'" },
|
|
{ re: /Awaiting next scan/i, why: "'Awaiting next scan' → 'Awaiting next read'" },
|
|
{ re: /Scanning\.\.\./, why: "loading 'Scanning…' → 'Reading…'" },
|
|
{ re: />\s*Scan\b(?!Icon)/, why: "JSX text 'Scan' → 'Read'" },
|
|
];
|
|
|
|
function walk(dir, out) {
|
|
for (const name of fs.readdirSync(dir)) {
|
|
const p = path.join(dir, name);
|
|
const st = fs.statSync(p);
|
|
if (st.isDirectory()) { if (name !== 'node_modules') walk(p, out); }
|
|
else if (/\.(tsx?|jsx?)$/.test(name)) out.push(p);
|
|
}
|
|
}
|
|
|
|
describe('VERB LAW — no user-visible "scan" copy', () => {
|
|
const files = [];
|
|
for (const r of ROOTS) if (fs.existsSync(r)) walk(r, files);
|
|
|
|
test('every rendered surface uses READ, never SCAN', () => {
|
|
const hits = [];
|
|
for (const f of files) {
|
|
const lines = fs.readFileSync(f, 'utf8').split('\n');
|
|
lines.forEach((line, i) => {
|
|
const t = line.trim();
|
|
// Comments aren't user-visible — skip comment-only lines (rate-limit
|
|
// docs, history notes). A rendered string never starts with a comment.
|
|
if (t.startsWith('//') || t.startsWith('*') || t.startsWith('/*')) return;
|
|
for (const { re, why } of FORBIDDEN) {
|
|
if (re.test(line)) hits.push(`${path.relative(process.cwd(), f)}:${i + 1} — ${why}\n ${line.trim()}`);
|
|
}
|
|
});
|
|
}
|
|
if (hits.length) throw new Error(`User-visible "scan" copy found (verb is READ):\n${hits.join('\n')}`);
|
|
expect(hits).toEqual([]);
|
|
});
|
|
});
|