//! CI guard for business assumptions. //! //! Catches at PR time what would otherwise only fail at prod boot: //! - `docs/business/assumptions.toml` parses //! - All consistency rules pass (sums, bounds, founding ≤ standard) //! - Every `{{ ... }}` marker in the live site-docs corpus resolves //! - No *bare* copy of a linted assumption value survives in the corpus //! - The `[synckit]` mirror still matches the authoritative Rust constants //! //! Run with: cargo test --test assumptions use makenotwork::synckit_billing; use mnw_assumptions::{Assumptions, code_span_ranges}; // Canonical assumptions.toml ships with the repo at server/docs/business/. // Cargo runs tests with cwd = crate manifest dir (MNW/server/), so the // relative path is just docs/..., no traversal. const ASSUMPTIONS_PATH: &str = "docs/business/assumptions.toml"; const SITE_DOCS_PATH: &str = "site-docs/public"; #[test] fn real_assumptions_file_parses_and_validates() { let a = Assumptions::load(ASSUMPTIONS_PATH) .unwrap_or_else(|e| panic!("failed to load {ASSUMPTIONS_PATH}: {e}")); a.validate() .unwrap_or_else(|e| panic!("assumptions failed validation:\n{e}")); } #[test] fn every_marker_in_site_docs_resolves() { let a = Assumptions::load(ASSUMPTIONS_PATH).expect("load"); let mut failures = Vec::new(); visit_markdown(std::path::Path::new(SITE_DOCS_PATH), &mut |path, body| { if !body.contains("{{") { return; } if let Err(e) = a.substitute(body) { failures.push(format!(" {}: {e}", path.display())); } }); assert!( failures.is_empty(), "{} doc(s) contain unresolved {{{{ … }}}} markers:\n{}", failures.len(), failures.join("\n") ); } /// `[synckit]` in assumptions.toml mirrors constants that live in Rust, so the /// public developer docs can substitute them. Rust is authoritative; this test /// is the drift guard, the same shape as the `tier_limits` ↔ `tier_bytes` rule /// inside `Assumptions::validate`. #[test] fn synckit_block_mirrors_billing_constants() { let a = Assumptions::load(ASSUMPTIONS_PATH).expect("load"); let num = |key: &str| { a.get(key) .unwrap_or_else(|| panic!("missing key {key}")) .as_f64() .unwrap_or_else(|| panic!("{key} is not numeric")) }; // Both sides are money, so compare in whole cents rather than comparing // f64s for equality. let cents = |key: &str| (num(key) * 100.0).round() as i64; assert_eq!( cents("synckit.storage_usd_per_gb_month"), synckit_billing::STORAGE_RATE_CENTS_PER_GB, "synckit.storage_usd_per_gb_month != STORAGE_RATE_CENTS_PER_GB" ); assert_eq!( cents("synckit.invoice_floor_usd"), synckit_billing::BASE_FLOOR_CENTS, "synckit.invoice_floor_usd != BASE_FLOOR_CENTS" ); assert_eq!( num("synckit.max_priced_gb").round() as i64, synckit_billing::MAX_STORAGE_GB, "synckit.max_priced_gb != MAX_STORAGE_GB" ); } // --- bare-value lint /// How a linted key renders when a writer types it by hand. Each variant maps /// to the marker + filter a doc would use, so the searched-for literal is /// exactly what substitution would have produced. #[derive(Copy, Clone)] enum Form { /// `{{ k | money }}`: `$0.30`. Money, /// `{{ k | percent }}`: `2.9%`. Percent, /// `${{ k }}`: `$580`, plus the comma-grouped hand-written form. Dollars, /// `{{ k }}`: the value verbatim (already a display string). Verbatim, } /// Assumption values distinctive enough that a bare occurrence in the corpus /// is a stale-copy bug rather than a coincidence. /// /// Deliberately NOT linted: the tier prices and Fan+ prices (small dollar /// amounts like `$8` collide with illustrative sale prices all over the guide), /// the tier envelope sizes (`5 MB` is also the sandbox per-file cap, `500 MB` /// the export cap, different limits that happen to share a number), and /// `uptime.target_downtime_hours_year` (a bare `44` is noise). Those still /// carry markers in the corpus; they just can't be policed by string search. const LINTED: &[(&str, Form)] = &[ ("expenses.F_monthly", Form::Dollars), ("stripe.percent", Form::Percent), ("stripe.fixed", Form::Money), ("stripe.dispute_fee", Form::Money), ("uptime.target_pct", Form::Percent), ("derived.R_cap", Form::Dollars), ("cohort.cap_display", Form::Verbatim), ("broadcasts.recipients_per_send_display", Form::Verbatim), ("synckit.storage_usd_per_gb_month", Form::Money), ("synckit.invoice_floor_usd", Form::Money), ("derived.annual_founding_basic", Form::Dollars), ("derived.annual_founding_small_files", Form::Dollars), ("derived.annual_founding_big_files", Form::Dollars), ("derived.annual_founding_everything", Form::Dollars), ("derived.annual_standard_basic", Form::Dollars), ("derived.annual_standard_small_files", Form::Dollars), ("derived.annual_standard_big_files", Form::Dollars), ("derived.annual_standard_everything", Form::Dollars), ]; /// Lines that legitimately carry a linted literal. Matched as /// (path suffix, substring of the offending line, reason), a substring rather /// than a line number so ordinary edits above don't invalidate the waiver. const EXEMPT: &[(&str, &str, &str)] = &[ ( "guide/git.md", "10,000 lines", "commit-diff display cap, unrelated to the broadcast recipient cap", ), ( "guide/items.md", "10,000 characters", "changelog field length, unrelated to the broadcast recipient cap", ), ( "about/how-we-work.md", "1,000 transactions", "illustrative transaction count in a revenue example, not the cohort cap", ), ( "guide/stripe.md", "| Canada |", "Canadian Stripe rate (C$), coincidentally the same digits as the US rate; \ only the US row is ours to substitute", ), ( "guide/stripe.md", "| Australia |", "Australian Stripe rate (A$), not the US flat fee", ), ]; #[test] fn no_bare_assumption_values_in_site_docs() { let a = Assumptions::load(ASSUMPTIONS_PATH).expect("load"); // Render each linted key through the engine itself, so the literal we hunt // for is byte-identical to what the marker would have emitted. let mut needles: Vec<(&str, String)> = Vec::new(); for &(key, form) in LINTED { assert!(a.get(key).is_some(), "linted key {key} is not in the table"); let render = |expr: &str| { a.substitute(&format!("{{{{ {expr} }}}}")) .unwrap_or_else(|e| panic!("cannot render {expr}: {e}")) }; match form { Form::Money => needles.push((key, render(&format!("{key} | money")))), Form::Percent => needles.push((key, render(&format!("{key} | percent")))), Form::Verbatim => needles.push((key, render(key))), Form::Dollars => { let plain = render(key); needles.push((key, format!("${plain}"))); let grouped = group_thousands(&plain); if grouped != plain { needles.push((key, format!("${grouped}"))); } } } } let mut findings = Vec::new(); visit_markdown(std::path::Path::new(SITE_DOCS_PATH), &mut |path, body| { let display = path.display().to_string(); let code = code_span_ranges(body); let prose = strip_ranges(body, &code); let prose = strip_markers(&prose); for (lineno, line) in prose.lines().enumerate() { for (key, needle) in &needles { if !contains_bare(line, needle) { continue; } if EXEMPT .iter() .any(|(file, snippet, _)| display.ends_with(file) && line.contains(snippet)) { continue; } findings.push(format!( " {display}:{}: bare {needle:?}. Use a {{{{ {key} }}}} marker\n {}", lineno + 1, line.trim() )); } } }); assert!( findings.is_empty(), "{} bare assumption value(s) in the corpus \ (substitute them, or add an EXEMPT entry with a reason):\n{}", findings.len(), findings.join("\n") ); } /// A waiver that no longer matches anything is a waiver that should be /// deleted, not carried. Keeps `EXEMPT` from silently outliving its reason. #[test] fn every_exemption_still_matches_a_line() { let mut stale = Vec::new(); for (file, snippet, reason) in EXEMPT { let path = std::path::Path::new(SITE_DOCS_PATH).join(file); let matched = std::fs::read_to_string(&path) .is_ok_and(|body| body.lines().any(|line| line.contains(*snippet))); if !matched { stale.push(format!( " {file}: {snippet:?} no longer present ({reason})" )); } } assert!( stale.is_empty(), "{} stale bare-value exemption(s), delete them:\n{}", stale.len(), stale.join("\n") ); } /// Blank out byte ranges (code spans, `{{ … }}` markers) with spaces, keeping /// every other byte at its original offset so line numbers stay truthful. fn strip_ranges(text: &str, ranges: &[(usize, usize)]) -> String { let mut out: Vec = text.as_bytes().to_vec(); for &(start, end) in ranges { for b in &mut out[start..end.min(text.len())] { if *b != b'\n' { *b = b' '; } } } String::from_utf8(out).expect("only ASCII bytes replaced") } /// Blank out `{{ … }}` markers: a value inside one is substituted, not bare. fn strip_markers(text: &str) -> String { let mut ranges = Vec::new(); let mut rest = text; let mut base = 0; while let Some(open) = rest.find("{{") { let after = open + 2; match rest[after..].find("}}") { Some(rel) => { let close = after + rel + 2; ranges.push((base + open, base + close)); base += close; rest = &rest[close..]; } // Unclosed marker: nothing further to blank. None => break, } } strip_ranges(text, &ranges) } /// True when `needle` occurs in `line` as a whole number, not as a fragment of /// a longer one. `$8` must not match inside `$8,410`, and `2.9%` must not match /// inside `12.9%`. fn contains_bare(line: &str, needle: &str) -> bool { let bytes = line.as_bytes(); let mut from = 0; while let Some(rel) = line[from..].find(needle) { let start = from + rel; let end = start + needle.len(); let before_ok = start == 0 || !matches!(bytes[start - 1], b'0'..=b'9' | b'.' | b',' | b'$'); let after_ok = end == bytes.len() || !matches!(bytes[end], b'0'..=b'9' | b'.' | b',' | b'%'); if before_ok && after_ok { return true; } from = end; } false } /// `61960` → `61,960`. Leaves anything with a decimal point alone. fn group_thousands(digits: &str) -> String { if !digits.bytes().all(|b| b.is_ascii_digit()) { return digits.to_string(); } let mut out = String::with_capacity(digits.len() + digits.len() / 3); let n = digits.len(); for (i, c) in digits.chars().enumerate() { if i > 0 && (n - i).is_multiple_of(3) { out.push(','); } out.push(c); } out } fn visit_markdown(dir: &std::path::Path, f: &mut impl FnMut(&std::path::Path, &str)) { let Ok(entries) = std::fs::read_dir(dir) else { return; }; for entry in entries.flatten() { let path = entry.path(); if path.is_dir() { visit_markdown(&path, f); } else if path.extension().and_then(|s| s.to_str()) == Some("md") && let Ok(body) = std::fs::read_to_string(&path) { f(&path, &body); } } } mod lint_unit { use super::*; #[test] fn contains_bare_rejects_longer_numbers() { assert!(contains_bare("costs $580 a month", "$580")); assert!(!contains_bare("revenue of $5,800", "$580")); assert!(!contains_bare("total $5804", "$580")); assert!(contains_bare("fee is 2.9% today", "2.9%")); assert!(!contains_bare("fee is 12.9% today", "2.9%")); assert!(!contains_bare("$10,000/month", "10,000")); assert!(contains_bare("cap is 10,000 per list", "10,000")); } #[test] fn strip_markers_preserves_offsets_and_lines() { let input = "a {{ x.y }} b\nc {{ z }} d\n"; let out = strip_markers(input); assert_eq!(out.len(), input.len()); assert_eq!(out.lines().count(), input.lines().count()); assert!(!out.contains("x.y"), "marker body must be blanked: {out:?}"); assert!(out.starts_with("a "), "prose must survive: {out:?}"); } #[test] fn strip_ranges_blanks_code_spans_only() { let input = "real $580 and `literal $580`"; let out = strip_ranges(input, &code_span_ranges(input)); assert!(contains_bare(&out, "$580"), "prose copy still visible"); assert_eq!(out.matches("$580").count(), 1, "code copy blanked: {out:?}"); } #[test] fn group_thousands_inserts_separators() { assert_eq!(group_thousands("61960"), "61,960"); assert_eq!(group_thousands("580"), "580"); assert_eq!(group_thousands("1000000"), "1,000,000"); assert_eq!(group_thousands("0.31"), "0.31"); } }